feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

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.
This commit is contained in:
mAi
2026-05-28 00:28:48 +02:00
parent 6c1d8cc0cf
commit 46dc4ec94b
5 changed files with 656 additions and 41 deletions

View File

@@ -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<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;
}
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
: `<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>
${sideLabel ? `<span class="builder-triplet-side">${escHtml(sideLabel)}</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;
@@ -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, unknown>): 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, unknown>): string {
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, "&amp;")
@@ -81,3 +265,7 @@ function escHtml(s: string): string {
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -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<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
@@ -92,6 +108,7 @@ interface State {
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
@@ -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<ProceedingTypeMeta[]> {
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;
@@ -167,7 +190,12 @@ async function patchScenario(id: string, body: Record<string, unknown>): 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<BuilderProceeding | null> {
return await fetchJSON<BuilderProceeding>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings",
@@ -179,6 +207,74 @@ async function addProceeding(
);
}
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
// ────────────────────────────────────────────────────────────────────────────
@@ -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<void> {
const meta = state.procTypesById.get(proc.proceeding_type_id);
if (!meta) {
host.innerHTML = `<div class="builder-triplet-error">${escHtml(
tDyn("builder.triplet.unknown_proceeding"),
t("builder.triplet.unknown_proceeding"),
)}</div>`;
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<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[] {
@@ -544,11 +916,17 @@ function wirePageHeader(): void {
export async function mountBuilder(): Promise<void> {
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)) {

View File

@@ -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>`;

View File

@@ -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)

View File

@@ -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
// ---------------------------------------------------------------------------