Merge: t-paliad-343 B1 — Litigation Builder shell + cold-open (m/paliad#153)

This commit is contained in:
mAi
2026-05-28 00:29:50 +02:00
8 changed files with 1817 additions and 271 deletions

View File

@@ -0,0 +1,147 @@
// Add-proceeding inline picker for the Litigation Builder.
//
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
// gates the Verfahren chip row, click → callback. Designed for B1's
// single-triplet flow and B2's multi-triplet stacking with no shape
// change between slices.
import { t } from "./i18n";
export interface ProceedingTypeMeta {
id: number;
code: string;
name: string;
nameEN: string;
// group / jurisdiction. The proceeding-types API returns "UPC" /
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
// only renders UPC.
group?: string;
jurisdiction?: string;
}
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
let activePopover: HTMLElement | null = null;
export function mountAddProceedingPicker(
anchor: HTMLElement,
types: ProceedingTypeMeta[],
onPick: OnPick,
): void {
closeActive();
const pop = document.createElement("div");
pop.className = "builder-picker-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("builder.picker.aria"));
const header = document.createElement("div");
header.className = "builder-picker-header";
header.innerHTML = `
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
`;
pop.appendChild(header);
// Forum row — UPC only for v1. Disabled chips render greyed.
const forumRow = document.createElement("div");
forumRow.className = "builder-picker-row";
forumRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
<div class="builder-picker-chips">
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
</div>
`;
pop.appendChild(forumRow);
const procRow = document.createElement("div");
procRow.className = "builder-picker-row";
procRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
`;
pop.appendChild(procRow);
const empty = document.createElement("p");
empty.className = "builder-picker-empty";
empty.hidden = true;
empty.textContent = t("builder.picker.empty");
pop.appendChild(empty);
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
const lang = document.documentElement.lang === "en" ? "en" : "de";
for (const meta of types) {
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-picker-chip builder-picker-chip--proc";
chip.setAttribute("data-code", meta.code);
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
chip.addEventListener("click", () => {
closeActive();
void onPick(meta);
});
procHost.appendChild(chip);
}
if (types.length === 0) empty.hidden = false;
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
closeActive();
});
// Position the popover under the anchor button.
positionUnder(pop, anchor);
document.body.appendChild(pop);
activePopover = pop;
document.addEventListener("click", onOutsideClick, true);
document.addEventListener("keydown", onEscape, true);
}
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
const rect = anchor.getBoundingClientRect();
pop.style.position = "absolute";
const top = rect.bottom + window.scrollY + 6;
// Default left = anchor's left; clamp so popover stays in viewport.
const left = Math.max(8, rect.left + window.scrollX);
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
pop.style.zIndex = "60";
}
function onOutsideClick(ev: Event): void {
if (!activePopover) return;
const target = ev.target as Node;
if (activePopover.contains(target)) return;
closeActive();
}
function onEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") closeActive();
}
function closeActive(): void {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
document.removeEventListener("click", onOutsideClick, true);
document.removeEventListener("keydown", onEscape, true);
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,83 @@
// ProceedingTriplet renderer for the Litigation Builder.
//
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
// perspective indicator + Detailgrad + columnar `proaktiv | court |
// reaktiv` body. B1 ships the read-only render; B2 wires perspective +
// flag strip + collapse/remove + 3-state event cards.
import { t, getLang } from "./i18n";
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
import type { BuilderProceeding } from "./builder";
import type { ProceedingTypeMeta } from "./builder-picker";
export interface RenderTripletInput {
proceeding: BuilderProceeding;
meta: ProceedingTypeMeta;
data: DeadlineResponse | null;
side: Side;
columnsHtml: string;
}
export function renderTriplet(input: RenderTripletInput): string {
const lang = getLang();
const procLabel = lang === "en"
? (input.meta.nameEN || input.meta.name)
: (input.meta.name || input.meta.nameEN);
const sideLabel = sidePillLabel(input.side);
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
const body = input.data
? input.columnsHtml
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
return `
<header class="builder-triplet-header">
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
${sideLabel ? `<span class="builder-triplet-side">${escHtml(sideLabel)}</span>` : ""}
${flagsBadge}
</header>
<div class="builder-triplet-body">
${body}
</div>
`;
}
function jurisdictionFor(meta: ProceedingTypeMeta): string {
if (meta.jurisdiction) return meta.jurisdiction;
if (meta.group) return meta.group;
const dot = meta.code.indexOf(".");
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
return meta.code.toUpperCase();
}
function sidePillLabel(side: Side): string {
switch (side) {
case "claimant":
return t("builder.triplet.side.claimant");
case "defendant":
return t("builder.triplet.side.defendant");
default:
return "";
}
}
function activeFlagsBadge(flags: Record<string, unknown>): string {
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
if (active.length === 0) return "";
const label = t("builder.triplet.flags.label");
const chips = active.map((f) =>
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
).join("");
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View File

@@ -0,0 +1,586 @@
// 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, tDyn, getLang } from "./i18n";
import {
calculateDeadlines,
renderColumnsBody,
type DeadlineResponse,
type Side,
} from "./views/verfahrensablauf-core";
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
import { renderTriplet } from "./builder-triplet";
// ────────────────────────────────────────────────────────────────────────────
// 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.
// ────────────────────────────────────────────────────────────────────────────
interface State {
active: BuilderScenarioDeep | null;
list: BuilderScenario[];
procTypes: ProceedingTypeMeta[];
procTypesById: Map<number, ProceedingTypeMeta>;
procTypesByCode: Map<string, ProceedingTypeMeta>;
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 };
}
const state: State = {
active: null,
list: [],
procTypes: [],
procTypesById: new Map(),
procTypesByCode: new Map(),
saveTimer: null,
pending: {},
};
// ────────────────────────────────────────────────────────────────────────────
// 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 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 },
): Promise<BuilderProceeding | null> {
return await fetchJSON<BuilderProceeding>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings",
{
method: "POST",
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) {
const tripletHost = document.createElement("article");
tripletHost.className = "builder-triplet-host";
tripletHost.setAttribute("data-proceeding-id", proc.id);
canvas.appendChild(tripletHost);
void renderProceedingTriplet(proc, tripletHost);
}
// Add-proceeding affordance always at the bottom — even B1's single
// triplet flow needs a Hinzufügen affordance once the canvas is empty
// OR exactly one triplet renders (the cold-open CTA can't survive
// post-create).
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);
}
async function renderProceedingTriplet(
proc: BuilderProceeding,
host: HTMLElement,
): Promise<void> {
const meta = state.procTypesById.get(proc.proceeding_type_id);
if (!meta) {
host.innerHTML = `<div class="builder-triplet-error">${escHtml(
tDyn("builder.triplet.unknown_proceeding"),
)}</div>`;
return;
}
const stichtag = proc.stichtag || state.active?.stichtag || todayISO();
const data: DeadlineResponse | null = await calculateDeadlines({
proceedingType: meta.code,
triggerDate: stichtag,
flags: scenarioFlagsToArray(proc.scenario_flags),
});
const side: Side = (proc.primary_party as Side) || null;
const tripletHtml = renderTriplet({
proceeding: proc,
meta,
data,
side,
columnsHtml: data ? renderColumnsBody(data, { editable: false, side, showDurations: false }) : "",
});
host.innerHTML = tripletHtml;
}
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;
}
// ────────────────────────────────────────────────────────────────────────────
// Actions
// ────────────────────────────────────────────────────────────────────────────
async function loadScenario(id: string): Promise<void> {
const deep = await fetchScenarioDeep(id);
if (!deep) {
setSaveState("error");
return;
}
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;
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
// ────────────────────────────────────────────────────────────────────────────
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();
// Load proceeding type catalog (Forum=UPC, Kind=proceeding) up-front
// so the add-proceeding picker is instant. PRD §0.4 — UPC v1.
state.procTypes = await fetchProceedingTypes();
state.procTypesById = new Map(state.procTypes.map((p) => [p.id, p]));
state.procTypesByCode = new Map(state.procTypes.map((p) => [p.code, p]));
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, "&amp;").replace(/"/g, "&quot;");
}
export function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// 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 };

View File

@@ -214,6 +214,69 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
"nav.procedures": "Verfahren & Fristen",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
"builder.header.scenario": "Szenario:",
"builder.header.akte": "Akte:",
"builder.header.stichtag": "Stichtag:",
"builder.header.search": "Suche:",
"builder.akte.none": "\u2014 ohne \u2014",
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
"builder.action.rename": "Benennen",
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
"builder.action.share": "Teilen",
"builder.action.promote": "Als Projekt anlegen",
"builder.mode.cold": "\u00dcbersicht",
"builder.mode.event": "Ereignis",
"builder.mode.akte": "Aus Akte",
"builder.panel.title": "Meine Szenarien",
"builder.panel.new": "+ Neues Szenario",
"builder.panel.empty": "Noch keine Szenarien.",
"builder.bucket.active": "Aktiv",
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
"builder.empty.cta": "Neues Szenario starten",
"builder.empty.recent": "Zuletzt bearbeitet",
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
"builder.picker.close": "Schlie\u00dfen",
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Verfahren:",
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
"builder.triplet.loading": "Berechne Fristen \u2026",
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
"builder.triplet.side.defendant": "Beklagten-Sicht",
"builder.triplet.flags.label": "Optionen:",
"builder.triplet.perspective.label": "Perspektive:",
"builder.triplet.perspective.none": "keine",
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
"builder.triplet.perspective.defendant": "Beklagter",
"builder.triplet.detailgrad.label": "Detailgrad:",
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
"builder.triplet.detailgrad.all_options": "Alle Optionen",
"builder.triplet.remove": "Entfernen",
"builder.triplet.collapse": "Einklappen",
"builder.triplet.expand": "Ausklappen",
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
"builder.event.state.planned": "geplant",
"builder.event.state.filed": "eingereicht",
"builder.event.state.skipped": "ausgelassen",
"builder.event.action.file": "Einreichen",
"builder.event.action.skip": "Auslassen",
"builder.event.action.reset": "Zur\u00fcck zu geplant",
"builder.event.actual_date.prompt": "Datum der Einreichung:",
"builder.event.skip_reason.prompt": "Grund (optional):",
"builder.event.horizon.label": "+{n} Optionen \u25be",
"builder.event.horizon.hide": "Optionen ausblenden",
"builder.save.idle": "\u00a0",
"builder.save.saving": "Speichert \u2026",
"builder.save.saved": "Gespeichert \u2713",
"builder.save.error": "Speichern fehlgeschlagen",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step2.perspective": "Perspektive und Datum",
@@ -3418,6 +3481,69 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
"nav.procedures": "Procedures & Deadlines",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
"builder.header.scenario": "Scenario:",
"builder.header.akte": "Matter:",
"builder.header.stichtag": "Anchor:",
"builder.header.search": "Search:",
"builder.akte.none": "— none —",
"builder.search.placeholder": "Event, scenario, matter …",
"builder.action.rename": "Name it",
"builder.action.rename.prompt": "Name for this scenario:",
"builder.action.share": "Share",
"builder.action.promote": "Create as project",
"builder.mode.cold": "Overview",
"builder.mode.event": "Event",
"builder.mode.akte": "From matter",
"builder.panel.title": "My scenarios",
"builder.panel.new": "+ New scenario",
"builder.panel.empty": "No scenarios yet.",
"builder.bucket.active": "Active",
"builder.empty.headline": "No scenario open.",
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
"builder.empty.cta": "Start a new scenario",
"builder.empty.recent": "Recent",
"builder.picker.placeholder": "— pick a scenario —",
"builder.picker.title": "Add proceeding",
"builder.picker.close": "Close",
"builder.picker.aria": "Pick a proceeding",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Proceeding:",
"builder.picker.empty": "No proceedings available.",
"builder.picker.future_jurisdiction": "Other forums coming later.",
"builder.canvas.add_proceeding": "+ Add proceeding",
"builder.triplet.loading": "Calculating deadlines …",
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
"builder.triplet.side.claimant": "Claimant view",
"builder.triplet.side.defendant": "Defendant view",
"builder.triplet.flags.label": "Options:",
"builder.triplet.perspective.label": "Perspective:",
"builder.triplet.perspective.none": "none",
"builder.triplet.perspective.claimant": "Claimant",
"builder.triplet.perspective.defendant": "Defendant",
"builder.triplet.detailgrad.label": "Detail:",
"builder.triplet.detailgrad.selected": "Selected",
"builder.triplet.detailgrad.all_options": "All options",
"builder.triplet.remove": "Remove",
"builder.triplet.collapse": "Collapse",
"builder.triplet.expand": "Expand",
"builder.triplet.no_flags": "(no flags for this proceeding type)",
"builder.event.state.planned": "planned",
"builder.event.state.filed": "filed",
"builder.event.state.skipped": "skipped",
"builder.event.action.file": "File",
"builder.event.action.skip": "Skip",
"builder.event.action.reset": "Reset to planned",
"builder.event.actual_date.prompt": "Date of filing:",
"builder.event.skip_reason.prompt": "Reason (optional):",
"builder.event.horizon.label": "+{n} optional ▾",
"builder.event.horizon.hide": "Hide optional",
"builder.save.idle": " ",
"builder.save.saving": "Saving …",
"builder.save.saved": "Saved ✓",
"builder.save.error": "Save failed",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step2.perspective": "Perspective and Date",

View File

@@ -1,150 +1,15 @@
// /tools/procedures client (m/paliad#151,
// docs/design-unified-procedural-events-tool-2026-05-27.md).
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
//
// Boot logic + tab switching for the unified procedural-events tool.
// Each entry tab mounts its own module; the search box and chip
// filters in the top filter strip are wired in U1+ as each slice adds
// its dimension-aware behaviour.
//
// U0 — Skeleton + tab toggling.
// U1 — Direkt suchen mounts Mode A.
// U2 — Geführt mounts Mode B wizard.
// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle.
//
// Mode A renders its shell into #fristen-overhaul-root (replacing
// children); Mode B renders into #fristen-overhaul-mode-host; the
// result view (post-commit) writes into #fristen-overhaul-root. To
// keep those IDs unique in the DOM, only the active tab's panel ever
// hosts the overhaul scaffold — installOverhaulHost() tears down any
// existing host and installs a fresh one inside the target panel
// before handing off to the per-mode module.
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
// emitted by procedures.tsx; this file boots the i18n + sidebar
// runtime and hands off to builder.ts.
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import { mountModeA } from "./fristenrechner-mode-a";
import { mountResultView } from "./fristenrechner-result";
import { mountWizard } from "./fristenrechner-wizard";
import { initVerfahrensablauf } from "./verfahrensablauf";
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
function readTabFromUrl(): ProceduresTab {
const params = new URLSearchParams(window.location.search);
const raw = params.get("mode");
if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab;
return "proceeding";
}
function writeTabToUrl(tab: ProceduresTab): void {
const url = new URL(window.location.href);
if (tab === "proceeding") {
url.searchParams.delete("mode");
} else {
url.searchParams.set("mode", tab);
}
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
// any existing host first, so the IDs stay unique across the page even
// when the user toggles between Direkt-suchen and Geführt — both Mode
// A and the wizard read these IDs from document.getElementById which
// returns the first match in DOM order, so two parallel hosts would
// cross-wire.
function installOverhaulHost(panelId: string): HTMLElement | null {
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
const panel = document.getElementById(panelId);
if (!panel) return null;
panel.innerHTML = `
<div class="procedures-overhaul-host">
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
<div id="fristen-overhaul-mode-host"></div>
</div>
</div>
`;
return panel;
}
function setActiveTabUI(tab: ProceduresTab): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
const panel = document.getElementById(`procedures-panel-${t}`);
const active = t === tab;
if (btn) {
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
}
if (panel) panel.hidden = !active;
}
}
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
// selectedType + lastResponse + listeners that re-bind on every
// proceeding click). Wire it exactly once per page load; on subsequent
// activations the existing DOM + listeners are reused so picked
// proceeding / dates / flags persist across tab switches.
let verfahrensablaufWired = false;
async function activateTab(tab: ProceduresTab): Promise<void> {
setActiveTabUI(tab);
if (tab === "search") {
installOverhaulHost("procedures-panel-search");
await mountModeA();
return;
}
if (tab === "wizard") {
installOverhaulHost("procedures-panel-wizard");
await mountWizard();
return;
}
if (tab === "proceeding") {
if (!verfahrensablaufWired) {
initVerfahrensablauf();
verfahrensablaufWired = true;
}
}
}
function wireTabs(): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
if (!btn) continue;
btn.addEventListener("click", () => {
void activateTab(t);
writeTabToUrl(t);
});
}
}
// boot dispatches on the URL: a deep link with `?event=` jumps straight
// to the linear result view (the Direkt-suchen tab stays as the visible
// context). Otherwise the requested tab — defaulting to "proceeding" —
// activates per readTabFromUrl().
async function boot(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const eventRef = params.get("event") || "";
if (eventRef) {
setActiveTabUI("search");
installOverhaulHost("procedures-panel-search");
await mountResultView({
eventRef,
triggerDate: params.get("trigger_date") || undefined,
party: params.get("party") || undefined,
courtId: params.get("court_id") || undefined,
});
return;
}
await activateTab(readTabFromUrl());
}
import { mountBuilder } from "./builder";
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
wireTabs();
void boot();
void mountBuilder();
});

View File

@@ -728,6 +728,67 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "builder.action.promote"
| "builder.action.rename"
| "builder.action.rename.prompt"
| "builder.action.share"
| "builder.akte.none"
| "builder.bucket.active"
| "builder.canvas.add_proceeding"
| "builder.empty.cta"
| "builder.empty.headline"
| "builder.empty.hint"
| "builder.empty.recent"
| "builder.event.action.file"
| "builder.event.action.reset"
| "builder.event.action.skip"
| "builder.event.actual_date.prompt"
| "builder.event.horizon.hide"
| "builder.event.horizon.label"
| "builder.event.skip_reason.prompt"
| "builder.event.state.filed"
| "builder.event.state.planned"
| "builder.event.state.skipped"
| "builder.header.akte"
| "builder.header.scenario"
| "builder.header.search"
| "builder.header.stichtag"
| "builder.mode.akte"
| "builder.mode.cold"
| "builder.mode.event"
| "builder.panel.empty"
| "builder.panel.new"
| "builder.panel.title"
| "builder.picker.aria"
| "builder.picker.axis.forum"
| "builder.picker.axis.proc"
| "builder.picker.close"
| "builder.picker.empty"
| "builder.picker.future_jurisdiction"
| "builder.picker.placeholder"
| "builder.picker.title"
| "builder.save.error"
| "builder.save.idle"
| "builder.save.saved"
| "builder.save.saving"
| "builder.search.placeholder"
| "builder.subtitle"
| "builder.triplet.collapse"
| "builder.triplet.detailgrad.all_options"
| "builder.triplet.detailgrad.label"
| "builder.triplet.detailgrad.selected"
| "builder.triplet.expand"
| "builder.triplet.flags.label"
| "builder.triplet.loading"
| "builder.triplet.no_flags"
| "builder.triplet.perspective.claimant"
| "builder.triplet.perspective.defendant"
| "builder.triplet.perspective.label"
| "builder.triplet.perspective.none"
| "builder.triplet.remove"
| "builder.triplet.side.claimant"
| "builder.triplet.side.defendant"
| "builder.triplet.unknown_proceeding"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"

View File

@@ -4,23 +4,19 @@ import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody";
// U0 — Skeleton for the unified procedural-events tool
// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md).
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
//
// Folds /tools/fristenrechner (Mode A + Mode B + result) and
// /tools/verfahrensablauf into a single page at /tools/procedures. Each
// later slice fills one of the four entry tabs:
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
// builder shell. Server-rendered chrome is minimal — the page-header
// scenario picker, side panel, and canvas are all hydrated by
// `builder.ts` at boot. The builder loads scenarios from
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
// per-proceeding triplets with the existing verfahrensablauf-core calc.
//
// U1 — Direkt suchen (Mode A search)
// U2 — Geführt (Mode B wizard)
// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter)
// U4 — Hard-cut 301 (drop legacy pages, redirect URLs)
//
// This file ships only the page chrome — sidebar, header, filter strip
// with search box, four entry-mode tabs, and the host containers the
// later slices mount their UI into. No data wiring.
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
export function renderProcedures(): string {
const today = new Date().toISOString().split("T")[0];
@@ -36,151 +32,142 @@ export function renderProcedures(): string {
<title data-i18n="procedures.title">Verfahren &amp; Fristen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-procedures">
<body className="has-sidebar page-procedures page-builder">
<Sidebar currentPath="/tools/procedures" />
<BottomNav currentPath="/tools/procedures" />
<main>
<section className="tool-page">
<section className="tool-page builder-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="procedures.heading">Verfahren &amp; Fristen</h1>
<p className="tool-subtitle" data-i18n="procedures.subtitle">
Verfahrensablauf, Fristenrechner und ger&uuml;hrte Suche in einem Tool.
<p className="tool-subtitle" data-i18n="builder.subtitle">
Litigation Builder &mdash; Szenarien bauen, Verfahren stapeln, Fristen behalten.
</p>
</div>
{/* Shared filter strip — search box + four chip groups
(forum / proceeding / event_kind / party). Lives at the
top of the page so every entry tab and output mode reads
the same active filter set (design §4 + m's Q3
divergence: search composes with chip filters). U0
ships the markup only; chip hydration + search wiring
arrive with U1-U3. */}
<section className="procedures-filter-strip" aria-label="Filter">
<div className="procedures-filter-search">
<svg className="procedures-filter-search-icon" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
id="procedures-search-input"
className="procedures-filter-search-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="procedures.filter.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing&hellip;"
/>
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
· Akte picker · Stichtag input. B1 wires the scenario picker
+ name action + Stichtag + save indicator. Akte / share /
promote land at B4 / B5; the affordances render disabled in
B1 so the layout is stable across slices. */}
<section className="builder-pageheader" aria-label="Builder-Steuerung">
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario w&auml;hlen"></select>
</label>
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
<span data-i18n="builder.save.idle">&nbsp;</span>
</span>
<span className="builder-pageheader-spacer"></span>
<button type="button" id="builder-rename-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
data-i18n="builder.action.rename">Benennen</button>
<button type="button" id="builder-share-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.share">Teilen</button>
<button type="button" id="builder-promote-btn"
className="builder-action-btn builder-action-btn--primary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.promote">Als Projekt anlegen</button>
</div>
<div className="procedures-filter-chips" id="procedures-filter-chips">
<div className="procedures-filter-chip-row" data-axis="forum">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="proc">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="kind">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="party">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
</div>
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte w&auml;hlen">
<option value="" data-i18n="builder.akte.none">&mdash; ohne &mdash;</option>
</select>
</label>
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
defaultValue={today} aria-label="Stichtag" />
</label>
<label className="builder-pageheader-field builder-pageheader-field--grow">
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
<input type="search" id="builder-search-input" className="builder-search-input"
data-i18n-placeholder="builder.search.placeholder"
placeholder="Ereignis, Szenario, Akte &hellip;"
autocomplete="off" spellcheck="false" disabled
title="Universelle Suche kommt in B3" />
</label>
</div>
</section>
{/* Entry-mode tab strip — all four tabs visible from boot
(m's Q3 divergence). The active tab is URL-driven
(?mode=proceeding|search|wizard|akte); cold open lands
on "proceeding" per design §11.5.Q3. */}
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
event-triggered + akte ship at B3 / B4 and are disabled
here so the layout stays stable across slices. */}
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
<button type="button"
className="procedures-tab is-active"
className="builder-mode is-active"
role="tab"
aria-selected="true"
data-tab="proceeding"
id="procedures-tab-proceeding">
<span className="procedures-tab-icon" aria-hidden="true">&#128218;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren w&auml;hlen</span>
data-mode="cold"
id="builder-mode-cold">
<span className="builder-mode-label" data-i18n="builder.mode.cold">&Uuml;bersicht</span>
</button>
<button type="button"
className="procedures-tab"
className="builder-mode"
role="tab"
aria-selected="false"
data-tab="search"
id="procedures-tab-search">
<span className="procedures-tab-icon" aria-hidden="true">&#9889;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
data-mode="event"
id="builder-mode-event"
disabled
title="In B3 verf&uuml;gbar">
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
</button>
<button type="button"
className="procedures-tab"
className="builder-mode"
role="tab"
aria-selected="false"
data-tab="wizard"
id="procedures-tab-wizard">
<span className="procedures-tab-icon" aria-hidden="true">&#129517;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Gef&uuml;hrt</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="akte"
id="procedures-tab-akte">
<span className="procedures-tab-icon" aria-hidden="true">&#128193;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
data-mode="akte"
id="builder-mode-akte"
disabled
title="In B4 verf&uuml;gbar">
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
</button>
</nav>
{/* Per-tab content hosts. Only one is visible at a time —
procedures.ts toggles `hidden` on the inactive ones.
Each later slice fills the corresponding host. */}
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
aria-labelledby="procedures-tab-proceeding">
{/* Verfahrensablauf wizard body — shared TSX component
used by /tools/verfahrensablauf (legacy) and the
unified /tools/procedures page. procedures.ts calls
initVerfahrensablauf() on the first activation of
this tab, which wires the .proceeding-btn clicks,
timeline-container, detail-mode toggle, etc. against
the markup. The legacy page's auto-boot is guarded
against the procedures-only #procedures-panel-proceeding
element so it doesn't fire twice. */}
<VerfahrensablaufBody todayIso={today} />
</section>
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
<div className="builder-body">
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
<header className="builder-sidepanel-header">
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
<button type="button" id="builder-new-scenario-btn"
className="builder-sidepanel-newbtn"
data-i18n="builder.panel.new">+ Neues Szenario</button>
</header>
<div className="builder-sidepanel-bucket" data-bucket="active">
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
</div>
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
</aside>
<section className="procedures-panel" id="procedures-panel-search" role="tabpanel"
aria-labelledby="procedures-tab-search" hidden></section>
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
aria-labelledby="procedures-tab-wizard" hidden></section>
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
aria-labelledby="procedures-tab-akte" hidden>
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
Akten-Einstieg folgt in einem sp&auml;teren Slice.
</div>
</section>
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
tree here; U0 leaves it empty + hidden so the
tab placeholders are the only thing visible. */}
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
aria-label="Tree output" hidden></section>
{/* Linear-drawer host. Inline drawer expanding beneath a
tree card (design §8 — desktop) AND the standalone
linear follow-up view that Mode A / Mode B land on
after locking a trigger event (design §3.2). U1
switches it on. */}
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
aria-label="Linear output" hidden></section>
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
<div id="builder-canvas" className="builder-canvas">
{/* Cold-open placeholder — replaced by triplet stack once a
scenario is loaded. */}
<div className="builder-empty" id="builder-empty">
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
Noch kein Szenario ge&ouml;ffnet.
</p>
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
Starte ein neues Szenario, w&auml;hle aus deiner Liste oder &uuml;bernimm eine Akte (B4).
</p>
<button type="button" id="builder-cta-new" className="builder-cta-new"
data-i18n="builder.empty.cta">
Neues Szenario starten
</button>
</div>
</div>
</section>
</div>
</div>
</section>
</main>

View File

@@ -19811,3 +19811,694 @@ a.fristen-overhaul-rule-source {
width: 100%;
}
}
/* --- Litigation Builder (m/paliad#153 B1+B2) --- */
.builder-page .tool-header {
margin-bottom: 0.75rem;
}
.builder-pageheader {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
margin-bottom: 0.6rem;
}
.builder-pageheader-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.builder-pageheader-field {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.builder-pageheader-field--grow {
flex: 1 1 220px;
}
.builder-pageheader-label {
color: var(--color-text-subtle);
font-weight: 500;
white-space: nowrap;
}
.builder-pageheader-spacer {
flex: 1 1 auto;
}
.builder-scenario-picker,
.builder-akte-picker,
.builder-stichtag-input,
.builder-search-input {
font: inherit;
padding: 0.3rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: 0.3rem;
background: var(--color-surface-2);
color: var(--color-text);
min-width: 200px;
}
.builder-search-input {
min-width: 260px;
}
.builder-scenario-picker:disabled,
.builder-akte-picker:disabled,
.builder-search-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.builder-save-status {
font-size: 0.85rem;
color: var(--color-text-subtle);
min-width: 8rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.builder-save-status[data-state="saving"] { color: var(--color-text-subtle); }
.builder-save-status[data-state="saved"] { color: var(--color-accent-strong-fg); }
.builder-save-status[data-state="error"] { color: var(--status-red-fg, #c5503a); }
.builder-action-btn {
font: inherit;
padding: 0.35rem 0.85rem;
border-radius: 0.3rem;
cursor: pointer;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.builder-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.builder-action-btn--primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-action-btn--primary:hover:not(:disabled) {
background: var(--color-accent-light);
}
.builder-action-btn--secondary:hover:not(:disabled) {
background: var(--color-surface-muted);
}
.builder-modebar {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
padding: 0.15rem;
margin-bottom: 0.75rem;
}
.builder-mode {
font: inherit;
background: transparent;
border: 0;
padding: 0.3rem 0.9rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-mode.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
}
.builder-mode:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.builder-body {
display: grid;
grid-template-columns: 240px 1fr;
gap: 1rem;
align-items: start;
}
.builder-sidepanel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.75rem;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow: auto;
}
.builder-sidepanel-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.builder-sidepanel-title {
font-size: 0.95rem;
margin: 0;
}
.builder-sidepanel-newbtn {
font: inherit;
font-size: 0.8rem;
background: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 0.3rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
color: var(--color-accent-dark);
}
.builder-sidepanel-newbtn:hover {
background: var(--color-accent-light);
}
.builder-bucket-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-subtle);
margin: 0.5rem 0 0.3rem;
}
.builder-scenario-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.builder-scenario-list-empty {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
}
.builder-scenario-list-item {
cursor: pointer;
border-radius: 0.3rem;
}
.builder-scenario-list-item.is-active {
background: var(--color-accent-soft-bg);
}
.builder-scenario-list-link {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: 0;
padding: 0.4rem 0.5rem;
font: inherit;
color: inherit;
cursor: pointer;
}
.builder-scenario-list-item:hover {
background: var(--color-surface-muted);
}
.builder-canvas-wrap {
min-height: 320px;
}
.builder-canvas {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.builder-empty {
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
}
.builder-empty-headline {
font-size: 1.05rem;
margin: 0 0 0.4rem;
}
.builder-empty-hint {
color: var(--color-text-subtle);
margin: 0 0 1rem;
}
.builder-cta-new {
font: inherit;
background: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 0.3rem;
padding: 0.55rem 1.2rem;
cursor: pointer;
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-cta-new:hover {
background: var(--color-accent-light);
}
.builder-recent {
margin-top: 1.5rem;
text-align: left;
}
.builder-recent-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-subtle);
margin: 0 0 0.5rem;
}
.builder-recent-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.builder-recent-item {
padding: 0.4rem 0.6rem;
background: var(--color-surface-2);
border-radius: 0.3rem;
cursor: pointer;
}
.builder-recent-item:hover {
background: var(--color-surface-muted);
}
.builder-triplet-host {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
overflow: hidden;
}
.builder-triplet-host[data-child="true"] {
margin-left: 1.5rem;
border-left: 3px solid var(--color-accent);
}
.builder-triplet-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
background: var(--color-surface-2);
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
flex-wrap: wrap;
}
.builder-triplet-jurisdiction {
background: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 600;
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
letter-spacing: 0.05em;
}
.builder-triplet-code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.78rem;
color: var(--color-text-subtle);
}
.builder-triplet-name {
font-weight: 500;
margin-right: auto;
}
.builder-triplet-side {
background: var(--color-accent-soft-bg);
color: var(--color-accent-soft-fg);
border: 1px solid var(--color-accent-soft-border);
padding: 0.1rem 0.45rem;
border-radius: 0.25rem;
font-size: 0.75rem;
}
.builder-triplet-flags {
font-size: 0.78rem;
color: var(--color-text-subtle);
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.builder-triplet-flag-chip {
background: var(--color-accent-soft-bg);
border: 1px solid var(--color-accent-soft-border);
color: var(--color-accent-soft-fg);
padding: 0.05rem 0.4rem;
border-radius: 0.25rem;
font-family: ui-monospace, Menlo, monospace;
}
.builder-triplet-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 0.85rem;
}
.builder-triplet-controls-label {
color: var(--color-text-subtle);
}
.builder-triplet-perspective,
.builder-triplet-detailgrad {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface-2);
padding: 0.1rem;
}
.builder-triplet-perspective button,
.builder-triplet-detailgrad button {
font: inherit;
font-size: 0.78rem;
border: 0;
background: transparent;
padding: 0.2rem 0.6rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-triplet-perspective button.is-active,
.builder-triplet-detailgrad button.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
}
.builder-triplet-remove {
margin-left: auto;
font: inherit;
font-size: 0.78rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.15rem 0.55rem;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-triplet-remove:hover {
border-color: var(--status-red-border, #d08070);
color: var(--status-red-fg, #c5503a);
}
.builder-triplet-flagstrip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 0.85rem;
}
.builder-triplet-flag-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.builder-triplet-flag-empty {
font-style: italic;
color: var(--color-text-subtle);
}
.builder-triplet-body {
padding: 0.85rem;
}
.builder-triplet-loading,
.builder-triplet-error {
padding: 1rem;
text-align: center;
color: var(--color-text-subtle);
font-style: italic;
}
.builder-add-proceeding-btn {
font: inherit;
background: var(--color-surface-2);
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
padding: 0.7rem;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-add-proceeding-btn:hover {
background: var(--color-accent-soft-bg);
border-color: var(--color-accent-soft-border);
color: var(--color-accent-soft-fg);
}
/* Add-proceeding popover */
.builder-picker-popover {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
min-width: 380px;
}
.builder-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.builder-picker-title {
font-size: 0.95rem;
}
.builder-picker-close {
font: inherit;
font-size: 1.2rem;
background: transparent;
border: 0;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-picker-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.builder-picker-axis-label {
flex: 0 0 6rem;
font-size: 0.85rem;
color: var(--color-text-subtle);
padding-top: 0.25rem;
}
.builder-picker-chips {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.builder-picker-chips--wrap {
flex: 1;
}
.builder-picker-chip {
font: inherit;
font-size: 0.85rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
padding: 0.25rem 0.55rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text);
}
.builder-picker-chip.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
border-color: var(--color-segment-active-border);
}
.builder-picker-chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.builder-picker-chip:hover:not(:disabled) {
background: var(--color-accent-soft-bg);
}
.builder-picker-chip--proc {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
text-align: left;
}
.builder-picker-chip-code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.72rem;
color: var(--color-text-subtle);
}
.builder-picker-empty {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
}
/* Event-card state overrides (B2). The 3-state machine sits on top of
the existing .fr-col-item card. The Builder render passes editable=false
to renderColumnsBody and overlays its own per-card state attributes
on top of the card root via data-builder-state. */
.fr-col-item[data-builder-state="filed"] {
background: var(--color-accent-soft-bg);
border-left: 3px solid var(--color-accent);
}
.fr-col-item[data-builder-state="filed"] .timeline-name::before {
content: "✓ ";
color: var(--color-accent-soft-fg);
font-weight: 600;
}
.fr-col-item[data-builder-state="skipped"] {
opacity: 0.55;
}
.fr-col-item[data-builder-state="skipped"] .timeline-name {
text-decoration: line-through;
}
.builder-event-actions {
display: flex;
gap: 0.3rem;
margin-top: 0.4rem;
}
.builder-event-action {
font: inherit;
font-size: 0.72rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.15rem 0.45rem;
cursor: pointer;
color: var(--color-text);
}
.builder-event-action:hover {
background: var(--color-accent-soft-bg);
}
.builder-event-action[data-action="file"] {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
.builder-event-action[data-action="file"]:hover {
background: var(--color-accent-light);
}
.builder-event-horizon-chip {
display: inline-block;
font-size: 0.72rem;
color: var(--color-accent-soft-fg);
background: var(--color-accent-soft-bg);
border: 1px solid var(--color-accent-soft-border);
border-radius: 0.25rem;
padding: 0.1rem 0.45rem;
margin-top: 0.3rem;
cursor: pointer;
}
.builder-event-horizon-chip:hover {
background: var(--color-accent-strong-bg);
}
/* Responsive: collapse side panel into stacked block on narrow viewports. */
@media (max-width: 900px) {
.builder-body {
grid-template-columns: 1fr;
}
.builder-sidepanel {
position: static;
max-height: none;
}
}
@media (max-width: 640px) {
.builder-pageheader-row {
flex-direction: column;
align-items: stretch;
gap: 0.4rem;
}
.builder-pageheader-field {
flex-wrap: wrap;
}
.builder-scenario-picker,
.builder-akte-picker,
.builder-search-input {
min-width: 0;
width: 100%;
}
}