diff --git a/frontend/src/client/fristenrechner-result.ts b/frontend/src/client/fristenrechner-result.ts index ff9cfcd..06d18ff 100644 --- a/frontend/src/client/fristenrechner-result.ts +++ b/frontend/src/client/fristenrechner-result.ts @@ -147,7 +147,8 @@ export async function mountModeShell(activeTab: ModeTab): Promise { const mod = await import("./fristenrechner-mode-a"); await mod.mountModeA(); } else { - host.innerHTML = `
${escHtml(t("deadlines.overhaul.wizard.coming_soon"))}
`; + const mod = await import("./fristenrechner-wizard"); + await mod.mountWizard(); } } diff --git a/frontend/src/client/fristenrechner-wizard.test.ts b/frontend/src/client/fristenrechner-wizard.test.ts new file mode 100644 index 0000000..479ceab --- /dev/null +++ b/frontend/src/client/fristenrechner-wizard.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { followUpsDifferByParty } from "./fristenrechner-wizard"; + +describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => { + test("true when both claimant and defendant rules present", () => { + expect(followUpsDifferByParty([ + { primary_party: "claimant" }, + { primary_party: "defendant" }, + ])).toBe(true); + }); + test("false when all claimant", () => { + expect(followUpsDifferByParty([ + { primary_party: "claimant" }, + { primary_party: "claimant" }, + ])).toBe(false); + }); + test("false when all defendant", () => { + expect(followUpsDifferByParty([ + { primary_party: "defendant" }, + ])).toBe(false); + }); + test("false when only 'both' rules", () => { + // "Both" rules are bilateral procedural moves (Vertraulichkeits- + // Erwiderung); they don't gate R5 because either party can be + // looking at them. + expect(followUpsDifferByParty([ + { primary_party: "both" }, + { primary_party: "both" }, + ])).toBe(false); + }); + test("false when only court rules", () => { + expect(followUpsDifferByParty([ + { primary_party: "court" }, + ])).toBe(false); + }); + test("true when mixed with both / court alongside the asymmetric pair", () => { + expect(followUpsDifferByParty([ + { primary_party: "both" }, + { primary_party: "claimant" }, + { primary_party: "court" }, + { primary_party: "defendant" }, + ])).toBe(true); + }); + test("false on empty list", () => { + expect(followUpsDifferByParty([])).toBe(false); + }); +}); diff --git a/frontend/src/client/fristenrechner-wizard.ts b/frontend/src/client/fristenrechner-wizard.ts new file mode 100644 index 0000000..904850a --- /dev/null +++ b/frontend/src/client/fristenrechner-wizard.ts @@ -0,0 +1,711 @@ +// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2). +// +// 3-5 question row stack that lands the user on one procedural_event +// (the trigger), then transitions to the shared §4 result view. +// +// R1 Was ist passiert? (event_kind) always asked +// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows +// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option +// R4 Welches Schriftstück? (procedural_event — land) always asked +// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ +// +// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier". +// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where +// perspective is a qualifier). +// Pre-fill + collapse rows from project (project.proceeding_type → +// R3 + R2 derived; project.our_side → R5). Preserve compatible +// downstream picks on back-navigation (§11.Q10). + +import { escAttr, escHtml } from "./views/verfahrensablauf-core"; +import { getLang, t, tDyn } from "./i18n"; +import { mountResultView } from "./fristenrechner-result"; + +// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we +// need; kept local so the wizard doesn't depend on Mode A. + +interface EventSearchHit { + id: string; + code: string; + name_de: string; + name_en: string; + event_kind?: string; + proceeding_type: { + id: number; + code: string; + name_de: string; + name_en: string; + jurisdiction?: string; + }; + follow_up_count: number; +} + +interface EventSearchResponse { + events: EventSearchHit[]; + total: number; +} + +interface ProceedingChip { + code: string; + name: string; + nameEN: string; + group: string; +} + +interface ProjectSummary { + id: string; + proceeding_type_id?: number | null; + our_side?: string | null; +} + +type Forum = "UPC" | "DE" | "EPA" | "DPMA"; +type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed"; +type WizardParty = "claimant" | "defendant"; + +// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by +// fristenrechner-result.mountModeShell which creates the host element +// under the overhaul root. +const WIZARD_HOST_ID = "fristen-overhaul-mode-host"; + +// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists +// so re-grouping happens in one place. +const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"]; +const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"]; + +// Single wizard state. Module-local; one wizard at a time. +interface WizardState { + // Picks. "" = not answered. R5 only set when the question is asked. + r1: EventKindRow | ""; + r2: Forum | ""; + r3: string; // proceeding_types.code + r4: string; // procedural_events.code + r5: WizardParty | ""; + + // Pre-fill provenance — when a pick came from the project context, + // the row renders with an "aus Akte" tag so the user notices. + r2FromProject: boolean; + r3FromProject: boolean; + r5FromProject: boolean; + + // Implicit fills — R2 auto-derived from R1 when R1 narrows to one + // forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but + // if downstream R3 lookup returns a single forum we can mark R2 as + // implicit). + r2Implicit: boolean; + r3Implicit: boolean; +} + +const state: WizardState = { + r1: "", r2: "", r3: "", r4: "", r5: "", + r2FromProject: false, r3FromProject: false, r5FromProject: false, + r2Implicit: false, r3Implicit: false, +}; + +// Loaded from the project (if any). +let projectSummary: ProjectSummary | null = null; + +// Proceeding chip cache key: jurisdiction × event_kind. +let lastProcCacheKey = ""; +let cachedProcChips: ProceedingChip[] = []; + +// Event chip cache: keyed on R3 code + R1 event_kind. +let lastEventCacheKey = ""; +let cachedEventChips: EventSearchHit[] = []; + +// Public API --------------------------------------------------------- + +export async function mountWizard(): Promise { + const host = document.getElementById(WIZARD_HOST_ID); + if (!host) return; + + // Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…). + const params = new URLSearchParams(window.location.search); + state.r1 = (params.get("kind") as EventKindRow) || ""; + state.r2 = (params.get("forum") as Forum) || ""; + state.r3 = params.get("pt") || ""; + state.r4 = params.get("event") || ""; + state.r5 = (params.get("party") as WizardParty) || ""; + + // Project prefills. + const projectId = params.get("project"); + if (projectId) { + projectSummary = await fetchProject(projectId); + await applyProjectPrefills(); + } else { + projectSummary = null; + } + + renderShell(); + void renderRows(); +} + +// applyProjectPrefills derives R2 + R3 + R5 from the project when they +// haven't been set explicitly. Project picks take precedence over +// unspecified state, but a user-supplied URL pick wins over the +// project default. +async function applyProjectPrefills(): Promise { + if (!projectSummary) return; + // Map our_side → R5. + if (!state.r5) { + const side = projectSummary.our_side; + if (side === "claimant" || side === "applicant" || side === "appellant") { + state.r5 = "claimant"; + state.r5FromProject = true; + } else if (side === "defendant" || side === "respondent") { + state.r5 = "defendant"; + state.r5FromProject = true; + } + } + // Map proceeding_type_id → R3 + infer R2 jurisdiction. + if (projectSummary.proceeding_type_id && !state.r3) { + const pt = await fetchProceedingByID(projectSummary.proceeding_type_id); + if (pt) { + state.r3 = pt.code; + state.r3FromProject = true; + if (pt.group && !state.r2) { + state.r2 = pt.group as Forum; + state.r2FromProject = true; + } + } + } +} + +// Render ------------------------------------------------------------- + +function renderShell(): void { + const host = document.getElementById(WIZARD_HOST_ID); + if (!host) return; + host.innerHTML = ` +
+
+

${escHtml(t("deadlines.overhaul.wizard.heading"))}

+

${escHtml(t("deadlines.overhaul.wizard.hint"))}

+
+
+
+ `; +} + +async function renderRows(): Promise { + const host = document.getElementById("fristen-wizard-rows"); + if (!host) return; + + // Resolve dynamic row prerequisites BEFORE building markup so chip + // sets are populated. + if (state.r1 && state.r2) { + await ensureProceedingChips(state.r2, state.r1); + // Auto-skip R3 when the narrowed pool has exactly one option. + if (!state.r3 && cachedProcChips.length === 1) { + state.r3 = cachedProcChips[0].code; + state.r3Implicit = true; + } + } + if (state.r1 && state.r3) { + await ensureEventChips(state.r3, state.r1); + } + + const rows: string[] = []; + rows.push(rowR1()); + if (shouldShowR2()) rows.push(rowR2()); + if (shouldShowR3()) rows.push(rowR3()); + if (shouldShowR4()) rows.push(rowR4()); + if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading()); + + host.innerHTML = rows.join(""); + wireRowEvents(); + + // R5 conditional check — fires after R4 picked. Inspects /follow-ups + // to see whether they actually differ by party. If yes, show R5. If + // no, or R5 already set, transition straight to result view. + if (state.r4) { + void maybeAdvanceFromR4(); + } +} + +// Should-show predicates -------------------------------------------- + +function shouldShowR2(): boolean { + // Skip R2 only when R1 narrows to a single forum — which today + // never happens for the closed event_kind set (every kind exists in + // multiple jurisdictions). Always show R2 until we have empirical + // evidence otherwise. + return state.r1 !== "" && state.r1 !== "missed"; +} + +function shouldShowR3(): boolean { + if (state.r1 === "" || state.r2 === "") return false; + if (state.r3 && state.r3Implicit) return true; // visible compact + return true; +} + +function shouldShowR4(): boolean { + return state.r3 !== "" && state.r1 !== ""; +} + +// shouldShowR5Sync renders the placeholder row immediately; the actual +// asked-or-not decision happens after the async follow-ups probe in +// maybeAdvanceFromR4. +function shouldShowR5Sync(): boolean { + return state.r4 !== ""; +} + +// Row builders ------------------------------------------------------ + +function rowR1(): string { + const chips = EVENT_KINDS.map((k) => { + const label = t(`deadlines.overhaul.kind.${k}` as never); + const icon = eventKindIcon(k); + return chipHtml("r1", k, label, state.r1 === k, icon); + }).join(""); + return rowShell({ + n: 1, + badge: "filter", + label: t("deadlines.overhaul.wizard.r1.label"), + active: !state.r1, + answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "", + body: `
${chips}
`, + }); +} + +function rowR2(): string { + const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join(""); + return rowShell({ + n: 2, + badge: "filter", + label: t("deadlines.overhaul.wizard.r2.label"), + active: !state.r2, + fromProject: state.r2FromProject, + answeredText: state.r2 || "", + body: `
${chips}
`, + }); +} + +function rowR3(): string { + if (cachedProcChips.length === 0) { + return rowShell({ + n: 3, badge: "qualifier", + label: t("deadlines.overhaul.wizard.r3.label"), + active: true, + body: `
${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}
`, + }); + } + const lang = getLang(); + const chips = cachedProcChips.map((p) => { + const label = lang === "en" ? p.nameEN || p.name : p.name; + return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code); + }).join(""); + let answered = ""; + if (state.r3) { + const hit = cachedProcChips.find((p) => p.code === state.r3); + if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name; + } + return rowShell({ + n: 3, + badge: "qualifier", + label: t("deadlines.overhaul.wizard.r3.label"), + active: !state.r3, + fromProject: state.r3FromProject, + implicit: state.r3Implicit, + answeredText: answered, + body: `
${chips}
`, + }); +} + +function rowR4(): string { + if (cachedEventChips.length === 0) { + return rowShell({ + n: 4, badge: "qualifier", + label: t("deadlines.overhaul.wizard.r4.label"), + active: true, + body: `
${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}
`, + }); + } + const lang = getLang(); + const chips = cachedEventChips.map((e) => { + const label = lang === "en" ? e.name_en || e.name_de : e.name_de; + return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow)); + }).join(""); + let answered = ""; + if (state.r4) { + const hit = cachedEventChips.find((e) => e.code === state.r4); + if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de; + } + return rowShell({ + n: 4, + badge: "qualifier", + label: t("deadlines.overhaul.wizard.r4.label"), + active: !state.r4, + answeredText: answered, + body: `
${chips}
`, + }); +} + +function rowR5Loading(): string { + // Placeholder while we probe whether R5 is needed. The async + // follow-ups probe replaces this with rowR5 chips or skips + // straight to the result view. + return rowShell({ + n: 5, badge: "qualifier", + label: t("deadlines.overhaul.wizard.r5.label"), + active: !state.r5, + fromProject: state.r5FromProject, + answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "", + body: `
${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}
`, + }); +} + +function rowR5Chips(): string { + const chips = (["claimant", "defendant"] as const).map((p) => + chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join(""); + return rowShell({ + n: 5, badge: "qualifier", + label: t("deadlines.overhaul.wizard.r5.label"), + active: !state.r5, + fromProject: state.r5FromProject, + answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "", + body: `
${chips}
`, + }); +} + +interface RowShellOpts { + n: number; + badge: "filter" | "qualifier"; + label: string; + active: boolean; + body: string; + answeredText?: string; + fromProject?: boolean; + implicit?: boolean; +} + +function rowShell(o: RowShellOpts): string { + const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` + + (o.active ? " is-active" : " is-answered") + + (o.fromProject ? " is-from-project" : "") + + (o.implicit ? " is-implicit" : ""); + const badgeText = o.badge === "filter" + ? t("deadlines.overhaul.wizard.badge.filter") + : t("deadlines.overhaul.wizard.badge.qualifier"); + const annotations: string[] = []; + if (o.fromProject) annotations.push(`${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}`); + if (o.implicit) annotations.push(`${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}`); + const answered = o.answeredText + ? `${escHtml(o.answeredText)}` + : ""; + const edit = !o.active + ? `` + : ""; + return ` +
+
+ ${o.n} + ${escHtml(badgeText)} + ${escHtml(o.label)} + ${annotations.join("")} + ${answered} + ${edit} +
+ ${o.active ? `
${o.body}
` : ""} +
+ `; +} + +// Event wiring ------------------------------------------------------ + +function wireRowEvents(): void { + document.querySelectorAll(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => { + btn.addEventListener("click", () => { + const axis = btn.dataset.axis || ""; + const value = btn.dataset.value || ""; + handleChip(axis, value); + }); + }); + document.querySelectorAll(".fristen-wizard-row-edit").forEach((btn) => { + btn.addEventListener("click", () => { + const n = parseInt(btn.dataset.row || "0", 10); + handleEdit(n); + }); + }); +} + +function handleChip(axis: string, value: string): void { + switch (axis) { + case "r1": { + if (state.r1 === value) return; + state.r1 = value as EventKindRow; + // R1 change resets R3/R4 (event-kind narrows the pools). + state.r3 = ""; + state.r3Implicit = false; + state.r4 = ""; + state.r5 = state.r5FromProject ? state.r5 : ""; + cachedEventChips = []; + lastEventCacheKey = ""; + cachedProcChips = []; + lastProcCacheKey = ""; + break; + } + case "r2": { + if (state.r2 === value) return; + state.r2 = value as Forum; + state.r2FromProject = false; + state.r2Implicit = false; + // R2 change may invalidate R3 → reset. + state.r3 = ""; + state.r3FromProject = false; + state.r3Implicit = false; + state.r4 = ""; + cachedProcChips = []; + lastProcCacheKey = ""; + cachedEventChips = []; + lastEventCacheKey = ""; + break; + } + case "r3": { + if (state.r3 === value) return; + state.r3 = value; + state.r3FromProject = false; + state.r3Implicit = false; + state.r4 = ""; + cachedEventChips = []; + lastEventCacheKey = ""; + break; + } + case "r4": { + if (state.r4 === value) return; + state.r4 = value; + break; + } + case "r5": { + if (state.r5 === value) return; + state.r5 = value as WizardParty; + state.r5FromProject = false; + break; + } + } + syncUrl(); + void renderRows(); +} + +function handleEdit(n: number): void { + switch (n) { + case 1: + state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : ""; + cachedProcChips = []; lastProcCacheKey = ""; + cachedEventChips = []; lastEventCacheKey = ""; + break; + case 2: + state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false; + state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false; + state.r4 = ""; + cachedProcChips = []; lastProcCacheKey = ""; + cachedEventChips = []; lastEventCacheKey = ""; + break; + case 3: + state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false; + state.r4 = ""; + cachedEventChips = []; lastEventCacheKey = ""; + break; + case 4: + state.r4 = ""; + state.r5 = state.r5FromProject ? state.r5 : ""; + break; + case 5: + state.r5 = ""; state.r5FromProject = false; + break; + } + syncUrl(); + void renderRows(); +} + +// maybeAdvanceFromR4 fetches /follow-ups for the picked event to +// decide whether R5 is needed. If R5 is already set OR the +// follow-ups don't differ by party, transition straight to the +// result view. Else swap the R5 loading row for the chip picker. +async function maybeAdvanceFromR4(): Promise { + if (!state.r4) return; + if (state.r5) { + // R5 already answered (project prefill or explicit pick) → go. + void launchResult(); + return; + } + // Probe follow-ups. + const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin); + url.searchParams.set("event", state.r4); + try { + const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!resp.ok) { + // Soft-fail → swap to R5 chips so the user can decide manually. + swapR5(rowR5Chips()); + return; + } + const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> }; + const differs = followUpsDifferByParty(data.follow_ups); + if (!differs) { + void launchResult(); + return; + } + swapR5(rowR5Chips()); + } catch { + swapR5(rowR5Chips()); + } +} + +function swapR5(html: string): void { + const host = document.getElementById("fristen-wizard-rows"); + if (!host) return; + const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]'); + if (!r5) { + host.insertAdjacentHTML("beforeend", html); + } else { + r5.outerHTML = html; + } + wireRowEvents(); +} + +function launchResult(): void { + // Hand off to the §4 result view. The URL already carries the + // picks via syncUrl(); add event= so the boot path treats this + // as a deep-link. + const url = new URL(window.location.href); + url.searchParams.set("overhaul", "1"); + url.searchParams.set("event", state.r4); + if (state.r5) url.searchParams.set("party", state.r5); + else url.searchParams.delete("party"); + history.pushState(null, "", url.pathname + url.search + url.hash); + void mountResultView({ eventRef: state.r4, party: state.r5 || undefined }); +} + +export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean { + let hasClaimant = false, hasDefendant = false; + for (const r of rows) { + if (r.primary_party === "claimant") hasClaimant = true; + else if (r.primary_party === "defendant") hasDefendant = true; + if (hasClaimant && hasDefendant) return true; + } + return false; +} + +// Fetches ----------------------------------------------------------- + +async function fetchProject(id: string): Promise { + try { + const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } }); + if (!resp.ok) return null; + return (await resp.json()) as ProjectSummary; + } catch { + return null; + } +} + +async function fetchProceedingByID(id: number): Promise { + // The proceeding-types endpoint returns codes, names, jurisdictions + // but doesn't carry the id (the wire shape FristenrechnerType is + // code-keyed). Walk the unfiltered list and pick by sort-order + // proximity / sort-fallback: we need the row whose id matches; since + // the wire doesn't expose id, fetch the projects detail to get the + // code directly. Cheap workaround: rely on /api/projects/{id}'s + // proceeding_type_id being matched against the proceeding-types list + // by jurisdiction round-trip is not possible without id. Instead + // expose the proceeding-types-by-id mapping via a follow-up endpoint + // later. For now hit the unfiltered list and assume the project's + // pick is in the active set. + // + // Pragmatic fallback: query the full list and return the only entry + // whose pseudo-id-via-sort-order matches. The lookup is unreliable + // until the wire shape includes id; for the project-prefill case the + // user can always re-pick R3 / R2 if the prefill misfires. + try { + const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } }); + if (!resp.ok) return null; + const list = (await resp.json()) as ProceedingChip[] | null; + if (!list || list.length === 0) return null; + // Without id in the wire we cannot match by id. Skip the prefill + // silently — R3 stays unanswered and the user picks manually. + // (S5/follow-up can extend the wire shape to include id.) + void id; + return null; + } catch { + return null; + } +} + +async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise { + const key = `${forum}\x00${kind}`; + if (lastProcCacheKey === key) return; + lastProcCacheKey = key; + const url = new URL("/api/tools/proceeding-types", window.location.origin); + url.searchParams.set("kind", "proceeding"); + url.searchParams.set("jurisdiction", forum); + if (kind !== "missed") url.searchParams.set("event_kind", kind); + try { + const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!resp.ok) { + cachedProcChips = []; + return; + } + const data = (await resp.json()) as ProceedingChip[] | null; + cachedProcChips = data || []; + } catch { + cachedProcChips = []; + } +} + +async function ensureEventChips(procCode: string, kind: EventKindRow): Promise { + const key = `${procCode}\x00${kind}`; + if (lastEventCacheKey === key) return; + lastEventCacheKey = key; + const url = new URL("/api/tools/fristenrechner/search", window.location.origin); + url.searchParams.set("kind", "events"); + url.searchParams.set("proc", procCode); + if (kind !== "missed") url.searchParams.set("event_kind", kind); + url.searchParams.set("limit", "100"); + try { + const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!resp.ok) { + cachedEventChips = []; + return; + } + const data = (await resp.json()) as EventSearchResponse; + cachedEventChips = data.events || []; + } catch { + cachedEventChips = []; + } +} + +// Helpers ----------------------------------------------------------- + +function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string { + const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`; + const tt = title ? ` title="${escAttr(title)}"` : ""; + const i = icon ? `` : ""; + return ``; +} + +function eventKindIcon(kind?: EventKindRow): string { + switch (kind) { + case "filing": return "📥"; + case "hearing": return "🏛️"; + case "decision": return "⚖️"; + case "order": return "📜"; + case "missed": return "⏲"; + default: return "📅"; + } +} + +function syncUrl(): void { + const url = new URL(window.location.href); + url.searchParams.set("overhaul", "1"); + url.searchParams.set("mode", "wizard"); + setOrClear(url, "kind", state.r1); + setOrClear(url, "forum", state.r2); + setOrClear(url, "pt", state.r3); + // event=… is set only on launchResult; the wizard URL carries the + // R4 candidate via r4= so back/forward navigates within the wizard. + setOrClear(url, "r4", state.r4); + setOrClear(url, "party", state.r5); + history.replaceState(null, "", url.pathname + url.search + url.hash); +} + +function setOrClear(url: URL, key: string, val: string): void { + if (val) url.searchParams.set(key, val); + else url.searchParams.delete(key); +} diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index cd0df4f..96aeee2 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1065,6 +1065,24 @@ const translations: Record> = { "deadlines.overhaul.kind.hearing": "Termin", "deadlines.overhaul.kind.decision": "Entscheidung", "deadlines.overhaul.kind.order": "Verf\u00fcgung", + "deadlines.overhaul.kind.missed": "Frist vers\u00e4umt", + + // Fristenrechner overhaul Mode B \u2014 gef\u00fchrter Wizard (S4, design \u00a73.2). + "deadlines.overhaul.wizard.heading": "Gef\u00fchrter Modus", + "deadlines.overhaul.wizard.hint": "Beantworte die Fragen oben nach unten \u2014 der Wizard landet auf einem Trigger-Ereignis und zeigt die Folge-Fristen.", + "deadlines.overhaul.wizard.r1.label": "Was ist passiert?", + "deadlines.overhaul.wizard.r2.label": "Vor welchem Gericht?", + "deadlines.overhaul.wizard.r3.label": "In welchem Verfahren?", + "deadlines.overhaul.wizard.r3.empty": "Kein Verfahren mit diesem Ereignistyp im gew\u00e4hlten Forum.", + "deadlines.overhaul.wizard.r4.label": "Welches Schriftst\u00fcck / welcher Termin?", + "deadlines.overhaul.wizard.r4.empty": "Keine Ereignisse zu dieser Auswahl.", + "deadlines.overhaul.wizard.r5.label": "Welche Seite vertreten Sie?", + "deadlines.overhaul.wizard.r5.probing": "Pr\u00fcfe, ob die Folge-Fristen seitenabh\u00e4ngig sind\u2026", + "deadlines.overhaul.wizard.badge.filter": "Filter", + "deadlines.overhaul.wizard.badge.qualifier": "Qualifier", + "deadlines.overhaul.wizard.edit": "\u00e4ndern", + "deadlines.overhaul.wizard.anno.from_project": "aus Akte", + "deadlines.overhaul.wizard.anno.implicit": "implizit", // Office labels (shared) "office.munich": "M\u00fcnchen", @@ -4233,6 +4251,24 @@ const translations: Record> = { "deadlines.overhaul.kind.hearing": "Hearing", "deadlines.overhaul.kind.decision": "Decision", "deadlines.overhaul.kind.order": "Order", + "deadlines.overhaul.kind.missed": "Missed deadline", + + // Fristenrechner overhaul Mode B — guided wizard (S4, design §3.2). + "deadlines.overhaul.wizard.heading": "Guided mode", + "deadlines.overhaul.wizard.hint": "Answer top-down — the wizard lands on a trigger event and shows the follow-up deadlines.", + "deadlines.overhaul.wizard.r1.label": "What happened?", + "deadlines.overhaul.wizard.r2.label": "Before which forum?", + "deadlines.overhaul.wizard.r3.label": "In which proceeding?", + "deadlines.overhaul.wizard.r3.empty": "No proceeding with this event kind in the chosen forum.", + "deadlines.overhaul.wizard.r4.label": "Which document / which hearing?", + "deadlines.overhaul.wizard.r4.empty": "No events for this selection.", + "deadlines.overhaul.wizard.r5.label": "Which party do you represent?", + "deadlines.overhaul.wizard.r5.probing": "Checking whether follow-ups depend on the side…", + "deadlines.overhaul.wizard.badge.filter": "Filter", + "deadlines.overhaul.wizard.badge.qualifier": "Qualifier", + "deadlines.overhaul.wizard.edit": "edit", + "deadlines.overhaul.wizard.anno.from_project": "from project", + "deadlines.overhaul.wizard.anno.implicit": "implicit", // Office labels (shared) "office.munich": "Munich", diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 1dcae18..ae432f1 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1391,6 +1391,7 @@ export type I18nKey = | "deadlines.overhaul.kind.decision" | "deadlines.overhaul.kind.filing" | "deadlines.overhaul.kind.hearing" + | "deadlines.overhaul.kind.missed" | "deadlines.overhaul.kind.order" | "deadlines.overhaul.load_error" | "deadlines.overhaul.loading" @@ -1424,7 +1425,22 @@ export type I18nKey = | "deadlines.overhaul.spawn.tooltip" | "deadlines.overhaul.trigger.date" | "deadlines.overhaul.trigger.label" + | "deadlines.overhaul.wizard.anno.from_project" + | "deadlines.overhaul.wizard.anno.implicit" + | "deadlines.overhaul.wizard.badge.filter" + | "deadlines.overhaul.wizard.badge.qualifier" | "deadlines.overhaul.wizard.coming_soon" + | "deadlines.overhaul.wizard.edit" + | "deadlines.overhaul.wizard.heading" + | "deadlines.overhaul.wizard.hint" + | "deadlines.overhaul.wizard.r1.label" + | "deadlines.overhaul.wizard.r2.label" + | "deadlines.overhaul.wizard.r3.empty" + | "deadlines.overhaul.wizard.r3.label" + | "deadlines.overhaul.wizard.r4.empty" + | "deadlines.overhaul.wizard.r4.label" + | "deadlines.overhaul.wizard.r5.label" + | "deadlines.overhaul.wizard.r5.probing" | "deadlines.party.both" | "deadlines.party.both.label" | "deadlines.party.claimant" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index cdce69e..df52383 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -19536,3 +19536,148 @@ a.fristen-overhaul-rule-source { text-align: right; } } + +/* === Fristenrechner overhaul Mode B — wizard (t-paliad-323 S4) ====== + * + * 3-5 row stack landing on a procedural_event. Row badge "Filter" vs + * "Qualifier" per m §11.Q3; "aus Akte" / "implizit" annotations per + * §11.Q10 (preserve compatible downstream picks on back-nav). + * ==================================================================== */ + +.fristen-wizard-root { + background: #fff; + border: 1px solid #e0e0d4; + border-radius: 0.7rem; + padding: 1rem 1.1rem; +} + +.fristen-wizard-header { + margin-bottom: 0.7rem; +} + +.fristen-wizard-title { + margin: 0 0 0.25rem 0; + font-size: 1.15rem; + color: #2a2a2a; +} + +.fristen-wizard-hint { + margin: 0; + font-size: 0.85rem; + color: #666; +} + +.fristen-wizard-rows { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.fristen-wizard-row { + background: #fafaf6; + border: 1px solid #e0e0d4; + border-radius: 0.6rem; + padding: 0.55rem 0.7rem; +} + +.fristen-wizard-row.is-active { + border-color: #b1c468; + box-shadow: 0 0 0 2px rgba(198, 244, 28, 0.15); +} + +.fristen-wizard-row.is-from-project { + background: #f4f8e0; +} + +.fristen-wizard-row.is-implicit { + opacity: 0.85; +} + +.fristen-wizard-row-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.fristen-wizard-row-n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + border-radius: 50%; + background: #c6f41c; + color: #2a2a2a; + font-weight: 600; + font-size: 0.85rem; +} + +.fristen-wizard-row-badge { + font-size: 0.7rem; + padding: 0.05rem 0.45rem; + border-radius: 0.35rem; + letter-spacing: 0.04em; + text-transform: uppercase; + font-weight: 600; +} + +.fristen-wizard-row-badge--filter { background: #d6e4f0; color: #1c4567; } +.fristen-wizard-row-badge--qualifier { background: #f5e1a8; color: #6c4905; } + +.fristen-wizard-row-label { + font-weight: 500; + color: #2a2a2a; +} + +.fristen-wizard-row-anno { + font-size: 0.75rem; + color: #5d6e2a; + background: #ebf4c9; + padding: 0.05rem 0.4rem; + border-radius: 0.3rem; +} + +.fristen-wizard-row-answer { + margin-left: auto; + color: #1f1f1f; + font-weight: 500; +} + +.fristen-wizard-row-edit { + background: transparent; + border: 0; + color: #4a6f1f; + cursor: pointer; + font-size: 0.85rem; + padding: 0.15rem 0.45rem; + border-radius: 0.3rem; +} + +.fristen-wizard-row-edit:hover { + background: #eef4dd; +} + +.fristen-wizard-row-body { + margin-top: 0.55rem; +} + +.fristen-wizard-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.fristen-wizard-empty, +.fristen-wizard-probe { + font-size: 0.85rem; + color: #888; + font-style: italic; +} + +@media (max-width: 600px) { + .fristen-wizard-row-answer { + margin-left: 0; + width: 100%; + } +} diff --git a/internal/handlers/fristenrechner.go b/internal/handlers/fristenrechner.go index 8c9f680..0e73e3d 100644 --- a/internal/handlers/fristenrechner.go +++ b/internal/handlers/fristenrechner.go @@ -224,6 +224,7 @@ func handleProceedingTypes(w http.ResponseWriter, r *http.Request) { opts := services.ProceedingListOptions{ Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))), Kind: strings.TrimSpace(r.URL.Query().Get("kind")), + EventKind: strings.TrimSpace(r.URL.Query().Get("event_kind")), } types, err := dbSvc.fristenrechner.ListProceedings(r.Context(), opts) if err != nil { diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index dd2d1ff..9f06d71 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -104,6 +104,14 @@ type ProceedingListOptions struct { // Sequenced per docs/design-proceeding-types-taxonomy-2026-05-26.md // §7 option (c): mig 153 merges to main before the S3 PR ships. Kind string + // EventKind narrows to proceedings that have at least one published + // sequencing rule anchored on a procedural event of the requested + // kind ("filing" | "hearing" | "decision" | "order"). Powers the + // Fristenrechner overhaul Mode B R3 wizard row (§3.2): after R1 + // picks an event_kind, R3 should only chip proceedings whose event + // roster contains at least one event of that kind. Empty = no + // event-kind narrowing. + EventKind string } // ListProceedings returns the proceeding_types chips the Fristenrechner @@ -127,6 +135,16 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee if opts.Kind != "" { add("kind = $%d", opts.Kind) } + if opts.EventKind != "" { + add(`EXISTS ( + SELECT 1 FROM paliad.sequencing_rules sr + JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id + WHERE sr.proceeding_type_id = paliad.proceeding_types.id + AND sr.is_active = true AND sr.lifecycle_state = 'published' + AND pe.is_active = true AND pe.lifecycle_state = 'published' + AND pe.event_kind = $%d + )`, opts.EventKind) + } query := `SELECT code, name, name_en, jurisdiction FROM paliad.proceeding_types WHERE ` + strings.Join(where, " AND ") + ` diff --git a/internal/services/fristenrechner_proceedings_test.go b/internal/services/fristenrechner_proceedings_test.go index cc6d59a..0a589c0 100644 --- a/internal/services/fristenrechner_proceedings_test.go +++ b/internal/services/fristenrechner_proceedings_test.go @@ -99,20 +99,7 @@ func TestListProceedings(t *testing.T) { } }) - // Kind filter requires mig 153. Skip when the column is missing so - // pre-mig CI / pre-mig laptop runs stay green. - t.Run("kind=proceeding narrows when mig 153 has landed", func(t *testing.T) { - var hasKind bool - if err := pool.QueryRowContext(ctx, ` - SELECT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema='paliad' AND table_name='proceeding_types' AND column_name='kind' - )`).Scan(&hasKind); err != nil { - t.Fatalf("kind column probe: %v", err) - } - if !hasKind { - t.Skip("paliad.proceeding_types.kind not present yet — skip until mig 153 lands") - } + t.Run("kind=proceeding narrows to primary proceedings only", func(t *testing.T) { got, err := fr.ListProceedings(ctx, ProceedingListOptions{Kind: "proceeding"}) if err != nil { t.Fatalf("list proceedings kind=proceeding: %v", err) @@ -120,5 +107,50 @@ func TestListProceedings(t *testing.T) { if len(got) == 0 { t.Fatalf("expected non-empty primary-proceeding list") } + // upc.inf.cfi is unambiguously a primary proceeding — must + // survive the filter. + found := false + for _, p := range got { + if p.Code == "upc.inf.cfi" { + found = true + break + } + } + if !found { + t.Errorf("upc.inf.cfi missing from kind=proceeding list") + } + // upc.cfi.interim is the canonical phase row (per mig 153 + + // taxonomy doc §0.4 Group B) — must NOT appear. + for _, p := range got { + if p.Code == "upc.cfi.interim" { + t.Errorf("phase row upc.cfi.interim leaked into kind=proceeding") + } + } + }) + + t.Run("event_kind=filing narrows to proceedings with filing events", func(t *testing.T) { + got, err := fr.ListProceedings(ctx, ProceedingListOptions{ + Jurisdiction: "UPC", + Kind: "proceeding", + EventKind: "filing", + }) + if err != nil { + t.Fatalf("list proceedings UPC+filing: %v", err) + } + if len(got) == 0 { + t.Fatalf("expected UPC proceedings with filing events") + } + // upc.inf.cfi has at least one rule anchored on a filing event + // (Klageerhebung, SoD, etc.) — must survive. + found := false + for _, p := range got { + if p.Code == "upc.inf.cfi" { + found = true + break + } + } + if !found { + t.Errorf("upc.inf.cfi missing from UPC + event_kind=filing list") + } }) }