Litigation Builder slice B6 (m/paliad#153 PRD §7.1 + §7.4 + §10) — last slice of the train; the Builder is now complete. Mobile basic-read (<640px, PRD §10): - builder.ts wireMobileGuard — a capture-phase document click listener that, only when matchMedia("(max-width:640px)") matches, intercepts taps on mutating affordances (rename/share/promote/new-scenario/add-proceeding + every in-triplet button/input/select), preventDefault+stopPropagation, and surfaces a "Auf größerem Bildschirm öffnen" toast. Desktop code paths are untouched (guard early-returns off-mobile). Column-triplets already collapse to a single column via the reused .fr-columns-view @640px rule; reading (open/switch scenarios, search, mode tabs) stays fully functional. - global.css — .builder-mobile-toast + full-bleed modal on phones. Dead U0-U4 catalog cleanup (PRD §7.4) — deleted, no remaining references (grep "from.*fristenrechner-|from.*verfahrensablauf" shows only the kept verfahrensablauf-core + verfahrensablauf-detail-mode the builder reuses): - client/fristenrechner-mode-a.ts, fristenrechner-result.ts(+test), fristenrechner-wizard.ts(+test) - client/verfahrensablauf.ts, client/views/event-card-choices.ts, client/views/verfahrensablauf-state.ts(+test) - components/VerfahrensablaufBody.tsx (/tools/fristenrechner + /tools/verfahrensablauf stay as 301 redirects to /tools/procedures — handlers already redirectToProcedures.) i18n finalised (DE+EN): removed 4 duplicate deadlines.party.* keys per block (behaviour-preserving — the later, winning copy is kept) and added the missing DE "cal.today". Codegen clean, no dupes, full DE/EN parity. go build/vet clean; bun build + 227 frontend tests pass. Playwright- verified: at 375px the triplet collapses to one column + the scenario list reads, while "+ Verfahren hinzufügen" and "Teilen" are blocked (toast shown, no action); at 1280px the same actions work normally.
1572 lines
60 KiB
TypeScript
1572 lines
60 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";
|
|
import {
|
|
mountAktePicker,
|
|
createScenarioFromProject,
|
|
renderAkteBanner,
|
|
type AktePickerHandle,
|
|
} from "./builder-akte";
|
|
import {
|
|
onScenarioFlagsChanged,
|
|
SCENARIO_FLAG_CHANGED_EVENT,
|
|
type ScenarioFlagChangedDetail,
|
|
} from "./scenario-flags";
|
|
import { openShareModal, type BuilderShareRow } from "./builder-shares";
|
|
import { openPromoteWizard } from "./builder-promote";
|
|
|
|
// 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[];
|
|
// B5 — scenarios shared read-only with me (the "Geteilt mit mir"
|
|
// bucket). Disjoint from `list` (which is owner-scoped). readonly is
|
|
// true when the active scenario is one of these OR is promoted —
|
|
// either way every mutating affordance is disabled + a watermark shows.
|
|
shared: BuilderScenario[];
|
|
readonly: boolean;
|
|
// owner display-name cache for the read-only watermark.
|
|
ownerNameById: Map<string, string>;
|
|
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;
|
|
// B4 — Akte-mode picker handle. Null until the page mounts; once
|
|
// mountAktePicker resolves, the handle lets us keep the picker
|
|
// selection reflective of the active scenario (origin_project_id).
|
|
aktePicker: AktePickerHandle | null;
|
|
}
|
|
|
|
const state: State = {
|
|
active: null,
|
|
list: [],
|
|
shared: [],
|
|
readonly: false,
|
|
ownerNameById: new Map(),
|
|
procTypes: [],
|
|
procTypesById: new Map(),
|
|
procTypesByCode: new Map(),
|
|
flagCatalog: [],
|
|
saveTimer: null,
|
|
pending: {},
|
|
mode: "cold",
|
|
anchorRuleID: null,
|
|
searchCtl: null,
|
|
aktePicker: 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[]> {
|
|
// B5 — pull every status so the side panel can bucket into Aktiv /
|
|
// Promoted / Archiviert. The picker + recent list filter to active.
|
|
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=all");
|
|
return Array.isArray(out) ? out : [];
|
|
}
|
|
|
|
async function fetchSharedScenarios(): Promise<BuilderScenario[]> {
|
|
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios/shared");
|
|
return Array.isArray(out) ? out : [];
|
|
}
|
|
|
|
// fetchOwnerNames lazily loads the user directory once so the read-only
|
|
// watermark can render "Geteilt von <Name>". Failures degrade to showing
|
|
// the owner uuid; the watermark is informational, not load-bearing.
|
|
async function ensureOwnerNames(): Promise<void> {
|
|
if (state.ownerNameById.size > 0) return;
|
|
const users = await fetchJSON<Array<{ id: string; display_name?: string; email: string }>>("/api/users");
|
|
if (!Array.isArray(users)) return;
|
|
for (const u of users) {
|
|
state.ownerNameById.set(u.id, (u.display_name || "").trim() || u.email);
|
|
}
|
|
}
|
|
|
|
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> {
|
|
// Owned (all statuses) + shared-with-me run in parallel.
|
|
const [owned, shared] = await Promise.all([fetchScenarios(), fetchSharedScenarios()]);
|
|
state.list = owned;
|
|
state.shared = shared;
|
|
renderScenarioList();
|
|
renderScenarioPicker();
|
|
}
|
|
|
|
// renderBucket paints one side-panel bucket UL + toggles its wrapper's
|
|
// hidden attribute when empty. The Aktiv bucket always renders (shows the
|
|
// empty hint); the others hide when they have no rows.
|
|
function renderBucket(listId: string, wrapId: string | null, scenarios: BuilderScenario[], alwaysShow: boolean): void {
|
|
const ul = document.getElementById(listId);
|
|
if (!ul) return;
|
|
if (wrapId) {
|
|
const wrap = document.getElementById(wrapId);
|
|
if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0;
|
|
}
|
|
if (scenarios.length === 0) {
|
|
ul.innerHTML = alwaysShow
|
|
? `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`
|
|
: "";
|
|
return;
|
|
}
|
|
const activeId = state.active?.id;
|
|
ul.innerHTML = scenarios.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 renderScenarioList(): void {
|
|
renderBucket("builder-scenario-list-active", null,
|
|
state.list.filter((s) => s.status === "active"), true);
|
|
renderBucket("builder-scenario-list-shared", "builder-bucket-shared", state.shared, false);
|
|
renderBucket("builder-scenario-list-promoted", "builder-bucket-promoted",
|
|
state.list.filter((s) => s.status === "promoted"), false);
|
|
renderBucket("builder-scenario-list-archived", "builder-bucket-archived",
|
|
state.list.filter((s) => s.status === "archived"), false);
|
|
}
|
|
|
|
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>`];
|
|
// Picker shows openable scenarios: active owned + shared-with-me.
|
|
const pickable = [
|
|
...state.list.filter((s) => s.status === "active"),
|
|
...state.shared,
|
|
];
|
|
// Ensure the currently-active scenario is selectable even if promoted/
|
|
// archived (so the dropdown reflects reality when one is open).
|
|
if (state.active && !pickable.some((s) => s.id === state.active!.id)) {
|
|
pickable.unshift(state.active);
|
|
}
|
|
for (const sc of pickable) {
|
|
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);
|
|
}
|
|
|
|
// B4 — Akte-backed flag toggles dual-write to projects.scenario_flags
|
|
// on the server. Mirror the cross-surface CustomEvent here so peer
|
|
// surfaces (Verfahrensablauf strip, project Verlauf, dashboard
|
|
// panes) listening via `scenario-flags.ts` re-sync without a fresh
|
|
// GET. The PATCH /api/builder/.../proceedings hop bypasses
|
|
// patchScenarioFlags (which is what normally dispatches), so we
|
|
// recreate the event here when the active scenario is project-
|
|
// backed AND the patched triplet is top-level.
|
|
if (
|
|
state.active.origin_project_id &&
|
|
!updated.parent_scenario_proceeding_id
|
|
) {
|
|
const detail: ScenarioFlagChangedDetail = {
|
|
projectId: state.active.origin_project_id,
|
|
flags: builderFlagsToBoolMap(updated.scenario_flags),
|
|
changedKeys: [flagKey],
|
|
};
|
|
document.dispatchEvent(new CustomEvent(SCENARIO_FLAG_CHANGED_EVENT, { detail }));
|
|
}
|
|
|
|
setSaveState("saved");
|
|
renderCanvas();
|
|
}
|
|
|
|
// builderFlagsToBoolMap converts the builder's loose
|
|
// Record<string, unknown> (the wire shape of scenario_proceedings.
|
|
// scenario_flags) into the strict Record<string, boolean> shape peer
|
|
// surfaces expect. Non-bool values are dropped — defensive parsing
|
|
// for the same reason the server's flagDeltaFromBuilder skips them.
|
|
function builderFlagsToBoolMap(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;
|
|
}
|
|
|
|
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 = {};
|
|
// B5 — read-only when the scenario is shared with me (I'm not the
|
|
// owner) or already promoted (server blocks mutations either way).
|
|
const isShared = state.shared.some((s) => s.id === id);
|
|
state.readonly = isShared || deep.status === "promoted";
|
|
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);
|
|
await applyScenarioChrome(deep, isShared);
|
|
// B4 — reflect the scenario's Akte link on the page-header picker
|
|
// + banner. Project-backed scenarios reveal the source project so
|
|
// the user knows the builder writes feed into that Akte; non-Akte
|
|
// (cold-open / event-triggered) scenarios reset the picker to
|
|
// "— ohne —" + hide the banner.
|
|
if (state.aktePicker) {
|
|
state.aktePicker.setSelectedProject(deep.origin_project_id ?? null);
|
|
}
|
|
renderAkteBanner(deep.origin_project_id ?? null);
|
|
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();
|
|
});
|
|
}
|
|
|
|
// applyScenarioChrome sets the page-header action buttons + read-only
|
|
// watermark + body class for the freshly-loaded scenario. Editable
|
|
// scenarios get rename / share / promote enabled; read-only ones (shared
|
|
// with me, or promoted) lock all three and show the watermark. The body
|
|
// class drives the CSS that neutralises in-canvas mutating affordances.
|
|
async function applyScenarioChrome(deep: BuilderScenarioDeep, isShared: boolean): Promise<void> {
|
|
document.body.classList.toggle("builder-readonly", state.readonly);
|
|
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
|
|
const share = document.getElementById("builder-share-btn") as HTMLButtonElement | null;
|
|
const promote = document.getElementById("builder-promote-btn") as HTMLButtonElement | null;
|
|
if (rename) rename.disabled = state.readonly;
|
|
if (share) share.disabled = state.readonly;
|
|
if (promote) promote.disabled = state.readonly;
|
|
|
|
const wm = document.getElementById("builder-readonly-watermark");
|
|
if (!wm) return;
|
|
if (!state.readonly) {
|
|
wm.hidden = true;
|
|
wm.textContent = "";
|
|
return;
|
|
}
|
|
if (isShared) {
|
|
await ensureOwnerNames();
|
|
const owner = (deep.owner_id && state.ownerNameById.get(deep.owner_id)) || deep.owner_id || "?";
|
|
wm.textContent = t("builder.readonly.watermark").replace("{owner}", owner);
|
|
} else {
|
|
// Promoted (owned) scenario — read-only reference.
|
|
wm.textContent = t("builder.bucket.promoted");
|
|
}
|
|
wm.hidden = false;
|
|
}
|
|
|
|
// resetScenarioChrome clears the page-header action state + watermark
|
|
// when no scenario is active (cold-open / picker cleared).
|
|
function resetScenarioChrome(): void {
|
|
document.body.classList.remove("builder-readonly");
|
|
for (const id of ["builder-rename-btn", "builder-share-btn", "builder-promote-btn"]) {
|
|
const b = document.getElementById(id) as HTMLButtonElement | null;
|
|
if (b) b.disabled = true;
|
|
}
|
|
const wm = document.getElementById("builder-readonly-watermark");
|
|
if (wm) {
|
|
wm.hidden = true;
|
|
wm.textContent = "";
|
|
}
|
|
}
|
|
|
|
// onShareClick opens the share modal for the active (owned, editable)
|
|
// scenario. PRD §2.5.
|
|
function onShareClick(): void {
|
|
if (!state.active || state.readonly) return;
|
|
void openShareModal({
|
|
scenarioId: state.active.id,
|
|
ownerId: state.active.owner_id,
|
|
currentShares: (state.active.shares as BuilderShareRow[]) ?? [],
|
|
onChanged: (shares) => {
|
|
if (state.active) state.active.shares = shares;
|
|
},
|
|
});
|
|
}
|
|
|
|
// onPromoteClick gathers the summary numbers for wizard step 1 and opens
|
|
// the promote-to-project wizard. PRD §2.4. The primary proceeding (lowest-
|
|
// ordinal top-level) + its spawned descendants are what the server
|
|
// promotes into one case file; additional standalone proceedings are
|
|
// reported in the summary as staying behind.
|
|
function onPromoteClick(): void {
|
|
if (!state.active || state.readonly) return;
|
|
const sc = state.active;
|
|
const topLevel = sc.proceedings
|
|
.filter((p) => !p.parent_scenario_proceeding_id)
|
|
.sort((a, b) => a.ordinal - b.ordinal);
|
|
const primary = topLevel[0];
|
|
if (!primary) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
// Collect primary + descendants to scope the event counts.
|
|
const subtree = new Set<string>([primary.id]);
|
|
for (let changed = true; changed; ) {
|
|
changed = false;
|
|
for (const p of sc.proceedings) {
|
|
if (p.parent_scenario_proceeding_id && subtree.has(p.parent_scenario_proceeding_id) && !subtree.has(p.id)) {
|
|
subtree.add(p.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
const evs = sc.events.filter((e) => subtree.has(e.scenario_proceeding_id));
|
|
const meta = state.procTypesById.get(primary.proceeding_type_id);
|
|
const label = meta ? meta.name || meta.code : "?";
|
|
const defaultParty = (primary.primary_party as "claimant" | "defendant" | undefined) ?? null;
|
|
void openPromoteWizard({
|
|
scenarioId: sc.id,
|
|
ownerId: sc.owner_id,
|
|
proceedingLabel: label,
|
|
filedCount: evs.filter((e) => e.state === "filed").length,
|
|
plannedCount: evs.filter((e) => e.state === "planned").length,
|
|
flagCount: Object.values(primary.scenario_flags).filter((v) => v === true).length,
|
|
extraTopLevel: topLevel.length - 1,
|
|
defaultOurSide: defaultParty,
|
|
defaultTitle: sc.name && sc.name !== "Unbenanntes Szenario" ? sc.name : "",
|
|
onSuccess: (projectId) => {
|
|
window.location.href = "/projects/" + encodeURIComponent(projectId);
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async function onPickProjectFromSearch(hit: ProjectSearchHit): Promise<void> {
|
|
// PRD §2.3 — picking a project from universal search puts the
|
|
// builder into Akte mode and creates a project-backed scenario.
|
|
// Switch the mode chip + sync the page-header Akte picker so the
|
|
// user sees the consistent state.
|
|
if (state.mode !== "akte") setMode("akte");
|
|
await loadAkteScenario(hit.id);
|
|
}
|
|
|
|
// loadAkteScenario is the canonical path that turns a project_id into
|
|
// a live builder scenario. POST /api/builder/scenarios/from-project
|
|
// returns the new scenario id; we then reuse loadScenario() (which
|
|
// fetches the deep payload + sets state.active + paints the canvas).
|
|
// Refreshes the scenario list afterwards so the side panel + picker
|
|
// pick up the new row.
|
|
async function loadAkteScenario(projectId: string): Promise<void> {
|
|
setSaveState("saving");
|
|
const created = await createScenarioFromProject(projectId);
|
|
if (!created) {
|
|
setSaveState("error");
|
|
return;
|
|
}
|
|
await refreshScenarioList();
|
|
await loadScenario(created.id);
|
|
if (state.aktePicker) state.aktePicker.setSelectedProject(projectId);
|
|
renderAkteBanner(projectId);
|
|
}
|
|
|
|
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-share-btn")?.addEventListener("click", () => {
|
|
onShareClick();
|
|
});
|
|
document.getElementById("builder-promote-btn")?.addEventListener("click", () => {
|
|
onPromoteClick();
|
|
});
|
|
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;
|
|
state.readonly = false;
|
|
writeScenarioToUrl(null);
|
|
resetScenarioChrome();
|
|
renderCanvas();
|
|
}
|
|
});
|
|
const stichtag = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
|
|
stichtag?.addEventListener("change", () => {
|
|
onStichtagChange(stichtag.value);
|
|
});
|
|
}
|
|
|
|
// wireScenarioFlagsListener keeps the builder in sync with cross-
|
|
// surface scenario_flag mutations. When a peer surface (Verfahrensablauf
|
|
// strip, project-detail page, project Verlauf) PATCHes
|
|
// /api/projects/{id}/scenario-flags, scenario-flags.ts dispatches a
|
|
// CustomEvent on document. If the changed project is the active
|
|
// scenario's origin_project_id, we refetch the scenario deep payload
|
|
// so the builder's top-level triplet picks up the new flags + any
|
|
// spawn children re-render. Non-matching events (unrelated project,
|
|
// or scenario isn't Akte-backed) are ignored.
|
|
function wireScenarioFlagsListener(): void {
|
|
onScenarioFlagsChanged(async (detail: ScenarioFlagChangedDetail) => {
|
|
const sc = state.active;
|
|
if (!sc || !sc.origin_project_id) return;
|
|
if (sc.origin_project_id !== detail.projectId) return;
|
|
// Avoid feedback loops: the builder's own PATCH (via patchScenario
|
|
// Flags inside scenario-flags.ts) fires this event too. We can't
|
|
// distinguish our own dispatch from a peer's, but the refetch
|
|
// path is idempotent — fetchScenarioDeep returns the same data
|
|
// we already have on first paint, and the canvas re-render is
|
|
// cheap. The save state stays "saved" because no fields are
|
|
// dirty.
|
|
const fresh = await fetchScenarioDeep(sc.id);
|
|
if (!fresh) return;
|
|
if (!Array.isArray(fresh.proceedings)) fresh.proceedings = [];
|
|
if (!Array.isArray(fresh.events)) fresh.events = [];
|
|
if (!Array.isArray(fresh.shares)) fresh.shares = [];
|
|
state.active = fresh;
|
|
renderCanvas();
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// B6 — mobile basic-read guard (PRD §10 + §7.1)
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Mutating affordances that get gated on narrow viewports. Reading
|
|
// (open a scenario from the side panel, switch via the picker, search,
|
|
// switch entry mode) stays fully functional — only scenario-mutating
|
|
// taps are intercepted.
|
|
const MOBILE_MUTATING_SELECTOR = [
|
|
"#builder-rename-btn",
|
|
"#builder-share-btn",
|
|
"#builder-promote-btn",
|
|
"#builder-new-scenario-btn",
|
|
"#builder-cta-new",
|
|
"#builder-add-proceeding-btn",
|
|
".builder-triplet-host button",
|
|
".builder-triplet-host input",
|
|
".builder-triplet-host select",
|
|
].join(",");
|
|
|
|
function isNarrowViewport(): boolean {
|
|
return typeof window.matchMedia === "function" &&
|
|
window.matchMedia("(max-width: 640px)").matches;
|
|
}
|
|
|
|
let mobileToastTimer: number | null = null;
|
|
|
|
function showMobileBlockedToast(): void {
|
|
let toast = document.getElementById("builder-mobile-toast");
|
|
if (!toast) {
|
|
toast = document.createElement("div");
|
|
toast.id = "builder-mobile-toast";
|
|
toast.className = "builder-mobile-toast";
|
|
toast.setAttribute("role", "status");
|
|
toast.setAttribute("aria-live", "polite");
|
|
document.body.appendChild(toast);
|
|
}
|
|
toast.textContent = t("builder.mobile.blocked");
|
|
toast.classList.add("is-visible");
|
|
if (mobileToastTimer !== null) window.clearTimeout(mobileToastTimer);
|
|
mobileToastTimer = window.setTimeout(() => {
|
|
document.getElementById("builder-mobile-toast")?.classList.remove("is-visible");
|
|
}, 2600);
|
|
}
|
|
|
|
// wireMobileGuard intercepts taps on mutating affordances when the
|
|
// viewport is narrow (<640px), surfacing the "Auf größerem Bildschirm
|
|
// öffnen" toast instead of running the action. Capture phase so it
|
|
// pre-empts the control's own (bubble-phase) handler; calling
|
|
// preventDefault on a checkbox click also blocks its toggle + change
|
|
// event. Desktop is untouched — the guard early-returns unless the media
|
|
// query matches, so the desktop interaction code paths stay identical
|
|
// (PRD §10).
|
|
function wireMobileGuard(): void {
|
|
document.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
if (!isNarrowViewport()) return;
|
|
const target = e.target as HTMLElement | null;
|
|
if (!target || !target.closest(MOBILE_MUTATING_SELECTOR)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
showMobileBlockedToast();
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
|
|
export async function mountBuilder(): Promise<void> {
|
|
wirePageHeader();
|
|
wireModeBar();
|
|
wireSearch();
|
|
wireScenarioFlagsListener();
|
|
wireMobileGuard();
|
|
// 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();
|
|
|
|
// B4 — mount the Akte picker in parallel with the scenario load.
|
|
// The picker fetches /api/projects?type=case; the call is gated on
|
|
// requireDB so dev/test environments without a DB pool return [].
|
|
// Independent of scenario state — we mount it before deciding
|
|
// whether to load a scenario so the picker is interactive even if
|
|
// the requested scenario fails to load.
|
|
state.aktePicker = await mountAktePicker(async (projectId) => {
|
|
if (state.mode !== "akte") setMode("akte");
|
|
await loadAkteScenario(projectId);
|
|
});
|
|
|
|
const requested = readScenarioFromUrl();
|
|
// Deep-link auto-load covers both owned scenarios and ones shared with
|
|
// me (so a "Geteilt mit mir" link opens straight into the read-only
|
|
// view, not the cold-open canvas). loadScenario derives read-only from
|
|
// state.shared, so the share watermark + locked affordances apply.
|
|
const isKnown =
|
|
requested != null &&
|
|
(state.list.some((s) => s.id === requested) || state.shared.some((s) => s.id === requested));
|
|
if (isKnown) {
|
|
await loadScenario(requested as string);
|
|
} 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 };
|