// 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; // m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's // primary_party is the side opposite the perspective. Drives the // Gegenseitig badge + muted style + unchecked default. is_cross_party: boolean; 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. // After Slice S5 (t-paliad-323), overhaul is the default; the legacy // wizard / row-stack / cascade is only reachable via `?legacy=1` for // a two-week deprecation window. The `?overhaul=1` deep links from // S2-S4 still work — they're now redundant with the default but kept // alive so bookmarks don't 302 / lose state. export function isOverhaulMode(): boolean { return new URLSearchParams(window.location.search).get("legacy") !== "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; } // MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep // link path bypasses these (jumps straight to the result view via // ?event=); the tabs appear when no event is locked yet. export type ModeTab = "search" | "wizard"; // mountModeShell renders the mode-tab pair under the page header and // hosts whichever mode panel is currently active. Called from the boot // path when no `?event=` is present. S3 wires Mode A; S4 will add // Mode B and the actual tab switching. export async function mountModeShell(activeTab: ModeTab): Promise { const root = document.getElementById("fristen-overhaul-root"); if (!root) return; root.hidden = false; // Defer to the per-mode module to render into the root. The tab // strip itself is a small header above the mode panel — for S3 we // render the shell + Mode A in one shot. // S4 will replace this with a real tab switcher. const tabs = `
`; root.innerHTML = tabs; // Wire tab switching. S3 only has Mode A wired; Mode B is a // placeholder until S4. root.querySelectorAll(".fristen-mode-tab").forEach((btn) => { btn.addEventListener("click", () => { const tab = (btn.dataset.tab || "search") as ModeTab; void mountModeShell(tab); }); }); // Mount the active mode panel into the host. S3 only routes "search"; // "wizard" renders a placeholder until S4 lands. const host = document.getElementById("fristen-overhaul-mode-host"); if (!host) return; if (activeTab === "search") { // Lazy import to keep the bundle layered and avoid a circular ref // between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts. const mod = await import("./fristenrechner-mode-a"); await mod.mountModeA(); } else { const mod = await import("./fristenrechner-wizard"); await mod.mountWizard(); } } // 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 crossPartyBadge = r.is_cross_party ? `${escHtml(t("deadlines.overhaul.crossparty.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} ${crossPartyBadge}
    ${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; // Cross-party rows are unconditionally excluded from write-back // (design §2.4). Even if the user manually checks the box, they // describe what the opponent files — not Akte work for our side. if (r.is_cross_party) 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; // Skip cross-party rows even if checked — they describe opposing- // side filings and don't belong in our side's Akte deadline set // (design §2.4, write-back exclusion). if (r.is_cross_party) 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 { // Cross-party rows are unchecked by default — they describe what the // OTHER side files. They render to honestly show the workflow, but // the Akte write-back excludes them unconditionally (design §2.4). if (r.is_cross_party) return false; 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); }