The Litigation Builder triplet renders /api/tools/fristenrechner output
verbatim and never applied the pre-existing filterByDetailMode pass that
the legacy /tools/verfahrensablauf page uses. With the engine fix
(3c840c0 — pkg/litigationplanner default IncludeOptional=false + trigger
event semantic anchoring) already in main, optional rules are dropped
server-side but rules with an unsatisfied trigger_event_id surface as
IsConditional. Without filterByDetailMode those still rendered as
"abhängig von ..." cards on the triplet, polluting m's "naked
proceeding with options but not always displayed" mental model.
upc.inf.cfi went from 7 mandatory backbone events to 29 visible cards
(22 conditional noise — Lodging of translations, Mängelbeseitigung,
Antrag auf Verweisung, Wiedereinsetzung, ...). Live BEFORE/AFTER
captured in exports/screenshots/.
Fix layers:
- Go handler (internal/handlers/fristenrechner.go): accept
includeOptional + triggerEventAnchors from request body and
forward to services.CalcOptions. Default zero values match the
engine defaults (suppress optionals + no fabricated dates for
trigger_event_id rules), so the wire is unchanged when callers
don't set them.
- TS calc surface (frontend/src/client/views/verfahrensablauf-core.ts):
add the same two fields to CalcParams + forward in the fetch body;
surface rulesAwaitingAnchor on DeadlineResponse mirroring
Timeline.RulesAwaitingAnchor.
- Builder triplet (frontend/src/client/builder.ts hydrateTriplet):
apply filterByDetailMode(detailgrad) before renderColumnsBody, with
detailgrad sourced from the proceeding row. "selected" (default)
drops conditional + optional rules; "all_options" passes
includeOptional=true so the engine returns the optional rules the
user can opt into.
- Legacy /tools/verfahrensablauf (frontend/src/client/verfahrensablauf.ts):
pass includeOptional based on detailMode + a small hasOptionalOptIn
helper so per-rule rule:<uuid>=true deviations still surface their
optional rule even in "selected" mode (the engine has no rule:<uuid>
awareness; without the opt-in the user's pick would silently no-op).
Tests:
- frontend/src/client/views/verfahrensablauf-core.test.ts: pin the
fetch body shape - includeOptional=true and triggerEventAnchors={...}
round-trip through the request; empty/default values are omitted so
the wire stays minimal.
bun build + bun test (269 pass) + go vet + go test
./internal/handlers/... ./pkg/litigationplanner/... all clean.
(m/paliad#153)
1195 lines
44 KiB
TypeScript
1195 lines
44 KiB
TypeScript
// Litigation Builder client (m/paliad#153 PRD §3, B1).
|
|
//
|
|
// Boots /tools/procedures. Talks to the B0 surface
|
|
// (/api/builder/scenarios/*) for persistence and reuses
|
|
// verfahrensablauf-core for the per-triplet calc + 3-column render.
|
|
//
|
|
// B1 ships:
|
|
// - Cold-open empty canvas + "Neues Szenario starten" CTA + recent list.
|
|
// - Scenario picker, name action, Stichtag, auto-save (500ms debounce).
|
|
// - Add-proceeding picker (Forum chip row → Verfahren chip row → Hinzufügen).
|
|
// - Single triplet renders end-to-end with calc.
|
|
// - Side panel "Meine Szenarien" with Aktiv bucket.
|
|
//
|
|
// B2 extends:
|
|
// - Multi-triplet stack with `+ Verfahren hinzufügen`.
|
|
// - Per-triplet perspective + flag strip.
|
|
// - Spawn child triplets render inline.
|
|
// - 3-state event cards (planned/filed/skipped) + per-card optional horizon.
|
|
|
|
import { t, getLang } from "./i18n";
|
|
import {
|
|
calculateDeadlines,
|
|
renderColumnsBody,
|
|
type DeadlineResponse,
|
|
type Side,
|
|
} from "./views/verfahrensablauf-core";
|
|
import { filterByDetailMode, type DetailMode } from "./verfahrensablauf-detail-mode";
|
|
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
|
|
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
|
|
import {
|
|
mountBuilderSearch,
|
|
type EventSearchHit,
|
|
type ScenarioSearchHit,
|
|
type ProjectSearchHit,
|
|
} from "./builder-search";
|
|
|
|
// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
|
|
// parent proceeding, the builder auto-creates a child proceeding row
|
|
// linked via parent_scenario_proceeding_id. When the flag flips back
|
|
// OFF, the child is deleted (its events cascade via the schema's ON
|
|
// DELETE CASCADE). Today's data has 2-deep nesting at most, with
|
|
// `with_ccr` on `upc.inf.cfi` as the load-bearing case; the map is
|
|
// data-driven so future flags slot in by adding rows.
|
|
//
|
|
// Entries are picked up by syncSpawnChildren after each successful
|
|
// flag PATCH. Falsy entries are simple flag-only flips with no spawn
|
|
// effect.
|
|
const SPAWN_MAP: Record<string, Record<string, string>> = {
|
|
// Parent proceeding code → flag_key → child proceeding code.
|
|
"upc.inf.cfi": { with_ccr: "upc.ccr.cfi" },
|
|
};
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Wire types — mirror internal/services/scenario_builder_service.go
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
export interface BuilderScenario {
|
|
id: string;
|
|
owner_id?: string;
|
|
name: string;
|
|
status: "active" | "archived" | "promoted";
|
|
origin_project_id?: string;
|
|
promoted_project_id?: string;
|
|
stichtag?: string;
|
|
notes?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface BuilderProceeding {
|
|
id: string;
|
|
scenario_id: string;
|
|
proceeding_type_id: number;
|
|
primary_party?: "claimant" | "defendant";
|
|
scenario_flags: Record<string, unknown>;
|
|
parent_scenario_proceeding_id?: string;
|
|
spawn_anchor_event_id?: string;
|
|
ordinal: number;
|
|
stichtag?: string;
|
|
detailgrad: "selected" | "all_options";
|
|
appeal_target?: string;
|
|
collapsed: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface BuilderEvent {
|
|
id: string;
|
|
scenario_proceeding_id: string;
|
|
sequencing_rule_id?: string;
|
|
procedural_event_id?: string;
|
|
custom_label?: string;
|
|
state: "planned" | "filed" | "skipped";
|
|
actual_date?: string;
|
|
skip_reason?: string;
|
|
notes?: string;
|
|
horizon_optional: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface BuilderScenarioDeep extends BuilderScenario {
|
|
proceedings: BuilderProceeding[];
|
|
events: BuilderEvent[];
|
|
shares: unknown[];
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Module state — single active scenario per tab.
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
type EntryMode = "cold" | "event" | "akte";
|
|
|
|
interface State {
|
|
active: BuilderScenarioDeep | null;
|
|
list: BuilderScenario[];
|
|
procTypes: ProceedingTypeMeta[];
|
|
procTypesById: Map<number, ProceedingTypeMeta>;
|
|
procTypesByCode: Map<string, ProceedingTypeMeta>;
|
|
flagCatalog: ScenarioFlagCatalogEntry[];
|
|
saveTimer: number | null;
|
|
// Pending field-level deltas merged before each PATCH flush. Avoids
|
|
// racing PATCHes overwriting each other when the user changes more
|
|
// than one field inside a 500ms window.
|
|
pending: { name?: string; stichtag?: string; notes?: string };
|
|
// B3: entry mode + anchor highlight. PRD §2.2 — when the user picks
|
|
// an event from universal search, the canvas renders one triplet
|
|
// with the picked rule highlighted (lime band + "DU BIST HIER"
|
|
// divider). anchorRuleID survives across re-renders within a single
|
|
// mode session; switching mode away from "event" clears it.
|
|
mode: EntryMode;
|
|
anchorRuleID: string | null;
|
|
searchCtl: { focus: () => void; close: () => void } | null;
|
|
}
|
|
|
|
const state: State = {
|
|
active: null,
|
|
list: [],
|
|
procTypes: [],
|
|
procTypesById: new Map(),
|
|
procTypesByCode: new Map(),
|
|
flagCatalog: [],
|
|
saveTimer: null,
|
|
pending: {},
|
|
mode: "cold",
|
|
anchorRuleID: null,
|
|
searchCtl: null,
|
|
};
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Fetch helpers
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T | null> {
|
|
try {
|
|
const resp = await fetch(input, init);
|
|
if (!resp.ok) {
|
|
const body = await resp.text().catch(() => "");
|
|
console.error("builder fetch error:", resp.status, input, body);
|
|
return null;
|
|
}
|
|
if (resp.status === 204) return null;
|
|
return (await resp.json()) as T;
|
|
} catch (err) {
|
|
console.error("builder network error:", input, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fetchScenarios(): Promise<BuilderScenario[]> {
|
|
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=active");
|
|
return Array.isArray(out) ? out : [];
|
|
}
|
|
|
|
async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
|
|
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
|
|
}
|
|
|
|
async function fetchProceedingTypes(): Promise<ProceedingTypeMeta[]> {
|
|
// PRD v1 is UPC-only; later jurisdictions plug into the same picker
|
|
// shape (Forum chip row gates the Verfahren chip row).
|
|
const out = await fetchJSON<ProceedingTypeMeta[]>(
|
|
"/api/tools/proceeding-types?jurisdiction=UPC&kind=proceeding",
|
|
);
|
|
return Array.isArray(out) ? out : [];
|
|
}
|
|
|
|
async function fetchFlagCatalog(): Promise<ScenarioFlagCatalogEntry[]> {
|
|
const out = await fetchJSON<ScenarioFlagCatalogEntry[]>("/api/builder/scenario-flag-catalog");
|
|
return Array.isArray(out) ? out : [];
|
|
}
|
|
|
|
async function createScenario(name?: string): Promise<BuilderScenario | null> {
|
|
const body: Record<string, unknown> = {};
|
|
if (name) body.name = name;
|
|
return await fetchJSON<BuilderScenario>("/api/builder/scenarios", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function patchScenario(id: string, body: Record<string, unknown>): Promise<BuilderScenario | null> {
|
|
return await fetchJSON<BuilderScenario>("/api/builder/scenarios/" + encodeURIComponent(id), {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function addProceeding(
|
|
scenarioID: string,
|
|
body: {
|
|
proceeding_type_id: number;
|
|
primary_party?: string;
|
|
parent_scenario_proceeding_id?: string;
|
|
spawn_anchor_event_id?: string;
|
|
},
|
|
): Promise<BuilderProceeding | null> {
|
|
return await fetchJSON<BuilderProceeding>(
|
|
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function patchProceeding(
|
|
scenarioID: string,
|
|
proceedingID: string,
|
|
body: Record<string, unknown>,
|
|
): Promise<BuilderProceeding | null> {
|
|
return await fetchJSON<BuilderProceeding>(
|
|
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
|
|
"/proceedings/" + encodeURIComponent(proceedingID),
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function deleteProceeding(scenarioID: string, proceedingID: string): Promise<boolean> {
|
|
try {
|
|
const resp = await fetch(
|
|
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
|
|
"/proceedings/" + encodeURIComponent(proceedingID),
|
|
{ method: "DELETE" },
|
|
);
|
|
return resp.ok;
|
|
} catch (err) {
|
|
console.error("builder delete proceeding error:", err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function addEvent(
|
|
scenarioID: string,
|
|
proceedingID: string,
|
|
body: {
|
|
sequencing_rule_id?: string;
|
|
state?: string;
|
|
actual_date?: string;
|
|
skip_reason?: string;
|
|
horizon_optional?: number;
|
|
},
|
|
): Promise<BuilderEvent | null> {
|
|
return await fetchJSON<BuilderEvent>(
|
|
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
|
|
"/proceedings/" + encodeURIComponent(proceedingID) + "/events",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function patchEvent(
|
|
scenarioID: string,
|
|
eventID: string,
|
|
body: Record<string, unknown>,
|
|
): Promise<BuilderEvent | null> {
|
|
return await fetchJSON<BuilderEvent>(
|
|
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
|
|
"/events/" + encodeURIComponent(eventID),
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// URL state
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
function readScenarioFromUrl(): string | null {
|
|
return new URLSearchParams(window.location.search).get("scenario");
|
|
}
|
|
|
|
function writeScenarioToUrl(id: string | null): void {
|
|
const url = new URL(window.location.href);
|
|
if (id) url.searchParams.set("scenario", id);
|
|
else url.searchParams.delete("scenario");
|
|
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Save indicator
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
type SaveState = "idle" | "saving" | "saved" | "error";
|
|
|
|
function setSaveState(s: SaveState): void {
|
|
const el = document.getElementById("builder-save-status");
|
|
if (!el) return;
|
|
el.setAttribute("data-state", s);
|
|
const span = el.querySelector("span");
|
|
if (!span) return;
|
|
const text =
|
|
s === "saving" ? t("builder.save.saving") :
|
|
s === "saved" ? t("builder.save.saved") :
|
|
s === "error" ? t("builder.save.error") :
|
|
t("builder.save.idle");
|
|
const key =
|
|
s === "saving" ? "builder.save.saving" :
|
|
s === "saved" ? "builder.save.saved" :
|
|
s === "error" ? "builder.save.error" :
|
|
"builder.save.idle";
|
|
span.setAttribute("data-i18n", key);
|
|
span.textContent = text;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Auto-save (500ms debounce per PRD §4.2 + §10).
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
function scheduleAutoSave(): void {
|
|
if (!state.active) return;
|
|
setSaveState("saving");
|
|
if (state.saveTimer !== null) {
|
|
window.clearTimeout(state.saveTimer);
|
|
}
|
|
state.saveTimer = window.setTimeout(() => {
|
|
void flushAutoSave();
|
|
}, 500);
|
|
}
|
|
|
|
async function flushAutoSave(): Promise<void> {
|
|
state.saveTimer = null;
|
|
if (!state.active) return;
|
|
const body = { ...state.pending };
|
|
state.pending = {};
|
|
if (Object.keys(body).length === 0) {
|
|
setSaveState("saved");
|
|
return;
|
|
}
|
|
const updated = await patchScenario(state.active.id, body);
|
|
if (!updated) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
state.active.name = updated.name;
|
|
state.active.status = updated.status;
|
|
state.active.stichtag = updated.stichtag;
|
|
state.active.notes = updated.notes;
|
|
state.active.updated_at = updated.updated_at;
|
|
setSaveState("saved");
|
|
// Refresh the side panel so the just-saved scenario floats to top.
|
|
await refreshScenarioList();
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Side panel + dropdown
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
async function refreshScenarioList(): Promise<void> {
|
|
state.list = await fetchScenarios();
|
|
renderScenarioList();
|
|
renderScenarioPicker();
|
|
}
|
|
|
|
function renderScenarioList(): void {
|
|
const ul = document.getElementById("builder-scenario-list-active");
|
|
if (!ul) return;
|
|
if (state.list.length === 0) {
|
|
ul.innerHTML = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
|
|
return;
|
|
}
|
|
const activeId = state.active?.id;
|
|
ul.innerHTML = state.list.map((sc) => {
|
|
const isActive = sc.id === activeId;
|
|
return (
|
|
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
|
|
` data-scenario-id="${escAttr(sc.id)}">` +
|
|
`<button type="button" class="builder-scenario-list-link">` +
|
|
`<span class="builder-scenario-list-name">${escHtml(sc.name)}</span>` +
|
|
`</button></li>`
|
|
);
|
|
}).join("");
|
|
ul.querySelectorAll<HTMLElement>(".builder-scenario-list-item").forEach((li) => {
|
|
const id = li.getAttribute("data-scenario-id");
|
|
if (!id) return;
|
|
li.addEventListener("click", () => {
|
|
void loadScenario(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderScenarioPicker(): void {
|
|
const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
|
|
if (!sel) return;
|
|
const placeholderText = t("builder.picker.placeholder");
|
|
const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
|
|
for (const sc of state.list) {
|
|
const selected = sc.id === state.active?.id ? " selected" : "";
|
|
opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
|
|
}
|
|
sel.innerHTML = opts.join("");
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Canvas rendering
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
function showEmpty(): void {
|
|
const canvas = document.getElementById("builder-canvas");
|
|
if (!canvas) return;
|
|
canvas.innerHTML = "";
|
|
const empty = document.createElement("div");
|
|
empty.id = "builder-empty";
|
|
empty.className = "builder-empty";
|
|
empty.innerHTML = `
|
|
<p class="builder-empty-headline">${escHtml(t("builder.empty.headline"))}</p>
|
|
<p class="builder-empty-hint">${escHtml(t("builder.empty.hint"))}</p>
|
|
<button type="button" id="builder-cta-new" class="builder-cta-new">
|
|
${escHtml(t("builder.empty.cta"))}
|
|
</button>
|
|
${renderRecentList()}
|
|
`;
|
|
canvas.appendChild(empty);
|
|
document.getElementById("builder-cta-new")?.addEventListener("click", () => {
|
|
void onNewScenarioClick();
|
|
});
|
|
empty.querySelectorAll<HTMLElement>(".builder-recent-item").forEach((li) => {
|
|
const id = li.getAttribute("data-scenario-id");
|
|
if (!id) return;
|
|
li.addEventListener("click", () => {
|
|
void loadScenario(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderRecentList(): string {
|
|
if (state.list.length === 0) return "";
|
|
const recent = state.list.slice(0, 5);
|
|
const items = recent.map((sc) => (
|
|
`<li class="builder-recent-item" data-scenario-id="${escAttr(sc.id)}">` +
|
|
`<span class="builder-recent-name">${escHtml(sc.name)}</span>` +
|
|
`</li>`
|
|
)).join("");
|
|
return (
|
|
`<div class="builder-recent">` +
|
|
`<h3 class="builder-recent-title">${escHtml(t("builder.empty.recent"))}</h3>` +
|
|
`<ul class="builder-recent-list">${items}</ul>` +
|
|
`</div>`
|
|
);
|
|
}
|
|
|
|
function renderCanvas(): void {
|
|
if (!state.active) {
|
|
showEmpty();
|
|
return;
|
|
}
|
|
const canvas = document.getElementById("builder-canvas");
|
|
if (!canvas) return;
|
|
canvas.innerHTML = "";
|
|
|
|
// Top-level proceedings sorted by ordinal (parent_scenario_proceeding_id IS NULL).
|
|
const topLevel = state.active.proceedings
|
|
.filter((p) => !p.parent_scenario_proceeding_id)
|
|
.sort((a, b) => a.ordinal - b.ordinal);
|
|
|
|
for (const proc of topLevel) {
|
|
renderProceedingTripletInto(canvas, proc, /*isChild*/ false);
|
|
// Inline child triplets (spawn nesting). PRD §3.6.
|
|
const children = state.active.proceedings
|
|
.filter((p) => p.parent_scenario_proceeding_id === proc.id)
|
|
.sort((a, b) => a.ordinal - b.ordinal);
|
|
for (const child of children) {
|
|
renderProceedingTripletInto(canvas, child, /*isChild*/ true);
|
|
}
|
|
}
|
|
|
|
// Add-proceeding affordance always at the bottom of the stack.
|
|
const addBtn = document.createElement("button");
|
|
addBtn.type = "button";
|
|
addBtn.className = "builder-add-proceeding-btn";
|
|
addBtn.id = "builder-add-proceeding-btn";
|
|
addBtn.textContent = t("builder.canvas.add_proceeding");
|
|
addBtn.addEventListener("click", () => {
|
|
openAddProceedingPicker(addBtn);
|
|
});
|
|
canvas.appendChild(addBtn);
|
|
}
|
|
|
|
function renderProceedingTripletInto(
|
|
canvas: HTMLElement,
|
|
proc: BuilderProceeding,
|
|
isChild: boolean,
|
|
): void {
|
|
const host = document.createElement("article");
|
|
host.className = "builder-triplet-host";
|
|
host.setAttribute("data-proceeding-id", proc.id);
|
|
if (isChild) host.setAttribute("data-child", "true");
|
|
canvas.appendChild(host);
|
|
void hydrateTriplet(proc, host, isChild);
|
|
}
|
|
|
|
async function hydrateTriplet(
|
|
proc: BuilderProceeding,
|
|
host: HTMLElement,
|
|
isChild: boolean,
|
|
): Promise<void> {
|
|
const meta = state.procTypesById.get(proc.proceeding_type_id);
|
|
if (!meta) {
|
|
host.innerHTML = `<div class="builder-triplet-error">${escHtml(
|
|
t("builder.triplet.unknown_proceeding"),
|
|
)}</div>`;
|
|
return;
|
|
}
|
|
const stichtag = proc.stichtag || state.active?.stichtag || todayISO();
|
|
// t-paliad-348 — port engine semantics to the Builder triplet calc:
|
|
// - `includeOptional` follows the proceeding's detailgrad. The
|
|
// "selected" default suppresses optional rules server-side
|
|
// (engine drops them); "all_options" opts in so the dimmed
|
|
// optional cards can be rendered for the lawyer to opt into.
|
|
// - `filterByDetailMode` then runs client-side over what the
|
|
// engine emitted, dropping isConditional rows (rules whose
|
|
// `trigger_event_id` anchor wasn't supplied) when the lawyer
|
|
// is on "selected"/"mandatory_only" — those rules belong to the
|
|
// "naked proceeding with options but not always displayed"
|
|
// mental model and shouldn't pollute the backbone view.
|
|
const detailgrad: DetailMode = (proc.detailgrad as DetailMode) || "selected";
|
|
const data: DeadlineResponse | null = await calculateDeadlines({
|
|
proceedingType: meta.code,
|
|
triggerDate: stichtag,
|
|
flags: scenarioFlagsToArray(proc.scenario_flags),
|
|
includeOptional: detailgrad === "all_options",
|
|
});
|
|
const side: Side = (proc.primary_party as Side) || null;
|
|
const eventsByRule = buildEventsByRule(proc.id);
|
|
const scenarioFlagsBool = scenarioFlagsToBoolMap(proc.scenario_flags);
|
|
const filteredData: DeadlineResponse | null = data
|
|
? { ...data, deadlines: filterByDetailMode(data.deadlines, detailgrad, scenarioFlagsBool) }
|
|
: null;
|
|
const columnsHtml = filteredData
|
|
? renderColumnsBody(filteredData, { editable: false, side, showDurations: false })
|
|
: "";
|
|
host.innerHTML = renderTriplet({
|
|
proceeding: proc,
|
|
meta,
|
|
data,
|
|
side,
|
|
flagCatalog: state.flagCatalog,
|
|
eventsByRule,
|
|
columnsHtml,
|
|
isChild,
|
|
});
|
|
wireTripletInteractions(host, proc, meta);
|
|
overlayEventStates(host, eventsByRule, {
|
|
onAction: (ruleId, action, payload) => {
|
|
void onEventAction(proc, ruleId, action, payload);
|
|
},
|
|
onHorizon: (ruleId, delta) => {
|
|
void onEventHorizon(proc, ruleId, delta);
|
|
},
|
|
});
|
|
// B3 — apply the event-anchor highlight + "DU BIST HIER" divider on
|
|
// the matching card. Only fires when the active mode is "event" and
|
|
// an anchor rule was set by the search-pick flow.
|
|
if (state.mode === "event" && state.anchorRuleID) {
|
|
applyAnchorHighlight(host, state.anchorRuleID);
|
|
}
|
|
}
|
|
|
|
// applyAnchorHighlight spotlights the picked event card on a freshly
|
|
// rendered triplet. PRD §2.2: lime band on the card + a horizontal
|
|
// "━━ DU BIST HIER ━━" divider above the next-coming events. The
|
|
// divider spans all 3 columns of the grid via grid-column: 1 / -1.
|
|
function applyAnchorHighlight(host: HTMLElement, ruleId: string): void {
|
|
const ruleKey = ruleId.toLowerCase();
|
|
// Attribute-value comparison via JS rather than a CSS selector so
|
|
// either-cased UUIDs from the server still match.
|
|
const anchorCard = Array.from(
|
|
host.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]"),
|
|
).find((el) => (el.getAttribute("data-rule-id") || "").toLowerCase() === ruleKey);
|
|
if (!anchorCard) return;
|
|
anchorCard.classList.add("builder-anchor-card");
|
|
|
|
// Locate the anchor's row in the .fr-columns-view grid. The grid
|
|
// is row-major with 3 cells per row after the 3 column headers
|
|
// (ours/court/opponent). The cell containing the anchor card is
|
|
// the closest .fr-col-cell ancestor; once we know its index in the
|
|
// grid's cell list, the row index is floor((idx - 3) / 3) (the
|
|
// -3 accounts for the header row).
|
|
const cell = anchorCard.closest<HTMLElement>(".fr-col-cell");
|
|
if (!cell) return;
|
|
const grid = cell.parentElement;
|
|
if (!grid || !grid.classList.contains("fr-columns-view")) return;
|
|
const cells = Array.from(grid.children) as HTMLElement[];
|
|
const idx = cells.indexOf(cell);
|
|
if (idx < 0) return;
|
|
// Insertion point: after the last cell of the anchor's row. The
|
|
// grid renders cells in row-major order; we step forward from the
|
|
// anchor cell to the end of the current row (2 cells past, modulo
|
|
// header row offset). A guarded fall-through inserts at the end of
|
|
// the grid if positions don't line up cleanly.
|
|
// Header row contributes 3 cells; body rows are 3 cells each.
|
|
const headerCells = 3;
|
|
if (idx < headerCells) return; // anchor on a header? shouldn't happen, no-op.
|
|
const bodyIdx = idx - headerCells;
|
|
const rowStart = headerCells + Math.floor(bodyIdx / 3) * 3;
|
|
const rowEnd = rowStart + 2; // inclusive
|
|
const after = cells[rowEnd] || cells[cells.length - 1];
|
|
|
|
const divider = document.createElement("div");
|
|
divider.className = "builder-anchor-divider";
|
|
divider.textContent = t("builder.search.anchor.divider");
|
|
divider.setAttribute("aria-hidden", "true");
|
|
after.insertAdjacentElement("afterend", divider);
|
|
}
|
|
|
|
function buildEventsByRule(proceedingID: string): Map<string, BuilderEvent> {
|
|
const out = new Map<string, BuilderEvent>();
|
|
if (!state.active) return out;
|
|
for (const ev of state.active.events) {
|
|
if (ev.scenario_proceeding_id !== proceedingID) continue;
|
|
if (!ev.sequencing_rule_id) continue;
|
|
out.set(ev.sequencing_rule_id.toLowerCase(), ev);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function wireTripletInteractions(
|
|
host: HTMLElement,
|
|
proc: BuilderProceeding,
|
|
meta: ProceedingTypeMeta,
|
|
): void {
|
|
// Perspective radio
|
|
host.querySelectorAll<HTMLElement>("[data-action='perspective']").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const value = btn.getAttribute("data-value") || "";
|
|
void onPerspectiveChange(proc, value);
|
|
});
|
|
});
|
|
// Detailgrad toggle
|
|
host.querySelectorAll<HTMLElement>("[data-action='detailgrad']").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const value = btn.getAttribute("data-value") || "selected";
|
|
void onDetailgradChange(proc, value);
|
|
});
|
|
});
|
|
// Remove
|
|
host.querySelectorAll<HTMLElement>("[data-action='remove']").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
void onRemoveProceeding(proc);
|
|
});
|
|
});
|
|
// Flag checkboxes
|
|
host.querySelectorAll<HTMLInputElement>("[data-action='flag']").forEach((box) => {
|
|
box.addEventListener("change", () => {
|
|
const key = box.getAttribute("data-flag-key");
|
|
if (!key) return;
|
|
void onFlagChange(proc, meta, key, box.checked);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function onPerspectiveChange(proc: BuilderProceeding, value: string): Promise<void> {
|
|
if (!state.active) return;
|
|
const updated = await patchProceeding(state.active.id, proc.id, {
|
|
primary_party: value,
|
|
});
|
|
if (!updated) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
applyProceedingPatch(updated);
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
}
|
|
|
|
async function onDetailgradChange(proc: BuilderProceeding, value: string): Promise<void> {
|
|
if (!state.active) return;
|
|
const updated = await patchProceeding(state.active.id, proc.id, {
|
|
detailgrad: value,
|
|
});
|
|
if (!updated) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
applyProceedingPatch(updated);
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
}
|
|
|
|
async function onRemoveProceeding(proc: BuilderProceeding): Promise<void> {
|
|
if (!state.active) return;
|
|
const confirmed = window.confirm(t("builder.triplet.remove") + " — " + state.procTypesById.get(proc.proceeding_type_id)?.code);
|
|
if (!confirmed) return;
|
|
const ok = await deleteProceeding(state.active.id, proc.id);
|
|
if (!ok) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
// Cascade in-memory: drop the proceeding + its child proceedings + events.
|
|
state.active.proceedings = state.active.proceedings.filter(
|
|
(p) => p.id !== proc.id && p.parent_scenario_proceeding_id !== proc.id,
|
|
);
|
|
state.active.events = state.active.events.filter(
|
|
(e) => state.active!.proceedings.some((p) => p.id === e.scenario_proceeding_id),
|
|
);
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
}
|
|
|
|
async function onFlagChange(
|
|
proc: BuilderProceeding,
|
|
meta: ProceedingTypeMeta,
|
|
flagKey: string,
|
|
enabled: boolean,
|
|
): Promise<void> {
|
|
if (!state.active) return;
|
|
const newFlags: Record<string, unknown> = { ...proc.scenario_flags, [flagKey]: enabled };
|
|
const updated = await patchProceeding(state.active.id, proc.id, {
|
|
scenario_flags: newFlags,
|
|
});
|
|
if (!updated) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
applyProceedingPatch(updated);
|
|
|
|
// Spawn handling — flip the child triplet in or out based on the
|
|
// SPAWN_MAP entry for this parent.
|
|
const spawnChildCode = SPAWN_MAP[meta.code]?.[flagKey];
|
|
if (spawnChildCode) {
|
|
await syncSpawnChild(updated, spawnChildCode, enabled);
|
|
}
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
}
|
|
|
|
async function syncSpawnChild(
|
|
parent: BuilderProceeding,
|
|
childCode: string,
|
|
enabled: boolean,
|
|
): Promise<void> {
|
|
if (!state.active) return;
|
|
const existing = state.active.proceedings.find(
|
|
(p) => p.parent_scenario_proceeding_id === parent.id,
|
|
);
|
|
if (enabled && !existing) {
|
|
const childMeta = state.procTypesByCode.get(childCode);
|
|
if (!childMeta) return;
|
|
const child = await addProceeding(state.active.id, {
|
|
proceeding_type_id: childMeta.id,
|
|
parent_scenario_proceeding_id: parent.id,
|
|
});
|
|
if (child) state.active.proceedings.push(child);
|
|
} else if (!enabled && existing) {
|
|
const ok = await deleteProceeding(state.active.id, existing.id);
|
|
if (ok) {
|
|
state.active.proceedings = state.active.proceedings.filter((p) => p.id !== existing.id);
|
|
state.active.events = state.active.events.filter(
|
|
(e) => e.scenario_proceeding_id !== existing.id,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyProceedingPatch(updated: BuilderProceeding): void {
|
|
if (!state.active) return;
|
|
const idx = state.active.proceedings.findIndex((p) => p.id === updated.id);
|
|
if (idx >= 0) state.active.proceedings[idx] = updated;
|
|
}
|
|
|
|
async function onEventAction(
|
|
proc: BuilderProceeding,
|
|
ruleId: string,
|
|
action: "file" | "skip" | "reset",
|
|
payload?: { date?: string; reason?: string },
|
|
): Promise<void> {
|
|
if (!state.active) return;
|
|
const ruleKey = ruleId.toLowerCase();
|
|
const existing = state.active.events.find(
|
|
(e) => e.scenario_proceeding_id === proc.id &&
|
|
e.sequencing_rule_id?.toLowerCase() === ruleKey,
|
|
);
|
|
if (action === "file") {
|
|
const date = payload?.date || todayISO();
|
|
if (existing) {
|
|
const upd = await patchEvent(state.active.id, existing.id, {
|
|
state: "filed",
|
|
actual_date: date,
|
|
});
|
|
if (upd) replaceEvent(upd);
|
|
} else {
|
|
const ev = await addEvent(state.active.id, proc.id, {
|
|
sequencing_rule_id: ruleId,
|
|
state: "filed",
|
|
actual_date: date,
|
|
});
|
|
if (ev) state.active.events.push(ev);
|
|
}
|
|
} else if (action === "skip") {
|
|
if (existing) {
|
|
const upd = await patchEvent(state.active.id, existing.id, {
|
|
state: "skipped",
|
|
skip_reason: payload?.reason ?? "",
|
|
});
|
|
if (upd) replaceEvent(upd);
|
|
} else {
|
|
const ev = await addEvent(state.active.id, proc.id, {
|
|
sequencing_rule_id: ruleId,
|
|
state: "skipped",
|
|
skip_reason: payload?.reason ?? "",
|
|
});
|
|
if (ev) state.active.events.push(ev);
|
|
}
|
|
} else {
|
|
// reset → either patch back to planned or delete the row outright.
|
|
// PATCH is simpler and keeps any horizon_optional the user had set;
|
|
// a separate "clear horizon" affordance handles full removal.
|
|
if (existing) {
|
|
const upd = await patchEvent(state.active.id, existing.id, { state: "planned" });
|
|
if (upd) replaceEvent(upd);
|
|
}
|
|
}
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
}
|
|
|
|
async function onEventHorizon(
|
|
proc: BuilderProceeding,
|
|
ruleId: string,
|
|
delta: 1 | -1,
|
|
): Promise<void> {
|
|
if (!state.active) return;
|
|
const ruleKey = ruleId.toLowerCase();
|
|
const existing = state.active.events.find(
|
|
(e) => e.scenario_proceeding_id === proc.id &&
|
|
e.sequencing_rule_id?.toLowerCase() === ruleKey,
|
|
);
|
|
if (existing) {
|
|
const newHorizon = Math.max(0, existing.horizon_optional + delta);
|
|
const upd = await patchEvent(state.active.id, existing.id, {
|
|
horizon_optional: newHorizon,
|
|
});
|
|
if (upd) replaceEvent(upd);
|
|
} else if (delta > 0) {
|
|
const ev = await addEvent(state.active.id, proc.id, {
|
|
sequencing_rule_id: ruleId,
|
|
horizon_optional: 1,
|
|
state: "planned",
|
|
});
|
|
if (ev) state.active.events.push(ev);
|
|
}
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
}
|
|
|
|
function replaceEvent(updated: BuilderEvent): void {
|
|
if (!state.active) return;
|
|
const idx = state.active.events.findIndex((e) => e.id === updated.id);
|
|
if (idx >= 0) state.active.events[idx] = updated;
|
|
else state.active.events.push(updated);
|
|
}
|
|
|
|
function scenarioFlagsToArray(flags: Record<string, unknown>): string[] {
|
|
// The calc API still consumes the historical flat-flag array form
|
|
// (string slug per active flag). Builder scenario_flags is the
|
|
// jsonb {flag_name: true|false|null} shape — translate by picking
|
|
// every truthy key.
|
|
const out: string[] = [];
|
|
for (const [k, v] of Object.entries(flags)) {
|
|
if (v === true) out.push(k);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// scenarioFlagsToBoolMap narrows the jsonb-shape scenario_flags blob
|
|
// (`{key: true|false|null|other}`) to the strict `Record<string, boolean>`
|
|
// shape filterByDetailMode consumes. The rule:<uuid>=true|false per-rule
|
|
// deviation keys flow through verbatim (their truthiness IS the override
|
|
// signal isRuleSelected reads).
|
|
function scenarioFlagsToBoolMap(flags: Record<string, unknown>): Record<string, boolean> {
|
|
const out: Record<string, boolean> = {};
|
|
for (const [k, v] of Object.entries(flags)) {
|
|
if (typeof v === "boolean") out[k] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Actions
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
async function loadScenario(id: string): Promise<void> {
|
|
const deep = await fetchScenarioDeep(id);
|
|
if (!deep) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
// Defensive: Go's encoding/json serialises a nil slice as `null`, not
|
|
// `[]`. The server initialises these arrays today, but normalising on
|
|
// the client too means a future regression (or an older deployed
|
|
// build) can't crash renderCanvas with `null.filter(...)`.
|
|
if (!Array.isArray(deep.proceedings)) deep.proceedings = [];
|
|
if (!Array.isArray(deep.events)) deep.events = [];
|
|
if (!Array.isArray(deep.shares)) deep.shares = [];
|
|
state.active = deep;
|
|
state.pending = {};
|
|
writeScenarioToUrl(id);
|
|
setSaveState("saved");
|
|
// Sync header inputs to scenario state.
|
|
const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
|
|
if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
|
|
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
|
|
if (rename) rename.disabled = false;
|
|
renderScenarioPicker();
|
|
renderScenarioList();
|
|
renderCanvas();
|
|
}
|
|
|
|
async function onNewScenarioClick(): Promise<void> {
|
|
// Scratch scenario per PRD §2.1 — anonymous until "Benennen". Server
|
|
// applies the default name "Unbenanntes Szenario".
|
|
const sc = await createScenario();
|
|
if (!sc) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
state.list.unshift(sc);
|
|
await loadScenario(sc.id);
|
|
// Open the add-proceeding picker so the user lands on the next action.
|
|
const btn = document.getElementById("builder-add-proceeding-btn") as HTMLElement | null;
|
|
if (btn) openAddProceedingPicker(btn);
|
|
}
|
|
|
|
function openAddProceedingPicker(anchor: HTMLElement): void {
|
|
if (!state.active) return;
|
|
mountAddProceedingPicker(anchor, state.procTypes, async (meta) => {
|
|
if (!state.active) return;
|
|
// Guard against a wire-shape regression: if the proceeding-types
|
|
// endpoint stops returning `id`, `meta.id` is undefined and
|
|
// JSON.stringify silently drops the field, the server rejects the
|
|
// POST with a 400, and fetchJSON swallows the error — the user
|
|
// sees "nothing happens" (t-paliad-345). Fail loud instead.
|
|
if (typeof meta.id !== "number" || meta.id <= 0) {
|
|
console.error("builder: missing proceeding_type id in picker meta", meta);
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
const proc = await addProceeding(state.active.id, {
|
|
proceeding_type_id: meta.id,
|
|
});
|
|
if (!proc) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
state.active.proceedings.push(proc);
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
});
|
|
}
|
|
|
|
async function onRenameClick(): Promise<void> {
|
|
if (!state.active) return;
|
|
const current = state.active.name;
|
|
const next = window.prompt(t("builder.action.rename.prompt"), current);
|
|
if (next === null) return;
|
|
const trimmed = next.trim();
|
|
if (!trimmed || trimmed === current) return;
|
|
state.pending.name = trimmed;
|
|
scheduleAutoSave();
|
|
state.active.name = trimmed;
|
|
renderScenarioPicker();
|
|
renderScenarioList();
|
|
}
|
|
|
|
function onStichtagChange(value: string): void {
|
|
if (!state.active) return;
|
|
state.active.stichtag = value;
|
|
state.pending.stichtag = value;
|
|
scheduleAutoSave();
|
|
// Re-render: the triplet's calc result depends on stichtag.
|
|
renderCanvas();
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Wiring
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Entry-mode + universal search (B3)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
function setMode(mode: EntryMode): void {
|
|
if (mode === state.mode) return;
|
|
// PRD §2.2 step 6: filter pills reset on mode switch. The current
|
|
// surface doesn't yet render forum/proc/party/kind chips, so the
|
|
// reset is a search-input clear; once the filter strip lands in a
|
|
// follow-up slice the chip state attaches here too.
|
|
const searchInput = document.getElementById("builder-search-input") as HTMLInputElement | null;
|
|
if (searchInput) searchInput.value = "";
|
|
state.searchCtl?.close();
|
|
|
|
state.mode = mode;
|
|
state.anchorRuleID = null;
|
|
|
|
// Reflect the mode on the radio strip.
|
|
document.querySelectorAll<HTMLElement>(".builder-mode[data-mode]").forEach((btn) => {
|
|
const isActive = btn.getAttribute("data-mode") === mode;
|
|
btn.classList.toggle("is-active", isActive);
|
|
btn.setAttribute("aria-selected", String(isActive));
|
|
});
|
|
|
|
// On entering Ereignis mode, autofocus the search input per PRD §2.2.
|
|
if (mode === "event" && state.searchCtl) {
|
|
state.searchCtl.focus();
|
|
}
|
|
// On leaving event mode, redraw the canvas so any anchor highlights
|
|
// are dropped.
|
|
if (mode !== "event") {
|
|
renderCanvas();
|
|
}
|
|
}
|
|
|
|
async function onPickEvent(hit: EventSearchHit): Promise<void> {
|
|
// PRD §2.2 — picking an event creates a scratch scenario with one
|
|
// triplet anchored on that event's proceeding type, with the event
|
|
// card auto-anchored (lime band + DU BIST HIER divider).
|
|
const sc = await createScenario();
|
|
if (!sc) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
state.list.unshift(sc);
|
|
// Load the new (empty) scenario, then add the anchored proceeding.
|
|
await loadScenario(sc.id);
|
|
if (!state.active) return;
|
|
state.anchorRuleID = hit.anchor_rule_id;
|
|
const proc = await addProceeding(state.active.id, {
|
|
proceeding_type_id: hit.proceeding_type.id,
|
|
});
|
|
if (!proc) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
state.active.proceedings.push(proc);
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
}
|
|
|
|
async function onPickScenarioFromSearch(hit: ScenarioSearchHit): Promise<void> {
|
|
await loadScenario(hit.id);
|
|
}
|
|
|
|
function onPickProjectFromSearch(hit: ProjectSearchHit): void {
|
|
// PRD §2.3 — Akte (project-backed) builder lands at B4. For now we
|
|
// surface a console hint and a visible save-state message so the
|
|
// user gets feedback rather than silence.
|
|
console.info("builder: project pick deferred to B4", hit);
|
|
setSaveState("idle");
|
|
const status = document.getElementById("builder-save-status");
|
|
if (status) {
|
|
const span = status.querySelector("span");
|
|
if (span) span.textContent = t("builder.search.hint.akte_b4");
|
|
}
|
|
}
|
|
|
|
function wireModeBar(): void {
|
|
document.querySelectorAll<HTMLElement>(".builder-mode[data-mode]").forEach((btn) => {
|
|
if (btn.hasAttribute("disabled")) return;
|
|
btn.addEventListener("click", () => {
|
|
const m = btn.getAttribute("data-mode") as EntryMode | null;
|
|
if (m) setMode(m);
|
|
});
|
|
});
|
|
}
|
|
|
|
function wireSearch(): void {
|
|
const input = document.getElementById("builder-search-input") as HTMLInputElement | null;
|
|
if (!input) return;
|
|
state.searchCtl = mountBuilderSearch(input, {
|
|
onPickEvent: (hit) => {
|
|
// Picking an event implicitly switches to event mode if not
|
|
// already there — keeps the affordance honest when the user
|
|
// searches without first clicking "Ereignis".
|
|
if (state.mode !== "event") setMode("event");
|
|
void onPickEvent(hit);
|
|
},
|
|
onPickScenario: (hit) => {
|
|
void onPickScenarioFromSearch(hit);
|
|
},
|
|
onPickProject: (hit) => {
|
|
onPickProjectFromSearch(hit);
|
|
},
|
|
});
|
|
}
|
|
|
|
function wirePageHeader(): void {
|
|
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
|
|
void onRenameClick();
|
|
});
|
|
document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
|
|
void onNewScenarioClick();
|
|
});
|
|
document.getElementById("builder-cta-new")?.addEventListener("click", () => {
|
|
void onNewScenarioClick();
|
|
});
|
|
const picker = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
|
|
picker?.addEventListener("change", () => {
|
|
const id = picker.value;
|
|
if (id) void loadScenario(id);
|
|
else {
|
|
state.active = null;
|
|
writeScenarioToUrl(null);
|
|
renderCanvas();
|
|
}
|
|
});
|
|
const stichtag = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
|
|
stichtag?.addEventListener("change", () => {
|
|
onStichtagChange(stichtag.value);
|
|
});
|
|
}
|
|
|
|
export async function mountBuilder(): Promise<void> {
|
|
wirePageHeader();
|
|
wireModeBar();
|
|
wireSearch();
|
|
// Parallel boot — proceeding type catalog (Forum=UPC, Kind=proceeding)
|
|
// for the add-proceeding picker + scenario_flag_catalog for the
|
|
// per-triplet flag strip. PRD §0.4 — UPC v1.
|
|
const [procTypes, flagCatalog] = await Promise.all([
|
|
fetchProceedingTypes(),
|
|
fetchFlagCatalog(),
|
|
]);
|
|
state.procTypes = procTypes;
|
|
state.procTypesById = new Map(state.procTypes.map((p) => [p.id, p]));
|
|
state.procTypesByCode = new Map(state.procTypes.map((p) => [p.code, p]));
|
|
state.flagCatalog = flagCatalog;
|
|
await refreshScenarioList();
|
|
const requested = readScenarioFromUrl();
|
|
if (requested && state.list.some((s) => s.id === requested)) {
|
|
await loadScenario(requested);
|
|
} else {
|
|
renderCanvas();
|
|
}
|
|
setSaveState("idle");
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// helpers
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
function todayISO(): string {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function escAttr(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
}
|
|
|
|
export function escHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
// Re-export getLang so the per-page bundle pulls i18n into the dep
|
|
// graph (the i18n module's side-effect-free initialiser otherwise
|
|
// gets tree-shaken when only string keys are referenced).
|
|
export { getLang };
|