From 9ab8dd8e0fcc3f7c74918f3cc245c72ef5f6fc26 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 22:09:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(fristenrechner):=20Slice=20S2=20=E2=80=94?= =?UTF-8?q?=20result=20view=20under=20=3Foverhaul=3D1=20(m/paliad#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `frontend/src/client/fristenrechner-result.ts` module renders the shared result surface defined in docs/design-fristenrechner-overhaul-2026-05-26.md §4: * Sticky trigger card — event icon + name, proceeding/jurisdiction chips, inline trigger-date input that re-fetches on change. * Four follow-up groups — Mandatory / Recommended / Optional / Conditional. SPAWNED rules fold into their priority bucket with a `⇲ neues Verfahren` badge (§11.Q5). Conditional bucket holds every rule with sr.condition_expr IS NOT NULL. * Per-rule rows — title, duration phrase, party chip, legal-source citation (with youpc.org link when available), pre-checked checkbox driven by `defaultChecked(r)` (mandatory + recommended on; conditional + court-set + optional off), inline ✏ Datum override that re-renders. * Write-back footer — conditional on `?project=` per §11.Q7; in kontextfrei mode the footer is hidden and an inline nudge invites the user to pick an Akte. CTA submits to the existing POST /api/projects/{id}/deadlines/bulk endpoint, stamping each row with `audit_reason: "Aus Fristenrechner — Trigger: {name} ({date})"` per §11.Q12. Mount + URL contract — when `?overhaul=1` is set in the URL, `fristenrechner.ts` hides every legacy panel (`fristen-step1`, `fristen-step2`, `fristen-pathway-a`, `fristen-pathway-b`, `fristen-step3a`, the step-1 summary) and shows the overhaul root instead. With `?overhaul=1&event=&trigger_date=…` the surface is deep-linkable end-to-end. Without `?event=` the empty-shell nudge renders — S3+S4 will mount the entry-mode UIs onto this same root. Verified — bun build clean, 249 frontend tests pass (incl. 9 new helper tests for groupFollowUps + defaultChecked), go build + vet clean, S1 live-DB tests still green. --- .../src/client/fristenrechner-result.test.ts | 72 +++ frontend/src/client/fristenrechner-result.ts | 611 ++++++++++++++++++ frontend/src/client/fristenrechner.ts | 58 ++ frontend/src/client/i18n.ts | 52 ++ frontend/src/fristenrechner.tsx | 9 + frontend/src/i18n-keys.ts | 20 + frontend/src/styles/global.css | 340 ++++++++++ 7 files changed, 1162 insertions(+) create mode 100644 frontend/src/client/fristenrechner-result.test.ts create mode 100644 frontend/src/client/fristenrechner-result.ts diff --git a/frontend/src/client/fristenrechner-result.test.ts b/frontend/src/client/fristenrechner-result.test.ts new file mode 100644 index 0000000..ea7fc86 --- /dev/null +++ b/frontend/src/client/fristenrechner-result.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { + defaultChecked, + groupFollowUps, + type FollowUpRule, +} from "./fristenrechner-result"; + +// Pure helpers exercised here; the DOM-driven render path is covered +// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the +// entry-mode UIs in later slices). + +function mk(partial: Partial): FollowUpRule { + return { + rule_id: "r" + Math.random().toString(36).slice(2, 8), + event_code: "evt", + title_de: "Frist", + title_en: "Deadline", + priority: "mandatory", + is_court_set: false, + is_spawn: false, + is_bilateral: false, + has_condition: false, + ...partial, + }; +} + +describe("groupFollowUps — design §4.2 priority+condition buckets", () => { + test("groups by priority; conditional takes precedence over priority", () => { + const rows = [ + mk({ priority: "mandatory" }), + mk({ priority: "recommended" }), + mk({ priority: "optional" }), + mk({ priority: "mandatory", has_condition: true }), // → conditional + mk({ priority: "optional", has_condition: true }), // → conditional + ]; + const g = groupFollowUps(rows); + expect(g.mandatory.length).toBe(1); + expect(g.recommended.length).toBe(1); + expect(g.optional.length).toBe(1); + expect(g.conditional.length).toBe(2); + }); + + test("unknown priority falls through to optional", () => { + const g = groupFollowUps([mk({ priority: "informational" })]); + expect(g.optional.length).toBe(1); + expect(g.mandatory.length).toBe(0); + }); +}); + +describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => { + test("mandatory rules pre-checked", () => { + expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true); + }); + test("recommended rules pre-checked", () => { + expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true); + }); + test("optional rules unchecked", () => { + expect(defaultChecked(mk({ priority: "optional" }))).toBe(false); + }); + test("conditional rules unchecked", () => { + expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false); + }); + test("court-set rules unchecked even when mandatory", () => { + expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false); + }); + test("spawned rules pre-checked when mandatory", () => { + expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true); + }); + test("spawned optional rules unchecked", () => { + expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false); + }); +}); diff --git a/frontend/src/client/fristenrechner-result.ts b/frontend/src/client/fristenrechner-result.ts new file mode 100644 index 0000000..d0857a9 --- /dev/null +++ b/frontend/src/client/fristenrechner-result.ts @@ -0,0 +1,611 @@ +// Fristenrechner overhaul — shared result view (design §4). +// +// Given a locked trigger event + a trigger date, this module renders +// the result surface: a sticky trigger card on top, then four priority +// groups (mandatory / recommended / optional / conditional) of follow-up +// rules with computed dates, then a write-back footer that calls the +// existing POST /api/projects/{id}/deadlines/bulk. +// +// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B +// wizard in S4) both land here once they've identified a trigger +// procedural_event. S2 mounts the surface under `?overhaul=1` and is +// deep-linkable on its own via `?overhaul=1&event=&trigger_date=…`. + +import { escAttr, escHtml } from "./views/verfahrensablauf-core"; +import { getLang, t, tDyn } from "./i18n"; + +// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors +// services.FollowUpsResponse server-side. +export interface FollowUpRule { + rule_id: string; + event_code: string; + title_de: string; + title_en: string; + priority: string; + primary_party?: string; + duration_value?: number; + duration_unit?: string; + timing?: string; + due_date?: string; + original_due_date?: string; + was_adjusted?: boolean; + is_court_set: boolean; + is_spawn: boolean; + is_bilateral: boolean; + has_condition: boolean; + rule_code?: string; + legal_source?: string; + legal_source_display?: string; + legal_source_url?: string; + notes_de?: string; + notes_en?: string; + spawn_label?: string; + spawn_proceeding_code?: string; + concept_id?: string; +} + +export interface FollowUpsResponse { + trigger: { + 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; + }; + anchor_rule_id: string; + }; + trigger_date: string; + party?: string; + follow_ups: FollowUpRule[]; +} + +// Per-rule UI state — checkbox, optional date override. +interface RuleSelection { + checked: boolean; + override?: string; +} + +// Module-local state. Single result view at a time; the surface +// re-renders in place when the user changes the trigger date or +// re-locks a different event. +let currentResponse: FollowUpsResponse | null = null; +const selections = new Map(); +let currentProjectId: string | null = null; + +// Public API ---------------------------------------------------------- + +// isOverhaulMode reports whether the page is in overhaul mode (S2+). +// True when `?overhaul=1` is present. Once S5 flips the flag, the +// reverse check (?legacy=1) replaces this. +export function isOverhaulMode(): boolean { + return new URLSearchParams(window.location.search).get("overhaul") === "1"; +} + +// resolveProjectId reads the active Akte from the URL query string. +// Returns null when in kontextfrei mode (no project picked). +function resolveProjectId(): string | null { + const p = new URLSearchParams(window.location.search).get("project"); + return p && p.length > 0 ? p : null; +} + +// MountOptions configures the surface entry. Both entry-mode paths +// (Mode A in S3, Mode B in S4) call mount() with the event reference +// that the user committed. +export interface MountOptions { + // eventRef is the procedural_event code OR its uuid OR the anchor + // sequencing_rule id. Resolved server-side; the wire returns the + // canonical code so the URL bookmark is stable. + eventRef: string; + // triggerDate is YYYY-MM-DD. Defaults to today when omitted. + triggerDate?: string; + // party is "claimant" | "defendant"; mode A may pass "both" or + // "court". When omitted, follow-ups are returned without party + // narrowing. + party?: string; + // courtId selects the holiday calendar for the per-rule date + // adjustment. Optional. + courtId?: string; +} + +// mountResultView fetches /follow-ups and renders the result surface +// into the host container. Re-callable: replaces previous state. +export async function mountResultView(opts: MountOptions): Promise { + const root = document.getElementById("fristen-overhaul-root"); + if (!root) return; + root.hidden = false; + + const triggerDate = opts.triggerDate || todayIso(); + currentProjectId = resolveProjectId(); + + // Show a quick "loading…" placeholder so the user sees something + // immediately, even on a cold fetch. + root.innerHTML = `
${escHtml(t("deadlines.overhaul.loading"))}
`; + + const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin); + url.searchParams.set("event", opts.eventRef); + url.searchParams.set("trigger_date", triggerDate); + if (opts.party) url.searchParams.set("party", opts.party); + if (opts.courtId) url.searchParams.set("court_id", opts.courtId); + + let data: FollowUpsResponse; + try { + const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({}) as { error?: string }); + root.innerHTML = `
${escHtml(body.error || t("deadlines.overhaul.load_error"))}
`; + return; + } + data = (await resp.json()) as FollowUpsResponse; + } catch (err) { + root.innerHTML = `
${escHtml(t("deadlines.overhaul.load_error"))}
`; + return; + } + + currentResponse = data; + selections.clear(); + for (const r of data.follow_ups) { + selections.set(r.rule_id, { checked: defaultChecked(r) }); + } + + renderSurface(); + // Reflect the canonical event code + trigger date in the URL so the + // deep-link survives a reload. + syncUrlState(data.trigger.code, data.trigger_date); +} + +// Render -------------------------------------------------------------- + +function renderSurface(): void { + const root = document.getElementById("fristen-overhaul-root"); + if (!root || !currentResponse) return; + + const lang = getLang(); + const trig = currentResponse.trigger; + const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de; + const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de; + const juris = trig.proceeding_type.jurisdiction || ""; + const kindIcon = eventKindIcon(trig.event_kind); + + const triggerCard = ` +
+
+ +

${escHtml(triggerName)}

+
+
+ ${escHtml(trig.code)} + ${escHtml(ptName)} + ${juris ? `${escHtml(juris)}` : ""} +
+
+ + +
+
+ `; + + const groups = groupFollowUps(currentResponse.follow_ups); + const groupHtml = renderGroups(groups, lang); + + const nudge = currentProjectId + ? "" + : `
${escHtml(t("deadlines.overhaul.nudge.no_project"))}
`; + + const footer = currentProjectId + ? renderFooter() + : ""; + + root.innerHTML = ` + ${triggerCard} + ${nudge} +
+ ${groupHtml} +
+ ${footer} +
+ `; + + wireSurfaceEvents(); +} + +export interface GroupedFollowUps { + mandatory: FollowUpRule[]; + recommended: FollowUpRule[]; + optional: FollowUpRule[]; + conditional: FollowUpRule[]; +} + +// groupFollowUps splits the wire list into the four visible groups per +// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes +// precedence over the priority bucket so a "nur wenn CCR" mandatory +// rule renders under Conditional with the gating language visible. +export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps { + const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] }; + for (const r of rows) { + if (r.has_condition) { + out.conditional.push(r); + continue; + } + switch (r.priority) { + case "mandatory": + out.mandatory.push(r); + break; + case "recommended": + out.recommended.push(r); + break; + case "optional": + out.optional.push(r); + break; + default: + // unknown / informational — fold into optional so the row is at + // least visible. Future Phase 2 'informational' tier gets a + // dedicated bucket once seeded. + out.optional.push(r); + } + } + return out; +} + +function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string { + const blocks: string[] = []; + if (groups.mandatory.length > 0) { + blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang)); + } + if (groups.recommended.length > 0) { + blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang)); + } + if (groups.optional.length > 0) { + blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang)); + } + if (groups.conditional.length > 0) { + blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang)); + } + if (blocks.length === 0) { + return `
${escHtml(t("deadlines.overhaul.empty"))}
`; + } + return blocks.join(""); +} + +function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string { + const items = rows.map((r) => renderRule(r, lang)).join(""); + return ` +
+

${escHtml(label)}

+
    + ${items} +
+
+ `; +} + +function renderRule(r: FollowUpRule, lang: "de" | "en"): string { + const title = lang === "en" ? r.title_en || r.title_de : r.title_de; + const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de; + const sel = selections.get(r.rule_id); + const checked = sel ? sel.checked : defaultChecked(r); + const dateOverride = sel?.override; + const computedDate = r.due_date || ""; + const effectiveDate = dateOverride || computedDate; + const disabled = r.is_court_set || (r.is_spawn && !r.due_date); + + // Duration phrase: "3 Monate" / "14 Tage" — language-aware. + const durationPhrase = formatDurationPhrase(r, lang); + const dateCell = r.is_court_set + ? `${escHtml(t("deadlines.court.set"))}` + : effectiveDate + ? `${escHtml(formatDateForLang(effectiveDate, lang))}` + : ``; + + const partyBadge = r.primary_party + ? `${escHtml(t(`deadlines.party.${r.primary_party}` as never))}` + : ""; + + const sourceBadge = r.legal_source_display + ? r.legal_source_url + ? `${escHtml(r.legal_source_display)}` + : `${escHtml(r.legal_source_display)}` + : r.rule_code + ? `${escHtml(r.rule_code)}` + : ""; + + const spawnBadge = r.is_spawn + ? `${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}` + : ""; + + const condBadge = r.has_condition + ? `${escHtml(t("deadlines.overhaul.condition.badge"))}` + : ""; + + const notesHtml = notes + ? `
${escHtml(t("deadlines.overhaul.notes.summary"))}

${escHtml(notes)}

` + : ""; + + const editBtn = r.is_court_set || r.is_spawn || !computedDate + ? "" + : ``; + + return ` +
  • + +
    +
    + ${escHtml(title)} + ${spawnBadge} + ${condBadge} +
    +
    + ${durationPhrase ? `${escHtml(durationPhrase)}` : ""} + ${partyBadge} + ${sourceBadge} +
    + ${notesHtml} +
    +
    + ${dateCell} + ${editBtn} +
    +
  • + `; +} + +function renderFooter(): string { + const selectedCount = countSelected(); + return ` +
    + + ${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))} + + +
    + `; +} + +// Event wiring -------------------------------------------------------- + +function wireSurfaceEvents(): void { + // Trigger-date change → re-fetch with new date. + const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null; + if (dateInput && currentResponse) { + dateInput.addEventListener("change", () => { + if (!currentResponse) return; + const newDate = dateInput.value; + if (!newDate) return; + void mountResultView({ + eventRef: currentResponse.trigger.code, + triggerDate: newDate, + party: currentResponse.party, + }); + }); + } + + // Checkbox toggles → update selections + footer count. + const root = document.getElementById("fristen-overhaul-root"); + if (root) { + root.querySelectorAll(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => { + cb.addEventListener("change", () => { + const id = cb.dataset.ruleId || ""; + const sel = selections.get(id) ?? { checked: cb.checked }; + sel.checked = cb.checked; + selections.set(id, sel); + refreshFooterCount(); + }); + }); + + // Per-rule date override. + root.querySelectorAll(".fristen-overhaul-rule-edit-date").forEach((btn) => { + btn.addEventListener("click", () => editRuleDate(btn)); + }); + } + + // Write-back CTA. + const cta = document.getElementById("fristen-overhaul-write-back"); + if (cta) cta.addEventListener("click", () => void submitWriteBack()); +} + +function editRuleDate(btn: HTMLButtonElement): void { + const ruleId = btn.dataset.ruleId || ""; + const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId); + if (!rule) return; + const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) }; + const current = sel.override || rule.due_date || todayIso(); + + const dateCell = btn.parentElement; + if (!dateCell) return; + const dateSpan = dateCell.querySelector(".fristen-overhaul-rule-date"); + if (!dateSpan) return; + + const input = document.createElement("input"); + input.type = "date"; + input.value = current; + input.className = "fristen-overhaul-rule-date-input"; + dateSpan.replaceWith(input); + btn.disabled = true; + input.focus(); + + const commit = () => { + const newDate = input.value; + if (newDate && newDate !== current) { + sel.override = newDate; + selections.set(ruleId, sel); + } + renderSurface(); + }; + input.addEventListener("blur", commit, { once: true }); + input.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") { + e.preventDefault(); + input.blur(); + } else if ((e as KeyboardEvent).key === "Escape") { + renderSurface(); + } + }); +} + +function refreshFooterCount(): void { + const countEl = document.getElementById("fristen-overhaul-footer-count"); + const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null; + const n = countSelected(); + if (countEl) { + countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n)); + } + if (cta) cta.disabled = n === 0; +} + +function countSelected(): number { + let n = 0; + if (!currentResponse) return 0; + for (const r of currentResponse.follow_ups) { + if (r.is_court_set) continue; + const sel = selections.get(r.rule_id); + if (sel?.checked) n++; + } + return n; +} + +// Write-back ---------------------------------------------------------- + +async function submitWriteBack(): Promise { + if (!currentResponse) return; + if (!currentProjectId) return; + const msg = document.getElementById("fristen-overhaul-msg"); + const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null; + const lang = getLang(); + + const deadlines: Array> = []; + for (const r of currentResponse.follow_ups) { + const sel = selections.get(r.rule_id); + if (!sel?.checked) continue; + if (r.is_court_set) continue; + const dueDate = sel.override || r.due_date; + if (!dueDate) continue; + const title = lang === "en" ? r.title_en || r.title_de : r.title_de; + const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de; + deadlines.push({ + title, + rule_code: r.rule_code || undefined, + due_date: dueDate, + original_due_date: r.original_due_date || r.due_date || undefined, + source: "fristenrechner", + rule_id: r.rule_id, + notes: notes || undefined, + audit_reason: auditReason(), + }); + } + + if (deadlines.length === 0 || !msg || !cta) return; + cta.disabled = true; + msg.textContent = ""; + msg.className = "fristen-overhaul-msg"; + + try { + const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deadlines }), + }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({}) as { error?: string }); + msg.textContent = body.error || t("deadlines.save.error"); + msg.className = "fristen-overhaul-msg form-msg-error"; + cta.disabled = false; + return; + } + msg.innerHTML = `${escHtml(t("deadlines.save.success"))} ${escHtml(t("deadlines.save.success.link"))}`; + msg.className = "fristen-overhaul-msg form-msg-ok"; + setTimeout(() => { + if (cta) cta.disabled = false; + }, 1500); + } catch { + msg.textContent = t("deadlines.save.error"); + msg.className = "fristen-overhaul-msg form-msg-error"; + cta.disabled = false; + } +} + +// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})". +function auditReason(): string { + if (!currentResponse) return ""; + const name = currentResponse.trigger.name_de; + const date = currentResponse.trigger_date; + return `Aus Fristenrechner — Trigger: ${name} (${date})`; +} + +// Helpers ------------------------------------------------------------- + +export function defaultChecked(r: FollowUpRule): boolean { + if (r.is_court_set) return false; + if (r.is_spawn) return r.priority === "mandatory"; + if (r.has_condition) return false; + return r.priority === "mandatory" || r.priority === "recommended"; +} + +function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string { + if (!r.duration_value || !r.duration_unit) return ""; + const unitDE: Record = { + days: "Tage", + months: "Monate", + weeks: "Wochen", + years: "Jahre", + }; + const unitEN: Record = { + days: "days", + months: "months", + weeks: "weeks", + years: "years", + }; + const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit; + return `${r.duration_value} ${u}`; +} + +function formatDateForLang(iso: string, lang: "de" | "en"): string { + // YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short). + if (!iso || iso.length < 10) return iso; + const [y, m, d] = iso.split("-"); + if (!y || !m || !d) return iso; + if (lang === "en") { + const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + const idx = parseInt(m, 10) - 1; + const mn = idx >= 0 && idx < months.length ? months[idx] : m; + return `${parseInt(d, 10)} ${mn} ${y}`; + } + return `${d}.${m}.${y}`; +} + +function eventKindIcon(kind?: string): string { + switch (kind) { + case "filing": return "📥"; // inbox/letter + case "hearing": return "🏛️"; // courthouse + case "decision": return "⚖️"; // scales + case "order": return "📜"; // page + default: return "📅"; // calendar + } +} + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +function syncUrlState(eventCode: string, triggerDate: string): void { + const url = new URL(window.location.href); + url.searchParams.set("overhaul", "1"); + url.searchParams.set("event", eventCode); + url.searchParams.set("trigger_date", triggerDate); + history.replaceState(null, "", url.pathname + url.search + url.hash); +} diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts index 71c7450..bb4c548 100644 --- a/frontend/src/client/fristenrechner.ts +++ b/frontend/src/client/fristenrechner.ts @@ -30,6 +30,7 @@ import { type EventChoice, type ChoiceKind, } from "./views/event-card-choices"; +import { isOverhaulMode, mountResultView } from "./fristenrechner-result"; let lastResponse: DeadlineResponse | null = null; @@ -113,6 +114,49 @@ onLangChange(() => { let selectedType = ""; +// t-paliad-323 Slice S2 — Fristenrechner overhaul boot. Hides the +// legacy step / pathway shells and mounts the result view. S3+S4 will +// hook entry-mode UIs into this; S2 is deep-link only. +function bootOverhaulMode(): void { + // Hide every legacy section so only the overhaul root is visible. + // The page wrapper (`
    `, `
    `, the + // tool-header) stays so the sidebar + title carry through. + const hideIds = [ + "fristen-step1", + "fristen-step1-summary", + "fristen-step2", + "fristen-pathway-b", + "fristen-step3a", + "fristen-pathway-a", + ]; + for (const id of hideIds) { + const el = document.getElementById(id); + if (el) { + el.hidden = true; + el.style.display = "none"; + } + } + + // S2 deep-link contract: ?overhaul=1&event=&trigger_date=… + // When event is missing, leave the surface empty — S3/S4 will mount + // entry-mode UIs onto this surface in later slices. + const params = new URLSearchParams(window.location.search); + const eventRef = params.get("event") || ""; + const triggerDate = params.get("trigger_date") || undefined; + const party = params.get("party") || undefined; + const courtId = params.get("court_id") || undefined; + + if (!eventRef) { + const root = document.getElementById("fristen-overhaul-root"); + if (root) { + root.hidden = false; + root.innerHTML = `
    ${t("deadlines.overhaul.empty")}
    `; + } + return; + } + void mountResultView({ eventRef, triggerDate, party, courtId }); +} + function showStep(n: number) { for (let i = 1; i <= 3; i++) { const el = document.getElementById(`step-${i}`); @@ -656,6 +700,20 @@ document.addEventListener("DOMContentLoaded", () => { initI18n(); initSidebar(); + // t-paliad-323 Slice S2 — Fristenrechner overhaul boot. + // When ?overhaul=1 is set, hide the legacy three-step wizard / + // Pathway A+B shells and mount the new result view in their place. + // Deep-linkable via ?overhaul=1&event=&trigger_date=…&project=… + // (the trigger date defaults to today when omitted). S3 (Mode A + // search) and S4 (Mode B wizard) will land users here once they + // identify a trigger event — for now the surface is reached only + // via deep link, but ?overhaul=1 alone shows the empty shell so + // the path is exercisable end-to-end. + if (isOverhaulMode()) { + bootOverhaulMode(); + return; + } + // Proceeding type selection document.querySelectorAll(".proceeding-btn").forEach((btn) => { btn.addEventListener("click", () => selectProceeding(btn)); diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index d6e151c..bff6c15 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1010,6 +1010,32 @@ const translations: Record> = { "deadlines.save.error": "\u00dcbernahme fehlgeschlagen.", "deadlines.save.skip_court_set": "Gerichtsbestimmte Termine ohne Datum werden \u00fcbersprungen.", + // Fristenrechner overhaul \u2014 shared result view (S2, design \u00a74). + "deadlines.overhaul.loading": "Folge-Fristen werden geladen\u2026", + "deadlines.overhaul.load_error": "Folge-Fristen konnten nicht geladen werden.", + "deadlines.overhaul.empty": "Keine Folge-Fristen f\u00fcr dieses Ereignis hinterlegt.", + "deadlines.overhaul.trigger.label": "Trigger-Ereignis", + "deadlines.overhaul.trigger.date": "Trigger-Datum:", + "deadlines.overhaul.followups.label": "Folge-Fristen", + "deadlines.overhaul.group.mandatory": "Pflicht", + "deadlines.overhaul.group.recommended": "Empfohlen", + "deadlines.overhaul.group.optional": "Kann (auf Antrag)", + "deadlines.overhaul.group.conditional": "Bedingt", + "deadlines.overhaul.spawn.badge": "\u21f2 neues Verfahren", + "deadlines.overhaul.spawn.tooltip": "Diese Regel leitet ein neues Verfahren ein.", + "deadlines.overhaul.condition.badge": "Nur unter Bedingung", + "deadlines.overhaul.notes.summary": "Hinweis", + "deadlines.overhaul.edit_date.label": "\u270f Datum", + "deadlines.overhaul.edit_date.title": "Datum manuell anpassen", + "deadlines.overhaul.select_rule": "Frist ausw\u00e4hlen", + "deadlines.overhaul.footer.count": "{n} Fristen ausgew\u00e4hlt", + "deadlines.overhaul.footer.cta": "In Akte eintragen", + "deadlines.overhaul.nudge.no_project": "Tipp: W\u00e4hle oben eine Akte, um diese Fristen einzutragen.", + "deadlines.party.claimant": "Kl\u00e4gerseite", + "deadlines.party.defendant": "Beklagtenseite", + "deadlines.party.both": "Beide Seiten", + "deadlines.party.court": "Gericht", + // Office labels (shared) "office.munich": "M\u00fcnchen", "office.duesseldorf": "D\u00fcsseldorf", @@ -4122,6 +4148,32 @@ const translations: Record> = { "deadlines.save.error": "Import failed.", "deadlines.save.skip_court_set": "Court-set entries with no date will be skipped.", + // Fristenrechner overhaul — shared result view (S2, design §4). + "deadlines.overhaul.loading": "Loading follow-up deadlines…", + "deadlines.overhaul.load_error": "Could not load follow-up deadlines.", + "deadlines.overhaul.empty": "No follow-up deadlines configured for this event.", + "deadlines.overhaul.trigger.label": "Trigger event", + "deadlines.overhaul.trigger.date": "Trigger date:", + "deadlines.overhaul.followups.label": "Follow-up deadlines", + "deadlines.overhaul.group.mandatory": "Mandatory", + "deadlines.overhaul.group.recommended": "Recommended", + "deadlines.overhaul.group.optional": "Optional", + "deadlines.overhaul.group.conditional": "Conditional", + "deadlines.overhaul.spawn.badge": "⇲ new proceeding", + "deadlines.overhaul.spawn.tooltip": "This rule initiates a new proceeding.", + "deadlines.overhaul.condition.badge": "Conditional", + "deadlines.overhaul.notes.summary": "Note", + "deadlines.overhaul.edit_date.label": "✏ Date", + "deadlines.overhaul.edit_date.title": "Edit date manually", + "deadlines.overhaul.select_rule": "Select deadline", + "deadlines.overhaul.footer.count": "{n} deadlines selected", + "deadlines.overhaul.footer.cta": "Add to project", + "deadlines.overhaul.nudge.no_project": "Tip: pick a project above to import these deadlines.", + "deadlines.party.claimant": "Claimant", + "deadlines.party.defendant": "Defendant", + "deadlines.party.both": "Both parties", + "deadlines.party.court": "Court", + // Office labels (shared) "office.munich": "Munich", "office.duesseldorf": "D\u00fcsseldorf", diff --git a/frontend/src/fristenrechner.tsx b/frontend/src/fristenrechner.tsx index fcf1230..c8e101f 100644 --- a/frontend/src/fristenrechner.tsx +++ b/frontend/src/fristenrechner.tsx @@ -123,6 +123,15 @@ export function renderFristenrechner(): string {

    + {/* t-paliad-323 Slice S2 — overhaul result view mount root. + Hidden by default; the client module shows this and hides + the legacy panels when `?overhaul=1` is present in the + URL. Deep-linkable on its own via + `?overhaul=1&event=&trigger_date=…`. Mode A (S3) + and Mode B wizard (S4) will land users on this surface + once they identify a trigger procedural_event. */} + + {/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the Akte (project) that scopes the rest of the flow. Filtered list of visible projects + "Neue Akte anlegen" link + diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 1abfdc8..283f6d1 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1377,6 +1377,26 @@ export type I18nKey = | "deadlines.neu.title" | "deadlines.notes.show" | "deadlines.optional.badge" + | "deadlines.overhaul.condition.badge" + | "deadlines.overhaul.edit_date.label" + | "deadlines.overhaul.edit_date.title" + | "deadlines.overhaul.empty" + | "deadlines.overhaul.followups.label" + | "deadlines.overhaul.footer.count" + | "deadlines.overhaul.footer.cta" + | "deadlines.overhaul.group.conditional" + | "deadlines.overhaul.group.mandatory" + | "deadlines.overhaul.group.optional" + | "deadlines.overhaul.group.recommended" + | "deadlines.overhaul.load_error" + | "deadlines.overhaul.loading" + | "deadlines.overhaul.notes.summary" + | "deadlines.overhaul.nudge.no_project" + | "deadlines.overhaul.select_rule" + | "deadlines.overhaul.spawn.badge" + | "deadlines.overhaul.spawn.tooltip" + | "deadlines.overhaul.trigger.date" + | "deadlines.overhaul.trigger.label" | "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 bc009eb..83095cf 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -18886,3 +18886,343 @@ dialog.quick-add-sheet::backdrop { gap: 0.5rem; } } + +/* === Fristenrechner overhaul (t-paliad-323 Slice S2) ================= + * + * Result-view surface mounted under `?overhaul=1`. Sticky trigger card + * on top, four priority groups of follow-up rules, write-back footer + * conditional on `?project=`. See + * docs/design-fristenrechner-overhaul-2026-05-26.md §4. + * ==================================================================== */ + +.fristen-overhaul-root { + display: block; + margin-top: 1.5rem; +} + +.fristen-overhaul-loading, +.fristen-overhaul-error, +.fristen-overhaul-empty, +.fristen-overhaul-nudge { + padding: 0.9rem 1.1rem; + border-radius: 0.6rem; + margin: 0.5rem 0; + background: #f4f4f0; + border: 1px solid #e3e3da; + color: #444; + font-size: 0.95rem; +} + +.fristen-overhaul-error { + background: #fde9e7; + border-color: #f0b8b1; + color: #732f25; +} + +.fristen-overhaul-nudge { + background: #f8fbe8; + border-color: #d2e08b; + color: #4d5a2a; +} + +.fristen-overhaul-trigger { + background: #fff; + border: 1px solid #d8d8cf; + border-radius: 0.8rem; + padding: 1rem 1.2rem; + margin-bottom: 1.2rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.04); +} + +.fristen-overhaul-trigger-header { + display: flex; + align-items: center; + gap: 0.7rem; +} + +.fristen-overhaul-kind-icon { + font-size: 1.5rem; + line-height: 1; +} + +.fristen-overhaul-trigger-title { + margin: 0; + font-size: 1.25rem; + color: #1f1f1f; +} + +.fristen-overhaul-trigger-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; + font-size: 0.9rem; + color: #555; +} + +.fristen-overhaul-trigger-code, +.fristen-overhaul-trigger-pt, +.fristen-overhaul-trigger-juris { + padding: 0.15rem 0.55rem; + border-radius: 0.4rem; + background: #f1f1eb; + color: #555; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 0.8rem; +} + +.fristen-overhaul-trigger-juris { + background: #d3edb7; + color: #38531a; + font-family: inherit; + font-weight: 600; +} + +.fristen-overhaul-trigger-date { + display: flex; + align-items: center; + gap: 0.7rem; + margin-top: 0.8rem; +} + +.fristen-overhaul-trigger-date-label { + font-size: 0.9rem; + color: #555; +} + +.fristen-overhaul-trigger-date-input { + padding: 0.35rem 0.55rem; + font-size: 0.95rem; + border: 1px solid #c8c8be; + border-radius: 0.4rem; + background: #fff; +} + +.fristen-overhaul-groups { + display: flex; + flex-direction: column; + gap: 1.1rem; +} + +.fristen-overhaul-group { + background: #fff; + border: 1px solid #e2e2d6; + border-radius: 0.7rem; + padding: 0.9rem 1.1rem; +} + +.fristen-overhaul-group--mandatory { border-left: 4px solid #c6f41c; } +.fristen-overhaul-group--recommended { border-left: 4px solid #99c4e3; } +.fristen-overhaul-group--optional { border-left: 4px solid #d4d4cc; } +.fristen-overhaul-group--conditional { border-left: 4px solid #f5b66a; } + +.fristen-overhaul-group-title { + margin: 0 0 0.6rem 0; + font-size: 1rem; + color: #2a2a2a; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.fristen-overhaul-rule-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.fristen-overhaul-rule { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.7rem; + align-items: start; + padding: 0.5rem 0.6rem; + background: #fafaf6; + border: 1px solid #ececde; + border-radius: 0.5rem; +} + +.fristen-overhaul-rule.is-disabled { + opacity: 0.7; +} + +.fristen-overhaul-rule-check { + display: flex; + align-items: center; + height: 1.4rem; + cursor: pointer; +} + +.fristen-overhaul-rule-body { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.fristen-overhaul-rule-title-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; +} + +.fristen-overhaul-rule-title { + font-weight: 600; + color: #1f1f1f; +} + +.fristen-overhaul-rule-spawn, +.fristen-overhaul-rule-cond { + font-size: 0.75rem; + padding: 0.05rem 0.45rem; + border-radius: 0.35rem; + background: #f3e5cf; + color: #6e4a1d; + white-space: nowrap; +} + +.fristen-overhaul-rule-cond { + background: #fff2d6; + color: #7a570e; +} + +.fristen-overhaul-rule-meta-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + font-size: 0.85rem; + color: #555; +} + +.fristen-overhaul-rule-duration { + color: #2a2a2a; +} + +.fristen-overhaul-rule-party { + padding: 0.05rem 0.45rem; + border-radius: 0.35rem; + font-size: 0.75rem; + background: #eef2e3; + color: #4a5d2a; +} + +.fristen-overhaul-rule-party--claimant { background: #d2e9ff; color: #1c4567; } +.fristen-overhaul-rule-party--defendant { background: #ffe2d7; color: #6e2c14; } +.fristen-overhaul-rule-party--court { background: #f0e2f7; color: #4f2c66; } + +.fristen-overhaul-rule-source { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 0.8rem; + color: #444; +} + +a.fristen-overhaul-rule-source { + color: #2d4f1a; + text-decoration: underline; + text-underline-offset: 2px; +} + +.fristen-overhaul-rule-notes { + margin-top: 0.3rem; + font-size: 0.85rem; + color: #555; +} + +.fristen-overhaul-rule-notes summary { + cursor: pointer; + color: #666; +} + +.fristen-overhaul-rule-date-cell { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + font-size: 0.95rem; + min-width: 6.5rem; +} + +.fristen-overhaul-rule-date { + font-weight: 600; + color: #1f1f1f; +} + +.fristen-overhaul-rule-date--unknown { + color: #999; + font-weight: 400; +} + +.fristen-overhaul-rule-court-set { + color: #6e4a1d; + font-style: italic; + font-size: 0.85rem; +} + +.fristen-overhaul-rule-date-input { + padding: 0.2rem 0.4rem; + font-size: 0.95rem; + border: 1px solid #c8c8be; + border-radius: 0.3rem; + background: #fff; +} + +.fristen-overhaul-rule-edit-date { + border: 0; + background: transparent; + color: #4a6f1f; + font-size: 0.8rem; + cursor: pointer; + padding: 0.1rem 0.3rem; + border-radius: 0.3rem; +} + +.fristen-overhaul-rule-edit-date:hover { + background: #eef4dd; +} + +.fristen-overhaul-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1.2rem; + padding: 0.9rem 1.1rem; + background: #f7fbe6; + border: 1px solid #d3e08b; + border-radius: 0.7rem; +} + +.fristen-overhaul-footer-count { + font-size: 0.95rem; + color: #3d501c; + font-weight: 500; +} + +.fristen-overhaul-footer-cta { + /* leans on btn-primary / btn-cta-lime classes from global */ +} + +.fristen-overhaul-msg { + margin-top: 0.8rem; + padding: 0.6rem 0.9rem; + font-size: 0.9rem; + border-radius: 0.4rem; +} + +.fristen-overhaul-msg.form-msg-ok { background: #e7f4d6; color: #3a5113; } +.fristen-overhaul-msg.form-msg-error { background: #fde9e7; color: #732f25; } + +@media (max-width: 600px) { + .fristen-overhaul-rule { + grid-template-columns: auto 1fr; + } + .fristen-overhaul-rule-date-cell { + grid-column: 1 / -1; + flex-direction: row; + justify-content: flex-end; + align-items: center; + min-width: 0; + } +}