From 46dc4ec94b76a808239244939034b82bb8f2bb61 Mon Sep 17 00:00:00 2001 From: mAi Date: Thu, 28 May 2026 00:28:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(builder):=20B2=20=E2=80=94=20multi-triplet?= =?UTF-8?q?=20stack=20+=20spawn=20nesting=20+=20per-event=20state=20(m/pal?= =?UTF-8?q?iad#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on B1 (commit 6c1d8cc). After this slice a user can compose a multi-proceeding scenario kontextfrei: stack proceedings, flip perspective per-triplet, toggle scenario flags, auto-spawn child proceedings on flag transitions, and mark individual event cards as planned / filed / skipped — all auto-saved to paliad.scenario_*. PRD §7.1 B2 acceptance shipped: - Multi-triplet stack: top-level proceedings sorted by ordinal, child proceedings nested inline with a left lime border. - Per-triplet controls bar: perspective radio (none / claimant / defendant), Detailgrad pill (selected / all options), Entfernen action. Each control PATCHes the proceeding row and re-renders the affected triplet. - Per-triplet flag strip: every paliad.scenario_flag_catalog row rendered as a checkbox, bound to scenario_proceedings.scenario_flags. Active flags also surface as chips in the triplet header for quick legibility. - Spawn nesting: when `with_ccr` flips ON on upc.inf.cfi the builder auto-POSTs an upc.ccr.cfi child proceeding linked via parent_scenario_proceeding_id; flip OFF deletes the child (events cascade via the schema). The SPAWN_MAP table is data-driven so future spawn flags slot in. - 3-state event cards (planned / filed / skipped): overlayEventStates walks the rendered .fr-col-item nodes (the data-rule-id hook added to verfahrensablauf-core in this slice) and stamps each card with data-builder-state + per-state action buttons (File / Skip / Reset to planned). Filed cards prompt for a date; skipped cards prompt for an optional reason. POSTs or PATCHes paliad.scenario_events keyed by sequencing_rule_id. - Per-card optional horizon chip: stores horizon_optional on the scenario_event row, increment / decrement chip on every card. The full surface awaits a calc-engine "optionals available" counter (PRD §3.4 follow-up); the persistence layer + UX hook are in place so the wiring lands without another schema touch. - Page-header Stichtag drives default dates for every triplet (the triplet's per-stichtag override path is wired but the per-triplet Stichtag input is a B3+ affordance). verfahrensablauf-core.renderColumnsBody now stamps data-rule-id (and data-submission-code as a future hook) on every .fr-col-item root — non-breaking enhancement; the legacy /tools/* pages don't read either attribute. Verified by re-running the existing 57-test suite. Backend: one new read-only endpoint GET /api/builder/scenario-flag-catalog passes through ScenarioFlagsService.ListCatalog so the builder doesn't need a per-project round-trip to render flag toggles. bun run build clean (3050 i18n keys), go vet ./... clean, go test ./... clean, frontend bun test (verfahrensablauf-core suite) 57 / 57 pass. --- frontend/src/client/builder-triplet.ts | 228 +++++++++- frontend/src/client/builder.ts | 418 +++++++++++++++++- .../src/client/views/verfahrensablauf-core.ts | 10 +- internal/handlers/handlers.go | 3 + internal/handlers/scenario_builder.go | 38 ++ 5 files changed, 656 insertions(+), 41 deletions(-) diff --git a/frontend/src/client/builder-triplet.ts b/frontend/src/client/builder-triplet.ts index ede12eb..89401a1 100644 --- a/frontend/src/client/builder-triplet.ts +++ b/frontend/src/client/builder-triplet.ts @@ -1,49 +1,140 @@ // ProceedingTriplet renderer for the Litigation Builder. // // PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name + -// perspective indicator + Detailgrad + columnar `proaktiv | court | -// reaktiv` body. B1 ships the read-only render; B2 wires perspective + -// flag strip + collapse/remove + 3-state event cards. +// 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, getLang } from "./i18n"; +import { t, tDyn, getLang } from "./i18n"; import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core"; -import type { BuilderProceeding } from "./builder"; +import type { BuilderProceeding, BuilderEvent } from "./builder"; import type { ProceedingTypeMeta } from "./builder-picker"; -export interface RenderTripletInput { +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; + // 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; } -export function renderTriplet(input: RenderTripletInput): string { +// 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 sideLabel = sidePillLabel(input.side); const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags); const body = input.data ? input.columnsHtml : `
${escHtml(t("builder.triplet.loading"))}
`; + const controls = renderControls(input); + const flagStrip = renderFlagStrip(input); + return `
${escHtml(jurisdictionFor(input.meta))} ${escHtml(input.meta.code)} ${escHtml(procLabel)} - ${sideLabel ? `${escHtml(sideLabel)}` : ""} ${flagsBadge}
+ ${controls} + ${flagStrip}
${body}
`; } +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 ``; + }; + const detailBtn = (value: string, key: string, current: string): string => { + const active = value === current ? " is-active" : ""; + return ``; + }; + + return `
+ ${escHtml(t("builder.triplet.perspective.label"))} +
+ ${radio("", "builder.triplet.perspective.none", perspective)} + ${radio("claimant", "builder.triplet.perspective.claimant", perspective)} + ${radio("defendant", "builder.triplet.perspective.defendant", perspective)} +
+ ${escHtml(t("builder.triplet.detailgrad.label"))} +
+ ${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)} + ${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)} +
+ +
`; +} + +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 `
+ ${escHtml(t("builder.triplet.no_flags"))} +
`; + } + const toggles = input.flagCatalog.map((entry) => { + const label = lang === "en" ? entry.label_en : entry.label_de; + const isOn = flags[entry.flag_key] === true; + return ``; + }).join(""); + return `
${toggles}
`; +} + function jurisdictionFor(meta: ProceedingTypeMeta): string { if (meta.jurisdiction) return meta.jurisdiction; if (meta.group) return meta.group; @@ -52,17 +143,6 @@ function jurisdictionFor(meta: ProceedingTypeMeta): string { return meta.code.toUpperCase(); } -function sidePillLabel(side: Side): string { - switch (side) { - case "claimant": - return t("builder.triplet.side.claimant"); - case "defendant": - return t("builder.triplet.side.defendant"); - default: - return ""; - } -} - function activeFlagsBadge(flags: Record): string { const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k); if (active.length === 0) return ""; @@ -73,6 +153,110 @@ function activeFlagsBadge(flags: Record): string { return `${escHtml(label)} ${chips}`; } +// 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, + 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(".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(".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 ` + + + `; + } + return ``; +} + function escHtml(s: string): string { return s .replace(/&/g, "&") @@ -81,3 +265,7 @@ function escHtml(s: string): string { .replace(/"/g, """) .replace(/'/g, "'"); } + +function escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """); +} diff --git a/frontend/src/client/builder.ts b/frontend/src/client/builder.ts index c8bea75..c92f49a 100644 --- a/frontend/src/client/builder.ts +++ b/frontend/src/client/builder.ts @@ -17,7 +17,7 @@ // - Spawn child triplets render inline. // - 3-state event cards (planned/filed/skipped) + per-card optional horizon. -import { t, tDyn, getLang } from "./i18n"; +import { t, getLang } from "./i18n"; import { calculateDeadlines, renderColumnsBody, @@ -25,7 +25,23 @@ import { type Side, } from "./views/verfahrensablauf-core"; import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker"; -import { renderTriplet } from "./builder-triplet"; +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> = { + // 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 @@ -92,6 +108,7 @@ interface State { procTypes: ProceedingTypeMeta[]; procTypesById: Map; procTypesByCode: Map; + 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 @@ -105,6 +122,7 @@ const state: State = { procTypes: [], procTypesById: new Map(), procTypesByCode: new Map(), + flagCatalog: [], saveTimer: null, pending: {}, }; @@ -147,6 +165,11 @@ async function fetchProceedingTypes(): Promise { return Array.isArray(out) ? out : []; } +async function fetchFlagCatalog(): Promise { + const out = await fetchJSON("/api/builder/scenario-flag-catalog"); + return Array.isArray(out) ? out : []; +} + async function createScenario(name?: string): Promise { const body: Record = {}; if (name) body.name = name; @@ -167,7 +190,12 @@ async function patchScenario(id: string, body: Record): Promise async function addProceeding( scenarioID: string, - body: { proceeding_type_id: number; primary_party?: string }, + body: { + proceeding_type_id: number; + primary_party?: string; + parent_scenario_proceeding_id?: string; + spawn_anchor_event_id?: string; + }, ): Promise { return await fetchJSON( "/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings", @@ -179,6 +207,74 @@ async function addProceeding( ); } +async function patchProceeding( + scenarioID: string, + proceedingID: string, + body: Record, +): Promise { + return await fetchJSON( + "/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 { + 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 { + return await fetchJSON( + "/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, +): Promise { + return await fetchJSON( + "/api/builder/scenarios/" + encodeURIComponent(scenarioID) + + "/events/" + encodeURIComponent(eventID), + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + // ──────────────────────────────────────────────────────────────────────────── // URL state // ──────────────────────────────────────────────────────────────────────────── @@ -371,17 +467,17 @@ function renderCanvas(): void { .sort((a, b) => a.ordinal - b.ordinal); for (const proc of topLevel) { - const tripletHost = document.createElement("article"); - tripletHost.className = "builder-triplet-host"; - tripletHost.setAttribute("data-proceeding-id", proc.id); - canvas.appendChild(tripletHost); - void renderProceedingTriplet(proc, tripletHost); + 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 — even B1's single - // triplet flow needs a Hinzufügen affordance once the canvas is empty - // OR exactly one triplet renders (the cold-open CTA can't survive - // post-create). + // Add-proceeding affordance always at the bottom of the stack. const addBtn = document.createElement("button"); addBtn.type = "button"; addBtn.className = "builder-add-proceeding-btn"; @@ -393,14 +489,28 @@ function renderCanvas(): void { canvas.appendChild(addBtn); } -async function renderProceedingTriplet( +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 { const meta = state.procTypesById.get(proc.proceeding_type_id); if (!meta) { host.innerHTML = `
${escHtml( - tDyn("builder.triplet.unknown_proceeding"), + t("builder.triplet.unknown_proceeding"), )}
`; return; } @@ -411,14 +521,276 @@ async function renderProceedingTriplet( flags: scenarioFlagsToArray(proc.scenario_flags), }); const side: Side = (proc.primary_party as Side) || null; - const tripletHtml = renderTriplet({ + const eventsByRule = buildEventsByRule(proc.id); + const columnsHtml = data + ? renderColumnsBody(data, { editable: false, side, showDurations: false }) + : ""; + host.innerHTML = renderTriplet({ proceeding: proc, meta, data, side, - columnsHtml: data ? renderColumnsBody(data, { editable: false, side, showDurations: false }) : "", + flagCatalog: state.flagCatalog, + eventsByRule, + columnsHtml, + isChild, }); - host.innerHTML = tripletHtml; + 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 { + const out = new Map(); + 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("[data-action='perspective']").forEach((btn) => { + btn.addEventListener("click", () => { + const value = btn.getAttribute("data-value") || ""; + void onPerspectiveChange(proc, value); + }); + }); + // Detailgrad toggle + host.querySelectorAll("[data-action='detailgrad']").forEach((btn) => { + btn.addEventListener("click", () => { + const value = btn.getAttribute("data-value") || "selected"; + void onDetailgradChange(proc, value); + }); + }); + // Remove + host.querySelectorAll("[data-action='remove']").forEach((btn) => { + btn.addEventListener("click", () => { + void onRemoveProceeding(proc); + }); + }); + // Flag checkboxes + host.querySelectorAll("[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 { + 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 { + 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 { + 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 { + if (!state.active) return; + const newFlags: Record = { ...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 { + 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 { + 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 { + 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[] { @@ -544,11 +916,17 @@ function wirePageHeader(): void { export async function mountBuilder(): Promise { wirePageHeader(); - // Load proceeding type catalog (Forum=UPC, Kind=proceeding) up-front - // so the add-proceeding picker is instant. PRD §0.4 — UPC v1. - state.procTypes = await fetchProceedingTypes(); + // 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)) { diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index c9311b1..bed1df9 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -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 `
+ // 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 `
${deadlineCardHtml(dl, cardOpts)} ${mirrorTag}
`; diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2d9b5a5..189aff4 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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) diff --git a/internal/handlers/scenario_builder.go b/internal/handlers/scenario_builder.go index 8e2022b..5765652 100644 --- a/internal/handlers/scenario_builder.go +++ b/internal/handlers/scenario_builder.go @@ -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 // ---------------------------------------------------------------------------