Compare commits
13 Commits
mai/newton
...
mai/euler/
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c77cb6e67 | |||
| 1f6e586c63 | |||
| a4b865d6bd | |||
| a905911cf4 | |||
| 88c03e922f | |||
| 6bcac2dd20 | |||
| 46dc4ec94b | |||
| 6c1d8cc0cf | |||
| 0c857026a2 | |||
| 3c840c0366 | |||
| 1b4b2e4758 | |||
| b78a984a7c | |||
| 1844df3ae6 |
147
frontend/src/client/builder-picker.ts
Normal file
147
frontend/src/client/builder-picker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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, """);
|
||||
}
|
||||
271
frontend/src/client/builder-triplet.ts
Normal file
271
frontend/src/client/builder-triplet.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// ProceedingTriplet renderer for the Litigation Builder.
|
||||
//
|
||||
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
|
||||
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
|
||||
// body.
|
||||
//
|
||||
// B2 wires the live controls — perspective radio, scenario-flag strip,
|
||||
// remove button, collapse — and the per-event-card overlays (3-state
|
||||
// machine, action buttons, optional-horizon chip). The 3-column body
|
||||
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
|
||||
// per-card overlays are layered on top after innerHTML write via the
|
||||
// data-rule-id hooks added in the same slice.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
|
||||
import type { BuilderProceeding, BuilderEvent } from "./builder";
|
||||
import type { ProceedingTypeMeta } from "./builder-picker";
|
||||
|
||||
export interface ScenarioFlagCatalogEntry {
|
||||
flag_key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description?: string;
|
||||
hidden_unless_set: boolean;
|
||||
}
|
||||
|
||||
export interface TripletViewInput {
|
||||
proceeding: BuilderProceeding;
|
||||
meta: ProceedingTypeMeta;
|
||||
data: DeadlineResponse | null;
|
||||
side: Side;
|
||||
// Flag catalog filtered to the keys the active proceeding actually
|
||||
// references via its rules' condition_expr. B2 passes the global
|
||||
// catalog and lets the user toggle any — flags that don't gate any
|
||||
// rule are simply no-ops on this triplet.
|
||||
flagCatalog: ScenarioFlagCatalogEntry[];
|
||||
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
|
||||
// for the per-card state machine. Cards whose rule is absent default
|
||||
// to "planned".
|
||||
eventsByRule: Map<string, BuilderEvent>;
|
||||
// Per-card optional-horizon registry. Each rule with optional
|
||||
// children carries a `+N Optionen` chip; the chip's count comes from
|
||||
// here (defaults to scenario_events.horizon_optional, falls back to
|
||||
// proceeding-level when not stored per-card).
|
||||
columnsHtml: string;
|
||||
isChild: boolean;
|
||||
}
|
||||
|
||||
// Triplet header + controls + columns body. Pure-string render; the
|
||||
// caller (builder.ts) wires click handlers on top.
|
||||
export function renderTriplet(input: TripletViewInput): string {
|
||||
const lang = getLang();
|
||||
const procLabel = lang === "en"
|
||||
? (input.meta.nameEN || input.meta.name)
|
||||
: (input.meta.name || input.meta.nameEN);
|
||||
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
|
||||
|
||||
const body = input.data
|
||||
? input.columnsHtml
|
||||
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
|
||||
|
||||
const controls = renderControls(input);
|
||||
const flagStrip = renderFlagStrip(input);
|
||||
|
||||
return `
|
||||
<header class="builder-triplet-header">
|
||||
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
|
||||
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
|
||||
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
|
||||
${flagsBadge}
|
||||
</header>
|
||||
${controls}
|
||||
${flagStrip}
|
||||
<div class="builder-triplet-body">
|
||||
${body}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderControls(input: TripletViewInput): string {
|
||||
const perspective = input.side ?? "";
|
||||
const detailgrad = input.proceeding.detailgrad || "selected";
|
||||
|
||||
const radio = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-perspective-btn${active}"
|
||||
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
const detailBtn = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
|
||||
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
|
||||
return `<div class="builder-triplet-controls">
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
|
||||
<div class="builder-triplet-perspective">
|
||||
${radio("", "builder.triplet.perspective.none", perspective)}
|
||||
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
|
||||
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
|
||||
</div>
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
|
||||
<div class="builder-triplet-detailgrad">
|
||||
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
|
||||
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
|
||||
</div>
|
||||
<button type="button" class="builder-triplet-remove" data-action="remove">
|
||||
${escHtml(t("builder.triplet.remove"))}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFlagStrip(input: TripletViewInput): string {
|
||||
// B2 ships the full global catalog. Flags that don't gate any of the
|
||||
// active proceeding's rules are still toggle-able but have no effect
|
||||
// on the calc result (the engine simply doesn't read them).
|
||||
const lang = getLang();
|
||||
const flags = input.proceeding.scenario_flags || {};
|
||||
if (input.flagCatalog.length === 0) {
|
||||
return `<div class="builder-triplet-flagstrip">
|
||||
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
|
||||
</div>`;
|
||||
}
|
||||
const toggles = input.flagCatalog.map((entry) => {
|
||||
const label = lang === "en" ? entry.label_en : entry.label_de;
|
||||
const isOn = flags[entry.flag_key] === true;
|
||||
return `<label class="builder-triplet-flag-toggle">
|
||||
<input type="checkbox"
|
||||
data-action="flag"
|
||||
data-flag-key="${escAttr(entry.flag_key)}"
|
||||
${isOn ? "checked" : ""} />
|
||||
<span>${escHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
|
||||
}
|
||||
|
||||
function jurisdictionFor(meta: ProceedingTypeMeta): string {
|
||||
if (meta.jurisdiction) return meta.jurisdiction;
|
||||
if (meta.group) return meta.group;
|
||||
const dot = meta.code.indexOf(".");
|
||||
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
|
||||
return meta.code.toUpperCase();
|
||||
}
|
||||
|
||||
function activeFlagsBadge(flags: Record<string, unknown>): string {
|
||||
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
|
||||
if (active.length === 0) return "";
|
||||
const label = t("builder.triplet.flags.label");
|
||||
const chips = active.map((f) =>
|
||||
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
|
||||
).join("");
|
||||
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
|
||||
}
|
||||
|
||||
// overlayEventStates walks the rendered .fr-col-item nodes and:
|
||||
// - sets data-builder-state from eventsByRule lookup;
|
||||
// - appends a per-card action row (file / skip / reset);
|
||||
// - shows a +N Optionen chip when the rule has optional children
|
||||
// (the chip placeholder; B2 ships the per-card horizon control —
|
||||
// the actual horizon-count→render expansion lands when the calc
|
||||
// engine surfaces "available optionals" for a parent rule, which
|
||||
// pasteur's Options.IncludeOptional flag already exposes server-
|
||||
// side; full wiring is a follow-up). Cards without optional
|
||||
// children get no chip.
|
||||
export function overlayEventStates(
|
||||
root: HTMLElement,
|
||||
eventsByRule: Map<string, BuilderEvent>,
|
||||
on: {
|
||||
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
|
||||
onHorizon: (ruleId: string, delta: 1 | -1) => void;
|
||||
},
|
||||
): void {
|
||||
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
|
||||
items.forEach((item) => {
|
||||
const ruleId = item.getAttribute("data-rule-id");
|
||||
if (!ruleId) return;
|
||||
const ev = eventsByRule.get(ruleId.toLowerCase());
|
||||
const state = ev?.state || "planned";
|
||||
item.setAttribute("data-builder-state", state);
|
||||
|
||||
// Append actions (idempotent: clear any prior overlay first).
|
||||
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "builder-event-actions";
|
||||
actions.innerHTML = actionButtonsHtml(state);
|
||||
item.appendChild(actions);
|
||||
|
||||
actions.addEventListener("click", (ev) => {
|
||||
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
|
||||
if (!action) return;
|
||||
ev.stopPropagation();
|
||||
if (action === "file") {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
|
||||
if (v === null) return;
|
||||
on.onAction(ruleId, "file", { date: v.trim() || today });
|
||||
} else if (action === "skip") {
|
||||
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
|
||||
if (reason === null) return;
|
||||
on.onAction(ruleId, "skip", { reason: reason.trim() });
|
||||
} else {
|
||||
on.onAction(ruleId, "reset");
|
||||
}
|
||||
});
|
||||
|
||||
// Per-card optional horizon chip. The PRD §3.4 places the chip on
|
||||
// every card with optional children; until the calc surface exposes
|
||||
// an "optionals available count" on each parent rule, the chip is
|
||||
// shown only when the card has a stored non-zero horizon (so the
|
||||
// user can see and reduce a previously-set horizon). This is the
|
||||
// graceful B2 baseline; the full surface lands once the engine
|
||||
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
|
||||
const horizonCount = ev?.horizon_optional ?? 0;
|
||||
if (horizonCount > 0) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-toggle");
|
||||
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, -1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
} else {
|
||||
// Inline "+ Optionen" affordance — adds a horizon entry when
|
||||
// first clicked. Tagged as data-builder-feature so the cleanup
|
||||
// sweep can rip it out if the calc surface lands a counter.
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-add");
|
||||
chip.setAttribute("data-builder-feature", "horizon-add");
|
||||
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, 1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function actionButtonsHtml(state: BuilderEvent["state"]): string {
|
||||
// Re-render the action row per state. Cards in the planned state
|
||||
// show "File / Skip"; filed/skipped cards show "Reset to planned".
|
||||
if (state === "planned") {
|
||||
return `
|
||||
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
|
||||
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
|
||||
`;
|
||||
}
|
||||
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
|
||||
}
|
||||
|
||||
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, """);
|
||||
}
|
||||
981
frontend/src/client/builder.ts
Normal file
981
frontend/src/client/builder.ts
Normal file
@@ -0,0 +1,981 @@
|
||||
// Litigation Builder client (m/paliad#153 PRD §3, B1).
|
||||
//
|
||||
// Boots /tools/procedures. Talks to the B0 surface
|
||||
// (/api/builder/scenarios/*) for persistence and reuses
|
||||
// verfahrensablauf-core for the per-triplet calc + 3-column render.
|
||||
//
|
||||
// B1 ships:
|
||||
// - Cold-open empty canvas + "Neues Szenario starten" CTA + recent list.
|
||||
// - Scenario picker, name action, Stichtag, auto-save (500ms debounce).
|
||||
// - Add-proceeding picker (Forum chip row → Verfahren chip row → Hinzufügen).
|
||||
// - Single triplet renders end-to-end with calc.
|
||||
// - Side panel "Meine Szenarien" with Aktiv bucket.
|
||||
//
|
||||
// B2 extends:
|
||||
// - Multi-triplet stack with `+ Verfahren hinzufügen`.
|
||||
// - Per-triplet perspective + flag strip.
|
||||
// - Spawn child triplets render inline.
|
||||
// - 3-state event cards (planned/filed/skipped) + per-card optional horizon.
|
||||
|
||||
import { t, getLang } from "./i18n";
|
||||
import {
|
||||
calculateDeadlines,
|
||||
renderColumnsBody,
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
|
||||
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
|
||||
|
||||
// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
|
||||
// parent proceeding, the builder auto-creates a child proceeding row
|
||||
// linked via parent_scenario_proceeding_id. When the flag flips back
|
||||
// OFF, the child is deleted (its events cascade via the schema's ON
|
||||
// DELETE CASCADE). Today's data has 2-deep nesting at most, with
|
||||
// `with_ccr` on `upc.inf.cfi` as the load-bearing case; the map is
|
||||
// data-driven so future flags slot in by adding rows.
|
||||
//
|
||||
// Entries are picked up by syncSpawnChildren after each successful
|
||||
// flag PATCH. Falsy entries are simple flag-only flips with no spawn
|
||||
// effect.
|
||||
const SPAWN_MAP: Record<string, Record<string, string>> = {
|
||||
// Parent proceeding code → flag_key → child proceeding code.
|
||||
"upc.inf.cfi": { with_ccr: "upc.ccr.cfi" },
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Wire types — mirror internal/services/scenario_builder_service.go
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BuilderScenario {
|
||||
id: string;
|
||||
owner_id?: string;
|
||||
name: string;
|
||||
status: "active" | "archived" | "promoted";
|
||||
origin_project_id?: string;
|
||||
promoted_project_id?: string;
|
||||
stichtag?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BuilderProceeding {
|
||||
id: string;
|
||||
scenario_id: string;
|
||||
proceeding_type_id: number;
|
||||
primary_party?: "claimant" | "defendant";
|
||||
scenario_flags: Record<string, unknown>;
|
||||
parent_scenario_proceeding_id?: string;
|
||||
spawn_anchor_event_id?: string;
|
||||
ordinal: number;
|
||||
stichtag?: string;
|
||||
detailgrad: "selected" | "all_options";
|
||||
appeal_target?: string;
|
||||
collapsed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BuilderEvent {
|
||||
id: string;
|
||||
scenario_proceeding_id: string;
|
||||
sequencing_rule_id?: string;
|
||||
procedural_event_id?: string;
|
||||
custom_label?: string;
|
||||
state: "planned" | "filed" | "skipped";
|
||||
actual_date?: string;
|
||||
skip_reason?: string;
|
||||
notes?: string;
|
||||
horizon_optional: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BuilderScenarioDeep extends BuilderScenario {
|
||||
proceedings: BuilderProceeding[];
|
||||
events: BuilderEvent[];
|
||||
shares: unknown[];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Module state — single active scenario per tab.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface State {
|
||||
active: BuilderScenarioDeep | null;
|
||||
list: BuilderScenario[];
|
||||
procTypes: ProceedingTypeMeta[];
|
||||
procTypesById: Map<number, ProceedingTypeMeta>;
|
||||
procTypesByCode: Map<string, ProceedingTypeMeta>;
|
||||
flagCatalog: ScenarioFlagCatalogEntry[];
|
||||
saveTimer: number | null;
|
||||
// Pending field-level deltas merged before each PATCH flush. Avoids
|
||||
// racing PATCHes overwriting each other when the user changes more
|
||||
// than one field inside a 500ms window.
|
||||
pending: { name?: string; stichtag?: string; notes?: string };
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
active: null,
|
||||
list: [],
|
||||
procTypes: [],
|
||||
procTypesById: new Map(),
|
||||
procTypesByCode: new Map(),
|
||||
flagCatalog: [],
|
||||
saveTimer: null,
|
||||
pending: {},
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fetch helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T | null> {
|
||||
try {
|
||||
const resp = await fetch(input, init);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "");
|
||||
console.error("builder fetch error:", resp.status, input, body);
|
||||
return null;
|
||||
}
|
||||
if (resp.status === 204) return null;
|
||||
return (await resp.json()) as T;
|
||||
} catch (err) {
|
||||
console.error("builder network error:", input, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScenarios(): Promise<BuilderScenario[]> {
|
||||
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=active");
|
||||
return Array.isArray(out) ? out : [];
|
||||
}
|
||||
|
||||
async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
|
||||
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
|
||||
}
|
||||
|
||||
async function fetchProceedingTypes(): Promise<ProceedingTypeMeta[]> {
|
||||
// PRD v1 is UPC-only; later jurisdictions plug into the same picker
|
||||
// shape (Forum chip row gates the Verfahren chip row).
|
||||
const out = await fetchJSON<ProceedingTypeMeta[]>(
|
||||
"/api/tools/proceeding-types?jurisdiction=UPC&kind=proceeding",
|
||||
);
|
||||
return Array.isArray(out) ? out : [];
|
||||
}
|
||||
|
||||
async function fetchFlagCatalog(): Promise<ScenarioFlagCatalogEntry[]> {
|
||||
const out = await fetchJSON<ScenarioFlagCatalogEntry[]>("/api/builder/scenario-flag-catalog");
|
||||
return Array.isArray(out) ? out : [];
|
||||
}
|
||||
|
||||
async function createScenario(name?: string): Promise<BuilderScenario | null> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (name) body.name = name;
|
||||
return await fetchJSON<BuilderScenario>("/api/builder/scenarios", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function patchScenario(id: string, body: Record<string, unknown>): Promise<BuilderScenario | null> {
|
||||
return await fetchJSON<BuilderScenario>("/api/builder/scenarios/" + encodeURIComponent(id), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function addProceeding(
|
||||
scenarioID: string,
|
||||
body: {
|
||||
proceeding_type_id: number;
|
||||
primary_party?: string;
|
||||
parent_scenario_proceeding_id?: string;
|
||||
spawn_anchor_event_id?: string;
|
||||
},
|
||||
): Promise<BuilderProceeding | null> {
|
||||
return await fetchJSON<BuilderProceeding>(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function patchProceeding(
|
||||
scenarioID: string,
|
||||
proceedingID: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<BuilderProceeding | null> {
|
||||
return await fetchJSON<BuilderProceeding>(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
|
||||
"/proceedings/" + encodeURIComponent(proceedingID),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteProceeding(scenarioID: string, proceedingID: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
|
||||
"/proceedings/" + encodeURIComponent(proceedingID),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
return resp.ok;
|
||||
} catch (err) {
|
||||
console.error("builder delete proceeding error:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addEvent(
|
||||
scenarioID: string,
|
||||
proceedingID: string,
|
||||
body: {
|
||||
sequencing_rule_id?: string;
|
||||
state?: string;
|
||||
actual_date?: string;
|
||||
skip_reason?: string;
|
||||
horizon_optional?: number;
|
||||
},
|
||||
): Promise<BuilderEvent | null> {
|
||||
return await fetchJSON<BuilderEvent>(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
|
||||
"/proceedings/" + encodeURIComponent(proceedingID) + "/events",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function patchEvent(
|
||||
scenarioID: string,
|
||||
eventID: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<BuilderEvent | null> {
|
||||
return await fetchJSON<BuilderEvent>(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
|
||||
"/events/" + encodeURIComponent(eventID),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// URL state
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function readScenarioFromUrl(): string | null {
|
||||
return new URLSearchParams(window.location.search).get("scenario");
|
||||
}
|
||||
|
||||
function writeScenarioToUrl(id: string | null): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (id) url.searchParams.set("scenario", id);
|
||||
else url.searchParams.delete("scenario");
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Save indicator
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
function setSaveState(s: SaveState): void {
|
||||
const el = document.getElementById("builder-save-status");
|
||||
if (!el) return;
|
||||
el.setAttribute("data-state", s);
|
||||
const span = el.querySelector("span");
|
||||
if (!span) return;
|
||||
const text =
|
||||
s === "saving" ? t("builder.save.saving") :
|
||||
s === "saved" ? t("builder.save.saved") :
|
||||
s === "error" ? t("builder.save.error") :
|
||||
t("builder.save.idle");
|
||||
const key =
|
||||
s === "saving" ? "builder.save.saving" :
|
||||
s === "saved" ? "builder.save.saved" :
|
||||
s === "error" ? "builder.save.error" :
|
||||
"builder.save.idle";
|
||||
span.setAttribute("data-i18n", key);
|
||||
span.textContent = text;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Auto-save (500ms debounce per PRD §4.2 + §10).
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function scheduleAutoSave(): void {
|
||||
if (!state.active) return;
|
||||
setSaveState("saving");
|
||||
if (state.saveTimer !== null) {
|
||||
window.clearTimeout(state.saveTimer);
|
||||
}
|
||||
state.saveTimer = window.setTimeout(() => {
|
||||
void flushAutoSave();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function flushAutoSave(): Promise<void> {
|
||||
state.saveTimer = null;
|
||||
if (!state.active) return;
|
||||
const body = { ...state.pending };
|
||||
state.pending = {};
|
||||
if (Object.keys(body).length === 0) {
|
||||
setSaveState("saved");
|
||||
return;
|
||||
}
|
||||
const updated = await patchScenario(state.active.id, body);
|
||||
if (!updated) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
state.active.name = updated.name;
|
||||
state.active.status = updated.status;
|
||||
state.active.stichtag = updated.stichtag;
|
||||
state.active.notes = updated.notes;
|
||||
state.active.updated_at = updated.updated_at;
|
||||
setSaveState("saved");
|
||||
// Refresh the side panel so the just-saved scenario floats to top.
|
||||
await refreshScenarioList();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Side panel + dropdown
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function refreshScenarioList(): Promise<void> {
|
||||
state.list = await fetchScenarios();
|
||||
renderScenarioList();
|
||||
renderScenarioPicker();
|
||||
}
|
||||
|
||||
function renderScenarioList(): void {
|
||||
const ul = document.getElementById("builder-scenario-list-active");
|
||||
if (!ul) return;
|
||||
if (state.list.length === 0) {
|
||||
ul.innerHTML = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
|
||||
return;
|
||||
}
|
||||
const activeId = state.active?.id;
|
||||
ul.innerHTML = state.list.map((sc) => {
|
||||
const isActive = sc.id === activeId;
|
||||
return (
|
||||
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
|
||||
` data-scenario-id="${escAttr(sc.id)}">` +
|
||||
`<button type="button" class="builder-scenario-list-link">` +
|
||||
`<span class="builder-scenario-list-name">${escHtml(sc.name)}</span>` +
|
||||
`</button></li>`
|
||||
);
|
||||
}).join("");
|
||||
ul.querySelectorAll<HTMLElement>(".builder-scenario-list-item").forEach((li) => {
|
||||
const id = li.getAttribute("data-scenario-id");
|
||||
if (!id) return;
|
||||
li.addEventListener("click", () => {
|
||||
void loadScenario(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderScenarioPicker(): void {
|
||||
const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholderText = t("builder.picker.placeholder");
|
||||
const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
|
||||
for (const sc of state.list) {
|
||||
const selected = sc.id === state.active?.id ? " selected" : "";
|
||||
opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Canvas rendering
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function showEmpty(): void {
|
||||
const canvas = document.getElementById("builder-canvas");
|
||||
if (!canvas) return;
|
||||
canvas.innerHTML = "";
|
||||
const empty = document.createElement("div");
|
||||
empty.id = "builder-empty";
|
||||
empty.className = "builder-empty";
|
||||
empty.innerHTML = `
|
||||
<p class="builder-empty-headline">${escHtml(t("builder.empty.headline"))}</p>
|
||||
<p class="builder-empty-hint">${escHtml(t("builder.empty.hint"))}</p>
|
||||
<button type="button" id="builder-cta-new" class="builder-cta-new">
|
||||
${escHtml(t("builder.empty.cta"))}
|
||||
</button>
|
||||
${renderRecentList()}
|
||||
`;
|
||||
canvas.appendChild(empty);
|
||||
document.getElementById("builder-cta-new")?.addEventListener("click", () => {
|
||||
void onNewScenarioClick();
|
||||
});
|
||||
empty.querySelectorAll<HTMLElement>(".builder-recent-item").forEach((li) => {
|
||||
const id = li.getAttribute("data-scenario-id");
|
||||
if (!id) return;
|
||||
li.addEventListener("click", () => {
|
||||
void loadScenario(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecentList(): string {
|
||||
if (state.list.length === 0) return "";
|
||||
const recent = state.list.slice(0, 5);
|
||||
const items = recent.map((sc) => (
|
||||
`<li class="builder-recent-item" data-scenario-id="${escAttr(sc.id)}">` +
|
||||
`<span class="builder-recent-name">${escHtml(sc.name)}</span>` +
|
||||
`</li>`
|
||||
)).join("");
|
||||
return (
|
||||
`<div class="builder-recent">` +
|
||||
`<h3 class="builder-recent-title">${escHtml(t("builder.empty.recent"))}</h3>` +
|
||||
`<ul class="builder-recent-list">${items}</ul>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderCanvas(): void {
|
||||
if (!state.active) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
const canvas = document.getElementById("builder-canvas");
|
||||
if (!canvas) return;
|
||||
canvas.innerHTML = "";
|
||||
|
||||
// Top-level proceedings sorted by ordinal (parent_scenario_proceeding_id IS NULL).
|
||||
const topLevel = state.active.proceedings
|
||||
.filter((p) => !p.parent_scenario_proceeding_id)
|
||||
.sort((a, b) => a.ordinal - b.ordinal);
|
||||
|
||||
for (const proc of topLevel) {
|
||||
renderProceedingTripletInto(canvas, proc, /*isChild*/ false);
|
||||
// Inline child triplets (spawn nesting). PRD §3.6.
|
||||
const children = state.active.proceedings
|
||||
.filter((p) => p.parent_scenario_proceeding_id === proc.id)
|
||||
.sort((a, b) => a.ordinal - b.ordinal);
|
||||
for (const child of children) {
|
||||
renderProceedingTripletInto(canvas, child, /*isChild*/ true);
|
||||
}
|
||||
}
|
||||
|
||||
// Add-proceeding affordance always at the bottom of the stack.
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "builder-add-proceeding-btn";
|
||||
addBtn.id = "builder-add-proceeding-btn";
|
||||
addBtn.textContent = t("builder.canvas.add_proceeding");
|
||||
addBtn.addEventListener("click", () => {
|
||||
openAddProceedingPicker(addBtn);
|
||||
});
|
||||
canvas.appendChild(addBtn);
|
||||
}
|
||||
|
||||
function renderProceedingTripletInto(
|
||||
canvas: HTMLElement,
|
||||
proc: BuilderProceeding,
|
||||
isChild: boolean,
|
||||
): void {
|
||||
const host = document.createElement("article");
|
||||
host.className = "builder-triplet-host";
|
||||
host.setAttribute("data-proceeding-id", proc.id);
|
||||
if (isChild) host.setAttribute("data-child", "true");
|
||||
canvas.appendChild(host);
|
||||
void hydrateTriplet(proc, host, isChild);
|
||||
}
|
||||
|
||||
async function hydrateTriplet(
|
||||
proc: BuilderProceeding,
|
||||
host: HTMLElement,
|
||||
isChild: boolean,
|
||||
): Promise<void> {
|
||||
const meta = state.procTypesById.get(proc.proceeding_type_id);
|
||||
if (!meta) {
|
||||
host.innerHTML = `<div class="builder-triplet-error">${escHtml(
|
||||
t("builder.triplet.unknown_proceeding"),
|
||||
)}</div>`;
|
||||
return;
|
||||
}
|
||||
const stichtag = proc.stichtag || state.active?.stichtag || todayISO();
|
||||
const data: DeadlineResponse | null = await calculateDeadlines({
|
||||
proceedingType: meta.code,
|
||||
triggerDate: stichtag,
|
||||
flags: scenarioFlagsToArray(proc.scenario_flags),
|
||||
});
|
||||
const side: Side = (proc.primary_party as Side) || null;
|
||||
const eventsByRule = buildEventsByRule(proc.id);
|
||||
const columnsHtml = data
|
||||
? renderColumnsBody(data, { editable: false, side, showDurations: false })
|
||||
: "";
|
||||
host.innerHTML = renderTriplet({
|
||||
proceeding: proc,
|
||||
meta,
|
||||
data,
|
||||
side,
|
||||
flagCatalog: state.flagCatalog,
|
||||
eventsByRule,
|
||||
columnsHtml,
|
||||
isChild,
|
||||
});
|
||||
wireTripletInteractions(host, proc, meta);
|
||||
overlayEventStates(host, eventsByRule, {
|
||||
onAction: (ruleId, action, payload) => {
|
||||
void onEventAction(proc, ruleId, action, payload);
|
||||
},
|
||||
onHorizon: (ruleId, delta) => {
|
||||
void onEventHorizon(proc, ruleId, delta);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildEventsByRule(proceedingID: string): Map<string, BuilderEvent> {
|
||||
const out = new Map<string, BuilderEvent>();
|
||||
if (!state.active) return out;
|
||||
for (const ev of state.active.events) {
|
||||
if (ev.scenario_proceeding_id !== proceedingID) continue;
|
||||
if (!ev.sequencing_rule_id) continue;
|
||||
out.set(ev.sequencing_rule_id.toLowerCase(), ev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wireTripletInteractions(
|
||||
host: HTMLElement,
|
||||
proc: BuilderProceeding,
|
||||
meta: ProceedingTypeMeta,
|
||||
): void {
|
||||
// Perspective radio
|
||||
host.querySelectorAll<HTMLElement>("[data-action='perspective']").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const value = btn.getAttribute("data-value") || "";
|
||||
void onPerspectiveChange(proc, value);
|
||||
});
|
||||
});
|
||||
// Detailgrad toggle
|
||||
host.querySelectorAll<HTMLElement>("[data-action='detailgrad']").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const value = btn.getAttribute("data-value") || "selected";
|
||||
void onDetailgradChange(proc, value);
|
||||
});
|
||||
});
|
||||
// Remove
|
||||
host.querySelectorAll<HTMLElement>("[data-action='remove']").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
void onRemoveProceeding(proc);
|
||||
});
|
||||
});
|
||||
// Flag checkboxes
|
||||
host.querySelectorAll<HTMLInputElement>("[data-action='flag']").forEach((box) => {
|
||||
box.addEventListener("change", () => {
|
||||
const key = box.getAttribute("data-flag-key");
|
||||
if (!key) return;
|
||||
void onFlagChange(proc, meta, key, box.checked);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function onPerspectiveChange(proc: BuilderProceeding, value: string): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const updated = await patchProceeding(state.active.id, proc.id, {
|
||||
primary_party: value,
|
||||
});
|
||||
if (!updated) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
applyProceedingPatch(updated);
|
||||
setSaveState("saved");
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
async function onDetailgradChange(proc: BuilderProceeding, value: string): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const updated = await patchProceeding(state.active.id, proc.id, {
|
||||
detailgrad: value,
|
||||
});
|
||||
if (!updated) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
applyProceedingPatch(updated);
|
||||
setSaveState("saved");
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
async function onRemoveProceeding(proc: BuilderProceeding): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const confirmed = window.confirm(t("builder.triplet.remove") + " — " + state.procTypesById.get(proc.proceeding_type_id)?.code);
|
||||
if (!confirmed) return;
|
||||
const ok = await deleteProceeding(state.active.id, proc.id);
|
||||
if (!ok) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
// Cascade in-memory: drop the proceeding + its child proceedings + events.
|
||||
state.active.proceedings = state.active.proceedings.filter(
|
||||
(p) => p.id !== proc.id && p.parent_scenario_proceeding_id !== proc.id,
|
||||
);
|
||||
state.active.events = state.active.events.filter(
|
||||
(e) => state.active!.proceedings.some((p) => p.id === e.scenario_proceeding_id),
|
||||
);
|
||||
setSaveState("saved");
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
async function onFlagChange(
|
||||
proc: BuilderProceeding,
|
||||
meta: ProceedingTypeMeta,
|
||||
flagKey: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const newFlags: Record<string, unknown> = { ...proc.scenario_flags, [flagKey]: enabled };
|
||||
const updated = await patchProceeding(state.active.id, proc.id, {
|
||||
scenario_flags: newFlags,
|
||||
});
|
||||
if (!updated) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
applyProceedingPatch(updated);
|
||||
|
||||
// Spawn handling — flip the child triplet in or out based on the
|
||||
// SPAWN_MAP entry for this parent.
|
||||
const spawnChildCode = SPAWN_MAP[meta.code]?.[flagKey];
|
||||
if (spawnChildCode) {
|
||||
await syncSpawnChild(updated, spawnChildCode, enabled);
|
||||
}
|
||||
setSaveState("saved");
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
async function syncSpawnChild(
|
||||
parent: BuilderProceeding,
|
||||
childCode: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const existing = state.active.proceedings.find(
|
||||
(p) => p.parent_scenario_proceeding_id === parent.id,
|
||||
);
|
||||
if (enabled && !existing) {
|
||||
const childMeta = state.procTypesByCode.get(childCode);
|
||||
if (!childMeta) return;
|
||||
const child = await addProceeding(state.active.id, {
|
||||
proceeding_type_id: childMeta.id,
|
||||
parent_scenario_proceeding_id: parent.id,
|
||||
});
|
||||
if (child) state.active.proceedings.push(child);
|
||||
} else if (!enabled && existing) {
|
||||
const ok = await deleteProceeding(state.active.id, existing.id);
|
||||
if (ok) {
|
||||
state.active.proceedings = state.active.proceedings.filter((p) => p.id !== existing.id);
|
||||
state.active.events = state.active.events.filter(
|
||||
(e) => e.scenario_proceeding_id !== existing.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyProceedingPatch(updated: BuilderProceeding): void {
|
||||
if (!state.active) return;
|
||||
const idx = state.active.proceedings.findIndex((p) => p.id === updated.id);
|
||||
if (idx >= 0) state.active.proceedings[idx] = updated;
|
||||
}
|
||||
|
||||
async function onEventAction(
|
||||
proc: BuilderProceeding,
|
||||
ruleId: string,
|
||||
action: "file" | "skip" | "reset",
|
||||
payload?: { date?: string; reason?: string },
|
||||
): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const ruleKey = ruleId.toLowerCase();
|
||||
const existing = state.active.events.find(
|
||||
(e) => e.scenario_proceeding_id === proc.id &&
|
||||
e.sequencing_rule_id?.toLowerCase() === ruleKey,
|
||||
);
|
||||
if (action === "file") {
|
||||
const date = payload?.date || todayISO();
|
||||
if (existing) {
|
||||
const upd = await patchEvent(state.active.id, existing.id, {
|
||||
state: "filed",
|
||||
actual_date: date,
|
||||
});
|
||||
if (upd) replaceEvent(upd);
|
||||
} else {
|
||||
const ev = await addEvent(state.active.id, proc.id, {
|
||||
sequencing_rule_id: ruleId,
|
||||
state: "filed",
|
||||
actual_date: date,
|
||||
});
|
||||
if (ev) state.active.events.push(ev);
|
||||
}
|
||||
} else if (action === "skip") {
|
||||
if (existing) {
|
||||
const upd = await patchEvent(state.active.id, existing.id, {
|
||||
state: "skipped",
|
||||
skip_reason: payload?.reason ?? "",
|
||||
});
|
||||
if (upd) replaceEvent(upd);
|
||||
} else {
|
||||
const ev = await addEvent(state.active.id, proc.id, {
|
||||
sequencing_rule_id: ruleId,
|
||||
state: "skipped",
|
||||
skip_reason: payload?.reason ?? "",
|
||||
});
|
||||
if (ev) state.active.events.push(ev);
|
||||
}
|
||||
} else {
|
||||
// reset → either patch back to planned or delete the row outright.
|
||||
// PATCH is simpler and keeps any horizon_optional the user had set;
|
||||
// a separate "clear horizon" affordance handles full removal.
|
||||
if (existing) {
|
||||
const upd = await patchEvent(state.active.id, existing.id, { state: "planned" });
|
||||
if (upd) replaceEvent(upd);
|
||||
}
|
||||
}
|
||||
setSaveState("saved");
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
async function onEventHorizon(
|
||||
proc: BuilderProceeding,
|
||||
ruleId: string,
|
||||
delta: 1 | -1,
|
||||
): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const ruleKey = ruleId.toLowerCase();
|
||||
const existing = state.active.events.find(
|
||||
(e) => e.scenario_proceeding_id === proc.id &&
|
||||
e.sequencing_rule_id?.toLowerCase() === ruleKey,
|
||||
);
|
||||
if (existing) {
|
||||
const newHorizon = Math.max(0, existing.horizon_optional + delta);
|
||||
const upd = await patchEvent(state.active.id, existing.id, {
|
||||
horizon_optional: newHorizon,
|
||||
});
|
||||
if (upd) replaceEvent(upd);
|
||||
} else if (delta > 0) {
|
||||
const ev = await addEvent(state.active.id, proc.id, {
|
||||
sequencing_rule_id: ruleId,
|
||||
horizon_optional: 1,
|
||||
state: "planned",
|
||||
});
|
||||
if (ev) state.active.events.push(ev);
|
||||
}
|
||||
setSaveState("saved");
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
function replaceEvent(updated: BuilderEvent): void {
|
||||
if (!state.active) return;
|
||||
const idx = state.active.events.findIndex((e) => e.id === updated.id);
|
||||
if (idx >= 0) state.active.events[idx] = updated;
|
||||
else state.active.events.push(updated);
|
||||
}
|
||||
|
||||
function scenarioFlagsToArray(flags: Record<string, unknown>): string[] {
|
||||
// The calc API still consumes the historical flat-flag array form
|
||||
// (string slug per active flag). Builder scenario_flags is the
|
||||
// jsonb {flag_name: true|false|null} shape — translate by picking
|
||||
// every truthy key.
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(flags)) {
|
||||
if (v === true) out.push(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Actions
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadScenario(id: string): Promise<void> {
|
||||
const deep = await fetchScenarioDeep(id);
|
||||
if (!deep) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
// Defensive: Go's encoding/json serialises a nil slice as `null`, not
|
||||
// `[]`. The server initialises these arrays today, but normalising on
|
||||
// the client too means a future regression (or an older deployed
|
||||
// build) can't crash renderCanvas with `null.filter(...)`.
|
||||
if (!Array.isArray(deep.proceedings)) deep.proceedings = [];
|
||||
if (!Array.isArray(deep.events)) deep.events = [];
|
||||
if (!Array.isArray(deep.shares)) deep.shares = [];
|
||||
state.active = deep;
|
||||
state.pending = {};
|
||||
writeScenarioToUrl(id);
|
||||
setSaveState("saved");
|
||||
// Sync header inputs to scenario state.
|
||||
const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
|
||||
if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
|
||||
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
|
||||
if (rename) rename.disabled = false;
|
||||
renderScenarioPicker();
|
||||
renderScenarioList();
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
async function onNewScenarioClick(): Promise<void> {
|
||||
// Scratch scenario per PRD §2.1 — anonymous until "Benennen". Server
|
||||
// applies the default name "Unbenanntes Szenario".
|
||||
const sc = await createScenario();
|
||||
if (!sc) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
state.list.unshift(sc);
|
||||
await loadScenario(sc.id);
|
||||
// Open the add-proceeding picker so the user lands on the next action.
|
||||
const btn = document.getElementById("builder-add-proceeding-btn") as HTMLElement | null;
|
||||
if (btn) openAddProceedingPicker(btn);
|
||||
}
|
||||
|
||||
function openAddProceedingPicker(anchor: HTMLElement): void {
|
||||
if (!state.active) return;
|
||||
mountAddProceedingPicker(anchor, state.procTypes, async (meta) => {
|
||||
if (!state.active) return;
|
||||
// Guard against a wire-shape regression: if the proceeding-types
|
||||
// endpoint stops returning `id`, `meta.id` is undefined and
|
||||
// JSON.stringify silently drops the field, the server rejects the
|
||||
// POST with a 400, and fetchJSON swallows the error — the user
|
||||
// sees "nothing happens" (t-paliad-345). Fail loud instead.
|
||||
if (typeof meta.id !== "number" || meta.id <= 0) {
|
||||
console.error("builder: missing proceeding_type id in picker meta", meta);
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
const proc = await addProceeding(state.active.id, {
|
||||
proceeding_type_id: meta.id,
|
||||
});
|
||||
if (!proc) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
state.active.proceedings.push(proc);
|
||||
setSaveState("saved");
|
||||
renderCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
async function onRenameClick(): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const current = state.active.name;
|
||||
const next = window.prompt(t("builder.action.rename.prompt"), current);
|
||||
if (next === null) return;
|
||||
const trimmed = next.trim();
|
||||
if (!trimmed || trimmed === current) return;
|
||||
state.pending.name = trimmed;
|
||||
scheduleAutoSave();
|
||||
state.active.name = trimmed;
|
||||
renderScenarioPicker();
|
||||
renderScenarioList();
|
||||
}
|
||||
|
||||
function onStichtagChange(value: string): void {
|
||||
if (!state.active) return;
|
||||
state.active.stichtag = value;
|
||||
state.pending.stichtag = value;
|
||||
scheduleAutoSave();
|
||||
// Re-render: the triplet's calc result depends on stichtag.
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Wiring
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function wirePageHeader(): void {
|
||||
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
|
||||
void onRenameClick();
|
||||
});
|
||||
document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
|
||||
void onNewScenarioClick();
|
||||
});
|
||||
document.getElementById("builder-cta-new")?.addEventListener("click", () => {
|
||||
void onNewScenarioClick();
|
||||
});
|
||||
const picker = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
|
||||
picker?.addEventListener("change", () => {
|
||||
const id = picker.value;
|
||||
if (id) void loadScenario(id);
|
||||
else {
|
||||
state.active = null;
|
||||
writeScenarioToUrl(null);
|
||||
renderCanvas();
|
||||
}
|
||||
});
|
||||
const stichtag = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
|
||||
stichtag?.addEventListener("change", () => {
|
||||
onStichtagChange(stichtag.value);
|
||||
});
|
||||
}
|
||||
|
||||
export async function mountBuilder(): Promise<void> {
|
||||
wirePageHeader();
|
||||
// Parallel boot — proceeding type catalog (Forum=UPC, Kind=proceeding)
|
||||
// for the add-proceeding picker + scenario_flag_catalog for the
|
||||
// per-triplet flag strip. PRD §0.4 — UPC v1.
|
||||
const [procTypes, flagCatalog] = await Promise.all([
|
||||
fetchProceedingTypes(),
|
||||
fetchFlagCatalog(),
|
||||
]);
|
||||
state.procTypes = procTypes;
|
||||
state.procTypesById = new Map(state.procTypes.map((p) => [p.id, p]));
|
||||
state.procTypesByCode = new Map(state.procTypes.map((p) => [p.code, p]));
|
||||
state.flagCatalog = flagCatalog;
|
||||
await refreshScenarioList();
|
||||
const requested = readScenarioFromUrl();
|
||||
if (requested && state.list.some((s) => s.id === requested)) {
|
||||
await loadScenario(requested);
|
||||
} else {
|
||||
renderCanvas();
|
||||
}
|
||||
setSaveState("idle");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function todayISO(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Re-export getLang so the per-page bundle pulls i18n into the dep
|
||||
// graph (the i18n module's side-effect-free initialiser otherwise
|
||||
// gets tree-shaken when only string keys are referenced).
|
||||
export { getLang };
|
||||
@@ -214,6 +214,69 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
|
||||
"nav.procedures": "Verfahren & Fristen",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
|
||||
"builder.header.scenario": "Szenario:",
|
||||
"builder.header.akte": "Akte:",
|
||||
"builder.header.stichtag": "Stichtag:",
|
||||
"builder.header.search": "Suche:",
|
||||
"builder.akte.none": "\u2014 ohne \u2014",
|
||||
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
|
||||
"builder.action.rename": "Benennen",
|
||||
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
|
||||
"builder.action.share": "Teilen",
|
||||
"builder.action.promote": "Als Projekt anlegen",
|
||||
"builder.mode.cold": "\u00dcbersicht",
|
||||
"builder.mode.event": "Ereignis",
|
||||
"builder.mode.akte": "Aus Akte",
|
||||
"builder.panel.title": "Meine Szenarien",
|
||||
"builder.panel.new": "+ Neues Szenario",
|
||||
"builder.panel.empty": "Noch keine Szenarien.",
|
||||
"builder.bucket.active": "Aktiv",
|
||||
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
|
||||
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
|
||||
"builder.empty.cta": "Neues Szenario starten",
|
||||
"builder.empty.recent": "Zuletzt bearbeitet",
|
||||
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
|
||||
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
|
||||
"builder.picker.close": "Schlie\u00dfen",
|
||||
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Verfahren:",
|
||||
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
|
||||
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
|
||||
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
|
||||
"builder.triplet.loading": "Berechne Fristen \u2026",
|
||||
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
|
||||
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
|
||||
"builder.triplet.side.defendant": "Beklagten-Sicht",
|
||||
"builder.triplet.flags.label": "Optionen:",
|
||||
"builder.triplet.perspective.label": "Perspektive:",
|
||||
"builder.triplet.perspective.none": "keine",
|
||||
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
|
||||
"builder.triplet.perspective.defendant": "Beklagter",
|
||||
"builder.triplet.detailgrad.label": "Detailgrad:",
|
||||
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
|
||||
"builder.triplet.detailgrad.all_options": "Alle Optionen",
|
||||
"builder.triplet.remove": "Entfernen",
|
||||
"builder.triplet.collapse": "Einklappen",
|
||||
"builder.triplet.expand": "Ausklappen",
|
||||
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
|
||||
"builder.event.state.planned": "geplant",
|
||||
"builder.event.state.filed": "eingereicht",
|
||||
"builder.event.state.skipped": "ausgelassen",
|
||||
"builder.event.action.file": "Einreichen",
|
||||
"builder.event.action.skip": "Auslassen",
|
||||
"builder.event.action.reset": "Zur\u00fcck zu geplant",
|
||||
"builder.event.actual_date.prompt": "Datum der Einreichung:",
|
||||
"builder.event.skip_reason.prompt": "Grund (optional):",
|
||||
"builder.event.horizon.label": "+{n} Optionen \u25be",
|
||||
"builder.event.horizon.hide": "Optionen ausblenden",
|
||||
"builder.save.idle": "\u00a0",
|
||||
"builder.save.saving": "Speichert \u2026",
|
||||
"builder.save.saved": "Gespeichert \u2713",
|
||||
"builder.save.error": "Speichern fehlgeschlagen",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
"deadlines.step2.perspective": "Perspektive und Datum",
|
||||
@@ -3418,6 +3481,69 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
|
||||
"nav.procedures": "Procedures & Deadlines",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
|
||||
"builder.header.scenario": "Scenario:",
|
||||
"builder.header.akte": "Matter:",
|
||||
"builder.header.stichtag": "Anchor:",
|
||||
"builder.header.search": "Search:",
|
||||
"builder.akte.none": "— none —",
|
||||
"builder.search.placeholder": "Event, scenario, matter …",
|
||||
"builder.action.rename": "Name it",
|
||||
"builder.action.rename.prompt": "Name for this scenario:",
|
||||
"builder.action.share": "Share",
|
||||
"builder.action.promote": "Create as project",
|
||||
"builder.mode.cold": "Overview",
|
||||
"builder.mode.event": "Event",
|
||||
"builder.mode.akte": "From matter",
|
||||
"builder.panel.title": "My scenarios",
|
||||
"builder.panel.new": "+ New scenario",
|
||||
"builder.panel.empty": "No scenarios yet.",
|
||||
"builder.bucket.active": "Active",
|
||||
"builder.empty.headline": "No scenario open.",
|
||||
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
|
||||
"builder.empty.cta": "Start a new scenario",
|
||||
"builder.empty.recent": "Recent",
|
||||
"builder.picker.placeholder": "— pick a scenario —",
|
||||
"builder.picker.title": "Add proceeding",
|
||||
"builder.picker.close": "Close",
|
||||
"builder.picker.aria": "Pick a proceeding",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Proceeding:",
|
||||
"builder.picker.empty": "No proceedings available.",
|
||||
"builder.picker.future_jurisdiction": "Other forums coming later.",
|
||||
"builder.canvas.add_proceeding": "+ Add proceeding",
|
||||
"builder.triplet.loading": "Calculating deadlines …",
|
||||
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
|
||||
"builder.triplet.side.claimant": "Claimant view",
|
||||
"builder.triplet.side.defendant": "Defendant view",
|
||||
"builder.triplet.flags.label": "Options:",
|
||||
"builder.triplet.perspective.label": "Perspective:",
|
||||
"builder.triplet.perspective.none": "none",
|
||||
"builder.triplet.perspective.claimant": "Claimant",
|
||||
"builder.triplet.perspective.defendant": "Defendant",
|
||||
"builder.triplet.detailgrad.label": "Detail:",
|
||||
"builder.triplet.detailgrad.selected": "Selected",
|
||||
"builder.triplet.detailgrad.all_options": "All options",
|
||||
"builder.triplet.remove": "Remove",
|
||||
"builder.triplet.collapse": "Collapse",
|
||||
"builder.triplet.expand": "Expand",
|
||||
"builder.triplet.no_flags": "(no flags for this proceeding type)",
|
||||
"builder.event.state.planned": "planned",
|
||||
"builder.event.state.filed": "filed",
|
||||
"builder.event.state.skipped": "skipped",
|
||||
"builder.event.action.file": "File",
|
||||
"builder.event.action.skip": "Skip",
|
||||
"builder.event.action.reset": "Reset to planned",
|
||||
"builder.event.actual_date.prompt": "Date of filing:",
|
||||
"builder.event.skip_reason.prompt": "Reason (optional):",
|
||||
"builder.event.horizon.label": "+{n} optional ▾",
|
||||
"builder.event.horizon.hide": "Hide optional",
|
||||
"builder.save.idle": " ",
|
||||
"builder.save.saving": "Saving …",
|
||||
"builder.save.saved": "Saved ✓",
|
||||
"builder.save.error": "Save failed",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
"deadlines.step2.perspective": "Perspective and Date",
|
||||
|
||||
@@ -1,150 +1,15 @@
|
||||
// /tools/procedures client (m/paliad#151,
|
||||
// docs/design-unified-procedural-events-tool-2026-05-27.md).
|
||||
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
|
||||
//
|
||||
// Boot logic + tab switching for the unified procedural-events tool.
|
||||
// Each entry tab mounts its own module; the search box and chip
|
||||
// filters in the top filter strip are wired in U1+ as each slice adds
|
||||
// its dimension-aware behaviour.
|
||||
//
|
||||
// U0 — Skeleton + tab toggling.
|
||||
// U1 — Direkt suchen mounts Mode A.
|
||||
// U2 — Geführt mounts Mode B wizard.
|
||||
// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle.
|
||||
//
|
||||
// Mode A renders its shell into #fristen-overhaul-root (replacing
|
||||
// children); Mode B renders into #fristen-overhaul-mode-host; the
|
||||
// result view (post-commit) writes into #fristen-overhaul-root. To
|
||||
// keep those IDs unique in the DOM, only the active tab's panel ever
|
||||
// hosts the overhaul scaffold — installOverhaulHost() tears down any
|
||||
// existing host and installs a fresh one inside the target panel
|
||||
// before handing off to the per-mode module.
|
||||
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
|
||||
// emitted by procedures.tsx; this file boots the i18n + sidebar
|
||||
// runtime and hands off to builder.ts.
|
||||
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountModeA } from "./fristenrechner-mode-a";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
import { mountWizard } from "./fristenrechner-wizard";
|
||||
import { initVerfahrensablauf } from "./verfahrensablauf";
|
||||
|
||||
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
|
||||
|
||||
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
|
||||
|
||||
function readTabFromUrl(): ProceduresTab {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("mode");
|
||||
if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab;
|
||||
return "proceeding";
|
||||
}
|
||||
|
||||
function writeTabToUrl(tab: ProceduresTab): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (tab === "proceeding") {
|
||||
url.searchParams.delete("mode");
|
||||
} else {
|
||||
url.searchParams.set("mode", tab);
|
||||
}
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
|
||||
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
|
||||
// any existing host first, so the IDs stay unique across the page even
|
||||
// when the user toggles between Direkt-suchen and Geführt — both Mode
|
||||
// A and the wizard read these IDs from document.getElementById which
|
||||
// returns the first match in DOM order, so two parallel hosts would
|
||||
// cross-wire.
|
||||
function installOverhaulHost(panelId: string): HTMLElement | null {
|
||||
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return null;
|
||||
panel.innerHTML = `
|
||||
<div class="procedures-overhaul-host">
|
||||
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
|
||||
<div id="fristen-overhaul-mode-host"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function setActiveTabUI(tab: ProceduresTab): void {
|
||||
for (const t of TABS) {
|
||||
const btn = document.getElementById(`procedures-tab-${t}`);
|
||||
const panel = document.getElementById(`procedures-panel-${t}`);
|
||||
const active = t === tab;
|
||||
if (btn) {
|
||||
btn.classList.toggle("is-active", active);
|
||||
btn.setAttribute("aria-selected", active ? "true" : "false");
|
||||
}
|
||||
if (panel) panel.hidden = !active;
|
||||
}
|
||||
}
|
||||
|
||||
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
|
||||
// selectedType + lastResponse + listeners that re-bind on every
|
||||
// proceeding click). Wire it exactly once per page load; on subsequent
|
||||
// activations the existing DOM + listeners are reused so picked
|
||||
// proceeding / dates / flags persist across tab switches.
|
||||
let verfahrensablaufWired = false;
|
||||
|
||||
async function activateTab(tab: ProceduresTab): Promise<void> {
|
||||
setActiveTabUI(tab);
|
||||
if (tab === "search") {
|
||||
installOverhaulHost("procedures-panel-search");
|
||||
await mountModeA();
|
||||
return;
|
||||
}
|
||||
if (tab === "wizard") {
|
||||
installOverhaulHost("procedures-panel-wizard");
|
||||
await mountWizard();
|
||||
return;
|
||||
}
|
||||
if (tab === "proceeding") {
|
||||
if (!verfahrensablaufWired) {
|
||||
initVerfahrensablauf();
|
||||
verfahrensablaufWired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireTabs(): void {
|
||||
for (const t of TABS) {
|
||||
const btn = document.getElementById(`procedures-tab-${t}`);
|
||||
if (!btn) continue;
|
||||
btn.addEventListener("click", () => {
|
||||
void activateTab(t);
|
||||
writeTabToUrl(t);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// boot dispatches on the URL: a deep link with `?event=` jumps straight
|
||||
// to the linear result view (the Direkt-suchen tab stays as the visible
|
||||
// context). Otherwise the requested tab — defaulting to "proceeding" —
|
||||
// activates per readTabFromUrl().
|
||||
async function boot(): Promise<void> {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const eventRef = params.get("event") || "";
|
||||
|
||||
if (eventRef) {
|
||||
setActiveTabUI("search");
|
||||
installOverhaulHost("procedures-panel-search");
|
||||
await mountResultView({
|
||||
eventRef,
|
||||
triggerDate: params.get("trigger_date") || undefined,
|
||||
party: params.get("party") || undefined,
|
||||
courtId: params.get("court_id") || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await activateTab(readTabFromUrl());
|
||||
}
|
||||
import { mountBuilder } from "./builder";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireTabs();
|
||||
void boot();
|
||||
void mountBuilder();
|
||||
});
|
||||
|
||||
@@ -1042,7 +1042,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return `<div class="${itemClasses}">
|
||||
// data-rule-id on the card root lets the Litigation Builder
|
||||
// overlay per-card state (planned/filed/skipped) + action
|
||||
// affordances onto cards rendered through this shared body
|
||||
// without re-implementing the columns renderer. Empty on
|
||||
// synthetic rows (appeal trigger marker etc.); the Builder
|
||||
// skips state lookup when missing.
|
||||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
|
||||
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
|
||||
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
|
||||
@@ -728,6 +728,67 @@ export type I18nKey =
|
||||
| "bottomnav.add.title"
|
||||
| "bottomnav.badge.deadlines"
|
||||
| "bottomnav.menu"
|
||||
| "builder.action.promote"
|
||||
| "builder.action.rename"
|
||||
| "builder.action.rename.prompt"
|
||||
| "builder.action.share"
|
||||
| "builder.akte.none"
|
||||
| "builder.bucket.active"
|
||||
| "builder.canvas.add_proceeding"
|
||||
| "builder.empty.cta"
|
||||
| "builder.empty.headline"
|
||||
| "builder.empty.hint"
|
||||
| "builder.empty.recent"
|
||||
| "builder.event.action.file"
|
||||
| "builder.event.action.reset"
|
||||
| "builder.event.action.skip"
|
||||
| "builder.event.actual_date.prompt"
|
||||
| "builder.event.horizon.hide"
|
||||
| "builder.event.horizon.label"
|
||||
| "builder.event.skip_reason.prompt"
|
||||
| "builder.event.state.filed"
|
||||
| "builder.event.state.planned"
|
||||
| "builder.event.state.skipped"
|
||||
| "builder.header.akte"
|
||||
| "builder.header.scenario"
|
||||
| "builder.header.search"
|
||||
| "builder.header.stichtag"
|
||||
| "builder.mode.akte"
|
||||
| "builder.mode.cold"
|
||||
| "builder.mode.event"
|
||||
| "builder.panel.empty"
|
||||
| "builder.panel.new"
|
||||
| "builder.panel.title"
|
||||
| "builder.picker.aria"
|
||||
| "builder.picker.axis.forum"
|
||||
| "builder.picker.axis.proc"
|
||||
| "builder.picker.close"
|
||||
| "builder.picker.empty"
|
||||
| "builder.picker.future_jurisdiction"
|
||||
| "builder.picker.placeholder"
|
||||
| "builder.picker.title"
|
||||
| "builder.save.error"
|
||||
| "builder.save.idle"
|
||||
| "builder.save.saved"
|
||||
| "builder.save.saving"
|
||||
| "builder.search.placeholder"
|
||||
| "builder.subtitle"
|
||||
| "builder.triplet.collapse"
|
||||
| "builder.triplet.detailgrad.all_options"
|
||||
| "builder.triplet.detailgrad.label"
|
||||
| "builder.triplet.detailgrad.selected"
|
||||
| "builder.triplet.expand"
|
||||
| "builder.triplet.flags.label"
|
||||
| "builder.triplet.loading"
|
||||
| "builder.triplet.no_flags"
|
||||
| "builder.triplet.perspective.claimant"
|
||||
| "builder.triplet.perspective.defendant"
|
||||
| "builder.triplet.perspective.label"
|
||||
| "builder.triplet.perspective.none"
|
||||
| "builder.triplet.remove"
|
||||
| "builder.triplet.side.claimant"
|
||||
| "builder.triplet.side.defendant"
|
||||
| "builder.triplet.unknown_proceeding"
|
||||
| "cal.day.back_to_month"
|
||||
| "cal.day.fri"
|
||||
| "cal.day.mon"
|
||||
|
||||
@@ -4,23 +4,19 @@ import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody";
|
||||
|
||||
// U0 — Skeleton for the unified procedural-events tool
|
||||
// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md).
|
||||
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
|
||||
//
|
||||
// Folds /tools/fristenrechner (Mode A + Mode B + result) and
|
||||
// /tools/verfahrensablauf into a single page at /tools/procedures. Each
|
||||
// later slice fills one of the four entry tabs:
|
||||
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
|
||||
// builder shell. Server-rendered chrome is minimal — the page-header
|
||||
// scenario picker, side panel, and canvas are all hydrated by
|
||||
// `builder.ts` at boot. The builder loads scenarios from
|
||||
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
|
||||
// per-proceeding triplets with the existing verfahrensablauf-core calc.
|
||||
//
|
||||
// U1 — Direkt suchen (Mode A search)
|
||||
// U2 — Geführt (Mode B wizard)
|
||||
// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter)
|
||||
// U4 — Hard-cut 301 (drop legacy pages, redirect URLs)
|
||||
//
|
||||
// This file ships only the page chrome — sidebar, header, filter strip
|
||||
// with search box, four entry-mode tabs, and the host containers the
|
||||
// later slices mount their UI into. No data wiring.
|
||||
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
|
||||
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
|
||||
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
|
||||
|
||||
export function renderProcedures(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
@@ -36,151 +32,142 @@ export function renderProcedures(): string {
|
||||
<title data-i18n="procedures.title">Verfahren & Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-procedures">
|
||||
<body className="has-sidebar page-procedures page-builder">
|
||||
<Sidebar currentPath="/tools/procedures" />
|
||||
<BottomNav currentPath="/tools/procedures" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<section className="tool-page builder-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="procedures.heading">Verfahren & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="procedures.subtitle">
|
||||
Verfahrensablauf, Fristenrechner und gerührte Suche in einem Tool.
|
||||
<p className="tool-subtitle" data-i18n="builder.subtitle">
|
||||
Litigation Builder — Szenarien bauen, Verfahren stapeln, Fristen behalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shared filter strip — search box + four chip groups
|
||||
(forum / proceeding / event_kind / party). Lives at the
|
||||
top of the page so every entry tab and output mode reads
|
||||
the same active filter set (design §4 + m's Q3
|
||||
divergence: search composes with chip filters). U0
|
||||
ships the markup only; chip hydration + search wiring
|
||||
arrive with U1-U3. */}
|
||||
<section className="procedures-filter-strip" aria-label="Filter">
|
||||
<div className="procedures-filter-search">
|
||||
<svg className="procedures-filter-search-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="procedures-search-input"
|
||||
className="procedures-filter-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="procedures.filter.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
/>
|
||||
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
|
||||
· Akte picker · Stichtag input. B1 wires the scenario picker
|
||||
+ name action + Stichtag + save indicator. Akte / share /
|
||||
promote land at B4 / B5; the affordances render disabled in
|
||||
B1 so the layout is stable across slices. */}
|
||||
<section className="builder-pageheader" aria-label="Builder-Steuerung">
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
|
||||
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario wählen"></select>
|
||||
</label>
|
||||
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
|
||||
<span data-i18n="builder.save.idle"> </span>
|
||||
</span>
|
||||
<span className="builder-pageheader-spacer"></span>
|
||||
<button type="button" id="builder-rename-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.rename">Benennen</button>
|
||||
<button type="button" id="builder-share-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
title="In B5 verfügbar"
|
||||
data-i18n="builder.action.share">Teilen</button>
|
||||
<button type="button" id="builder-promote-btn"
|
||||
className="builder-action-btn builder-action-btn--primary"
|
||||
disabled
|
||||
title="In B5 verfügbar"
|
||||
data-i18n="builder.action.promote">Als Projekt anlegen</button>
|
||||
</div>
|
||||
<div className="procedures-filter-chips" id="procedures-filter-chips">
|
||||
<div className="procedures-filter-chip-row" data-axis="forum">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="proc">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="kind">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="party">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
|
||||
</div>
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
|
||||
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte wählen">
|
||||
<option value="" data-i18n="builder.akte.none">— ohne —</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
|
||||
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
|
||||
defaultValue={today} aria-label="Stichtag" />
|
||||
</label>
|
||||
<label className="builder-pageheader-field builder-pageheader-field--grow">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
|
||||
<input type="search" id="builder-search-input" className="builder-search-input"
|
||||
data-i18n-placeholder="builder.search.placeholder"
|
||||
placeholder="Ereignis, Szenario, Akte …"
|
||||
autocomplete="off" spellcheck="false" disabled
|
||||
title="Universelle Suche kommt in B3" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Entry-mode tab strip — all four tabs visible from boot
|
||||
(m's Q3 divergence). The active tab is URL-driven
|
||||
(?mode=proceeding|search|wizard|akte); cold open lands
|
||||
on "proceeding" per design §11.5.Q3. */}
|
||||
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
|
||||
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
|
||||
event-triggered + akte ship at B3 / B4 and are disabled
|
||||
here so the layout stays stable across slices. */}
|
||||
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
|
||||
<button type="button"
|
||||
className="procedures-tab is-active"
|
||||
className="builder-mode is-active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
data-tab="proceeding"
|
||||
id="procedures-tab-proceeding">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">📚</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren wählen</span>
|
||||
data-mode="cold"
|
||||
id="builder-mode-cold">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.cold">Übersicht</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="search"
|
||||
id="procedures-tab-search">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">⚡</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
|
||||
data-mode="event"
|
||||
id="builder-mode-event"
|
||||
disabled
|
||||
title="In B3 verfügbar">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="wizard"
|
||||
id="procedures-tab-wizard">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">🧭</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Geführt</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="akte"
|
||||
id="procedures-tab-akte">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">📁</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
|
||||
data-mode="akte"
|
||||
id="builder-mode-akte"
|
||||
disabled
|
||||
title="In B4 verfügbar">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Per-tab content hosts. Only one is visible at a time —
|
||||
procedures.ts toggles `hidden` on the inactive ones.
|
||||
Each later slice fills the corresponding host. */}
|
||||
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-proceeding">
|
||||
{/* Verfahrensablauf wizard body — shared TSX component
|
||||
used by /tools/verfahrensablauf (legacy) and the
|
||||
unified /tools/procedures page. procedures.ts calls
|
||||
initVerfahrensablauf() on the first activation of
|
||||
this tab, which wires the .proceeding-btn clicks,
|
||||
timeline-container, detail-mode toggle, etc. against
|
||||
the markup. The legacy page's auto-boot is guarded
|
||||
against the procedures-only #procedures-panel-proceeding
|
||||
element so it doesn't fire twice. */}
|
||||
<VerfahrensablaufBody todayIso={today} />
|
||||
</section>
|
||||
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
|
||||
<div className="builder-body">
|
||||
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
|
||||
<header className="builder-sidepanel-header">
|
||||
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
|
||||
<button type="button" id="builder-new-scenario-btn"
|
||||
className="builder-sidepanel-newbtn"
|
||||
data-i18n="builder.panel.new">+ Neues Szenario</button>
|
||||
</header>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="active">
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
|
||||
</div>
|
||||
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
|
||||
</aside>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-search" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-search" hidden></section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-wizard" hidden></section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-akte" hidden>
|
||||
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
|
||||
Akten-Einstieg folgt in einem späteren Slice.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
|
||||
tree here; U0 leaves it empty + hidden so the
|
||||
tab placeholders are the only thing visible. */}
|
||||
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
|
||||
aria-label="Tree output" hidden></section>
|
||||
|
||||
{/* Linear-drawer host. Inline drawer expanding beneath a
|
||||
tree card (design §8 — desktop) AND the standalone
|
||||
linear follow-up view that Mode A / Mode B land on
|
||||
after locking a trigger event (design §3.2). U1
|
||||
switches it on. */}
|
||||
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
|
||||
aria-label="Linear output" hidden></section>
|
||||
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
|
||||
<div id="builder-canvas" className="builder-canvas">
|
||||
{/* Cold-open placeholder — replaced by triplet stack once a
|
||||
scenario is loaded. */}
|
||||
<div className="builder-empty" id="builder-empty">
|
||||
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
|
||||
Noch kein Szenario geöffnet.
|
||||
</p>
|
||||
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
|
||||
Starte ein neues Szenario, wähle aus deiner Liste oder übernimm eine Akte (B4).
|
||||
</p>
|
||||
<button type="button" id="builder-cta-new" className="builder-cta-new"
|
||||
data-i18n="builder.empty.cta">
|
||||
Neues Szenario starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -19811,3 +19811,694 @@ a.fristen-overhaul-rule-source {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Litigation Builder (m/paliad#153 B1+B2) --- */
|
||||
|
||||
.builder-page .tool-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-pageheader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.builder-pageheader-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.builder-pageheader-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.builder-pageheader-field--grow {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
.builder-pageheader-label {
|
||||
color: var(--color-text-subtle);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.builder-pageheader-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.builder-scenario-picker,
|
||||
.builder-akte-picker,
|
||||
.builder-stichtag-input,
|
||||
.builder-search-input {
|
||||
font: inherit;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.3rem;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.builder-search-input {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.builder-scenario-picker:disabled,
|
||||
.builder-akte-picker:disabled,
|
||||
.builder-search-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.builder-save-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
min-width: 8rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.builder-save-status[data-state="saving"] { color: var(--color-text-subtle); }
|
||||
.builder-save-status[data-state="saved"] { color: var(--color-accent-strong-fg); }
|
||||
.builder-save-status[data-state="error"] { color: var(--status-red-fg, #c5503a); }
|
||||
|
||||
.builder-action-btn {
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.builder-action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.builder-action-btn--primary {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.builder-action-btn--primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.builder-action-btn--secondary:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.builder-modebar {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
padding: 0.15rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-mode {
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-mode.is-active {
|
||||
background: var(--color-segment-active-bg);
|
||||
color: var(--color-segment-active-fg);
|
||||
}
|
||||
|
||||
.builder-mode:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.builder-body {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.builder-sidepanel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.builder-sidepanel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.builder-sidepanel-title {
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.builder-sidepanel-newbtn {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
background: var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.builder-sidepanel-newbtn:hover {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.builder-bucket-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-subtle);
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
}
|
||||
|
||||
.builder-scenario-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.builder-scenario-list-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.builder-scenario-list-item {
|
||||
cursor: pointer;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.builder-scenario-list-item.is-active {
|
||||
background: var(--color-accent-soft-bg);
|
||||
}
|
||||
|
||||
.builder-scenario-list-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-scenario-list-item:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.builder-canvas-wrap {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.builder-canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-empty {
|
||||
background: var(--color-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.builder-empty-headline {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.builder-empty-hint {
|
||||
color: var(--color-text-subtle);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.builder-cta-new {
|
||||
font: inherit;
|
||||
background: var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.builder-cta-new:hover {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.builder-recent {
|
||||
margin-top: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.builder-recent-title {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-subtle);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.builder-recent-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.builder-recent-item {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-recent-item:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.builder-triplet-host {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.builder-triplet-host[data-child="true"] {
|
||||
margin-left: 1.5rem;
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.builder-triplet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: var(--color-surface-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.builder-triplet-jurisdiction {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.builder-triplet-code {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-name {
|
||||
font-weight: 500;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.builder-triplet-side {
|
||||
background: var(--color-accent-soft-bg);
|
||||
color: var(--color-accent-soft-fg);
|
||||
border: 1px solid var(--color-accent-soft-border);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-triplet-flags {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-subtle);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.builder-triplet-flag-chip {
|
||||
background: var(--color-accent-soft-bg);
|
||||
border: 1px solid var(--color-accent-soft-border);
|
||||
color: var(--color-accent-soft-fg);
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.builder-triplet-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.builder-triplet-controls-label {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-perspective,
|
||||
.builder-triplet-detailgrad {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-2);
|
||||
padding: 0.1rem;
|
||||
}
|
||||
|
||||
.builder-triplet-perspective button,
|
||||
.builder-triplet-detailgrad button {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-perspective button.is-active,
|
||||
.builder-triplet-detailgrad button.is-active {
|
||||
background: var(--color-segment-active-bg);
|
||||
color: var(--color-segment-active-fg);
|
||||
}
|
||||
|
||||
.builder-triplet-remove {
|
||||
margin-left: auto;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-remove:hover {
|
||||
border-color: var(--status-red-border, #d08070);
|
||||
color: var(--status-red-fg, #c5503a);
|
||||
}
|
||||
|
||||
.builder-triplet-flagstrip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.builder-triplet-flag-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-triplet-flag-empty {
|
||||
font-style: italic;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-body {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.builder-triplet-loading,
|
||||
.builder-triplet-error {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.builder-add-proceeding-btn {
|
||||
font: inherit;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.7rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-add-proceeding-btn:hover {
|
||||
background: var(--color-accent-soft-bg);
|
||||
border-color: var(--color-accent-soft-border);
|
||||
color: var(--color-accent-soft-fg);
|
||||
}
|
||||
|
||||
/* Add-proceeding popover */
|
||||
|
||||
.builder-picker-popover {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.builder-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.builder-picker-title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.builder-picker-close {
|
||||
font: inherit;
|
||||
font-size: 1.2rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-picker-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.builder-picker-axis-label {
|
||||
flex: 0 0 6rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.builder-picker-chips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.builder-picker-chips--wrap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.builder-picker-chip {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.builder-picker-chip.is-active {
|
||||
background: var(--color-segment-active-bg);
|
||||
color: var(--color-segment-active-fg);
|
||||
border-color: var(--color-segment-active-border);
|
||||
}
|
||||
|
||||
.builder-picker-chip:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.builder-picker-chip:hover:not(:disabled) {
|
||||
background: var(--color-accent-soft-bg);
|
||||
}
|
||||
|
||||
.builder-picker-chip--proc {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.builder-picker-chip-code {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-picker-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Event-card state overrides (B2). The 3-state machine sits on top of
|
||||
the existing .fr-col-item card. The Builder render passes editable=false
|
||||
to renderColumnsBody and overlays its own per-card state attributes
|
||||
on top of the card root via data-builder-state. */
|
||||
|
||||
.fr-col-item[data-builder-state="filed"] {
|
||||
background: var(--color-accent-soft-bg);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.fr-col-item[data-builder-state="filed"] .timeline-name::before {
|
||||
content: "✓ ";
|
||||
color: var(--color-accent-soft-fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fr-col-item[data-builder-state="skipped"] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.fr-col-item[data-builder-state="skipped"] .timeline-name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.builder-event-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.builder-event-action {
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.builder-event-action:hover {
|
||||
background: var(--color-accent-soft-bg);
|
||||
}
|
||||
|
||||
.builder-event-action[data-action="file"] {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.builder-event-action[data-action="file"]:hover {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.builder-event-horizon-chip {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-accent-soft-fg);
|
||||
background: var(--color-accent-soft-bg);
|
||||
border: 1px solid var(--color-accent-soft-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
margin-top: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-event-horizon-chip:hover {
|
||||
background: var(--color-accent-strong-bg);
|
||||
}
|
||||
|
||||
/* Responsive: collapse side panel into stacked block on narrow viewports. */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.builder-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.builder-sidepanel {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.builder-pageheader-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.builder-pageheader-field {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.builder-scenario-picker,
|
||||
.builder-akte-picker,
|
||||
.builder-search-input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +537,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
|
||||
// m/paliad#153 B2 — read-only passthrough so the builder can render
|
||||
// per-triplet flag toggles without a per-project round-trip.
|
||||
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
|
||||
// Dev-only test route — gated to PaliadinOwnerEmail (m).
|
||||
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
|
||||
|
||||
|
||||
@@ -388,6 +388,44 @@ func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario flag catalog passthrough (m/paliad#153 B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
|
||||
//
|
||||
// Returns every row of paliad.scenario_flag_catalog so the Litigation
|
||||
// Builder can render per-triplet flag toggles without a per-project
|
||||
// round-trip. The catalog itself is global (no jurisdiction or
|
||||
// proceeding scope baked into the table); which flags actually apply
|
||||
// to a given proceeding type is decided by the calc engine via
|
||||
// condition_expr at calculation time. The client renders every catalog
|
||||
// flag and lets the user toggle them — flags with no effect on the
|
||||
// active proceeding's rules simply have no condition_expr referencing
|
||||
// them, so toggling is a no-op.
|
||||
//
|
||||
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
|
||||
// visibility checks aren't needed because the catalog is global.
|
||||
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.scenarioFlags == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "Flag-Katalog konnte nicht geladen werden",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-only test route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
|
||||
@@ -145,7 +145,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
AND pe.event_kind = $%d
|
||||
)`, opts.EventKind)
|
||||
}
|
||||
query := `SELECT code, name, name_en, jurisdiction
|
||||
query := `SELECT id, code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY sort_order`
|
||||
@@ -160,7 +160,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
for rows.Next() {
|
||||
var t lp.FristenrechnerType
|
||||
var juris sql.NullString
|
||||
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if juris.Valid {
|
||||
|
||||
@@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides(
|
||||
}
|
||||
var dRows []drow
|
||||
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
|
||||
q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
FROM paliad.deadlines d
|
||||
WHERE ` + scopeFilter
|
||||
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {
|
||||
|
||||
@@ -204,7 +204,15 @@ func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, sc
|
||||
return nil, ErrScenarioBuilderNotVisible
|
||||
}
|
||||
|
||||
deep := &BuilderScenarioDeep{BuilderScenario: *sc}
|
||||
deep := &BuilderScenarioDeep{
|
||||
BuilderScenario: *sc,
|
||||
// Initialise to empty so the JSON response always carries arrays,
|
||||
// not null — the builder frontend's renderCanvas calls .filter on
|
||||
// proceedings/events unconditionally once state.active is set.
|
||||
Proceedings: []BuilderProceeding{},
|
||||
Events: []BuilderEvent{},
|
||||
Shares: []BuilderShare{},
|
||||
}
|
||||
|
||||
if err := s.db.SelectContext(ctx, &deep.Proceedings, `
|
||||
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
|
||||
@@ -405,6 +405,23 @@ func parseInlineSpans(text string) []inlineSpan {
|
||||
i := 0
|
||||
n := len(text)
|
||||
for i < n {
|
||||
// Preserve {{...}} placeholders verbatim. Underscores and
|
||||
// other Markdown-significant chars inside a placeholder key
|
||||
// (e.g. {{project.case_number}}) must not be interpreted as
|
||||
// bold/italic delimiters — otherwise the key gets stripped of
|
||||
// its underscores and the v1 placeholder pass looks up the
|
||||
// wrong key, surfacing [KEIN WERT: project.casenumber] in the
|
||||
// preview.
|
||||
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
|
||||
rel := strings.Index(text[i+2:], "}}")
|
||||
if rel >= 0 {
|
||||
end := i + 2 + rel + 2
|
||||
cur.WriteString(text[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
// Unmatched {{ — fall through to plain character handling.
|
||||
}
|
||||
// Bold delimiters first (longer match wins over italic).
|
||||
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
|
||||
flush()
|
||||
|
||||
@@ -86,6 +86,90 @@ func TestRenderMarkdownToOOXML_PlaceholdersPassThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_PlaceholderUnderscoresPreserved(t *testing.T) {
|
||||
// Regression: a placeholder key containing underscores (project.case_number,
|
||||
// user.display_name, project.patent_number_upc) used to get its underscores
|
||||
// consumed by the italic/bold inline scanner — the OOXML stored
|
||||
// {{project.casenumber}} and the preview surfaced
|
||||
// [KEIN WERT: project.casenumber] instead of the real value.
|
||||
cases := []string{
|
||||
"{{project.case_number}}",
|
||||
"{{user.display_name}}",
|
||||
"{{project.patent_number_upc}}",
|
||||
"prefix {{project.case_number}} suffix",
|
||||
"two: {{a.b_c}} and {{d.e_f}}",
|
||||
"mixed: _italic_ then {{project.case_number}} then __bold__",
|
||||
}
|
||||
for _, in := range cases {
|
||||
out := RenderMarkdownToOOXML(in, "Normal")
|
||||
// Every placeholder substring in the input must appear verbatim
|
||||
// in the output (XML escaping is irrelevant for {} and _).
|
||||
for _, ph := range extractPlaceholders(in) {
|
||||
if !strings.Contains(out, ph) {
|
||||
t.Errorf("input %q: placeholder %q lost; got %q", in, ph, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
|
||||
// Direct guard on the inline scanner. {{project.case_number}} must
|
||||
// emit as a single non-italic span containing the full placeholder.
|
||||
spans := parseInlineSpans("{{project.case_number}}")
|
||||
if len(spans) != 1 {
|
||||
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
|
||||
}
|
||||
if spans[0].Italic || spans[0].Bold {
|
||||
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
|
||||
}
|
||||
if spans[0].Text != "{{project.case_number}}" {
|
||||
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_ItalicAroundPlaceholder(t *testing.T) {
|
||||
// Italic delimiters outside a placeholder still work; the placeholder
|
||||
// itself stays literal even when it sits between italics.
|
||||
spans := parseInlineSpans("_before_ {{x.y_z}} _after_")
|
||||
var saw struct {
|
||||
italicBefore bool
|
||||
placeholder bool
|
||||
italicAfter bool
|
||||
}
|
||||
for _, s := range spans {
|
||||
if s.Italic && s.Text == "before" {
|
||||
saw.italicBefore = true
|
||||
}
|
||||
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
|
||||
saw.placeholder = true
|
||||
}
|
||||
if s.Italic && s.Text == "after" {
|
||||
saw.italicAfter = true
|
||||
}
|
||||
}
|
||||
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
|
||||
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// extractPlaceholders pulls every {{...}} occurrence out of a Markdown
|
||||
// source. Tiny helper, only used by the regression test above.
|
||||
func extractPlaceholders(s string) []string {
|
||||
var out []string
|
||||
for {
|
||||
start := strings.Index(s, "{{")
|
||||
if start < 0 {
|
||||
return out
|
||||
}
|
||||
end := strings.Index(s[start+2:], "}}")
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
out = append(out, s[start:start+2+end+2])
|
||||
s = s[start+2+end+2:]
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_XMLEscape(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("a & b < c > d", "")
|
||||
if strings.Contains(out, " & ") {
|
||||
|
||||
@@ -205,7 +205,11 @@ func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T)
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
// IncludeOptional=true because translation_request carries
|
||||
// priority='optional'; the test exercises the before-child-of-
|
||||
// court-set-parent flow, which is orthogonal to the optional-rule
|
||||
// suppression added in t-paliad-342.
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
@@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
// User pins the oral hearing to 2026-10-15.
|
||||
// User pins the oral hearing to 2026-10-15. IncludeOptional=true
|
||||
// because translation_request is priority='optional' (t-paliad-342).
|
||||
opts := CalcOptions{
|
||||
IncludeOptional: true,
|
||||
AnchorOverrides: map[string]string{
|
||||
oralCode: "2026-10-15",
|
||||
},
|
||||
|
||||
@@ -80,6 +80,21 @@ func Calculate(
|
||||
overrideDates[code] = od
|
||||
}
|
||||
|
||||
// Trigger-event anchors keyed by paliad.trigger_events.code
|
||||
// (t-paliad-342). Parsed up-front so malformed dates error before
|
||||
// the rule walk. When a rule has trigger_event_id set, the engine
|
||||
// looks up triggerAnchorByCode[trigger_event.code] for the
|
||||
// semantic anchor instead of falling back to the proceeding's
|
||||
// trigger date.
|
||||
triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors))
|
||||
for code, dateStr := range opts.TriggerEventAnchors {
|
||||
td, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err)
|
||||
}
|
||||
triggerAnchorByCode[code] = td
|
||||
}
|
||||
|
||||
// Look up proceeding type metadata.
|
||||
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
||||
if err != nil {
|
||||
@@ -213,6 +228,7 @@ func Calculate(
|
||||
perCardAppellant := opts.PerCardAppellant
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
hiddenCount := 0
|
||||
rulesAwaitingAnchor := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range walkRules {
|
||||
@@ -227,6 +243,17 @@ func Calculate(
|
||||
continue
|
||||
}
|
||||
|
||||
// Optional-rule suppression (t-paliad-342 / youpcorg#2570).
|
||||
// Rules tagged priority='optional' don't auto-fire in the
|
||||
// default timeline; the caller opts in via
|
||||
// CalcOptions.IncludeOptional. Cascade through skippedIDs so
|
||||
// children chaining off the suppressed rule also drop — they
|
||||
// can't compute a date against a missing parent.
|
||||
if r.Priority == "optional" && !opts.IncludeOptional {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
// SkipRules suppression (t-paliad-265).
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||
@@ -327,15 +354,43 @@ func Calculate(
|
||||
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
||||
// trigger_event_id, that catalog event is the actual semantic
|
||||
// anchor — not the parent_id node, which is only the calc-time
|
||||
// arithmetic anchor. Only the user-facing wire fields shift;
|
||||
// parentRule (and the parent_id chain feeding parentIsCourtSet
|
||||
// and the calc-time arithmetic below) stays anchored on the
|
||||
// rule tree.
|
||||
// arithmetic anchor. Only the user-facing wire fields shift
|
||||
// here; the calc-time anchor logic for trigger_event_id rules
|
||||
// lives just below.
|
||||
var triggerEventAnchor time.Time
|
||||
var hasTriggerEventAnchor bool
|
||||
if r.TriggerEventID != nil {
|
||||
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||
d.ParentRuleCode = te.Code
|
||||
d.ParentRuleName = te.NameDE
|
||||
d.ParentRuleNameEN = te.Name
|
||||
if td, ok := triggerAnchorByCode[te.Code]; ok {
|
||||
triggerEventAnchor = td
|
||||
hasTriggerEventAnchor = true
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event semantic-anchor suppression (t-paliad-342 /
|
||||
// youpcorg#2568). When a rule has an explicit trigger_event_id
|
||||
// but the caller hasn't supplied a date for that event via
|
||||
// CalcOptions.TriggerEventAnchors, the engine refuses to
|
||||
// fabricate a date off the proceeding's trigger date — the
|
||||
// rule's semantic anchor is the event itself, not the SoC.
|
||||
// Render IsConditional with empty dates and propagate via
|
||||
// courtSet so descendants chaining off this rule also surface
|
||||
// as conditional rather than projecting fictional dates.
|
||||
if !hasTriggerEventAnchor {
|
||||
d.IsConditional = true
|
||||
d.IsCourtSet = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
rulesAwaitingAnchor++
|
||||
if r.SubmissionCode != nil {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +434,20 @@ func Calculate(
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event anchor wins over the bucket logic below: a
|
||||
// zero-duration rule with trigger_event_id is "occurs on the
|
||||
// trigger event's date". Anchor missing was already caught
|
||||
// above (suppression branch).
|
||||
if hasTriggerEventAnchor {
|
||||
d.DueDate = triggerEventAnchor.Format("2006-01-02")
|
||||
d.OriginalDate = d.DueDate
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = triggerEventAnchor
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
if r.ParentID == nil && !r.IsCourtSet {
|
||||
// Bucket 1: timeline anchor.
|
||||
d.IsRootEvent = true
|
||||
@@ -457,11 +526,19 @@ func Calculate(
|
||||
continue
|
||||
}
|
||||
|
||||
// Anchor: prefer alt-anchor (e.g. priority_date for
|
||||
// epa.grant.exa publish) when supplied, then parent's computed
|
||||
// date (or user override), then trigger date.
|
||||
// Anchor priority:
|
||||
// 1. trigger_event_id semantic anchor (t-paliad-342) — when
|
||||
// the rule has trigger_event_id and the caller supplied a
|
||||
// date in TriggerEventAnchors, that date wins over the
|
||||
// parent chain AND the priority_date alt-anchor. The
|
||||
// missing-anchor case was already short-circuited above.
|
||||
// 2. priority_date alt-anchor (epa.grant.exa publish).
|
||||
// 3. parent's computed date (or user override).
|
||||
// 4. proceeding trigger date (default fallback).
|
||||
baseDate := triggerDate
|
||||
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
if hasTriggerEventAnchor {
|
||||
baseDate = triggerEventAnchor
|
||||
} else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
baseDate = *priorityDate
|
||||
} else if r.ParentID != nil {
|
||||
for _, prev := range rules {
|
||||
@@ -635,12 +712,13 @@ func Calculate(
|
||||
}
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
RulesAwaitingAnchor: rulesAwaitingAnchor,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding`.
|
||||
|
||||
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Tests for t-paliad-342 / youpcorg#2568 + #2570.
|
||||
//
|
||||
// Two paired engine semantics:
|
||||
//
|
||||
// - Optional rules (priority='optional') don't auto-fire in the
|
||||
// default timeline; the caller opts in via
|
||||
// CalcOptions.IncludeOptional.
|
||||
// - Rules with explicit trigger_event_id anchor on the trigger
|
||||
// event's date (CalcOptions.TriggerEventAnchors keyed by
|
||||
// trigger_events.code). Missing anchor = render conditional
|
||||
// instead of fabricating a date off the proceeding's trigger date.
|
||||
|
||||
// stubCatalogWithTriggers extends stubCatalog with a trigger-events
|
||||
// map so the engine can resolve TriggerEventID → code for the
|
||||
// trigger-anchor branch. stubCatalog from before_court_set_anchor_test.go
|
||||
// returns an empty map, which suffices for tests that don't exercise
|
||||
// trigger_event_id; here we need real entries.
|
||||
type stubCatalogWithTriggers struct {
|
||||
stubCatalog
|
||||
triggerEvents map[int64]TriggerEvent
|
||||
}
|
||||
|
||||
func (s *stubCatalogWithTriggers) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]TriggerEvent, error) {
|
||||
out := make(map[int64]TriggerEvent, len(ids))
|
||||
for _, id := range ids {
|
||||
if te, ok := s.triggerEvents[id]; ok {
|
||||
out[id] = te
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mandatory_socRule builds a minimal SoC root rule + the proceeding
|
||||
// type wrapper that nearly every test below needs.
|
||||
func mandatorySocFixture(t *testing.T) (ProceedingType, Rule, uuid.UUID) {
|
||||
t.Helper()
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "Verletzungsverfahren",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
socID, _ := uuid.NewRandom()
|
||||
socCode := "upc.inf.cfi.soc"
|
||||
procIDPtr := &procID
|
||||
str := func(s string) *string { return &s }
|
||||
soc := Rule{
|
||||
ID: socID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &socCode,
|
||||
Name: "Klageerhebung",
|
||||
NameEN: "SoC",
|
||||
PrimaryParty: str("claimant"),
|
||||
DurationValue: 0,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
}
|
||||
return pt, soc, socID
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventIDWithoutAnchor_Suppressed verifies the
|
||||
// RoP.109.5 bug from youpcorg#2568: a rule with trigger_event_id and
|
||||
// no parent_id must NOT fall back to the proceeding's trigger date.
|
||||
// The buggy behaviour rendered the rule with a fabricated date 2 weeks
|
||||
// before the user's SoC date.
|
||||
func TestCalculate_TriggerEventIDWithoutAnchor_Suppressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
ruleID, _ := uuid.NewRandom()
|
||||
ruleCode := "upc.inf.cfi.rop_109_5"
|
||||
rop109_5Trigger := int64(49)
|
||||
rop109_5 := Rule{
|
||||
ID: ruleID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &ruleCode,
|
||||
Name: "Vorbereitung mündliche Verhandlung",
|
||||
NameEN: "Oral hearing preparation",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 100,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
TriggerEventID: &rop109_5Trigger,
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
|
||||
triggerEvents: map[int64]TriggerEvent{
|
||||
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
|
||||
},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
rop, ok := byCode[ruleCode]
|
||||
if !ok {
|
||||
t.Fatalf("RoP.109.5 missing from timeline; want present with conditional-no-date")
|
||||
}
|
||||
if rop.DueDate != "" {
|
||||
t.Errorf("RoP.109.5: DueDate=%q, want empty (no oral_hearing anchor supplied)", rop.DueDate)
|
||||
}
|
||||
if !rop.IsConditional {
|
||||
t.Errorf("RoP.109.5: IsConditional=%v, want true", rop.IsConditional)
|
||||
}
|
||||
if timeline.RulesAwaitingAnchor != 1 {
|
||||
t.Errorf("RulesAwaitingAnchor=%d, want 1", timeline.RulesAwaitingAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventIDWithAnchor_Renders verifies the
|
||||
// caller-supplied trigger-event anchor produces correct arithmetic.
|
||||
// 2 weeks before 2026-10-15 = 2026-10-01.
|
||||
func TestCalculate_TriggerEventIDWithAnchor_Renders(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
ruleID, _ := uuid.NewRandom()
|
||||
ruleCode := "upc.inf.cfi.rop_109_5"
|
||||
rop109_5Trigger := int64(49)
|
||||
rop109_5 := Rule{
|
||||
ID: ruleID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &ruleCode,
|
||||
Name: "Vorbereitung mündliche Verhandlung",
|
||||
NameEN: "Oral hearing preparation",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 100,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
TriggerEventID: &rop109_5Trigger,
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
|
||||
triggerEvents: map[int64]TriggerEvent{
|
||||
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
|
||||
},
|
||||
}
|
||||
|
||||
opts := CalcOptions{
|
||||
TriggerEventAnchors: map[string]string{
|
||||
"oral_hearing": "2026-10-15",
|
||||
},
|
||||
}
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
rop := byCode[ruleCode]
|
||||
if rop.DueDate != "2026-10-01" {
|
||||
t.Errorf("RoP.109.5: DueDate=%q, want 2026-10-01 (2 weeks before oral_hearing 2026-10-15)", rop.DueDate)
|
||||
}
|
||||
if rop.IsConditional {
|
||||
t.Errorf("RoP.109.5: IsConditional=%v, want false (anchor supplied)", rop.IsConditional)
|
||||
}
|
||||
if timeline.RulesAwaitingAnchor != 0 {
|
||||
t.Errorf("RulesAwaitingAnchor=%d, want 0", timeline.RulesAwaitingAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_MandatoryRule_RendersByDefault is the control case for
|
||||
// the optional-suppression fix: mandatory rules render with their
|
||||
// computed dates by default. Prevents regression where the optional
|
||||
// filter accidentally drops mandatory rules too.
|
||||
func TestCalculate_MandatoryRule_RendersByDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
replyID, _ := uuid.NewRandom()
|
||||
replyCode := "upc.inf.cfi.reply"
|
||||
reply := Rule{
|
||||
ID: replyID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &replyCode,
|
||||
Name: "Klageerwiderung",
|
||||
NameEN: "Reply to SoC",
|
||||
PrimaryParty: str("defendant"),
|
||||
DurationValue: 3,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 10,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, reply}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
got, ok := byCode[replyCode]
|
||||
if !ok {
|
||||
t.Fatalf("mandatory reply rule missing from default timeline")
|
||||
}
|
||||
if got.DueDate != "2026-08-26" {
|
||||
t.Errorf("reply: DueDate=%q, want 2026-08-26 (3 months after SoC)", got.DueDate)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_OptionalRule_SuppressedByDefault pins the
|
||||
// youpcorg#2570 fix: priority='optional' rules don't render in the
|
||||
// default timeline. The caller opts in via IncludeOptional=true.
|
||||
func TestCalculate_OptionalRule_SuppressedByDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
confID, _ := uuid.NewRandom()
|
||||
confCode := "upc.inf.cfi.rop_262_2"
|
||||
conf := Rule{
|
||||
ID: confID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &confCode,
|
||||
Name: "Erwiderung Vertraulichkeitsantrag",
|
||||
NameEN: "Reply to confidentiality motion",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 20,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "optional",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.Code == confCode {
|
||||
t.Errorf("optional R.262(2) rule rendered in default timeline (DueDate=%q); want suppressed", d.DueDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_OptionalRule_RendersWithIncludeOptional verifies the
|
||||
// opt-in path: when the caller passes IncludeOptional=true, optional
|
||||
// rules show up in the timeline with their computed dates.
|
||||
func TestCalculate_OptionalRule_RendersWithIncludeOptional(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
confID, _ := uuid.NewRandom()
|
||||
confCode := "upc.inf.cfi.rop_262_2"
|
||||
conf := Rule{
|
||||
ID: confID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &confCode,
|
||||
Name: "Erwiderung Vertraulichkeitsantrag",
|
||||
NameEN: "Reply to confidentiality motion",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 20,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "optional",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
got, ok := byCode[confCode]
|
||||
if !ok {
|
||||
t.Fatalf("optional R.262(2) missing from timeline despite IncludeOptional=true")
|
||||
}
|
||||
// R.262(2) is the "optional opposing-side" pattern (priority=optional,
|
||||
// primary_party=both, parent=SoC root) — the engine renders this as
|
||||
// IsConditional (no concrete date) per the t-paliad-289 logic
|
||||
// preserved in the walk. The point of this test is that the rule
|
||||
// is no longer suppressed wholesale by the t-paliad-342 default —
|
||||
// it surfaces, just with the conditional-render UX.
|
||||
if !got.IsConditional {
|
||||
t.Errorf("R.262(2): IsConditional=%v, want true (optional opposing-side, no real trigger recorded)", got.IsConditional)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventID_MalformedAnchor_Errors ensures
|
||||
// malformed dates in TriggerEventAnchors fail fast at the top of the
|
||||
// engine, before any rule walking — same protocol as AnchorOverrides.
|
||||
func TestCalculate_TriggerEventID_MalformedAnchor_Errors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc}},
|
||||
}
|
||||
|
||||
opts := CalcOptions{
|
||||
TriggerEventAnchors: map[string]string{
|
||||
"oral_hearing": "15-10-2026", // wrong format
|
||||
},
|
||||
}
|
||||
_, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err == nil {
|
||||
t.Fatalf("Calculate: want error on malformed TriggerEventAnchors date, got nil")
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,25 @@ type CalcOptions struct {
|
||||
// filter applied) so a stale frontend chip doesn't break the
|
||||
// timeline render — see IsValidAppealTarget.
|
||||
AppealTarget string
|
||||
|
||||
// IncludeOptional surfaces rules with priority='optional' in the
|
||||
// default timeline (t-paliad-342 / youpcorg#2570). Default false:
|
||||
// optional rules don't auto-fire alongside mandatory ones. The
|
||||
// caller (paliad /tools/procedures, youpc.org/deadlines) wires this
|
||||
// to a user-facing "show optional applications" toggle.
|
||||
IncludeOptional bool
|
||||
|
||||
// TriggerEventAnchors supplies concrete dates for procedural events
|
||||
// referenced by rules' trigger_event_id (t-paliad-342 / youpcorg#2568).
|
||||
// Key = paliad.trigger_events.code (e.g. "oral_hearing"), value =
|
||||
// YYYY-MM-DD. When a rule carries an explicit trigger_event_id, that
|
||||
// catalog event is the authoritative semantic anchor: arithmetic
|
||||
// resolves against TriggerEventAnchors[code] if set, otherwise the
|
||||
// rule is suppressed as IsConditional (no fabricated date off the
|
||||
// user's trigger date). Empty map = engine never anchors on a
|
||||
// trigger event, so every rule with trigger_event_id surfaces as
|
||||
// conditional.
|
||||
TriggerEventAnchors map[string]string
|
||||
}
|
||||
|
||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||
@@ -375,6 +394,13 @@ type Timeline struct {
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
// RulesAwaitingAnchor counts rules suppressed because their
|
||||
// trigger_event_id anchor date wasn't supplied via
|
||||
// CalcOptions.TriggerEventAnchors (t-paliad-342). Such rules still
|
||||
// render in the timeline as IsConditional (no date) — the field
|
||||
// gives the caller a single integer for "N rules waiting on an
|
||||
// anchor" UI affordances + telemetry.
|
||||
RulesAwaitingAnchor int `json:"rulesAwaitingAnchor,omitempty"`
|
||||
}
|
||||
|
||||
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
||||
@@ -505,7 +531,17 @@ type RuleCalculationProceeding struct {
|
||||
|
||||
// FristenrechnerType mirrors the /api/tools/proceeding-types response
|
||||
// metadata.
|
||||
//
|
||||
// ID is the paliad.proceeding_types primary key. Surfaces so frontend
|
||||
// pickers (Litigation Builder add-proceeding, fristenrechner-wizard
|
||||
// project prefill) can POST the FK directly without a code→id round
|
||||
// trip. Historically code-keyed; the Litigation Builder POSTing
|
||||
// proceeding_type_id (int) to /api/builder/scenarios/{id}/proceedings
|
||||
// forced surfacing the id (t-paliad-345 — the missing id meant the
|
||||
// POST silently sent body={} and the "+ Verfahren hinzufügen" button
|
||||
// did nothing).
|
||||
type FristenrechnerType struct {
|
||||
ID int `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
|
||||
50
pkg/litigationplanner/types_wire_test.go
Normal file
50
pkg/litigationplanner/types_wire_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFristenrechnerType_WireShapeIncludesID is the regression test for
|
||||
// t-paliad-345: the /api/tools/proceeding-types JSON response must
|
||||
// include `id` so frontend pickers (Litigation Builder add-proceeding,
|
||||
// fristenrechner-wizard project prefill) can POST proceeding_type_id
|
||||
// directly without a code→id round trip. When the id was missing the
|
||||
// Litigation Builder "+ Verfahren hinzufügen" button silently dropped
|
||||
// the proceeding_type_id from the POST body (JSON.stringify omits
|
||||
// undefined keys), the server rejected with 400, and the client
|
||||
// swallowed the error — user-visible symptom was "nothing happens".
|
||||
func TestFristenrechnerType_WireShapeIncludesID(t *testing.T) {
|
||||
in := FristenrechnerType{
|
||||
ID: 42,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "UPC Verletzungsverfahren",
|
||||
NameEN: "UPC Infringement Action",
|
||||
Group: "UPC",
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
if !strings.Contains(got, `"id":42`) {
|
||||
t.Errorf("missing id in wire shape: %s", got)
|
||||
}
|
||||
for _, want := range []string{`"code":"upc.inf.cfi"`, `"nameEN":"UPC Infringement Action"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in wire shape: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Round-trip — a client that posts the id back to /api/builder/
|
||||
// scenarios/{id}/proceedings should see it preserved as an integer
|
||||
// (paliad.scenario_proceedings.proceeding_type_id is INT, not UUID).
|
||||
var out FristenrechnerType
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.ID != 42 {
|
||||
t.Errorf("id lost on round-trip: got %d want 42", out.ID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user