diff --git a/frontend/src/client/fristenrechner-mode-a.ts b/frontend/src/client/fristenrechner-mode-a.ts new file mode 100644 index 0000000..4344104 --- /dev/null +++ b/frontend/src/client/fristenrechner-mode-a.ts @@ -0,0 +1,507 @@ +// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1). +// +// Power-user surface: a filter strip (Forum / Verfahren / Was passierte / +// Partei) over a free-text search box over a result list of +// procedural_events. Clicking a row locks the event as the trigger and +// transitions to the shared result view (S2). Inbox channel chip lives +// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA +// / Postal auto-sets the Forum chip. +// +// Section-split visual hierarchy per m §11.Q3: filter strip on top +// ("Filter (eingrenzen)") with the four chip groups, search box and +// result list below — clicking a result row IS the qualifier action. + +import { escAttr, escHtml } from "./views/verfahrensablauf-core"; +import { getLang, t, tDyn } from "./i18n"; +import { mountResultView } from "./fristenrechner-result"; + +// Wire shape from GET /api/tools/fristenrechner/search?kind=events. +// Mirrors services.EventSearchResponse server-side. +interface EventSearchHit { + id: string; + code: string; + name_de: string; + name_en: string; + event_kind?: string; + description?: string; + primary_party?: string; + proceeding_type: { + id: number; + code: string; + name_de: string; + name_en: string; + jurisdiction?: string; + }; + anchor_rule_id: string; + follow_up_count: number; + concept_id?: string; + score: number; +} + +interface EventSearchResponse { + query: string; + events: EventSearchHit[]; + total: number; +} + +interface ProceedingChip { + code: string; + name: string; + nameEN: string; + group: string; +} + +// Module-local state — single Mode A surface at a time. +interface ModeAState { + jurisdiction: string; // "" = Alle + proc: string; // proceeding_types.code, "" = Alle + eventKind: string; // "" = Alle + party: string; // "" = Alle (Mode A's filter semantics, §11.Q8) + q: string; // free-text query + inbox: string; // CMS / bea / postal / "" — secondary, design §3.3 + inboxOpen: boolean; +} + +const state: ModeAState = { + jurisdiction: "", + proc: "", + eventKind: "", + party: "", + q: "", + inbox: "", + inboxOpen: false, +}; + +// Debounce token for search input — avoid hammering the server on +// every keystroke. +let searchSeq = 0; +let searchTimer: ReturnType | null = null; + +// Chip data — static. Forum and event-kind are closed-set per design; +// party is closed-set with "Beide" option (Mode A is filter mode, +// §11.Q8). Inbox secondary chip set per §3.3. +const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const; +const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const; +const PARTIES = ["claimant", "defendant", "both"] as const; + +// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE, +// Postal → no narrowing (postal arrives at every jurisdiction). +const INBOX_TO_FORUM: Record = { + cms: "UPC", + bea: "DE", + postal: "", +}; + +// MODE_A_HOST_ID is the DOM id of the container Mode A renders into. +// The mode shell (fristenrechner-result.mountModeShell) creates this +// element under the overhaul root and hands it to Mode A; Mode A +// otherwise has no opinion about its placement on the page. +const MODE_A_HOST_ID = "fristen-overhaul-mode-host"; + +export function isModeASurfaceMounted(): boolean { + return !!document.getElementById("fristen-mode-a-root"); +} + +// mountModeA renders the Mode A surface into the overhaul root. Reads +// initial state from URL params so deep links restore the previous +// filter / search state. +export async function mountModeA(): Promise { + const root = document.getElementById(MODE_A_HOST_ID); + if (!root) return; + + // Hydrate state from URL. + const params = new URLSearchParams(window.location.search); + state.jurisdiction = (params.get("forum") || "").toUpperCase(); + state.proc = params.get("pt") || ""; + state.eventKind = params.get("kind") || ""; + state.party = params.get("party") || ""; + state.q = params.get("q") || ""; + + renderShell(); + await loadProceedingChips(); + void runSearch(); +} + +// renderShell builds the Mode A markup. Idempotent re-call from the +// boot path; row-level rewrites use renderResults / renderFilterStrip +// for finer-grained updates. +function renderShell(): void { + const root = document.getElementById("fristen-overhaul-root"); + if (!root) return; + root.innerHTML = ` +
+
+
+ ${escHtml(t("deadlines.overhaul.modea.filters.heading"))} +
+
+ ${escHtml(t("deadlines.overhaul.modea.axis.forum"))} +
+
+
+ ${escHtml(t("deadlines.overhaul.modea.axis.proc"))} +
+
+
+ ${escHtml(t("deadlines.overhaul.modea.axis.kind"))} +
+
+
+ ${escHtml(t("deadlines.overhaul.modea.axis.party"))} +
+
+
+ ${escHtml(t("deadlines.overhaul.modea.inbox.summary"))} +
+ ${escHtml(t("deadlines.overhaul.modea.axis.inbox"))} +
+
+
+
+ + + +
+
+ ${escHtml(t("deadlines.overhaul.modea.results.heading"))} + +
+
    +
    +
    + `; + + renderForumChips(); + renderKindChips(); + renderPartyChips(); + renderInboxChips(); + // Proceeding chips render later, after fetch. + + // Wire search input. + const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null; + if (input) { + input.addEventListener("input", () => { + state.q = input.value; + scheduleSearch(180); + }); + input.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") { + e.preventDefault(); + scheduleSearch(0); + } + }); + } +} + +// Filter-strip chip renderers ---------------------------------------- + +function renderForumChips(): void { + const host = document.getElementById("fristen-mode-a-chips-forum"); + if (!host) return; + const chips = [ + chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""), + ...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)), + ]; + host.innerHTML = chips.join(""); + host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { + btn.addEventListener("click", () => { + const v = btn.dataset.value || ""; + state.jurisdiction = v; + // Forum change invalidates the proc pick if it falls outside. + state.proc = ""; + syncUrl(); + renderForumChips(); + void loadProceedingChips(); + scheduleSearch(0); + }); + }); +} + +function renderKindChips(): void { + const host = document.getElementById("fristen-mode-a-chips-kind"); + if (!host) return; + const chips = [ + chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""), + ...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))), + ]; + host.innerHTML = chips.join(""); + host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { + btn.addEventListener("click", () => { + state.eventKind = btn.dataset.value || ""; + syncUrl(); + renderKindChips(); + scheduleSearch(0); + }); + }); +} + +function renderPartyChips(): void { + const host = document.getElementById("fristen-mode-a-chips-party"); + if (!host) return; + const chips = [ + chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""), + ...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)), + ]; + host.innerHTML = chips.join(""); + host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { + btn.addEventListener("click", () => { + state.party = btn.dataset.value || ""; + syncUrl(); + renderPartyChips(); + scheduleSearch(0); + }); + }); +} + +function renderInboxChips(): void { + const host = document.getElementById("fristen-mode-a-chips-inbox"); + if (!host) return; + const opts = [ + { v: "", label: t("deadlines.overhaul.modea.chip.all") }, + { v: "cms", label: "CMS" }, + { v: "bea", label: "beA" }, + { v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") }, + ]; + host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join(""); + host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { + btn.addEventListener("click", () => { + const v = btn.dataset.value || ""; + state.inbox = v; + // Auto-nudge forum from inbox per design §3.3. + const nudge = INBOX_TO_FORUM[v]; + if (nudge !== undefined && nudge !== "") { + state.jurisdiction = nudge; + state.proc = ""; + renderForumChips(); + void loadProceedingChips(); + } + renderInboxChips(); + scheduleSearch(0); + }); + }); +} + +// Proceeding chips — dynamic fetch. + +let lastProcFetchKey = ""; + +async function loadProceedingChips(): Promise { + const host = document.getElementById("fristen-mode-a-chips-proc"); + if (!host) return; + const key = `j=${state.jurisdiction}`; + if (lastProcFetchKey === key) return; // cached for current jurisdiction + lastProcFetchKey = key; + host.innerHTML = `${escHtml(t("deadlines.overhaul.modea.loading"))}`; + + const url = new URL("/api/tools/proceeding-types", window.location.origin); + url.searchParams.set("kind", "proceeding"); + if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction); + + let chips: ProceedingChip[] = []; + try { + const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (resp.ok) { + const data = (await resp.json()) as ProceedingChip[] | null; + chips = data || []; + } + } catch { + // Soft-fail: chip strip just hides; search still runs without + // proceeding narrowing. + } + + renderProceedingChips(chips); +} + +function renderProceedingChips(chips: ProceedingChip[]): void { + const host = document.getElementById("fristen-mode-a-chips-proc"); + if (!host) return; + const lang = getLang(); + if (chips.length === 0) { + host.innerHTML = `${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}`; + return; + } + const rendered = [ + chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""), + ...chips.map((c) => { + const label = lang === "en" ? c.nameEN || c.name : c.name; + return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code); + }), + ]; + host.innerHTML = rendered.join(""); + host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { + btn.addEventListener("click", () => { + state.proc = btn.dataset.value || ""; + syncUrl(); + renderProceedingChips(chips); + scheduleSearch(0); + }); + }); +} + +// Search ------------------------------------------------------------ + +function scheduleSearch(delayMs: number): void { + if (searchTimer !== null) clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + searchTimer = null; + void runSearch(); + }, delayMs); +} + +async function runSearch(): Promise { + searchSeq++; + const mySeq = searchSeq; + + const list = document.getElementById("fristen-mode-a-result-list"); + const count = document.getElementById("fristen-mode-a-results-count"); + if (!list || !count) return; + + list.innerHTML = `
  • ${escHtml(t("deadlines.overhaul.modea.loading"))}
  • `; + count.textContent = ""; + + const url = new URL("/api/tools/fristenrechner/search", window.location.origin); + url.searchParams.set("kind", "events"); + if (state.q) url.searchParams.set("q", state.q); + if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction); + if (state.proc) url.searchParams.set("proc", state.proc); + if (state.eventKind) url.searchParams.set("event_kind", state.eventKind); + if (state.party) url.searchParams.set("party", state.party); + + let data: EventSearchResponse; + try { + const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!resp.ok) { + if (mySeq === searchSeq) { + list.innerHTML = `
  • ${escHtml(t("deadlines.overhaul.modea.search_error"))}
  • `; + } + return; + } + data = (await resp.json()) as EventSearchResponse; + } catch { + if (mySeq === searchSeq) { + list.innerHTML = `
  • ${escHtml(t("deadlines.overhaul.modea.search_error"))}
  • `; + } + return; + } + + if (mySeq !== searchSeq) return; // stale response + + renderResults(data); +} + +function renderResults(data: EventSearchResponse): void { + const list = document.getElementById("fristen-mode-a-result-list"); + const count = document.getElementById("fristen-mode-a-results-count"); + if (!list || !count) return; + count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total)); + + if (data.events.length === 0) { + list.innerHTML = `
  • ${escHtml(t("deadlines.overhaul.modea.no_results"))}
  • `; + return; + } + + const lang = getLang(); + list.innerHTML = data.events.map((e) => { + const name = lang === "en" ? e.name_en || e.name_de : e.name_de; + const pt = e.proceeding_type; + const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de; + const icon = eventKindIconForChip(e.event_kind); + const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count)); + const juris = pt.jurisdiction || ""; + return ` +
  • + +
    +
    ${escHtml(name)}
    +
    + ${escHtml(pt.code)} + ${escHtml(ptName)} + ${juris ? `${escHtml(juris)}` : ""} + ${escHtml(followUps)} +
    +
    + +
  • + `; + }).join(""); + + list.querySelectorAll(".fristen-mode-a-result").forEach((li) => { + li.addEventListener("click", () => commitEvent(li.dataset.eventCode || "")); + li.addEventListener("keydown", (e) => { + const k = (e as KeyboardEvent).key; + if (k === "Enter" || k === " ") { + e.preventDefault(); + commitEvent(li.dataset.eventCode || ""); + } + }); + }); +} + +// Commit — user picked a result; lock the event as trigger and +// transition to the §4 result view (S2). +function commitEvent(code: string): void { + if (!code) return; + // Reflect in URL before re-mounting so the result view's deep link + // is consistent. + const url = new URL(window.location.href); + url.searchParams.set("overhaul", "1"); + url.searchParams.set("event", code); + // Preserve project / forum / kind filters so a back-navigation + // brings Mode A back with the same filters. + history.pushState(null, "", url.pathname + url.search + url.hash); + void mountResultView({ + eventRef: code, + party: state.party || undefined, + }); +} + +// 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 t = title ? ` title="${escAttr(title)}"` : ""; + const i = icon ? `` : ""; + return ``; +} + +function eventKindIconForChip(kind?: string): string { + switch (kind) { + case "filing": return "📥"; + case "hearing": return "🏛️"; + case "decision": return "⚖️"; + case "order": return "📜"; + default: return "🔍"; + } +} + +// syncUrl writes the active filter set into the URL so the deep link +// restores Mode A in the same state. +function syncUrl(): void { + const url = new URL(window.location.href); + url.searchParams.set("overhaul", "1"); + setOrClear(url, "forum", state.jurisdiction); + setOrClear(url, "pt", state.proc); + setOrClear(url, "kind", state.eventKind); + setOrClear(url, "party", state.party); + setOrClear(url, "q", state.q); + 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/fristenrechner-result.ts b/frontend/src/client/fristenrechner-result.ts index d0857a9..ff9cfcd 100644 --- a/frontend/src/client/fristenrechner-result.ts +++ b/frontend/src/client/fristenrechner-result.ts @@ -94,6 +94,63 @@ function resolveProjectId(): string | null { 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 { + host.innerHTML = `
    ${escHtml(t("deadlines.overhaul.wizard.coming_soon"))}
    `; + } +} + // 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. diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts index bb4c548..de669fb 100644 --- a/frontend/src/client/fristenrechner.ts +++ b/frontend/src/client/fristenrechner.ts @@ -30,7 +30,7 @@ import { type EventChoice, type ChoiceKind, } from "./views/event-card-choices"; -import { isOverhaulMode, mountResultView } from "./fristenrechner-result"; +import { isOverhaulMode, mountModeShell, mountResultView } from "./fristenrechner-result"; let lastResponse: DeadlineResponse | null = null; @@ -147,11 +147,12 @@ function bootOverhaulMode(): void { 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")}
    `; - } + // No trigger event locked yet → show the mode tab pair + active + // mode panel (S3 = Mode A direct search; S4 will add Mode B + // wizard). The mode param in the URL picks which tab opens + // first; default is search (S3). + const mode = (params.get("mode") || "search") === "wizard" ? "wizard" : "search"; + void mountModeShell(mode); return; } void mountResultView({ eventRef, triggerDate, party, courtId }); diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index bff6c15..cd0df4f 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1036,6 +1036,36 @@ const translations: Record> = { "deadlines.party.both": "Beide Seiten", "deadlines.party.court": "Gericht", + // Fristenrechner overhaul Mode A \u2014 Direkt suchen (S3, design \u00a73.1). + "deadlines.overhaul.modes.label": "Modus", + "deadlines.overhaul.modes.search": "Direkt suchen", + "deadlines.overhaul.modes.wizard": "Gef\u00fchrt", + "deadlines.overhaul.wizard.coming_soon": "Gef\u00fchrter Modus kommt im n\u00e4chsten Slice.", + "deadlines.overhaul.modea.filters.label": "Filter", + "deadlines.overhaul.modea.filters.heading": "Filter (eingrenzen)", + "deadlines.overhaul.modea.axis.forum": "Forum:", + "deadlines.overhaul.modea.axis.proc": "Verfahren:", + "deadlines.overhaul.modea.axis.kind": "Was passierte:", + "deadlines.overhaul.modea.axis.party": "Partei:", + "deadlines.overhaul.modea.axis.inbox": "Eingangsweg:", + "deadlines.overhaul.modea.chip.all": "Alle", + "deadlines.overhaul.modea.inbox.summary": "Erweitert: Eingangsweg", + "deadlines.overhaul.modea.inbox.postal": "Postal", + "deadlines.overhaul.modea.search.label": "Suche", + "deadlines.overhaul.modea.search.placeholder": "Klageerhebung, Hinweisbeschluss, m\u00fcndliche Verhandlung\u2026", + "deadlines.overhaul.modea.results.label": "Ergebnisse", + "deadlines.overhaul.modea.results.heading": "Ergebnisse (klicken zum Einrasten als Trigger)", + "deadlines.overhaul.modea.results.count": "{n} Treffer", + "deadlines.overhaul.modea.row.followups": "{n} Folge-Fristen", + "deadlines.overhaul.modea.loading": "Wird geladen\u2026", + "deadlines.overhaul.modea.no_results": "Keine Treffer f\u00fcr diese Filter.", + "deadlines.overhaul.modea.no_proceedings": "Keine Verfahren in diesem Forum.", + "deadlines.overhaul.modea.search_error": "Suche fehlgeschlagen.", + "deadlines.overhaul.kind.filing": "Eingereicht", + "deadlines.overhaul.kind.hearing": "Termin", + "deadlines.overhaul.kind.decision": "Entscheidung", + "deadlines.overhaul.kind.order": "Verf\u00fcgung", + // Office labels (shared) "office.munich": "M\u00fcnchen", "office.duesseldorf": "D\u00fcsseldorf", @@ -4174,6 +4204,36 @@ const translations: Record> = { "deadlines.party.both": "Both parties", "deadlines.party.court": "Court", + // Fristenrechner overhaul Mode A — Direct search (S3, design §3.1). + "deadlines.overhaul.modes.label": "Mode", + "deadlines.overhaul.modes.search": "Direct search", + "deadlines.overhaul.modes.wizard": "Guided", + "deadlines.overhaul.wizard.coming_soon": "Guided mode coming in the next slice.", + "deadlines.overhaul.modea.filters.label": "Filters", + "deadlines.overhaul.modea.filters.heading": "Filters (narrow)", + "deadlines.overhaul.modea.axis.forum": "Forum:", + "deadlines.overhaul.modea.axis.proc": "Proceeding:", + "deadlines.overhaul.modea.axis.kind": "What happened:", + "deadlines.overhaul.modea.axis.party": "Party:", + "deadlines.overhaul.modea.axis.inbox": "Inbox channel:", + "deadlines.overhaul.modea.chip.all": "All", + "deadlines.overhaul.modea.inbox.summary": "Advanced: Inbox channel", + "deadlines.overhaul.modea.inbox.postal": "Postal", + "deadlines.overhaul.modea.search.label": "Search", + "deadlines.overhaul.modea.search.placeholder": "Statement of Claim, decision notice, oral hearing…", + "deadlines.overhaul.modea.results.label": "Results", + "deadlines.overhaul.modea.results.heading": "Results (click to lock as trigger)", + "deadlines.overhaul.modea.results.count": "{n} hits", + "deadlines.overhaul.modea.row.followups": "{n} follow-ups", + "deadlines.overhaul.modea.loading": "Loading…", + "deadlines.overhaul.modea.no_results": "No hits for these filters.", + "deadlines.overhaul.modea.no_proceedings": "No proceedings in this forum.", + "deadlines.overhaul.modea.search_error": "Search failed.", + "deadlines.overhaul.kind.filing": "Filed", + "deadlines.overhaul.kind.hearing": "Hearing", + "deadlines.overhaul.kind.decision": "Decision", + "deadlines.overhaul.kind.order": "Order", + // Office labels (shared) "office.munich": "Munich", "office.duesseldorf": "D\u00fcsseldorf", diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 283f6d1..1dcae18 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1388,8 +1388,35 @@ export type I18nKey = | "deadlines.overhaul.group.mandatory" | "deadlines.overhaul.group.optional" | "deadlines.overhaul.group.recommended" + | "deadlines.overhaul.kind.decision" + | "deadlines.overhaul.kind.filing" + | "deadlines.overhaul.kind.hearing" + | "deadlines.overhaul.kind.order" | "deadlines.overhaul.load_error" | "deadlines.overhaul.loading" + | "deadlines.overhaul.modea.axis.forum" + | "deadlines.overhaul.modea.axis.inbox" + | "deadlines.overhaul.modea.axis.kind" + | "deadlines.overhaul.modea.axis.party" + | "deadlines.overhaul.modea.axis.proc" + | "deadlines.overhaul.modea.chip.all" + | "deadlines.overhaul.modea.filters.heading" + | "deadlines.overhaul.modea.filters.label" + | "deadlines.overhaul.modea.inbox.postal" + | "deadlines.overhaul.modea.inbox.summary" + | "deadlines.overhaul.modea.loading" + | "deadlines.overhaul.modea.no_proceedings" + | "deadlines.overhaul.modea.no_results" + | "deadlines.overhaul.modea.results.count" + | "deadlines.overhaul.modea.results.heading" + | "deadlines.overhaul.modea.results.label" + | "deadlines.overhaul.modea.row.followups" + | "deadlines.overhaul.modea.search.label" + | "deadlines.overhaul.modea.search.placeholder" + | "deadlines.overhaul.modea.search_error" + | "deadlines.overhaul.modes.label" + | "deadlines.overhaul.modes.search" + | "deadlines.overhaul.modes.wizard" | "deadlines.overhaul.notes.summary" | "deadlines.overhaul.nudge.no_project" | "deadlines.overhaul.select_rule" @@ -1397,6 +1424,7 @@ export type I18nKey = | "deadlines.overhaul.spawn.tooltip" | "deadlines.overhaul.trigger.date" | "deadlines.overhaul.trigger.label" + | "deadlines.overhaul.wizard.coming_soon" | "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 83095cf..cdce69e 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -19226,3 +19226,313 @@ a.fristen-overhaul-rule-source { min-width: 0; } } + +/* === Fristenrechner overhaul Mode A + mode tabs (t-paliad-323 S3) === + * + * Mode tab pair + filter strip + search box + result list per + * docs/design-fristenrechner-overhaul-2026-05-26.md §3.1. + * Section-split visual hierarchy per m §11.Q3: filter strip on top + * ("Filter (eingrenzen)" header), result list below (clicking a row + * IS the qualifier commit). + * ==================================================================== */ + +.fristen-mode-tabs { + display: flex; + gap: 0.4rem; + margin: 0 0 1rem 0; + border-bottom: 2px solid #e3e3da; +} + +.fristen-mode-tab { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 1.1rem; + background: transparent; + border: 0; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + color: #555; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + border-radius: 0.4rem 0.4rem 0 0; +} + +.fristen-mode-tab:hover { + background: #f5f5ef; + color: #1f1f1f; +} + +.fristen-mode-tab.is-active { + color: #2a2a2a; + border-bottom-color: #c6f41c; + background: #fbfdf3; +} + +.fristen-mode-tab-icon { + font-size: 1.1rem; +} + +.fristen-mode-tab-label { + font-weight: 500; +} + +.fristen-mode-a-root { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.fristen-mode-a-filters { + background: #f7f8f2; + border: 1px solid #e2e3d7; + border-radius: 0.7rem; + padding: 0.8rem 1rem; +} + +.fristen-mode-a-filters-header { + font-size: 0.8rem; + letter-spacing: 0.05em; + color: #6e7256; + text-transform: uppercase; + margin-bottom: 0.6rem; + font-weight: 600; +} + +.fristen-mode-a-chip-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.4rem; +} + +.fristen-mode-a-axis-label { + font-size: 0.85rem; + color: #555; + min-width: 7rem; + font-weight: 500; +} + +.fristen-mode-a-chips { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.fristen-mode-a-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.65rem; + border: 1px solid #d4d4c9; + background: #fff; + color: #2a2a2a; + border-radius: 1rem; + font-size: 0.85rem; + cursor: pointer; + font-family: inherit; +} + +.fristen-mode-a-chip:hover { + border-color: #b9c98f; + background: #f6fae5; +} + +.fristen-mode-a-chip.is-active { + background: #d8ed90; + border-color: #98b545; + color: #2c3d10; + font-weight: 600; +} + +.fristen-mode-a-chip-icon { + font-size: 0.95rem; +} + +.fristen-mode-a-chip-loading, +.fristen-mode-a-chip-empty { + color: #888; + font-size: 0.85rem; + font-style: italic; +} + +.fristen-mode-a-inbox { + margin-top: 0.5rem; + padding-top: 0.4rem; + border-top: 1px dashed #d8d8cd; +} + +.fristen-mode-a-inbox-summary { + cursor: pointer; + color: #5f664a; + font-size: 0.85rem; + margin-bottom: 0.3rem; + user-select: none; +} + +.fristen-mode-a-inbox-summary:hover { + color: #2a2a2a; +} + +.fristen-mode-a-search { + background: #fff; + border: 1px solid #d8d8ce; + border-radius: 0.7rem; + padding: 0.6rem 0.9rem; +} + +.fristen-mode-a-search-input-wrap { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.fristen-mode-a-search-icon { + color: #888; +} + +.fristen-mode-a-search-input { + flex: 1 1 auto; + border: 0; + outline: none; + font-size: 1rem; + background: transparent; + padding: 0.3rem 0; + color: #1f1f1f; +} + +.fristen-mode-a-results { + background: #fff; + border: 1px solid #e0e0d4; + border-radius: 0.7rem; + padding: 0.7rem 0.9rem; +} + +.fristen-mode-a-results-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.5rem; +} + +.fristen-mode-a-results-title { + font-weight: 600; + color: #2a2a2a; +} + +.fristen-mode-a-results-count { + font-size: 0.85rem; + color: #777; +} + +.fristen-mode-a-result-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 60vh; + overflow-y: auto; +} + +.fristen-mode-a-result { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.7rem; + align-items: center; + padding: 0.55rem 0.7rem; + background: #fafaf6; + border: 1px solid #ececde; + border-radius: 0.5rem; + cursor: pointer; +} + +.fristen-mode-a-result:hover, +.fristen-mode-a-result:focus { + background: #f1f7d8; + border-color: #c2d182; + outline: none; +} + +.fristen-mode-a-result-icon { + font-size: 1.3rem; +} + +.fristen-mode-a-result-body { + min-width: 0; +} + +.fristen-mode-a-result-title { + font-weight: 600; + color: #1f1f1f; +} + +.fristen-mode-a-result-meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + font-size: 0.8rem; + color: #555; + margin-top: 0.2rem; +} + +.fristen-mode-a-result-pt { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + padding: 0.05rem 0.45rem; + background: #efefe6; + border-radius: 0.3rem; +} + +.fristen-mode-a-result-pt-name { + color: #555; +} + +.fristen-mode-a-result-juris { + padding: 0.05rem 0.45rem; + background: #d3edb7; + color: #38531a; + border-radius: 0.3rem; + font-weight: 600; +} + +.fristen-mode-a-result-followups { + color: #4a6f1f; + font-weight: 500; +} + +.fristen-mode-a-result-cta { + color: #88a554; + font-size: 1.2rem; +} + +.fristen-mode-a-result-loading, +.fristen-mode-a-result-empty, +.fristen-mode-a-result-error { + list-style: none; + padding: 0.7rem 0.5rem; + color: #888; + font-style: italic; + font-size: 0.9rem; +} + +.fristen-mode-a-result-error { + color: #732f25; +} + +@media (max-width: 600px) { + .fristen-mode-a-axis-label { + min-width: 0; + width: 100%; + } + .fristen-mode-a-result { + grid-template-columns: auto 1fr; + } + .fristen-mode-a-result-cta { + grid-column: 1 / -1; + text-align: right; + } +} diff --git a/internal/handlers/fristenrechner.go b/internal/handlers/fristenrechner.go index 75e8aa9..8c9f680 100644 --- a/internal/handlers/fristenrechner.go +++ b/internal/handlers/fristenrechner.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strings" "github.com/google/uuid" @@ -204,6 +205,15 @@ func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) { // Returns 503 with an empty array when DATABASE_URL is unset so the page // still renders (buttons are server-rendered from tsx and don't depend on // this endpoint for existence, only for dynamic list updates). +// +// Optional query params (Fristenrechner overhaul S3, m/paliad#146): +// jurisdiction - "UPC" | "DE" | "EPA" | "DPMA". Narrows the chip +// pool to one jurisdiction. Empty = any. +// kind - "proceeding" | "phase" | "side_action" | "meta". +// Narrows to one structural kind from the taxonomy +// cleanup (m/paliad#147, mig 153). Mode A passes +// "proceeding" to exclude phase / side_action / meta +// rows. Empty = any. func handleProceedingTypes(w http.ResponseWriter, r *http.Request) { if dbSvc == nil || dbSvc.fristenrechner == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ @@ -211,7 +221,11 @@ func handleProceedingTypes(w http.ResponseWriter, r *http.Request) { }) return } - types, err := dbSvc.fristenrechner.ListFristenrechnerTypes(r.Context()) + opts := services.ProceedingListOptions{ + Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))), + Kind: strings.TrimSpace(r.URL.Query().Get("kind")), + } + types, err := dbSvc.fristenrechner.ListProceedings(r.Context(), opts) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Verfahrenstypen nicht laden"}) return diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index 7dfa767..dd2d1ff 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -82,13 +82,59 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu // specific surface (the wire shape FristenrechnerType is owned by the // package but the SQL filter is paliad-side). func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.FristenrechnerType, error) { - rows, err := s.rules.db.QueryxContext(ctx, ` - SELECT code, name, name_en, jurisdiction - FROM paliad.proceeding_types - WHERE category = 'fristenrechner' AND is_active = true - ORDER BY sort_order`) + return s.ListProceedings(ctx, ProceedingListOptions{}) +} + +// ProceedingListOptions narrows ListProceedings. Empty values = no +// filter on that axis. Added for the Fristenrechner overhaul S3 +// (m/paliad#146): Mode A's "Verfahren" filter chip strip needs to scope +// the proceeding pool by the user's Forum pick (jurisdiction) and by +// kind='proceeding' to exclude the phase / side_action / meta rows +// landed in the taxonomy cleanup (m/paliad#147, mig 153). +type ProceedingListOptions struct { + // Jurisdiction narrows to one jurisdiction code (UPC / DE / EPA / + // DPMA). Empty = any. + Jurisdiction string + // Kind narrows to one structural kind (proceeding / phase / + // side_action / meta). Mode A passes "proceeding" to exclude the + // phase / side_action / meta rows from the chip strip. Empty = any. + // + // Filter referenced before mig 153 lands the column → callers + // pre-mig get a "column kind does not exist" error from Postgres. + // 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 +} + +// ListProceedings returns the proceeding_types chips the Fristenrechner +// overhaul Mode A renders in its filter strip. Filters apply +// progressively: pre-mig 153 Kind=="" is the safe default; post-mig 153 +// Mode A passes Kind="proceeding" to exclude the phase / side_action / +// meta rows. +func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts ProceedingListOptions) ([]lp.FristenrechnerType, error) { + where := []string{ + "category = 'fristenrechner'", + "is_active = true", + } + args := []any{} + add := func(clause string, val any) { + args = append(args, val) + where = append(where, fmt.Sprintf(clause, len(args))) + } + if opts.Jurisdiction != "" { + add("jurisdiction = $%d", opts.Jurisdiction) + } + if opts.Kind != "" { + add("kind = $%d", opts.Kind) + } + query := `SELECT code, name, name_en, jurisdiction + FROM paliad.proceeding_types + WHERE ` + strings.Join(where, " AND ") + ` + ORDER BY sort_order` + + rows, err := s.rules.db.QueryxContext(ctx, query, args...) if err != nil { - return nil, fmt.Errorf("list fristenrechner types: %w", err) + return nil, fmt.Errorf("list proceedings: %w", err) } defer rows.Close() diff --git a/internal/services/fristenrechner_proceedings_test.go b/internal/services/fristenrechner_proceedings_test.go new file mode 100644 index 0000000..cc6d59a --- /dev/null +++ b/internal/services/fristenrechner_proceedings_test.go @@ -0,0 +1,124 @@ +package services + +import ( + "context" + "os" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/internal/db" +) + +// TestListProceedings covers the proceeding chip-pool query that powers +// the Fristenrechner overhaul Mode A "Verfahren" filter strip (S3, +// design §3.1). The legacy callers go through ListFristenrechnerTypes +// (no filters) — that path stays green here. The new ListProceedings +// API accepts Jurisdiction + Kind filters; the Kind filter requires +// mig 153 to have landed, so this test skips the Kind=proceeding case +// when the column doesn't yet exist. +func TestListProceedings(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set — skipping live DB test") + } + if err := db.ApplyMigrations(url); err != nil { + t.Fatalf("apply migrations: %v", err) + } + pool, err := sqlx.Connect("postgres", url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + ctx := context.Background() + + rules := NewDeadlineRuleService(pool) + holidays := NewHolidayService(pool) + courts := NewCourtService(pool) + fr := NewFristenrechnerService(rules, holidays, courts) + + t.Run("no filters returns the legacy fristenrechner set", func(t *testing.T) { + got, err := fr.ListProceedings(ctx, ProceedingListOptions{}) + if err != nil { + t.Fatalf("list proceedings: %v", err) + } + if len(got) == 0 { + t.Fatalf("expected non-empty proceeding list") + } + // Sanity — upc.inf.cfi should always be in the active set. + found := false + for _, p := range got { + if p.Code == "upc.inf.cfi" { + found = true + break + } + } + if !found { + t.Errorf("upc.inf.cfi not in proceedings list") + } + }) + + t.Run("jurisdiction=UPC narrows to UPC-only", func(t *testing.T) { + got, err := fr.ListProceedings(ctx, ProceedingListOptions{Jurisdiction: "UPC"}) + if err != nil { + t.Fatalf("list proceedings UPC: %v", err) + } + if len(got) == 0 { + t.Fatalf("expected UPC proceedings") + } + for _, p := range got { + if p.Group != "UPC" { + t.Errorf("non-UPC proceeding leaked: %s (group=%q)", p.Code, p.Group) + } + } + }) + + t.Run("jurisdiction=DE returns DE proceedings", func(t *testing.T) { + got, err := fr.ListProceedings(ctx, ProceedingListOptions{Jurisdiction: "DE"}) + if err != nil { + t.Fatalf("list proceedings DE: %v", err) + } + if len(got) == 0 { + t.Fatalf("expected DE proceedings") + } + for _, p := range got { + if p.Group != "DE" { + t.Errorf("non-DE proceeding leaked: %s (group=%q)", p.Code, p.Group) + } + } + }) + + t.Run("ListFristenrechnerTypes legacy alias still works", func(t *testing.T) { + got, err := fr.ListFristenrechnerTypes(ctx) + if err != nil { + t.Fatalf("list fristenrechner types: %v", err) + } + if len(got) == 0 { + t.Fatalf("expected non-empty types") + } + }) + + // 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") + } + got, err := fr.ListProceedings(ctx, ProceedingListOptions{Kind: "proceeding"}) + if err != nil { + t.Fatalf("list proceedings kind=proceeding: %v", err) + } + if len(got) == 0 { + t.Fatalf("expected non-empty primary-proceeding list") + } + }) +}