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.
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
// 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function escAttr(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||
}
|