Revert "Merge: t-paliad-338 T1 — workflow-tracker shell replaces catalog (m/paliad#152)"
This reverts commit2e6427dca6, reversing changes made to9fe06094a8.
This commit is contained in:
@@ -212,33 +212,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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": "<name>"} — 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<string>): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
const n = node as Record<string, unknown>;
|
||||
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<string>();
|
||||
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<string, string[]> = {
|
||||
"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<string, string> = {
|
||||
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<RenderedTimeline> {
|
||||
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 = `
|
||||
<span class="tracker-proceeding-jur">${escHtml(jur)}</span>
|
||||
<h3 class="tracker-proceeding-name">${escHtml(procName)}</h3>
|
||||
<span class="tracker-proceeding-code" title="${escHtml(params.proceedingType)}">${escHtml(params.proceedingType)}</span>
|
||||
<button type="button" class="tracker-proceeding-toggle" data-action="proc-toggle"
|
||||
data-code="${escHtml(params.proceedingType)}"
|
||||
aria-label="${escHtml(t("procedures.proceeding.toggle"))}">
|
||||
${escHtml(params.collapsed ? t("procedures.proceeding.show") : t("procedures.proceeding.hide"))}
|
||||
</button>
|
||||
`;
|
||||
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 = `<div class="tracker-proceeding-loading">${escHtml(t("procedures.timelines.loading"))}</div>`;
|
||||
card.appendChild(body);
|
||||
|
||||
// Fetch + render.
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: params.proceedingType,
|
||||
triggerDate: params.triggerDate,
|
||||
flags: params.flags,
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
body.innerHTML = `<div class="tracker-proceeding-error">${escHtml(t("procedures.timelines.error"))}</div>`;
|
||||
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 <ul class="tracker-tree-…"> 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 `<div class="tracker-proceeding-empty">${escHtml(t("procedures.timelines.empty"))}</div>`;
|
||||
}
|
||||
|
||||
// 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<string, CalculatedDeadline[]> = {};
|
||||
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[] = [`<ul class="tracker-tree tracker-tree-root">`];
|
||||
for (const root of roots) {
|
||||
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId));
|
||||
}
|
||||
parts.push(`</ul>`);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function renderTreeNode(
|
||||
dl: CalculatedDeadline,
|
||||
childrenOf: Record<string, CalculatedDeadline[]>,
|
||||
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
|
||||
? `<button type="button" class="tracker-node-pin" data-action="pin"
|
||||
data-rule-id="${escHtml(dl.ruleId)}"
|
||||
aria-label="${escHtml(t("procedures.node.pin"))}"
|
||||
title="${escHtml(t("procedures.node.pin"))}">📌</button>`
|
||||
: "";
|
||||
const fokusBtn = isAnchored
|
||||
? `<button type="button" class="tracker-node-fokus" data-action="fokus"
|
||||
aria-label="${escHtml(t("procedures.node.fokus"))}"
|
||||
title="${escHtml(t("procedures.node.fokus"))}">🔍</button>`
|
||||
: "";
|
||||
|
||||
const meta = `
|
||||
<div class="tracker-node-line">
|
||||
<span class="tracker-node-bullet" aria-hidden="true"></span>
|
||||
<span class="tracker-node-name">${escHtml(name)}</span>
|
||||
${ref ? `<span class="tracker-node-ref">${escHtml(ref)}</span>` : ""}
|
||||
${dateLabel ? `<span class="tracker-node-date">${escHtml(dateLabel)}</span>` : ""}
|
||||
${partyBadge ? `<span class="tracker-node-party tracker-node-party--${escHtml(dl.party || "")}">${escHtml(partyBadge)}</span>` : ""}
|
||||
${pinBtn}
|
||||
${fokusBtn}
|
||||
</div>
|
||||
${isAnchored ? `<div class="tracker-node-here" role="note">${escHtml(t("procedures.node.here"))}</div>` : ""}
|
||||
`;
|
||||
|
||||
let inner = meta;
|
||||
if (children.length > 0) {
|
||||
const kids = children
|
||||
.map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId))
|
||||
.join("");
|
||||
inner += `<ul class="tracker-tree tracker-tree--depth-${depth + 1}">${kids}</ul>`;
|
||||
}
|
||||
|
||||
return `<li class="tracker-node ${priorityClass}${anchorClass}${courtClass}"${ruleIdAttr}${codeAttr}>${inner}</li>`;
|
||||
}
|
||||
|
||||
// ─── 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<string, CalculatedDeadline> = {};
|
||||
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(`<span class="tracker-zoom-crumb">${escHtml(name)}</span>`);
|
||||
}
|
||||
const anchorName = lang === "en" ? (anchor.nameEN || anchor.name) : (anchor.name || anchor.nameEN);
|
||||
breadcrumbParts.push(`<span class="tracker-zoom-crumb tracker-zoom-crumb--anchor">${escHtml(anchorName)}</span>`);
|
||||
|
||||
const breadcrumb = `<nav class="tracker-zoom-breadcrumb" aria-label="${escHtml(t("procedures.zoom.breadcrumb"))}">
|
||||
${breadcrumbParts.join('<span class="tracker-zoom-crumb-sep">▸</span>')}
|
||||
</nav>`;
|
||||
|
||||
// Subtree: anchor + all descendants (parentRuleCode chain rooted
|
||||
// at the anchor).
|
||||
const descendants = new Set<string>([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
|
||||
? `<div class="tracker-zoom-hidden">${escHtml(tDyn("procedures.zoom.hidden").replace("{n}", String(hiddenCount)))}</div>`
|
||||
: "";
|
||||
|
||||
return breadcrumb + subtreeBody + hiddenLine;
|
||||
}
|
||||
|
||||
// ─── search hit highlight (URL `?event=`) ──────────────────────────────────
|
||||
//
|
||||
// T1 affordance: when `?event=<rule_id>` 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<HTMLElement>(`[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));
|
||||
}
|
||||
@@ -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=<text> — free-text search
|
||||
// ?forum=<id> — single forum (upc/de/epa/dpma)
|
||||
// ?procs=<csv> — comma-separated proceeding codes
|
||||
// ?party=<x> — claimant/defendant/both/""
|
||||
// ?trigger_date=<iso> — global Stichtag
|
||||
// ?event=<rule_id> — scroll-highlight matching node (no anchor
|
||||
// pin / zoom yet; T2 layering)
|
||||
// ?flags=<csv> — 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<string>();
|
||||
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 = `
|
||||
<div class="procedures-overhaul-host">
|
||||
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
|
||||
<div id="fristen-overhaul-mode-host"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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<void> {
|
||||
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<void> {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const eventRef = params.get("event") || "";
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
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<HTMLButtonElement>("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<void> {
|
||||
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 = `<div class="tracker-timelines-placeholder">${t("procedures.timelines.loading")}</div>`;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="procedures.heading">Verfahren & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="procedures.subtitle">
|
||||
Verfahrensabläufe als Zeitstrahl — suchen, filtern, Verzweigungen wählen.
|
||||
Verfahrensablauf, Fristenrechner und gerührte Suche in einem Tool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<section className="tracker-find" aria-label="Filter" id="tracker-find">
|
||||
<div className="tracker-find-search">
|
||||
<svg className="tracker-find-search-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
{/* Shared filter strip — search box + four chip groups
|
||||
(forum / proceeding / event_kind / party). Lives at the
|
||||
top of the page so every entry tab and output mode reads
|
||||
the same active filter set (design §4 + m's Q3
|
||||
divergence: search composes with chip filters). U0
|
||||
ships the markup only; chip hydration + search wiring
|
||||
arrive with U1-U3. */}
|
||||
<section className="procedures-filter-strip" aria-label="Filter">
|
||||
<div className="procedures-filter-search">
|
||||
<svg className="procedures-filter-search-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
@@ -74,53 +67,120 @@ export function renderProcedures(): string {
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="tracker-search-input"
|
||||
className="tracker-find-search-input"
|
||||
id="procedures-search-input"
|
||||
className="procedures-filter-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="procedures.filter.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="forum">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-forum" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="proc">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-proc" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="party">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-party" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="date">
|
||||
<label htmlFor="tracker-trigger-date" className="tracker-find-axis-label"
|
||||
data-i18n="procedures.filter.axis.date">Stichtag:</label>
|
||||
<input
|
||||
type="date"
|
||||
id="tracker-trigger-date"
|
||||
className="tracker-find-date-input"
|
||||
value={today}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-summary" id="tracker-find-summary" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
{/* Timeline body — one card per matched proceeding. Cards
|
||||
are appended by procedures-tracker.ts on boot and
|
||||
re-rendered when the find header changes. */}
|
||||
<section className="tracker-timelines" id="tracker-timelines" aria-label="Verfahren">
|
||||
<div className="tracker-timelines-placeholder" id="tracker-timelines-placeholder"
|
||||
data-i18n="procedures.timelines.loading">
|
||||
Verfahren werden geladen…
|
||||
<div className="procedures-filter-chips" id="procedures-filter-chips">
|
||||
<div className="procedures-filter-chip-row" data-axis="forum">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="proc">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="kind">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="party">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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. */}
|
||||
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
|
||||
<button type="button"
|
||||
className="procedures-tab is-active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
data-tab="proceeding"
|
||||
id="procedures-tab-proceeding">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">📚</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren wählen</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="search"
|
||||
id="procedures-tab-search">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">⚡</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="wizard"
|
||||
id="procedures-tab-wizard">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">🧭</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Geführt</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="akte"
|
||||
id="procedures-tab-akte">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">📁</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* 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. */}
|
||||
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-proceeding">
|
||||
{/* 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. */}
|
||||
<VerfahrensablaufBody todayIso={today} />
|
||||
</section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-search" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-search" hidden></section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-wizard" hidden></section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-akte" hidden>
|
||||
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
|
||||
Akten-Einstieg folgt in einem späteren Slice.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
|
||||
tree here; U0 leaves it empty + hidden so the
|
||||
tab placeholders are the only thing visible. */}
|
||||
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
|
||||
aria-label="Tree output" hidden></section>
|
||||
|
||||
{/* 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. */}
|
||||
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
|
||||
aria-label="Linear output" hidden></section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user