From b54e938bdfeb559616cee7dff8eee6e1dbe6ba8b Mon Sep 17 00:00:00 2001 From: m Date: Tue, 5 May 2026 14:04:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(t-paliad-136):=20Phase=20B=20=E2=80=94=20c?= =?UTF-8?q?ard-click=20=E2=86=92=20calc=20panel=20=E2=86=92=20add=20to=20p?= =?UTF-8?q?roject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 result cards were dead-ends: clicking a Klageerwiderung pill showed no deadline; users had to switch to Pathway A's wizard, retype the date, and read the deadline out of the timeline. v4 makes the card the entry to a single-rule calculator + add-to-project flow per m's 2026-05-05 11:58 feedback. Backend (single-rule calc, no parent walk): - New POST /api/tools/fristenrechner/calculate-rule endpoint accepts either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and optional condition flags. Returns rule metadata + computed dueDate + originalDate + adjustment-reason chip data. - FristenrechnerService.CalculateRule() reuses the existing addDuration + HolidayService.AdjustForNonWorkingDaysWithReason pipeline so t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC- Sommerferien skip both apply automatically. Court-determined rules (party='court' or event_type ∈ hearing/decision/order) return IsCourtSet=true and an empty due date. - Flag-conditional rules surface FlagsRequired even when the caller hasn't supplied the flag — the UI uses this to render checkboxes; toggling recomputes live. With all flags satisfied + alt_duration_* present, the calc swaps to alt values (existing semantics). - Live-DB integration test covers plain calc, court-set, flag handling, and error paths (skipped without TEST_DATABASE_URL). Frontend (inline calc panel): - Click any card body or rule pill → expand inline panel inside the card (only one open at a time). Pill picker (radio chips) appears when the card has 2+ rule pills; first preselected. Trigger date defaults to today (m's Q3). Flag checkboxes auto-render from the rule's condition_flag. - Result row shows due date, "(N units from triggerDate)", and a shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC- Sommerferien (27.7.–28.8.)"). - "Zu Akte hinzufügen" CTA → inline project picker → POST to existing /api/projects/{id}/deadlines/bulk with a single-element array using source='fristenrechner' (m's Q2: existing tag, no new audit category). - Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy drill-to-Pathway-A semantics via anchors. Trigger pills (Wiedereinsetzung, etc.) keep the trigger-event drill — they don't have a single rule to compute. - Escape collapses the open card. CSS: lime accent border on hover/expanded; dashed top border for the calc panel; mobile-friendly grid for the pill picker. UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed already; Phase B's pill picker renders both pills (leave-to-appeal + notice-of-appeal) when the user hits one of those leaves. --- frontend/src/client/fristenrechner.ts | 500 ++++++++++++++++++++++- frontend/src/client/i18n.ts | 38 ++ frontend/src/i18n-keys.ts | 18 + frontend/src/styles/global.css | 251 ++++++++++++ internal/handlers/fristenrechner.go | 61 +++ internal/handlers/handlers.go | 1 + internal/services/fristenrechner.go | 252 ++++++++++++ internal/services/fristenrechner_test.go | 130 ++++++ 8 files changed, 1239 insertions(+), 12 deletions(-) diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts index b6f9dbe..1bab037 100644 --- a/frontend/src/client/fristenrechner.ts +++ b/frontend/src/client/fristenrechner.ts @@ -1333,30 +1333,501 @@ function renderSearchResultsInto(containerId: string, data: SearchResponse) {
${cardsHtml}
`; } -// wirePillClicks attaches the rule/trigger drill-in click handler to a -// results container. Idempotent across re-renders because the listener -// lives on the container, not on individual pill anchors. +// wirePillClicks attaches the v4 card-click → inline calc-panel handler +// to a results container. Idempotent across re-renders because the +// listener lives on the container, not on individual pill anchors. +// +// v4 (t-paliad-136 Phase B): the primary click action on ANY card — +// header, pill, or body — expands the card with an inline calc panel +// (trigger-date input + flag checkboxes + computed deadline + add-to- +// project CTA). Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve +// the legacy drill-to-Pathway-A semantics via the `
` +// fallback (browser default new-tab behaviour). Trigger pills still +// drill to the youpc-style trigger-event picker on plain click — those +// concepts (Wiedereinsetzung, Weiterbehandlung) don't have a single +// rule to compute against, so the inline calc panel doesn't apply. function wirePillClicks(container: HTMLElement) { if (container.dataset.pillClicksWired === "1") return; container.dataset.pillClicksWired = "1"; container.addEventListener("click", (e) => { + // Clicks inside an open calc panel are handled by their own + // listeners — do not re-trigger the expand/collapse logic. + if ((e.target as HTMLElement).closest(".fristen-card-calc")) return; + const pill = (e.target as HTMLElement).closest(".fristen-pill"); - if (!pill) return; + const card = (e.target as HTMLElement).closest(".fristen-card"); + if (!card) return; + const me = e as MouseEvent; + // Modifier-key fallback: let `` open in a new tab / new + // window for users who want to deep-link into Pathway A. Don't + // interfere with normal text selection (no expand on shift-drag). if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return; - e.preventDefault(); - const kind = pill.dataset.kind; - if (kind === "rule") { - const proc = pill.dataset.proc; - if (!proc) return; - drillToProceeding(proc, pill.dataset.focus || null); - } else if (kind === "trigger") { + + // Trigger pills drill to the trigger-event picker (legacy youpc + // pathway). The inline calc panel only applies to rule pills. + if (pill && pill.dataset.kind === "trigger") { + e.preventDefault(); const id = Number(pill.dataset.triggerId); if (Number.isFinite(id)) drillToTrigger(id); + return; + } + + e.preventDefault(); + expandCardCalc(card, pill); + }); +} + +// ============================================================================ +// v4 card-click → inline calc panel (t-paliad-136 Phase B) +// ============================================================================ +// Click a result card → expand inline → user enters trigger date + flags → +// server computes one deadline → user can add it to a project. +// +// Only one card may be expanded at a time (multiple panels would confuse +// "which trigger date am I looking at?"). Collapsing happens automatically +// when another card is clicked, when × is pressed, or when the user clicks +// outside the panel. + +interface RuleCalcResponse { + rule: { + id: string; + localCode?: string; + nameDE: string; + nameEN: string; + ruleRef?: string; + legalSource?: string; + legalSourceDisplay?: string; + durationValue: number; + durationUnit: string; + party?: string; + isMandatory: boolean; + notesDE?: string; + notesEN?: string; + }; + proceeding: { code: string; nameDE: string; nameEN: string }; + triggerDate: string; + originalDate: string; + dueDate: string; + wasAdjusted: boolean; + adjustmentReason?: { + holidays?: Array<{ name: string; date: string }>; + upcVacation?: boolean; + moveToNextWorkday?: boolean; + }; + isCourtSet: boolean; + flagsApplied?: string[]; + flagsRequired?: string[]; +} + +let lastCalcByCard: WeakMap = new WeakMap(); +let calcDebounce: number | undefined; +let calcSeq = 0; + +function collapseAnyExpandedCard() { + document.querySelectorAll(".fristen-card.is-expanded").forEach((c) => { + c.classList.remove("is-expanded"); + c.setAttribute("aria-expanded", "false"); + const panel = c.querySelector(".fristen-card-calc"); + if (panel) panel.remove(); + }); +} + +function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) { + // Click a different card → collapse the current one first. + if (!card.classList.contains("is-expanded")) { + collapseAnyExpandedCard(); + } else { + // Already expanded; if the user clicked a different pill, switch + // selection. If they clicked the body again, do nothing. + if (autoSelectPill) selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus); + return; + } + + const payload = card.dataset.cardPayload; + if (!payload) return; + let cardData: SearchCard; + try { + cardData = JSON.parse(payload) as SearchCard; + } catch { + return; + } + // Only rule pills are computable. Drop trigger pills from the picker. + const rulePills = cardData.pills.filter((p) => p.kind === "rule"); + if (rulePills.length === 0) return; + + card.classList.add("is-expanded"); + card.setAttribute("aria-expanded", "true"); + + const panel = buildCalcPanel(cardData, rulePills); + card.appendChild(panel); + + // Auto-select the clicked pill if it's a rule pill; otherwise the + // first pill is preselected by buildCalcPanel. + if (autoSelectPill && autoSelectPill.dataset.kind === "rule") { + selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus); + } + + scheduleCardCalc(card); +} + +function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement { + const panel = document.createElement("div"); + panel.className = "fristen-card-calc"; + // stopPropagation so clicks inside the panel don't bubble to the + // card-level expand handler. + panel.addEventListener("click", (e) => e.stopPropagation()); + panel.addEventListener("keydown", (e) => e.stopPropagation()); + + const lang = getLang(); + const today = new Date().toISOString().split("T")[0]; + + // Pill picker (only when >1 rule pill). + const pickerHtml = rulePills.length <= 1 + ? `` + : `
+ ${escHtml(t("deadlines.card.calc.pill_picker.label"))} + ${rulePills.map((p, i) => { + const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : ""; + const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de; + const src = p.legal_source_display || p.legal_source || ""; + return ``; + }).join("")} +
`; + + panel.innerHTML = ` + + ${pickerHtml} +
+ + +
+
+
${escHtml(t("deadlines.card.calc.result.calculating"))}
+
+
+ +
+
+ `; + + // Wire interactions. + const close = panel.querySelector(".fristen-card-calc-close")!; + close.addEventListener("click", () => { + const card = panel.closest(".fristen-card"); + if (card) { + card.classList.remove("is-expanded"); + card.setAttribute("aria-expanded", "false"); + panel.remove(); + } + }); + + const dateInput = panel.querySelector(".fristen-card-calc-trigger-input")!; + dateInput.addEventListener("input", () => { + const card = panel.closest(".fristen-card"); + if (card) scheduleCardCalc(card); + }); + + panel.querySelectorAll('input[name="fristen-card-calc-pill"]').forEach((r) => { + r.addEventListener("change", () => { + const card = panel.closest(".fristen-card"); + if (card) scheduleCardCalc(card, 0); + }); + }); + + panel.querySelector(".fristen-card-calc-add")!.addEventListener("click", () => { + const card = panel.closest(".fristen-card"); + if (!card) return; + const last = lastCalcByCard.get(card); + if (!last) return; + void addCalcToProject(card, last); + }); + + return panel; +} + +function selectCalcPill(card: HTMLElement, proc?: string | null, focus?: string | null) { + if (!proc) return; + const radios = card.querySelectorAll('input[name="fristen-card-calc-pill"]'); + radios.forEach((r) => { + if (r.dataset.proc === proc && (!focus || r.dataset.focus === focus)) { + r.checked = true; + r.dispatchEvent(new Event("change", { bubbles: true })); } }); } +function scheduleCardCalc(card: HTMLElement, delayMs = 200) { + if (calcDebounce !== undefined) clearTimeout(calcDebounce); + calcDebounce = window.setTimeout(() => void runCardCalc(card), delayMs); +} + +async function runCardCalc(card: HTMLElement) { + const panel = card.querySelector(".fristen-card-calc"); + if (!panel) return; + const result = panel.querySelector(".fristen-card-calc-result")!; + const addBtn = panel.querySelector(".fristen-card-calc-add")!; + const msgEl = panel.querySelector(".fristen-card-calc-msg")!; + + const dateInput = panel.querySelector(".fristen-card-calc-trigger-input")!; + const triggerDate = dateInput.value; + if (!triggerDate) return; + + // Resolve currently-selected pill (proc + ruleLocalCode). + let proc = ""; + let focus = ""; + const checked = panel.querySelector('input[name="fristen-card-calc-pill"]:checked'); + if (checked) { + proc = checked.dataset.proc || ""; + focus = checked.dataset.focus || ""; + } else { + const hidden = panel.querySelector(".fristen-card-calc-pill-picker"); + if (hidden) { + proc = hidden.dataset.proc || ""; + focus = hidden.dataset.focus || ""; + } + } + if (!proc || !focus) return; + + // Read flag checkboxes. + const flags: string[] = []; + panel.querySelectorAll('.fristen-card-calc-flags input[type="checkbox"]:checked').forEach((cb) => { + if (cb.value) flags.push(cb.value); + }); + + result.innerHTML = `
${escHtml(t("deadlines.card.calc.result.calculating"))}
`; + msgEl.textContent = ""; + addBtn.disabled = true; + + const seq = ++calcSeq; + let resp: Response; + try { + resp = await fetch("/api/tools/fristenrechner/calculate-rule", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + proceedingCode: proc, + ruleLocalCode: focus, + triggerDate, + flags, + }), + }); + } catch { + if (seq !== calcSeq) return; + result.innerHTML = `
${escHtml(t("deadlines.card.calc.result.error"))}
`; + return; + } + if (seq !== calcSeq) return; + if (!resp.ok) { + const data = (await resp.json().catch(() => ({} as { error?: string }))) as { error?: string }; + result.innerHTML = `
${escHtml(data.error || t("deadlines.card.calc.result.error"))}
`; + return; + } + const calc = (await resp.json()) as RuleCalcResponse; + if (seq !== calcSeq) return; + + lastCalcByCard.set(card, calc); + renderCalcResult(card, calc); + syncFlagCheckboxes(card, calc); + addBtn.disabled = calc.isCourtSet || !calc.dueDate; + if (calc.isCourtSet) { + addBtn.textContent = t("deadlines.card.calc.add_to_project.disabled"); + } else { + addBtn.textContent = t("deadlines.card.calc.add_to_project"); + } +} + +function syncFlagCheckboxes(card: HTMLElement, calc: RuleCalcResponse) { + const flagsEl = card.querySelector(".fristen-card-calc-flags"); + if (!flagsEl) return; + const required = calc.flagsRequired || []; + if (required.length === 0) { + flagsEl.hidden = true; + flagsEl.innerHTML = ""; + return; + } + flagsEl.hidden = false; + // Preserve current state when re-rendering: check current DOM for + // existing checkbox values so user input survives a recalc. + const existing = new Map(); + flagsEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + existing.set(cb.value, cb.checked); + }); + const labelKey = (flag: string) => { + const k = `deadlines.card.calc.flag.${flag}`; + const localised = tDyn(k); + return localised === k ? flag : localised; + }; + flagsEl.innerHTML = ` + ${escHtml(t("deadlines.card.calc.flags.label"))} + ${required.map((f) => { + const checked = existing.get(f) ?? (calc.flagsApplied || []).includes(f); + return ``; + }).join("")} + `; + flagsEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + cb.addEventListener("change", () => scheduleCardCalc(card, 0)); + }); +} + +function renderCalcResult(card: HTMLElement, calc: RuleCalcResponse) { + const result = card.querySelector(".fristen-card-calc-result"); + if (!result) return; + if (calc.isCourtSet) { + result.innerHTML = `
${escHtml(t("deadlines.card.calc.result.court_set"))}
`; + return; + } + const lang = getLang(); + const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE; + const durLabel = `${calc.rule.durationValue} ${formatDurationUnit(calc.rule.durationUnit, lang)}`; + const dueLabel = formatDate(calc.dueDate); + const fromLabel = formatDate(calc.triggerDate); + const adjustmentChip = calc.wasAdjusted + ? renderAdjustmentChip(calc, lang) + : ""; + result.innerHTML = ` +
+ + ${escHtml(dueLabel)} + (${escHtml(durLabel)} ${escHtml(t("deadlines.card.calc.result.from_trigger"))} ${escHtml(fromLabel)}) +
+ ${adjustmentChip} +
${escHtml(ruleName)}
+ `; +} + +function renderAdjustmentChip(calc: RuleCalcResponse, _lang: "de" | "en"): string { + const reason = calc.adjustmentReason; + let why = ""; + if (reason && reason.upcVacation) { + why = "UPC-Sommerferien (27.7.–28.8.)"; + } else if (reason && reason.holidays && reason.holidays.length > 0) { + why = reason.holidays.map((h) => h.name).join(", "); + } else { + why = "Wochenende / Feiertag"; + } + return `
+ ⚠ ${escHtml(t("deadlines.card.calc.result.shifted_from"))} ${escHtml(formatDate(calc.originalDate))} + ${escHtml(t("deadlines.card.calc.result.shifted_because"))} ${escHtml(why)}. +
`; +} + +function formatDurationUnit(unit: string, lang: "de" | "en"): string { + const map: Record = { + days: { de: "Tage", en: "days" }, + working_days: { de: "Arbeitstage", en: "working days" }, + weeks: { de: "Wochen", en: "weeks" }, + months: { de: "Monate", en: "months" }, + years: { de: "Jahre", en: "years" }, + }; + return map[unit] ? map[unit][lang] : unit; +} + +async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) { + const panel = card.querySelector(".fristen-card-calc"); + if (!panel) return; + const msgEl = panel.querySelector(".fristen-card-calc-msg")!; + const addBtn = panel.querySelector(".fristen-card-calc-add")!; + msgEl.textContent = ""; + addBtn.disabled = true; + + const projects = await fetchProjects(); + if (projects.length === 0) { + addBtn.disabled = false; + msgEl.innerHTML = `${escHtml(t("deadlines.save.modal.no_akten"))}
${escHtml(t("deadlines.save.modal.no_akten.link"))}`; + return; + } + + // Inline picker — render a compact + ${projects.map((p) => { + const ref = (p.reference || "").trim(); + const indent = projectIndent(p.path); + const label = ref ? `${indent}${ref} — ${p.title}` : `${indent}${p.title}`; + return ``; + }).join("")} + + + + + + `; + + const sel = msgEl.querySelector(".fristen-card-calc-add-select")!; + msgEl.querySelector(".fristen-card-calc-add-cancel")!.addEventListener("click", () => { + msgEl.innerHTML = ""; + addBtn.disabled = false; + }); + msgEl.querySelector(".fristen-card-calc-add-confirm")!.addEventListener("click", async () => { + const projectID = sel.value; + if (!projectID) return; + const dlNotes = lang === "en" + ? (calc.rule.notesEN || calc.rule.notesDE) + : calc.rule.notesDE; + const payload = { + deadlines: [{ + title: ruleName, + rule_code: calc.rule.ruleRef || undefined, + due_date: calc.dueDate, + original_due_date: calc.originalDate || undefined, + // m's Q2 (2026-05-05): use 'fristenrechner' (existing tag), not + // 'fristenrechner_card'. Audit-log differentiation is not needed. + source: "fristenrechner", + notes: dlNotes || undefined, + }], + }; + const confirm = msgEl.querySelector(".fristen-card-calc-add-confirm")!; + confirm.disabled = true; + try { + const r = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!r.ok) { + const data = (await r.json().catch(() => ({}))) as { error?: string }; + msgEl.innerHTML = `${escHtml(data.error || t("deadlines.save.error"))}`; + addBtn.disabled = false; + return; + } + msgEl.innerHTML = `${escHtml(t("deadlines.save.success"))} (${escHtml(dueLabel)}) ${escHtml(t("deadlines.save.success.link"))}`; + addBtn.disabled = false; + } catch { + msgEl.innerHTML = `${escHtml(t("deadlines.save.error"))}`; + addBtn.disabled = false; + } + }); +} + +// Collapse the open card on Escape key for quick keyboard exit. +document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + const open = document.querySelector(".fristen-card.is-expanded"); + if (open) { + open.classList.remove("is-expanded"); + open.setAttribute("aria-expanded", "false"); + open.querySelector(".fristen-card-calc")?.remove(); + } +}); + function renderConceptCard(card: SearchCard, lang: "de" | "en"): string { const name = lang === "en" ? card.concept.name_en : card.concept.name_de; const altName = lang === "en" ? card.concept.name_de : card.concept.name_en; @@ -1380,8 +1851,13 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
${triggerPills.map((p) => renderPill(p, lang)).join("")}
`; + // v4 (t-paliad-136 Phase B): stash the card payload on the article so + // expandCardCalc() can read pills + concept name without re-querying. + // JSON.stringify → escAttr survives htmlentity round-trip. + const cardPayload = escAttr(JSON.stringify(card)); + return ` -
+