diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index c416ca7..1d6312a 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -212,33 +212,6 @@ const translations: Record> = { "procedures.tab.wizard": "Gef\u00fchrt", "procedures.tab.akte": "Aus Akte", "procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.", - - // Workflow-tracker shell (m/paliad#152 T1+) \u2014 keys for the new - // /tools/procedures shape (find header + per-proceeding cards). - "procedures.filter.axis.date": "Stichtag:", - "procedures.filter.forum.all": "Alle", - "procedures.filter.party.all": "Alle", - "procedures.timelines.loading": "Verfahren werden geladen\u2026", - "procedures.timelines.empty": "Keine Verfahren passen. Filter zur\u00fccksetzen.", - "procedures.timelines.error": "Fehler beim Laden dieses Verfahrens.", - "procedures.timelines.options": "Optionen:", - "procedures.timelines.court_set": "vom Gericht bestimmt", - "procedures.cold_open.hint": "Suchen oder filtern, um andere Verfahren einzublenden.", - "procedures.find.summary.empty": "Keine Treffer.", - "procedures.find.summary.one": "{n} Verfahren", - "procedures.find.summary.many": "{n} Verfahren", - "procedures.find.summary.anchor": "Anker: {name}", - "procedures.node.pin": "An dieses Ereignis anheften", - "procedures.node.fokus": "Fokus \u2014 andere Zweige ausblenden", - "procedures.node.here": "\u2500\u2500 DU BIST HIER \u2500\u2500", - "procedures.zoom.breadcrumb": "Pfad", - "procedures.zoom.hidden": "{n} weitere Schritte verborgen \u2014 Fokus aufheben f\u00fcr volle Ansicht", - "procedures.proceeding.toggle": "Verfahren ein-/ausblenden", - "procedures.proceeding.show": "zeigen", - "procedures.proceeding.hide": "ausblenden", - "deadlines.flag.amend": "Mit Antrag auf Patent\u00e4nderung", - "deadlines.flag.cci": "Mit Verletzungswiderklage", - "nav.procedures": "Verfahren & Fristen", "deadlines.step1": "Verfahrensart w\u00e4hlen", @@ -3443,32 +3416,6 @@ const translations: Record> = { "procedures.tab.wizard": "Guided", "procedures.tab.akte": "From matter", "procedures.panel.akte.placeholder": "Matter entry ships in a later slice.", - - // Workflow-tracker shell (m/paliad#152 T1+). - "procedures.filter.axis.date": "As of:", - "procedures.filter.forum.all": "All", - "procedures.filter.party.all": "All", - "procedures.timelines.loading": "Loading proceedings…", - "procedures.timelines.empty": "No proceedings match. Reset filters.", - "procedures.timelines.error": "Failed to load this proceeding.", - "procedures.timelines.options": "Options:", - "procedures.timelines.court_set": "court-set", - "procedures.cold_open.hint": "Search or filter to surface other proceedings.", - "procedures.find.summary.empty": "No matches.", - "procedures.find.summary.one": "{n} proceeding", - "procedures.find.summary.many": "{n} proceedings", - "procedures.find.summary.anchor": "Anchor: {name}", - "procedures.node.pin": "Pin this event as the anchor", - "procedures.node.fokus": "Focus — hide sibling branches", - "procedures.node.here": "── YOU ARE HERE ──", - "procedures.zoom.breadcrumb": "Path", - "procedures.zoom.hidden": "{n} more steps hidden — unfocus to see all", - "procedures.proceeding.toggle": "Toggle proceeding", - "procedures.proceeding.show": "show", - "procedures.proceeding.hide": "hide", - "deadlines.flag.amend": "With patent amendment request", - "deadlines.flag.cci": "With infringement counterclaim", - "nav.procedures": "Procedures & Deadlines", "deadlines.step1": "Select Proceeding Type", diff --git a/frontend/src/client/procedures-tracker.ts b/frontend/src/client/procedures-tracker.ts deleted file mode 100644 index 046f98b..0000000 --- a/frontend/src/client/procedures-tracker.ts +++ /dev/null @@ -1,514 +0,0 @@ -// procedures-tracker — render module for /tools/procedures (m/paliad#152 -// T1 + onwards, docs/design-procedures-workflow-tracker-2026-05-27.md). -// -// Responsibilities: -// - Per-proceeding card render: header + chained tree by parent_id + -// priority-styled bullets + scenario-flag fork checkboxes. -// - Tree layout: children grouped under their parentRuleCode in -// deadlines-list order, root rules surface at depth 0. -// - Default detail mode = "selected" (mandatory + recommended + -// scenario-flag-enabled). Conditional rules whose gate is OFF are -// filtered out by the calculator and don't surface; the -// corresponding fork checkbox on the gating node reveals them when -// toggled ON. -// -// T1 floor: card-level "Optionen" strip carries the scenario-flag -// forks at the top of each proceeding card. Per-node inline placement -// (the design's stated final shape — fork checkbox on the actual -// gating node) is a T2 refinement; T1 keeps forks discoverable but -// scoped per proceeding so they're not the global-page strip m's bug -// #5 flagged. - -import { t, tDyn, getLang } from "./i18n"; -import { - type CalculatedDeadline, - type DeadlineResponse, - calculateDeadlines, - escHtml, - formatDate, -} from "./views/verfahrensablauf-core"; -import { filterByDetailMode } from "./verfahrensablauf-detail-mode"; - -// ProceedingDef — the catalog of proceedings the find header pills and -// the cold-open default surface against. Kept in sync with paliad's -// proceeding_types catalog as of 2026-05-27 (matches -// VerfahrensablaufBody.tsx's listing). -export interface ProceedingDef { - code: string; - forum: "upc" | "de" | "epa" | "dpma"; - i18nKey: string; - nameDE: string; - nameEN: string; -} - -export const PROCEEDINGS: ProceedingDef[] = [ - { code: "upc.inf.cfi", forum: "upc", i18nKey: "deadlines.upc.inf.cfi", nameDE: "Verletzungsverfahren", nameEN: "Infringement (CFI)" }, - { code: "upc.rev.cfi", forum: "upc", i18nKey: "deadlines.upc.rev.cfi", nameDE: "Nichtigkeitsklage", nameEN: "Revocation (CFI)" }, - { code: "upc.ccr.cfi", forum: "upc", i18nKey: "deadlines.upc.ccr.cfi", nameDE: "Widerklage auf Nichtigkeit", nameEN: "Counterclaim for revocation" }, - { code: "upc.pi.cfi", forum: "upc", i18nKey: "deadlines.upc.pi.cfi", nameDE: "Einstw. Maßnahmen", nameEN: "Provisional measures" }, - { code: "upc.apl.unified", forum: "upc", i18nKey: "deadlines.upc.apl.unified", nameDE: "Berufung UPC", nameEN: "Appeal UPC" }, - { code: "upc.dmgs.cfi", forum: "upc", i18nKey: "deadlines.upc.dmgs.cfi", nameDE: "Schadensbemessung", nameEN: "Damages" }, - { code: "upc.disc.cfi", forum: "upc", i18nKey: "deadlines.upc.disc.cfi", nameDE: "Bucheinsicht", nameEN: "Inspection of accounts" }, - { code: "de.inf.lg", forum: "de", i18nKey: "deadlines.de.inf.lg", nameDE: "LG Verletzungsklage", nameEN: "LG infringement (DE 1st inst.)" }, - { code: "de.inf.olg", forum: "de", i18nKey: "deadlines.de.inf.olg", nameDE: "OLG Berufung", nameEN: "OLG appeal" }, - { code: "de.inf.bgh", forum: "de", i18nKey: "deadlines.de.inf.bgh", nameDE: "BGH Revision / NZB", nameEN: "BGH revision / NZB" }, - { code: "de.null.bpatg", forum: "de", i18nKey: "deadlines.de.null.bpatg", nameDE: "BPatG Nichtigkeit", nameEN: "BPatG revocation" }, - { code: "de.null.bgh", forum: "de", i18nKey: "deadlines.de.null.bgh", nameDE: "BGH Berufung (Nichtigkeit)", nameEN: "BGH revocation appeal" }, - { code: "epa.opp.opd", forum: "epa", i18nKey: "deadlines.epa.opp.opd", nameDE: "Einspruchsverfahren EPA", nameEN: "EPO opposition" }, - { code: "epa.opp.boa", forum: "epa", i18nKey: "deadlines.epa.opp.boa", nameDE: "Beschwerdeverfahren EPA", nameEN: "EPO appeal" }, - { code: "epa.grant.exa", forum: "epa", i18nKey: "deadlines.epa.grant.exa", nameDE: "EP-Erteilungsverfahren", nameEN: "EP grant" }, - { code: "dpma.opp.dpma", forum: "dpma", i18nKey: "deadlines.dpma.opp.dpma", nameDE: "Einspruch DPMA", nameEN: "DPMA opposition" }, - { code: "dpma.appeal.bpatg", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bpatg", nameDE: "Beschwerde BPatG (DPMA)", nameEN: "BPatG appeal (DPMA)" }, - { code: "dpma.appeal.bgh", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bgh", nameDE: "Rechtsbeschwerde BGH", nameEN: "BGH legal appeal" }, -]; - -// COLD_OPEN_DEFAULTS — design §8 / §11.Q4. When no URL params and no -// Akte context, the page renders these 6 proceedings stacked. Hint -// text above the timelines invites the user to filter for more. -export const COLD_OPEN_DEFAULTS: string[] = [ - "upc.inf.cfi", - "upc.rev.cfi", - "upc.apl.unified", - "de.inf.lg", - "epa.opp.opd", - "dpma.opp.dpma", -]; - -// FORUM_LABEL mirrors the forum-pill label and the proceeding card -// header jurisdiction prefix. Same slugs the proceeding-types catalog -// carries. -const FORUM_LABEL: Record = { - upc: "UPC", - de: "DE", - epa: "EPA", - dpma: "DPMA", -}; - -export function lookupProceeding(code: string): ProceedingDef | undefined { - return PROCEEDINGS.find((p) => p.code === code); -} - -export function proceedingDisplayName(code: string): string { - const def = lookupProceeding(code); - if (!def) return code; - const lang = getLang(); - return lang === "en" ? def.nameEN : def.nameDE; -} - -// ─── condition_expr flag extraction ───────────────────────────────────────── -// -// Walks the jsonb tree shape documented in pkg/litigationplanner/expr.go: -// {"flag": ""} — leaf -// {"op": "and|or|not", "args":[…]} — composite -// Returns the set of flag names mentioned in the expression. The page -// uses this to decide which scenario_flag forks apply to a given -// proceeding (the union over all conditional rules' expressions). - -function collectFlagsFromExpr(node: unknown, out: Set): void { - if (!node || typeof node !== "object") return; - const n = node as Record; - if (typeof n.flag === "string" && n.flag) { - out.add(n.flag); - return; - } - if (Array.isArray(n.args)) { - for (const arg of n.args) collectFlagsFromExpr(arg, out); - } -} - -export function gatingFlagsForProceeding(deadlines: CalculatedDeadline[]): string[] { - const set = new Set(); - for (const dl of deadlines) { - if (!dl.conditionExpr) continue; - collectFlagsFromExpr(dl.conditionExpr, set); - } - return Array.from(set).sort(); -} - -// Hard-coded fallback set: when the calc was run without a flag, the -// conditional rules gated by that flag are filtered out server-side -// (selected mode doesn't surface their condition_expr). The fallback -// lists the flags each proceeding *can* gate so the per-card Optionen -// strip surfaces them even on first render with the flag off. -// -// Mirrors mig 084's backfill: each scenario_flag → the proceedings -// where it's referenced by at least one rule. Today's catalog: 18 -// conditional rules across upc.inf.cfi (with_ccr / with_amend) and -// upc.rev.cfi (with_amend / with_cci). Keep in sync with -// paliad.sequencing_rules.condition_expr. -const FALLBACK_FLAGS: Record = { - "upc.inf.cfi": ["with_ccr", "with_amend"], - "upc.rev.cfi": ["with_amend", "with_cci"], -}; - -export function applicableFlagsForProceeding( - code: string, - deadlines: CalculatedDeadline[], -): string[] { - const fromExpr = gatingFlagsForProceeding(deadlines); - if (fromExpr.length > 0) return fromExpr; - return FALLBACK_FLAGS[code] || []; -} - -// flagLabel maps scenario_flag keys to i18n keys (matches the -// existing deadlines.flag.* keys used by VerfahrensablaufBody). -const FLAG_I18N: Record = { - with_ccr: "deadlines.flag.ccr", - with_amend: "deadlines.flag.amend", - with_cci: "deadlines.flag.cci", -}; - -function labelForFlag(flagKey: string, proceeding: string): string { - // upc.inf.cfi with_amend = R.30 amendment request; upc.rev.cfi - // with_amend = R.49.2(a) amendment. Different labels even though the - // flag key is the same. Honour the inf vs rev distinction. - if (flagKey === "with_amend" && proceeding === "upc.inf.cfi") return t("deadlines.flag.inf_amend"); - if (flagKey === "with_amend" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_amend"); - if (flagKey === "with_cci" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_cci"); - const key = FLAG_I18N[flagKey]; - return key ? t(key) : flagKey; -} - -// ─── per-proceeding render ────────────────────────────────────────────────── - -export interface TimelineRenderParams { - proceedingType: string; - triggerDate: string; - flags: string[]; - anchorRuleId?: string; - zoom?: boolean; - // collapsed=true renders the card as a one-line header only with a - // [zeigen] link. Used by §6.5: when an anchor is pinned + >1 - // proceeding visible, non-anchored proceedings collapse so the - // anchor's full context owns the page. - collapsed?: boolean; -} - -export interface RenderedTimeline { - card: HTMLElement; - data: DeadlineResponse | null; - // hasAnchor true iff the rendered card contains the active - // anchorRuleId. Drives the multi-proceeding auto-collapse decision - // back in procedures.ts. - hasAnchor: boolean; -} - -// renderCard takes a proceeding code + flag set + trigger date and -// returns a fully-wired card element ready to mount. Re-running with a -// new flag set requires a fresh fetch (calc applies the flag set -// server-side). -export async function renderCard(params: TimelineRenderParams): Promise { - const card = document.createElement("article"); - card.className = "tracker-proceeding"; - card.dataset.proceeding = params.proceedingType; - - // Header — proceeding name + jurisdiction badge + flag strip host. - // Flag strip hydrates from the calc response below. - const def = lookupProceeding(params.proceedingType); - const jur = def ? (FORUM_LABEL[def.forum] || "") : ""; - const procName = proceedingDisplayName(params.proceedingType); - const header = document.createElement("header"); - header.className = "tracker-proceeding-header"; - header.innerHTML = ` - ${escHtml(jur)} -

${escHtml(procName)}

- ${escHtml(params.proceedingType)} - - `; - card.appendChild(header); - - // Collapsed state — header-only render (§6.5). Bail before issuing - // the calc fetch; the card has no body. hasAnchor stays false here - // because we never resolved the data. - if (params.collapsed) { - card.classList.add("tracker-proceeding--collapsed"); - return { card, data: null, hasAnchor: false }; - } - - // Optionen strip — scenario flag checkboxes scoped to this card. - // Hydrated after the calc response so the applicable flag set is - // known. T1 floor: card-level placement; T2+ may move these to - // inline on the actual gating node per the design. - const optionsStrip = document.createElement("div"); - optionsStrip.className = "tracker-proceeding-options"; - optionsStrip.hidden = true; - card.appendChild(optionsStrip); - - // Body — chained tree mounts here once the calc returns. - const body = document.createElement("div"); - body.className = "tracker-proceeding-body"; - body.innerHTML = `
${escHtml(t("procedures.timelines.loading"))}
`; - card.appendChild(body); - - // Fetch + render. - const data = await calculateDeadlines({ - proceedingType: params.proceedingType, - triggerDate: params.triggerDate, - flags: params.flags, - }); - - if (!data) { - body.innerHTML = `
${escHtml(t("procedures.timelines.error"))}
`; - return { card, data: null, hasAnchor: false }; - } - - // Hydrate the Optionen strip with the applicable scenario flags. - const applicable = applicableFlagsForProceeding(params.proceedingType, data.deadlines); - if (applicable.length > 0) { - const labelSpan = document.createElement("span"); - labelSpan.className = "tracker-proceeding-options-label"; - labelSpan.textContent = t("procedures.timelines.options"); - optionsStrip.appendChild(labelSpan); - - for (const flag of applicable) { - const label = document.createElement("label"); - label.className = "tracker-proceeding-option"; - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.value = flag; - cb.checked = params.flags.includes(flag); - cb.dataset.flag = flag; - label.appendChild(cb); - const text = document.createElement("span"); - text.textContent = labelForFlag(flag, params.proceedingType); - label.appendChild(text); - optionsStrip.appendChild(label); - } - optionsStrip.hidden = false; - } - - // Filter to selected detail mode (mandatory + recommended + - // active-flag-gated). Conditional rules whose gate is OFF were - // already dropped server-side — the filter just removes optionals - // not explicitly selected. - const filtered = filterByDetailMode(data.deadlines, "selected", null); - - // Anchor-present detection: does this card's rule set contain the - // active anchor rule id? Drives the multi-proceeding scope logic - // and the zoom branch below. - const hasAnchor = !!(params.anchorRuleId && filtered.some((d) => d.ruleId === params.anchorRuleId)); - - if (params.zoom && hasAnchor && params.anchorRuleId) { - body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId); - } else { - body.innerHTML = renderTreeBody(filtered, params.anchorRuleId); - } - - if (hasAnchor) card.classList.add("tracker-proceeding--anchored"); - - return { card, data, hasAnchor }; -} - -// ─── tree builder ────────────────────────────────────────────────────────── -// -// Builds a `parentRuleCode → child[]` map, then walks from each root -// (no parent) emitting nested
    nodes. The -// calculator already sorts the deadlines into a sensible chain (root -// → linear-deepest-first); the tree builder preserves that order. - -function renderTreeBody(deadlines: CalculatedDeadline[], anchorRuleId?: string): string { - if (deadlines.length === 0) { - return `
    ${escHtml(t("procedures.timelines.empty"))}
    `; - } - - // Index by code so children can find their parent visually. Track - // every code that appears in the filtered set — children whose - // parent isn't in the set surface as orphan roots. - const present = new Set(deadlines.map((d) => d.code)); - const childrenOf: Record = {}; - const roots: CalculatedDeadline[] = []; - - for (const dl of deadlines) { - const parent = dl.parentRuleCode || ""; - if (parent && present.has(parent)) { - (childrenOf[parent] ||= []).push(dl); - } else { - roots.push(dl); - } - } - - const parts: string[] = [`
      `]; - for (const root of roots) { - parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId)); - } - parts.push(`
    `); - return parts.join(""); -} - -function renderTreeNode( - dl: CalculatedDeadline, - childrenOf: Record, - depth: number, - anchorRuleId?: string, -): string { - const children = childrenOf[dl.code] || []; - const isAnchored = !!(anchorRuleId && dl.ruleId === anchorRuleId); - const lang = getLang(); - - // Priority-driven bullet style. - const priorityClass = `tracker-node--${dl.priority || "mandatory"}`; - const anchorClass = isAnchored ? " tracker-node--anchored" : ""; - const courtClass = dl.isCourtSet ? " tracker-node--court" : ""; - - const name = lang === "en" ? (dl.nameEN || dl.name) : (dl.name || dl.nameEN); - const ref = dl.legalSourceDisplay || dl.ruleRef || ""; - const dateLabel = dl.isCourtSet - ? t("procedures.timelines.court_set") - : (dl.dueDate ? formatDate(dl.dueDate) : ""); - - // Party badge — one-letter affordance to the right. - const partyBadge = dl.party === "court" - ? "G" - : dl.party === "claimant" - ? "K" - : dl.party === "defendant" - ? "B" - : dl.party === "both" ? "K+B" : ""; - - const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escHtml(dl.ruleId)}"` : ""; - const codeAttr = ` data-code="${escHtml(dl.code)}"`; - - // Pin + Fokus affordances. Pin is always available on nodes with a - // real ruleId (synthetic appeal-trigger markers carry no id). Fokus - // only on the currently anchored node. - const pinBtn = dl.ruleId - ? `` - : ""; - const fokusBtn = isAnchored - ? `` - : ""; - - const meta = ` -
    - - ${escHtml(name)} - ${ref ? `${escHtml(ref)}` : ""} - ${dateLabel ? `${escHtml(dateLabel)}` : ""} - ${partyBadge ? `${escHtml(partyBadge)}` : ""} - ${pinBtn} - ${fokusBtn} -
    - ${isAnchored ? `
    ${escHtml(t("procedures.node.here"))}
    ` : ""} - `; - - let inner = meta; - if (children.length > 0) { - const kids = children - .map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId)) - .join(""); - inner += `
      ${kids}
    `; - } - - return `
  • ${inner}
  • `; -} - -// ─── zoom mode (§6.2) ────────────────────────────────────────────────────── -// -// When the user clicks [🔍] on the anchored node, the proceeding card -// re-renders into zoom mode: -// - Ancestors of the anchor collapse to a single breadcrumb at the -// top of the card (proceeding-code ▸ root ▸ … ▸ anchor). -// - Sibling branches at each ancestor depth fold to a one-line -// "… N weitere verborgen — [zeigen]" summary. -// - The anchored node renders full with all its successors. -// -// Sibling-expand-on-demand: when the user clicks [zeigen] on a fold -// summary, the corresponding sibling subtree expands inline. State is -// per-card in sessionStorage so a reload keeps it. - -function renderZoomedBody(deadlines: CalculatedDeadline[], anchorRuleId: string): string { - const anchor = deadlines.find((d) => d.ruleId === anchorRuleId); - if (!anchor) { - // The anchor is no longer in the filtered set (e.g. the user - // toggled a flag that hid it). Fall back to the full tree so the - // user can re-pin. - return renderTreeBody(deadlines, anchorRuleId); - } - - // Build the parent chain (anchor → root). The chain is walked via - // parentRuleCode; bail at the first missing parent or at >20 hops - // (defensive — the deepest tree today is ~5). - const byCode: Record = {}; - for (const dl of deadlines) byCode[dl.code] = dl; - const ancestors: CalculatedDeadline[] = []; - let cursor: CalculatedDeadline | undefined = anchor; - let safety = 20; - while (cursor && safety-- > 0) { - const parentCode = cursor.parentRuleCode || ""; - if (!parentCode) break; - const parent = byCode[parentCode]; - if (!parent) break; - ancestors.unshift(parent); - cursor = parent; - } - - const lang = getLang(); - const breadcrumbParts: string[] = []; - for (const a of ancestors) { - const name = lang === "en" ? (a.nameEN || a.name) : (a.name || a.nameEN); - breadcrumbParts.push(`${escHtml(name)}`); - } - const anchorName = lang === "en" ? (anchor.nameEN || anchor.name) : (anchor.name || anchor.nameEN); - breadcrumbParts.push(`${escHtml(anchorName)}`); - - const breadcrumb = ``; - - // Subtree: anchor + all descendants (parentRuleCode chain rooted - // at the anchor). - const descendants = new Set([anchor.code]); - const queue = [anchor.code]; - while (queue.length > 0) { - const code = queue.shift()!; - for (const dl of deadlines) { - if (dl.parentRuleCode === code && !descendants.has(dl.code)) { - descendants.add(dl.code); - queue.push(dl.code); - } - } - } - const subtree = deadlines.filter((d) => descendants.has(d.code)); - const subtreeBody = renderTreeBody(subtree, anchorRuleId); - - // Sibling count summary — descendants ignored. Stays terse so the - // page tells the user how much is hidden without listing it. - const totalCount = deadlines.length; - const hiddenCount = totalCount - subtree.length; - const hiddenLine = hiddenCount > 0 - ? `
    ${escHtml(tDyn("procedures.zoom.hidden").replace("{n}", String(hiddenCount)))}
    ` - : ""; - - return breadcrumb + subtreeBody + hiddenLine; -} - -// ─── search hit highlight (URL `?event=`) ────────────────────────────────── -// -// T1 affordance: when `?event=` is set, scroll the matching -// node into view and apply a transient highlight. Anchor pin + zoom is -// a T2 layering on top of this. - -export function scrollAnchorIntoView(card: HTMLElement, anchorRuleId: string): void { - const node = card.querySelector(`[data-rule-id="${CSS.escape(anchorRuleId)}"]`); - if (!node) return; - node.classList.add("tracker-node--highlight"); - node.scrollIntoView({ behavior: "smooth", block: "center" }); - setTimeout(() => node.classList.remove("tracker-node--highlight"), 3000); -} - -// summarise — short status line for the find-header summary. -export function summariseRender(rendered: RenderedTimeline[]): string { - const proc = rendered.length; - if (proc === 0) return t("procedures.find.summary.empty"); - if (proc === 1) return tDyn("procedures.find.summary.one").replace("{n}", "1"); - return tDyn("procedures.find.summary.many").replace("{n}", String(proc)); -} diff --git a/frontend/src/client/procedures.ts b/frontend/src/client/procedures.ts index 76a76cc..0723a22 100644 --- a/frontend/src/client/procedures.ts +++ b/frontend/src/client/procedures.ts @@ -1,520 +1,150 @@ -// /tools/procedures client (m/paliad#152 T1, -// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1). +// /tools/procedures client (m/paliad#151, +// docs/design-unified-procedural-events-tool-2026-05-27.md). // -// Workflow-tracker shell — replaces the 4-tab catalog (U0-U4) shipped -// earlier today with a single canonical shape: +// Boot logic + tab switching for the unified procedural-events tool. +// Each entry tab mounts its own module; the search box and chip +// filters in the top filter strip are wired in U1+ as each slice adds +// its dimension-aware behaviour. // -// 1. Sticky find header — search input + forum / Verfahren / Partei -// pill rows + global Stichtag. The header narrows the timeline -// set rendered below. -// 2. Timeline body — one card per matched proceeding, rendered as -// a chained tree by parent_id with priority-styled bullets. +// U0 — Skeleton + tab toggling. +// U1 — Direkt suchen mounts Mode A. +// U2 — Geführt mounts Mode B wizard. +// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle. // -// URL state (T1): -// ?q= — free-text search -// ?forum= — single forum (upc/de/epa/dpma) -// ?procs= — comma-separated proceeding codes -// ?party= — claimant/defendant/both/"" -// ?trigger_date= — global Stichtag -// ?event= — scroll-highlight matching node (no anchor -// pin / zoom yet; T2 layering) -// ?flags= — scenario flag overrides; default off -// -// Legacy ?mode= params from the catalog UI are dropped silently — the -// /tools/fristenrechner + /tools/verfahrensablauf URLs still 301 -// redirect here. -// -// T2-T4 layer on this shell: -// T2 — anchor pin + zoom + multi-proceeding scope. -// T3 — Akte landing + actuals overlay. -// T4 — appeal-target + court-set choices + per-proceeding Alle Optionen. +// Mode A renders its shell into #fristen-overhaul-root (replacing +// children); Mode B renders into #fristen-overhaul-mode-host; the +// result view (post-commit) writes into #fristen-overhaul-root. To +// keep those IDs unique in the DOM, only the active tab's panel ever +// hosts the overhaul scaffold — installOverhaulHost() tears down any +// existing host and installs a fresh one inside the target panel +// before handing off to the per-mode module. -import { initI18n, t, tDyn } from "./i18n"; +import { initI18n } from "./i18n"; import { initSidebar } from "./sidebar"; -import { - COLD_OPEN_DEFAULTS, - PROCEEDINGS, - type ProceedingDef, - type RenderedTimeline, - proceedingDisplayName, - renderCard, - scrollAnchorIntoView, - summariseRender, -} from "./procedures-tracker"; +import { mountModeA } from "./fristenrechner-mode-a"; +import { mountResultView } from "./fristenrechner-result"; +import { mountWizard } from "./fristenrechner-wizard"; +import { initVerfahrensablauf } from "./verfahrensablauf"; -type ForumId = "upc" | "de" | "epa" | "dpma" | ""; -type PartyId = "claimant" | "defendant" | "both" | ""; +type ProceduresTab = "proceeding" | "search" | "wizard" | "akte"; -// Find state. Single source of truth; URL keeps it shareable. -const state = { - q: "", - forum: "" as ForumId, - procs: [] as string[], - party: "" as PartyId, - triggerDate: todayISO(), - event: "", - zoom: false, - flags: [] as string[], -}; +const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"]; -// Per-anchor user-expanded set — when the multi-proceeding auto-collapse -// kicks in (§6.5), the user can [zeigen] specific proceedings. We track -// the explicit expansions so re-renders keep them open. Reset whenever -// the anchor changes (new pin clears prior expansion state). -let userExpanded = new Set(); -let lastAnchor = ""; - -function todayISO(): string { - return new Date().toISOString().split("T")[0]; -} - -// ─── URL state ───────────────────────────────────────────────────────────── - -function readStateFromURL(): void { +function readTabFromUrl(): ProceduresTab { const params = new URLSearchParams(window.location.search); - state.q = params.get("q") || ""; - state.forum = (params.get("forum") || "") as ForumId; - const procs = params.get("procs") || ""; - state.procs = procs ? procs.split(",").map((s) => s.trim()).filter(Boolean) : []; - state.party = (params.get("party") || "") as PartyId; - state.triggerDate = params.get("trigger_date") || todayISO(); - state.event = params.get("event") || ""; - state.zoom = params.get("zoom") === "1"; - const flags = params.get("flags") || ""; - state.flags = flags ? flags.split(",").map((s) => s.trim()).filter(Boolean) : []; - lastAnchor = state.event; + const raw = params.get("mode"); + if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab; + return "proceeding"; } -function writeStateToURL(): void { +function writeTabToUrl(tab: ProceduresTab): void { const url = new URL(window.location.href); - const sp = url.searchParams; - setOrDelete(sp, "q", state.q); - setOrDelete(sp, "forum", state.forum); - setOrDelete(sp, "procs", state.procs.join(",")); - setOrDelete(sp, "party", state.party); - setOrDelete(sp, "trigger_date", state.triggerDate === todayISO() ? "" : state.triggerDate); - setOrDelete(sp, "event", state.event); - setOrDelete(sp, "zoom", state.event && state.zoom ? "1" : ""); - setOrDelete(sp, "flags", state.flags.join(",")); - // Legacy ?mode= from the U0-U4 catalog era → drop on every state write - // so a bookmarked URL self-cleans on first interaction. - sp.delete("mode"); - history.replaceState(null, "", url.pathname + (sp.toString() ? "?" + sp.toString() : "") + url.hash); + if (tab === "proceeding") { + url.searchParams.delete("mode"); + } else { + url.searchParams.set("mode", tab); + } + history.replaceState(null, "", url.pathname + url.search + url.hash); } -// onAnchorChanged keeps userExpanded in sync. A fresh pin clears the -// prior expansion set so the auto-collapse rule (§6.5) kicks in from -// scratch; unpinning clears it too so the full multi-proceeding view -// returns. -function onAnchorChanged(next: string): void { - if (next === lastAnchor) return; - userExpanded = new Set(); - if (!next) state.zoom = false; - lastAnchor = next; +// installOverhaulHost moves the (legacy) #fristen-overhaul-root / +// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears +// any existing host first, so the IDs stay unique across the page even +// when the user toggles between Direkt-suchen and Geführt — both Mode +// A and the wizard read these IDs from document.getElementById which +// returns the first match in DOM order, so two parallel hosts would +// cross-wire. +function installOverhaulHost(panelId: string): HTMLElement | null { + document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove()); + const panel = document.getElementById(panelId); + if (!panel) return null; + panel.innerHTML = ` +
    +
    +
    +
    +
    + `; + return panel; } -function setOrDelete(sp: URLSearchParams, key: string, value: string): void { - if (value) sp.set(key, value); - else sp.delete(key); -} - -// ─── pill hydration ──────────────────────────────────────────────────────── - -function hydrateForumPills(): void { - const host = document.getElementById("tracker-pills-forum"); - if (!host) return; - host.innerHTML = ""; - const all: { id: ForumId; label: string }[] = [ - { id: "", label: t("procedures.filter.forum.all") }, - { id: "upc", label: "UPC" }, - { id: "de", label: "DE" }, - { id: "epa", label: "EPA" }, - { id: "dpma", label: "DPMA" }, - ]; - for (const f of all) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "tracker-pill"; - btn.textContent = f.label; - btn.dataset.forum = f.id; - if (state.forum === f.id) btn.classList.add("is-active"); - btn.addEventListener("click", () => { - state.forum = f.id; - // Drop procs that no longer match the active forum. - if (state.forum) { - state.procs = state.procs.filter((code) => { - const def = PROCEEDINGS.find((p) => p.code === code); - return def && def.forum === state.forum; - }); - } - writeStateToURL(); - hydrateForumPills(); - hydrateProcPills(); - void rerender(); - }); - host.appendChild(btn); +function setActiveTabUI(tab: ProceduresTab): void { + for (const t of TABS) { + const btn = document.getElementById(`procedures-tab-${t}`); + const panel = document.getElementById(`procedures-panel-${t}`); + const active = t === tab; + if (btn) { + btn.classList.toggle("is-active", active); + btn.setAttribute("aria-selected", active ? "true" : "false"); + } + if (panel) panel.hidden = !active; } } -function hydrateProcPills(): void { - const host = document.getElementById("tracker-pills-proc"); - if (!host) return; - host.innerHTML = ""; - const visible = state.forum - ? PROCEEDINGS.filter((p) => p.forum === state.forum) - : PROCEEDINGS; - for (const p of visible) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "tracker-pill"; - btn.textContent = proceedingDisplayName(p.code); - btn.dataset.code = p.code; - btn.title = p.code; - if (state.procs.includes(p.code)) btn.classList.add("is-active"); - btn.addEventListener("click", () => { - if (state.procs.includes(p.code)) { - state.procs = state.procs.filter((c) => c !== p.code); - } else { - state.procs = [...state.procs, p.code]; - } - writeStateToURL(); - hydrateProcPills(); - void rerender(); - }); - host.appendChild(btn); +// Verfahrensablauf wiring is idempotent-unfriendly (module-local +// selectedType + lastResponse + listeners that re-bind on every +// proceeding click). Wire it exactly once per page load; on subsequent +// activations the existing DOM + listeners are reused so picked +// proceeding / dates / flags persist across tab switches. +let verfahrensablaufWired = false; + +async function activateTab(tab: ProceduresTab): Promise { + setActiveTabUI(tab); + if (tab === "search") { + installOverhaulHost("procedures-panel-search"); + await mountModeA(); + return; + } + if (tab === "wizard") { + installOverhaulHost("procedures-panel-wizard"); + await mountWizard(); + return; + } + if (tab === "proceeding") { + if (!verfahrensablaufWired) { + initVerfahrensablauf(); + verfahrensablaufWired = true; + } } } -function hydratePartyPills(): void { - const host = document.getElementById("tracker-pills-party"); - if (!host) return; - host.innerHTML = ""; - const all: { id: PartyId; key: string }[] = [ - { id: "", key: "procedures.filter.party.all" }, - { id: "claimant", key: "deadlines.side.claimant" }, - { id: "defendant", key: "deadlines.side.defendant" }, - ]; - for (const p of all) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "tracker-pill"; - btn.textContent = t(p.key as never); - btn.dataset.party = p.id; - if (state.party === p.id) btn.classList.add("is-active"); +function wireTabs(): void { + for (const t of TABS) { + const btn = document.getElementById(`procedures-tab-${t}`); + if (!btn) continue; btn.addEventListener("click", () => { - state.party = p.id; - writeStateToURL(); - hydratePartyPills(); - void rerender(); + void activateTab(t); + writeTabToUrl(t); }); - host.appendChild(btn); } } -// ─── search box ──────────────────────────────────────────────────────────── -// -// Debounced 200ms. Free-text matches procedural events via the existing -// /api/tools/fristenrechner/search?kind=events endpoint; the hits drive -// proceeding pre-selection (the proceedings the hits live in surface -// in the timeline body) and the first hit's rule_id becomes the -// `?event=` anchor. +// boot dispatches on the URL: a deep link with `?event=` jumps straight +// to the linear result view (the Direkt-suchen tab stays as the visible +// context). Otherwise the requested tab — defaulting to "proceeding" — +// activates per readTabFromUrl(). +async function boot(): Promise { + const params = new URLSearchParams(window.location.search); + const eventRef = params.get("event") || ""; -let searchTimer: ReturnType | null = null; - -function wireSearchInput(): void { - const input = document.getElementById("tracker-search-input") as HTMLInputElement | null; - if (!input) return; - input.value = state.q; - input.addEventListener("input", () => { - if (searchTimer !== null) clearTimeout(searchTimer); - searchTimer = setTimeout(() => { - searchTimer = null; - void onSearchChanged(input.value.trim()); - }, 200); - }); -} - -async function onSearchChanged(q: string): Promise { - state.q = q; - // Empty query → revert to pill-driven set (or cold-open default). - if (!q) { - state.event = ""; - writeStateToURL(); - void rerender(); + if (eventRef) { + setActiveTabUI("search"); + installOverhaulHost("procedures-panel-search"); + await mountResultView({ + eventRef, + triggerDate: params.get("trigger_date") || undefined, + party: params.get("party") || undefined, + courtId: params.get("court_id") || undefined, + }); return; } - try { - const url = new URL("/api/tools/fristenrechner/search", window.location.origin); - url.searchParams.set("q", q); - url.searchParams.set("kind", "events"); - url.searchParams.set("limit", "20"); - if (state.forum) url.searchParams.set("jurisdiction", state.forum.toUpperCase()); - if (state.party) url.searchParams.set("party", state.party); - const resp = await fetch(url.pathname + url.search, { headers: { Accept: "application/json" } }); - if (!resp.ok) { - writeStateToURL(); - void rerender(); - return; - } - const body = await resp.json(); - const events = Array.isArray(body.events) ? body.events : []; - - // Collect distinct proceeding_type codes from the hits and pre-seed - // state.procs. If exactly one hit, scroll-highlight its anchor rule. - const procs: string[] = []; - for (const ev of events) { - const code = ev?.proceeding_type?.code; - if (typeof code === "string" && code && !procs.includes(code)) procs.push(code); - } - state.procs = procs; - const nextEvent = events.length === 1 && events[0]?.anchor_rule_id - ? String(events[0].anchor_rule_id) - : ""; - onAnchorChanged(nextEvent); - state.event = nextEvent; - writeStateToURL(); - hydrateProcPills(); - void rerender(); - } catch (e) { - console.error("tracker search failed", e); - writeStateToURL(); - void rerender(); - } + await activateTab(readTabFromUrl()); } -// ─── trigger date input ──────────────────────────────────────────────────── - -function wireTriggerDateInput(): void { - const input = document.getElementById("tracker-trigger-date") as HTMLInputElement | null; - if (!input) return; - input.value = state.triggerDate; - input.addEventListener("change", () => { - const next = input.value || todayISO(); - if (next === state.triggerDate) return; - state.triggerDate = next; - writeStateToURL(); - void rerender(); - }); -} - -// ─── flag toggle wiring ──────────────────────────────────────────────────── -// -// Forks on the per-proceeding "Optionen" strip dispatch via event -// delegation so re-rendering doesn't need to re-bind. Each tick mutates -// state.flags and triggers a re-render of the specific card. - -function wireFlagDelegation(): void { - const host = document.getElementById("tracker-timelines"); - if (!host) return; - host.addEventListener("change", (ev) => { - const target = ev.target as HTMLInputElement; - if (!target || target.tagName !== "INPUT") return; - if (target.type !== "checkbox") return; - const flagKey = target.dataset.flag || ""; - if (!flagKey) return; - if (target.checked) { - if (!state.flags.includes(flagKey)) state.flags = [...state.flags, flagKey]; - } else { - state.flags = state.flags.filter((f) => f !== flagKey); - } - writeStateToURL(); - void rerender(); - }); -} - -// wireClickDelegation handles pin / fokus / proc-toggle. Single listener -// on the timelines host; the render functions stamp data-action on the -// affordances they emit. -function wireClickDelegation(): void { - const host = document.getElementById("tracker-timelines"); - if (!host) return; - host.addEventListener("click", (ev) => { - const btn = (ev.target as HTMLElement | null)?.closest("button[data-action]"); - if (!btn) return; - const action = btn.dataset.action || ""; - ev.preventDefault(); - - if (action === "pin") { - const ruleId = btn.dataset.ruleId || ""; - if (!ruleId) return; - // Click on the already-anchored node un-pins. Toggle pattern. - const next = state.event === ruleId ? "" : ruleId; - onAnchorChanged(next); - state.event = next; - writeStateToURL(); - void rerender(); - return; - } - - if (action === "fokus") { - // Toggle zoom on the current anchor. - if (!state.event) return; - state.zoom = !state.zoom; - writeStateToURL(); - void rerender(); - return; - } - - if (action === "proc-toggle") { - const code = btn.dataset.code || ""; - if (!code) return; - if (userExpanded.has(code)) userExpanded.delete(code); - else userExpanded.add(code); - void rerender(); - return; - } - }); -} - -// ─── render driver ───────────────────────────────────────────────────────── - -let currentRender = 0; - -function pickProceedingsToRender(): string[] { - if (state.procs.length > 0) return state.procs; - if (state.forum) { - return PROCEEDINGS.filter((p) => p.forum === state.forum).map((p) => p.code); - } - return COLD_OPEN_DEFAULTS; -} - -async function rerender(): Promise { - const seq = ++currentRender; - const host = document.getElementById("tracker-timelines"); - const summary = document.getElementById("tracker-find-summary"); - if (!host) return; - - // Loading placeholder during render. The placeholder is replaced - // atomically once all cards return, so the user doesn't see - // intermediate flicker between cards. - host.innerHTML = `
    ${t("procedures.timelines.loading")}
    `; - - const codes = pickProceedingsToRender(); - - // Cold-open hint: if we're showing the curated default set, surface - // a small instruction so the user knows the page expects further - // narrowing for non-default proceedings. - const isColdOpen = state.procs.length === 0 && !state.forum && !state.q; - const hasAnchor = !!state.event; - const multiProceeding = codes.length > 1; - - // Multi-proceeding anchor scope (§6.5). When an anchor is pinned and - // multiple proceedings are visible, non-anchored proceedings render - // as a one-line header card with a [zeigen] link. We don't know yet - // which card carries the anchor — that's a property of the calc - // response. Two-pass render: first pass resolves anchor location; - // second pass renders collapsed/full per code. Cheap because the - // collapsed render skips the calc fetch entirely. - // - // Optimisation path: probe just one card per render to find the - // anchor's home. Probe the matching code first when we already know - // it (cached on `lastAnchorProceeding` below). - - // First-pass: render every card, collect which ones carry the anchor. - const firstPass: RenderedTimeline[] = await Promise.all( - codes.map((code) => - renderCard({ - proceedingType: code, - triggerDate: state.triggerDate, - flags: state.flags, - anchorRuleId: state.event || undefined, - zoom: state.zoom, - }), - ), - ); - - if (seq !== currentRender) return; // a newer render started; bail. - - // If we have an anchor + multi-proceeding, decide which cards - // collapse. Cards with hasAnchor=true stay expanded; others collapse - // unless the user explicitly expanded them via [zeigen]. - let rendered: RenderedTimeline[] = firstPass; - if (hasAnchor && multiProceeding) { - rendered = await Promise.all( - firstPass.map(async (r) => { - if (r.hasAnchor) return r; - if (userExpanded.has(r.card.dataset.proceeding || "")) return r; - // Re-render collapsed. - return renderCard({ - proceedingType: r.card.dataset.proceeding || "", - triggerDate: state.triggerDate, - flags: state.flags, - anchorRuleId: state.event || undefined, - collapsed: true, - }); - }), - ); - if (seq !== currentRender) return; - } - - host.innerHTML = ""; - - if (isColdOpen) { - const hint = document.createElement("div"); - hint.className = "tracker-cold-open-hint"; - hint.textContent = t("procedures.cold_open.hint"); - host.appendChild(hint); - } - - for (const r of rendered) host.appendChild(r.card); - - if (rendered.length === 0) { - const empty = document.createElement("div"); - empty.className = "tracker-timelines-empty"; - empty.textContent = t("procedures.timelines.empty"); - host.appendChild(empty); - } - - // Find-header summary line. When an anchor is pinned, surface the - // anchor's name so the user has a visual confirmation of where - // they are. - if (summary) { - const base = summariseRender(rendered); - if (hasAnchor) { - const anchorName = findAnchorName(firstPass, state.event); - summary.textContent = anchorName - ? `${base} · ${tDyn("procedures.find.summary.anchor").replace("{name}", anchorName)}` - : base; - } else { - summary.textContent = base; - } - } - - // Scroll-highlight the anchored node, if any. Walks every card so a - // ?event= deep link works even when the same rule appears in a - // shared-chain proceeding (e.g. inf.cfi and ccr.cfi). - if (state.event) { - for (const r of rendered) scrollAnchorIntoView(r.card, state.event); - } -} - -// findAnchorName resolves the anchored rule's display name across the -// rendered cards. Returns "" when the anchor isn't in any visible -// proceeding (e.g. invalid ?event= deep link). -function findAnchorName(rendered: RenderedTimeline[], ruleId: string): string { - if (!ruleId) return ""; - for (const r of rendered) { - if (!r.data) continue; - const hit = r.data.deadlines.find((d) => d.ruleId === ruleId); - if (!hit) continue; - return hit.name || hit.nameEN || ruleId; - } - return ""; -} - -// ─── boot ────────────────────────────────────────────────────────────────── - document.addEventListener("DOMContentLoaded", () => { initI18n(); initSidebar(); - readStateFromURL(); - hydrateForumPills(); - hydrateProcPills(); - hydratePartyPills(); - wireSearchInput(); - wireTriggerDateInput(); - wireFlagDelegation(); - wireClickDelegation(); - void rerender(); + wireTabs(); + void boot(); }); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 7628896..55befa6 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1358,8 +1358,6 @@ export type I18nKey = | "deadlines.filter.status" | "deadlines.filter.thisweek" | "deadlines.filter.today" - | "deadlines.flag.amend" - | "deadlines.flag.cci" | "deadlines.flag.ccr" | "deadlines.flag.inf_amend" | "deadlines.flag.rev_amend" @@ -2205,40 +2203,19 @@ export type I18nKey = | "partner_unit.members_label" | "partner_unit.none" | "partner_unit.subtitle" - | "procedures.cold_open.hint" - | "procedures.filter.axis.date" | "procedures.filter.axis.forum" | "procedures.filter.axis.kind" | "procedures.filter.axis.party" | "procedures.filter.axis.proc" - | "procedures.filter.forum.all" - | "procedures.filter.party.all" | "procedures.filter.search.placeholder" - | "procedures.find.summary.anchor" - | "procedures.find.summary.empty" - | "procedures.find.summary.many" - | "procedures.find.summary.one" | "procedures.heading" - | "procedures.node.fokus" - | "procedures.node.here" - | "procedures.node.pin" | "procedures.panel.akte.placeholder" - | "procedures.proceeding.hide" - | "procedures.proceeding.show" - | "procedures.proceeding.toggle" | "procedures.subtitle" | "procedures.tab.akte" | "procedures.tab.proceeding" | "procedures.tab.search" | "procedures.tab.wizard" - | "procedures.timelines.court_set" - | "procedures.timelines.empty" - | "procedures.timelines.error" - | "procedures.timelines.loading" - | "procedures.timelines.options" | "procedures.title" - | "procedures.zoom.breadcrumb" - | "procedures.zoom.hidden" | "project.instance_level.appeal" | "project.instance_level.cassation" | "project.instance_level.first" diff --git a/frontend/src/procedures.tsx b/frontend/src/procedures.tsx index 5e0519b..a4bea0f 100644 --- a/frontend/src/procedures.tsx +++ b/frontend/src/procedures.tsx @@ -4,32 +4,23 @@ import { PaliadinWidget } from "./components/PaliadinWidget"; import { BottomNav } from "./components/BottomNav"; import { Footer } from "./components/Footer"; import { PWAHead } from "./components/PWAHead"; +import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody"; -// /tools/procedures — workflow-tracker shell (m/paliad#152 T1, -// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1). +// U0 — Skeleton for the unified procedural-events tool +// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md). // -// Single canonical shape: -// 1. Sticky find header — search input + forum / Verfahren / Partei -// pill rows + global Stichtag. The header narrows the timeline -// set rendered below; it is not itself a tab strip. -// 2. Timeline body — one card per matched proceeding, rendered as a -// chained tree by parent_id with priority-styled bullets. Cold -// open renders the 6 curated default proceedings from -// design §8 / §11.Q4. +// Folds /tools/fristenrechner (Mode A + Mode B + result) and +// /tools/verfahrensablauf into a single page at /tools/procedures. Each +// later slice fills one of the four entry tabs: // -// Each later slice layers on top of this shell: -// T2 — anchor pin + zoom + multi-proceeding scope (§6.5). -// T3 — Akte landing + actuals overlay. -// T4 — appeal-target chip group + court-set choices + per-proceeding -// "Alle Optionen" toggle. -// T5 — dead-code removal (the old per-tab fristenrechner-mode-*, -// fristenrechner-wizard, fristenrechner-result, verfahrensablauf -// modules + their CSS once nothing imports them). +// U1 — Direkt suchen (Mode A search) +// U2 — Geführt (Mode B wizard) +// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter) +// U4 — Hard-cut 301 (drop legacy pages, redirect URLs) // -// No DB dependency — the page itself is static HTML; data flows over -// the existing /api/tools/fristenrechner endpoints. The 4 entry-mode -// tabs the catalog (U0-U4) shipped earlier today are deleted in this -// PR per m's Q7 divergent pick (direct replace, no flag). +// This file ships only the page chrome — sidebar, header, filter strip +// with search box, four entry-mode tabs, and the host containers the +// later slices mount their UI into. No data wiring. export function renderProcedures(): string { const today = new Date().toISOString().split("T")[0]; @@ -55,18 +46,20 @@ export function renderProcedures(): string {

    Verfahren & Fristen

    - Verfahrensabläufe als Zeitstrahl — suchen, filtern, Verzweigungen wählen. + Verfahrensablauf, Fristenrechner und gerührte Suche in einem Tool.

    - {/* Find affordance (design §2). Sticky header — search, - forum + Verfahren + Partei pills, and the global - Stichtag (date input). Pills hydrate from - procedures-tracker on boot; markup carries the host - rows only. */} -
    -
    - +
    +
    - -
    - Forum: -
    -
    - -
    - Verfahren: -
    -
    - -
    - Partei: -
    -
    - -
    - - -
    - -
    -
    - - {/* Timeline body — one card per matched proceeding. Cards - are appended by procedures-tracker.ts on boot and - re-rendered when the find header changes. */} -
    -
    - Verfahren werden geladen… +
    +
    + Forum: +
    +
    +
    + Verfahren: +
    +
    +
    + Ereignisart: +
    +
    +
    + Partei: +
    +
    + + {/* Entry-mode tab strip — all four tabs visible from boot + (m's Q3 divergence). The active tab is URL-driven + (?mode=proceeding|search|wizard|akte); cold open lands + on "proceeding" per design §11.5.Q3. */} + + + {/* Per-tab content hosts. Only one is visible at a time — + procedures.ts toggles `hidden` on the inactive ones. + Each later slice fills the corresponding host. */} +
    + {/* Verfahrensablauf wizard body — shared TSX component + used by /tools/verfahrensablauf (legacy) and the + unified /tools/procedures page. procedures.ts calls + initVerfahrensablauf() on the first activation of + this tab, which wires the .proceeding-btn clicks, + timeline-container, detail-mode toggle, etc. against + the markup. The legacy page's auto-boot is guarded + against the procedures-only #procedures-panel-proceeding + element so it doesn't fire twice. */} + +
    + + + + + + + + {/* Tree output host. Slice U3 mounts the Verfahrensablauf + tree here; U0 leaves it empty + hidden so the + tab placeholders are the only thing visible. */} + + + {/* Linear-drawer host. Inline drawer expanding beneath a + tree card (design §8 — desktop) AND the standalone + linear follow-up view that Mode A / Mode B land on + after locking a trigger event (design §3.2). U1 + switches it on. */} + diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index b1cac3c..7de21e5 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -19811,479 +19811,3 @@ a.fristen-overhaul-rule-source { width: 100%; } } - -/* ============================================================================ - * Workflow tracker — /tools/procedures (m/paliad#152 T1+). - * - * Single canonical shape: sticky find header + per-proceeding cards - * rendered as chained trees by parent_id. See - * docs/design-procedures-workflow-tracker-2026-05-27.md. - * T1 ships the shell + flat fork checkboxes; T2-T4 layer anchor pin / - * Akte / appeal-target on top. - * ========================================================================= */ - -.tracker-find { - position: sticky; - top: 0; - z-index: 5; - background: var(--color-bg); - border-bottom: 1px solid var(--color-border); - padding: 0.75rem 0 0.5rem; - margin-bottom: 1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.tracker-find-search { - position: relative; - display: flex; - align-items: center; -} - -.tracker-find-search-icon { - position: absolute; - left: 0.6rem; - color: var(--color-text-subtle); - pointer-events: none; -} - -.tracker-find-search-input { - width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.2rem; - border: 1px solid var(--color-border-strong); - border-radius: 6px; - background: var(--color-bg-subtle); - color: var(--color-text); - font-size: 0.95rem; -} - -.tracker-find-search-input:focus { - outline: 2px solid var(--color-accent); - outline-offset: -1px; - border-color: var(--color-accent); -} - -.tracker-find-row { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.tracker-find-axis-label { - font-size: 0.85rem; - font-weight: 500; - color: var(--color-text-muted); - min-width: 5rem; -} - -.tracker-find-pills { - display: flex; - flex-wrap: wrap; - gap: 0.3rem; -} - -.tracker-pill { - padding: 0.25rem 0.6rem; - border: 1px solid var(--color-border-strong); - border-radius: 999px; - background: var(--color-bg-subtle); - color: var(--color-text); - font-size: 0.85rem; - cursor: pointer; - transition: background 0.1s, border-color 0.1s; -} - -.tracker-pill:hover { - background: var(--color-bg-lime-tint); - border-color: var(--color-accent); -} - -.tracker-pill.is-active { - background: var(--color-accent); - border-color: var(--color-accent); - color: var(--color-accent-fg); - font-weight: 500; -} - -.tracker-find-date-input { - padding: 0.3rem 0.5rem; - border: 1px solid var(--color-border-strong); - border-radius: 4px; - background: var(--color-bg-subtle); - color: var(--color-text); - font-size: 0.9rem; -} - -.tracker-find-summary { - font-size: 0.85rem; - color: var(--color-text-muted); - min-height: 1.2rem; -} - -.tracker-timelines { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.tracker-timelines-placeholder, -.tracker-timelines-empty, -.tracker-cold-open-hint { - padding: 0.75rem; - text-align: center; - font-size: 0.9rem; - color: var(--color-text-muted); - font-style: italic; -} - -.tracker-cold-open-hint { - background: var(--color-bg-lime-tint); - border: 1px dashed var(--color-accent); - border-radius: 6px; - font-style: normal; - color: var(--color-text); -} - -/* ─── per-proceeding card ───────────────────────────────────────── */ - -.tracker-proceeding { - border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg); - overflow: hidden; -} - -.tracker-proceeding-header { - display: flex; - align-items: center; - gap: 0.6rem; - padding: 0.6rem 0.9rem; - background: var(--color-bg-subtle); - border-bottom: 1px solid var(--color-border); -} - -.tracker-proceeding-jur { - display: inline-block; - padding: 0.1rem 0.4rem; - background: var(--color-text); - color: var(--color-bg); - font-size: 0.7rem; - font-weight: 600; - letter-spacing: 0.05em; - border-radius: 3px; -} - -.tracker-proceeding-name { - margin: 0; - font-size: 1rem; - font-weight: 600; - flex: 1; -} - -.tracker-proceeding-code { - font-size: 0.75rem; - color: var(--color-text-subtle); - font-family: monospace; -} - -.tracker-proceeding-options { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem; - padding: 0.5rem 0.9rem; - border-bottom: 1px solid var(--color-border); - background: var(--color-bg); - font-size: 0.85rem; -} - -.tracker-proceeding-options-label { - font-weight: 500; - color: var(--color-text-muted); -} - -.tracker-proceeding-option { - display: inline-flex; - align-items: center; - gap: 0.3rem; - cursor: pointer; -} - -.tracker-proceeding-option input[type="checkbox"] { - margin: 0; - accent-color: var(--color-accent); -} - -.tracker-proceeding-body { - padding: 0.75rem 0.9rem; -} - -.tracker-proceeding-loading, -.tracker-proceeding-empty, -.tracker-proceeding-error { - font-size: 0.85rem; - color: var(--color-text-subtle); - font-style: italic; - text-align: center; - padding: 0.5rem; -} - -.tracker-proceeding-error { - color: var(--color-text); - background: rgba(245, 101, 101, 0.08); - border-left: 3px solid #ef4444; - font-style: normal; -} - -/* ─── tree nodes ────────────────────────────────────────────────── */ - -.tracker-tree { - list-style: none; - margin: 0; - padding: 0; -} - -.tracker-tree-root { - position: relative; -} - -.tracker-tree .tracker-tree { - padding-left: 1.4rem; - position: relative; - border-left: 1px dashed var(--color-border-strong); - margin-left: 0.5rem; -} - -.tracker-node { - position: relative; - padding: 0.3rem 0; -} - -.tracker-node-line { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.25rem 0.4rem; - border-radius: 4px; - font-size: 0.9rem; - line-height: 1.3; - transition: background 0.1s; -} - -.tracker-node:hover > .tracker-node-line { - background: var(--color-bg-lime-tint); -} - -.tracker-node-bullet { - display: inline-block; - width: 0.7rem; - height: 0.7rem; - border-radius: 50%; - background: var(--color-text); - flex-shrink: 0; -} - -.tracker-node--mandatory > .tracker-node-line .tracker-node-bullet { - background: var(--color-text); -} - -.tracker-node--recommended > .tracker-node-line .tracker-node-bullet { - background: var(--color-text); - opacity: 0.6; -} - -.tracker-node--optional > .tracker-node-line .tracker-node-bullet { - background: transparent; - border: 1.5px dotted var(--color-text-muted); - width: 0.65rem; - height: 0.65rem; -} - -.tracker-node--informational > .tracker-node-line .tracker-node-bullet { - background: var(--color-text-subtle); - opacity: 0.5; -} - -.tracker-node--court > .tracker-node-line .tracker-node-bullet { - background: #5b8def; -} - -.tracker-node-name { - flex: 1; - font-weight: 500; - min-width: 8rem; -} - -.tracker-node-ref { - font-size: 0.75rem; - color: var(--color-text-muted); - font-family: monospace; -} - -.tracker-node-date { - font-size: 0.8rem; - color: var(--color-text-muted); - white-space: nowrap; - margin-left: auto; -} - -.tracker-node-party { - display: inline-block; - min-width: 1.4rem; - text-align: center; - padding: 0.05rem 0.35rem; - border-radius: 3px; - font-size: 0.7rem; - font-weight: 600; - background: var(--color-bg-subtle); - color: var(--color-text-muted); - border: 1px solid var(--color-border); -} - -.tracker-node-party--court { - background: rgba(91, 141, 239, 0.12); - color: #2c5fb8; - border-color: rgba(91, 141, 239, 0.3); -} - -.tracker-node-party--claimant { - background: rgba(34, 197, 94, 0.10); - color: #1f7a4a; - border-color: rgba(34, 197, 94, 0.25); -} - -.tracker-node-party--defendant { - background: rgba(245, 158, 11, 0.10); - color: #92500a; - border-color: rgba(245, 158, 11, 0.25); -} - -.tracker-node--anchored > .tracker-node-line { - background: var(--color-bg-lime-tint); - border-left: 4px solid var(--color-accent); - padding-left: 0.5rem; -} - -.tracker-node-here { - display: block; - margin: 0.2rem 0 0.2rem 0.4rem; - padding: 0.15rem 0.4rem; - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.1em; - color: var(--color-accent-dark); - background: var(--color-accent); - border-radius: 3px; - width: max-content; -} - -.tracker-node-pin, -.tracker-node-fokus { - border: none; - background: transparent; - cursor: pointer; - font-size: 0.85rem; - padding: 0.1rem 0.3rem; - border-radius: 3px; - opacity: 0.35; - transition: opacity 0.1s, background 0.1s; -} - -.tracker-node:hover > .tracker-node-line .tracker-node-pin, -.tracker-node--anchored > .tracker-node-line .tracker-node-pin, -.tracker-node-fokus { - opacity: 1; -} - -.tracker-node-pin:hover, -.tracker-node-fokus:hover { - background: var(--color-bg-lime-tint); -} - -.tracker-proceeding--anchored { - border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent); -} - -.tracker-proceeding-toggle { - border: 1px solid var(--color-border-strong); - background: var(--color-bg); - color: var(--color-text-muted); - font-size: 0.8rem; - padding: 0.15rem 0.5rem; - border-radius: 4px; - cursor: pointer; -} - -.tracker-proceeding-toggle:hover { - background: var(--color-bg-lime-tint); - border-color: var(--color-accent); - color: var(--color-text); -} - -/* Collapsed proceeding card — header-only render (§6.5). The toggle - * shows "zeigen"; clicking expands. Body stays absent. */ -.tracker-proceeding--collapsed .tracker-proceeding-header { - border-bottom: none; -} - -/* Zoom breadcrumb (§6.2). One-line ancestors trail above the focused - * subtree. The anchor crumb at the right is highlighted lime so the - * user sees their current depth. */ -.tracker-zoom-breadcrumb { - font-size: 0.85rem; - color: var(--color-text-muted); - padding: 0.3rem 0.5rem; - margin-bottom: 0.4rem; - border-bottom: 1px dashed var(--color-border); - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.3rem; -} - -.tracker-zoom-crumb { - padding: 0.05rem 0.3rem; -} - -.tracker-zoom-crumb--anchor { - background: var(--color-accent); - color: var(--color-accent-fg); - border-radius: 3px; - font-weight: 500; -} - -.tracker-zoom-crumb-sep { - color: var(--color-text-subtle); -} - -.tracker-zoom-hidden { - font-size: 0.8rem; - color: var(--color-text-muted); - font-style: italic; - text-align: center; - margin-top: 0.5rem; - padding-top: 0.3rem; - border-top: 1px dashed var(--color-border); -} - -.tracker-node--highlight > .tracker-node-line { - animation: tracker-node-flash 3s ease-out; -} - -@keyframes tracker-node-flash { - 0% { background: var(--color-accent); } - 100% { background: transparent; } -} - -@media (max-width: 600px) { - .tracker-find-axis-label { - min-width: 4rem; - } - .tracker-node-date { - margin-left: 0; - } -}