feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (m/paliad#153)
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:
@@ -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, "&")
|
||||
@@ -81,3 +265,7 @@ function escHtml(s: string): string {
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user