Files
paliad/frontend/src/client/builder-picker.ts
mAi 6c1d8cc0cf feat(builder): B1 — Litigation Builder shell + cold-open mode (m/paliad#153)
Replaces cronus's U0-U4 catalog at /tools/procedures with a
persistence-backed builder shell on top of B0's API surface
(/api/builder/scenarios/*, t-paliad-340).

PRD §7.1 B1 acceptance shipped:
  - Page header: scenario picker, name action, Akte picker stub,
    Stichtag input, search input, save status indicator.
  - Entry-mode radio (cold-open active; event-triggered + akte
    rendered disabled for B3/B4 layout stability).
  - Empty canvas with "Neues Szenario starten" CTA and a 5-most-recent
    list rendered when the user has saved scenarios.
  - Side panel "Meine Szenarien" with the Aktiv bucket; clicking an
    item loads the scenario into the canvas.
  - Add-proceeding inline picker (Forum chip row → Verfahren chip row
    → Hinzufügen). UPC v1; other forums chipped but disabled.
  - First proceeding triplet renders end-to-end via
    verfahrensablauf-core.calculateDeadlines + renderColumnsBody (the
    existing 3-column proaktiv|court|reaktiv body, read-only in B1).
  - Auto-save with 500ms debounce on name + stichtag patches; save
    status flips idle → saving → saved/error in the page header.

New client modules under frontend/src/client/:
  - builder.ts       — orchestrator (URL state, fetch, auto-save loop,
                       canvas render, scenario-list re-paint).
  - builder-picker.ts — inline Forum/Verfahren popover for the
                       add-proceeding affordance.
  - builder-triplet.ts — single-triplet header + body wrapper.

procedures.tsx rewritten as the shell scaffolding (sidebar, page
header, mode radio, two-column body); procedures.ts now boots the
builder instead of toggling the 4-tab catalog.

Legacy U0-U4 modules (verfahrensablauf.ts, verfahrensablauf-state.ts,
VerfahrensablaufBody.tsx, procedures' tab toggle in client/procedures.ts,
fristenrechner-* mounts) are no longer reachable from /tools/procedures
but kept in the tree for the B6 cleanup sweep per PRD §7.4.

i18n.ts grew 60 keys × 2 langs under builder.*. global.css grew a
self-contained .builder-* block at the file tail.

bun run build, go vet ./..., and go test ./... all green.
2026-05-28 00:20:46 +02:00

148 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Add-proceeding inline picker for the Litigation Builder.
//
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
// gates the Verfahren chip row, click → callback. Designed for B1's
// single-triplet flow and B2's multi-triplet stacking with no shape
// change between slices.
import { t } from "./i18n";
export interface ProceedingTypeMeta {
id: number;
code: string;
name: string;
nameEN: string;
// group / jurisdiction. The proceeding-types API returns "UPC" /
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
// only renders UPC.
group?: string;
jurisdiction?: string;
}
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
let activePopover: HTMLElement | null = null;
export function mountAddProceedingPicker(
anchor: HTMLElement,
types: ProceedingTypeMeta[],
onPick: OnPick,
): void {
closeActive();
const pop = document.createElement("div");
pop.className = "builder-picker-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("builder.picker.aria"));
const header = document.createElement("div");
header.className = "builder-picker-header";
header.innerHTML = `
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
`;
pop.appendChild(header);
// Forum row — UPC only for v1. Disabled chips render greyed.
const forumRow = document.createElement("div");
forumRow.className = "builder-picker-row";
forumRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
<div class="builder-picker-chips">
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
</div>
`;
pop.appendChild(forumRow);
const procRow = document.createElement("div");
procRow.className = "builder-picker-row";
procRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
`;
pop.appendChild(procRow);
const empty = document.createElement("p");
empty.className = "builder-picker-empty";
empty.hidden = true;
empty.textContent = t("builder.picker.empty");
pop.appendChild(empty);
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
const lang = document.documentElement.lang === "en" ? "en" : "de";
for (const meta of types) {
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-picker-chip builder-picker-chip--proc";
chip.setAttribute("data-code", meta.code);
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
chip.addEventListener("click", () => {
closeActive();
void onPick(meta);
});
procHost.appendChild(chip);
}
if (types.length === 0) empty.hidden = false;
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
closeActive();
});
// Position the popover under the anchor button.
positionUnder(pop, anchor);
document.body.appendChild(pop);
activePopover = pop;
document.addEventListener("click", onOutsideClick, true);
document.addEventListener("keydown", onEscape, true);
}
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
const rect = anchor.getBoundingClientRect();
pop.style.position = "absolute";
const top = rect.bottom + window.scrollY + 6;
// Default left = anchor's left; clamp so popover stays in viewport.
const left = Math.max(8, rect.left + window.scrollX);
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
pop.style.zIndex = "60";
}
function onOutsideClick(ev: Event): void {
if (!activePopover) return;
const target = ev.target as Node;
if (activePopover.contains(target)) return;
closeActive();
}
function onEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") closeActive();
}
function closeActive(): void {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
document.removeEventListener("click", onOutsideClick, true);
document.removeEventListener("keydown", onEscape, true);
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}