chore(procedures): T5 — drop dead code from the U0-U4 catalog (m/paliad#152)
The workflow tracker (T1-T4) replaces every consumer of the entry-mode
modules. Verified via grep that no non-deleted file imports the
following before removal:
Deleted (10 files):
- client/fristenrechner-mode-a.ts (Mode A search panel)
- client/fristenrechner-wizard.ts (Mode B guided wizard)
- client/fristenrechner-wizard.test.ts
- client/fristenrechner-result.ts (post-commit result-view)
- client/fristenrechner-result.test.ts
- client/verfahrensablauf.ts (Verfahrensablauf panel client)
- client/views/event-card-choices.ts (per-card choice popover —
only verfahrensablauf.ts consumed it)
- client/views/verfahrensablauf-state.ts (URL + storage helpers —
only verfahrensablauf.ts consumed it)
- client/views/verfahrensablauf-state.test.ts
- components/VerfahrensablaufBody.tsx (the 4-tab proceeding picker
body — no consumer after T1)
Kept (still load-bearing):
- client/views/verfahrensablauf-core.ts — procedures-tracker uses
calculateDeadlines + CalculatedDeadline + escHtml + formatDate.
- client/views/verfahrensablauf-core.test.ts
- client/verfahrensablauf-detail-mode.ts — procedures-tracker uses
filterByDetailMode under the per-proceeding "Alle Optionen"
toggle (T4).
- client/verfahrensablauf-detail-mode.test.ts
The .css classes (.fristen-wizard-*, .verfahrensablauf-*) still live
in global.css; they're cheap orphans (no selector match in the new
DOM) and a CSS housekeeping pass is outside this train's scope. The
i18n keys (deadlines.flag.*, deadlines.detail.*, deadlines.view.*,
deadlines.side.*) likewise stay — some are used dynamically via tDyn
on the tracker, others remain candidates for a future i18n sweep.
Frontend tests: 217 pass (264 → 217, the deltas are the 3 deleted
test files: fristenrechner-result, fristenrechner-wizard,
verfahrensablauf-state). Build + go vet clean.
t-paliad-338
This commit is contained in:
@@ -1,507 +0,0 @@
|
||||
// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1).
|
||||
//
|
||||
// Power-user surface: a filter strip (Forum / Verfahren / Was passierte /
|
||||
// Partei) over a free-text search box over a result list of
|
||||
// procedural_events. Clicking a row locks the event as the trigger and
|
||||
// transitions to the shared result view (S2). Inbox channel chip lives
|
||||
// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA
|
||||
// / Postal auto-sets the Forum chip.
|
||||
//
|
||||
// Section-split visual hierarchy per m §11.Q3: filter strip on top
|
||||
// ("Filter (eingrenzen)") with the four chip groups, search box and
|
||||
// result list below — clicking a result row IS the qualifier action.
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
|
||||
// Wire shape from GET /api/tools/fristenrechner/search?kind=events.
|
||||
// Mirrors services.EventSearchResponse server-side.
|
||||
interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
description?: string;
|
||||
primary_party?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
anchor_rule_id: string;
|
||||
follow_up_count: number;
|
||||
concept_id?: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface EventSearchResponse {
|
||||
query: string;
|
||||
events: EventSearchHit[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProceedingChip {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
// Module-local state — single Mode A surface at a time.
|
||||
interface ModeAState {
|
||||
jurisdiction: string; // "" = Alle
|
||||
proc: string; // proceeding_types.code, "" = Alle
|
||||
eventKind: string; // "" = Alle
|
||||
party: string; // "" = Alle (Mode A's filter semantics, §11.Q8)
|
||||
q: string; // free-text query
|
||||
inbox: string; // CMS / bea / postal / "" — secondary, design §3.3
|
||||
inboxOpen: boolean;
|
||||
}
|
||||
|
||||
const state: ModeAState = {
|
||||
jurisdiction: "",
|
||||
proc: "",
|
||||
eventKind: "",
|
||||
party: "",
|
||||
q: "",
|
||||
inbox: "",
|
||||
inboxOpen: false,
|
||||
};
|
||||
|
||||
// Debounce token for search input — avoid hammering the server on
|
||||
// every keystroke.
|
||||
let searchSeq = 0;
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Chip data — static. Forum and event-kind are closed-set per design;
|
||||
// party is closed-set with "Beide" option (Mode A is filter mode,
|
||||
// §11.Q8). Inbox secondary chip set per §3.3.
|
||||
const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const;
|
||||
const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const;
|
||||
const PARTIES = ["claimant", "defendant", "both"] as const;
|
||||
|
||||
// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE,
|
||||
// Postal → no narrowing (postal arrives at every jurisdiction).
|
||||
const INBOX_TO_FORUM: Record<string, string> = {
|
||||
cms: "UPC",
|
||||
bea: "DE",
|
||||
postal: "",
|
||||
};
|
||||
|
||||
// MODE_A_HOST_ID is the DOM id of the container Mode A renders into.
|
||||
// The mode shell (fristenrechner-result.mountModeShell) creates this
|
||||
// element under the overhaul root and hands it to Mode A; Mode A
|
||||
// otherwise has no opinion about its placement on the page.
|
||||
const MODE_A_HOST_ID = "fristen-overhaul-mode-host";
|
||||
|
||||
export function isModeASurfaceMounted(): boolean {
|
||||
return !!document.getElementById("fristen-mode-a-root");
|
||||
}
|
||||
|
||||
// mountModeA renders the Mode A surface into the overhaul root. Reads
|
||||
// initial state from URL params so deep links restore the previous
|
||||
// filter / search state.
|
||||
export async function mountModeA(): Promise<void> {
|
||||
const root = document.getElementById(MODE_A_HOST_ID);
|
||||
if (!root) return;
|
||||
|
||||
// Hydrate state from URL.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.jurisdiction = (params.get("forum") || "").toUpperCase();
|
||||
state.proc = params.get("pt") || "";
|
||||
state.eventKind = params.get("kind") || "";
|
||||
state.party = params.get("party") || "";
|
||||
state.q = params.get("q") || "";
|
||||
|
||||
renderShell();
|
||||
await loadProceedingChips();
|
||||
void runSearch();
|
||||
}
|
||||
|
||||
// renderShell builds the Mode A markup. Idempotent re-call from the
|
||||
// boot path; row-level rewrites use renderResults / renderFilterStrip
|
||||
// for finer-grained updates.
|
||||
function renderShell(): void {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div id="fristen-mode-a-root" class="fristen-mode-a-root">
|
||||
<section class="fristen-mode-a-filters" aria-label="${escAttr(t("deadlines.overhaul.modea.filters.label"))}">
|
||||
<header class="fristen-mode-a-filters-header">
|
||||
<span class="fristen-mode-a-filters-title">${escHtml(t("deadlines.overhaul.modea.filters.heading"))}</span>
|
||||
</header>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="forum">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.forum"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-forum"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="proc">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.proc"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-proc"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="kind">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.kind"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-kind"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="party">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.party"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-party"></div>
|
||||
</div>
|
||||
<details class="fristen-mode-a-inbox" ${state.inboxOpen ? "open" : ""}>
|
||||
<summary class="fristen-mode-a-inbox-summary">${escHtml(t("deadlines.overhaul.modea.inbox.summary"))}</summary>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="inbox">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.inbox"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-inbox"></div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="fristen-mode-a-search" aria-label="${escAttr(t("deadlines.overhaul.modea.search.label"))}">
|
||||
<div class="fristen-mode-a-search-input-wrap">
|
||||
<svg class="fristen-mode-a-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>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="search" id="fristen-mode-a-search-input"
|
||||
class="fristen-mode-a-search-input"
|
||||
autocomplete="off" spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.overhaul.modea.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
value="${escAttr(state.q)}" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fristen-mode-a-results" aria-label="${escAttr(t("deadlines.overhaul.modea.results.label"))}">
|
||||
<header class="fristen-mode-a-results-header">
|
||||
<span class="fristen-mode-a-results-title">${escHtml(t("deadlines.overhaul.modea.results.heading"))}</span>
|
||||
<span class="fristen-mode-a-results-count" id="fristen-mode-a-results-count"></span>
|
||||
</header>
|
||||
<ul class="fristen-mode-a-result-list" id="fristen-mode-a-result-list" role="listbox" aria-live="polite"></ul>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderForumChips();
|
||||
renderKindChips();
|
||||
renderPartyChips();
|
||||
renderInboxChips();
|
||||
// Proceeding chips render later, after fetch.
|
||||
|
||||
// Wire search input.
|
||||
const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.addEventListener("input", () => {
|
||||
state.q = input.value;
|
||||
scheduleSearch(180);
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
e.preventDefault();
|
||||
scheduleSearch(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter-strip chip renderers ----------------------------------------
|
||||
|
||||
function renderForumChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-forum");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""),
|
||||
...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.dataset.value || "";
|
||||
state.jurisdiction = v;
|
||||
// Forum change invalidates the proc pick if it falls outside.
|
||||
state.proc = "";
|
||||
syncUrl();
|
||||
renderForumChips();
|
||||
void loadProceedingChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderKindChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-kind");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""),
|
||||
...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.eventKind = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderKindChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderPartyChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-party");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""),
|
||||
...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.party = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderPartyChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInboxChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-inbox");
|
||||
if (!host) return;
|
||||
const opts = [
|
||||
{ v: "", label: t("deadlines.overhaul.modea.chip.all") },
|
||||
{ v: "cms", label: "CMS" },
|
||||
{ v: "bea", label: "beA" },
|
||||
{ v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") },
|
||||
];
|
||||
host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.dataset.value || "";
|
||||
state.inbox = v;
|
||||
// Auto-nudge forum from inbox per design §3.3.
|
||||
const nudge = INBOX_TO_FORUM[v];
|
||||
if (nudge !== undefined && nudge !== "") {
|
||||
state.jurisdiction = nudge;
|
||||
state.proc = "";
|
||||
renderForumChips();
|
||||
void loadProceedingChips();
|
||||
}
|
||||
renderInboxChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Proceeding chips — dynamic fetch.
|
||||
|
||||
let lastProcFetchKey = "";
|
||||
|
||||
async function loadProceedingChips(): Promise<void> {
|
||||
const host = document.getElementById("fristen-mode-a-chips-proc");
|
||||
if (!host) return;
|
||||
const key = `j=${state.jurisdiction}`;
|
||||
if (lastProcFetchKey === key) return; // cached for current jurisdiction
|
||||
lastProcFetchKey = key;
|
||||
host.innerHTML = `<span class="fristen-mode-a-chip-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</span>`;
|
||||
|
||||
const url = new URL("/api/tools/proceeding-types", window.location.origin);
|
||||
url.searchParams.set("kind", "proceeding");
|
||||
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
|
||||
|
||||
let chips: ProceedingChip[] = [];
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (resp.ok) {
|
||||
const data = (await resp.json()) as ProceedingChip[] | null;
|
||||
chips = data || [];
|
||||
}
|
||||
} catch {
|
||||
// Soft-fail: chip strip just hides; search still runs without
|
||||
// proceeding narrowing.
|
||||
}
|
||||
|
||||
renderProceedingChips(chips);
|
||||
}
|
||||
|
||||
function renderProceedingChips(chips: ProceedingChip[]): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-proc");
|
||||
if (!host) return;
|
||||
const lang = getLang();
|
||||
if (chips.length === 0) {
|
||||
host.innerHTML = `<span class="fristen-mode-a-chip-empty">${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}</span>`;
|
||||
return;
|
||||
}
|
||||
const rendered = [
|
||||
chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""),
|
||||
...chips.map((c) => {
|
||||
const label = lang === "en" ? c.nameEN || c.name : c.name;
|
||||
return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code);
|
||||
}),
|
||||
];
|
||||
host.innerHTML = rendered.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.proc = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderProceedingChips(chips);
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search ------------------------------------------------------------
|
||||
|
||||
function scheduleSearch(delayMs: number): void {
|
||||
if (searchTimer !== null) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
searchTimer = null;
|
||||
void runSearch();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
searchSeq++;
|
||||
const mySeq = searchSeq;
|
||||
|
||||
const list = document.getElementById("fristen-mode-a-result-list");
|
||||
const count = document.getElementById("fristen-mode-a-results-count");
|
||||
if (!list || !count) return;
|
||||
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</li>`;
|
||||
count.textContent = "";
|
||||
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("kind", "events");
|
||||
if (state.q) url.searchParams.set("q", state.q);
|
||||
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
|
||||
if (state.proc) url.searchParams.set("proc", state.proc);
|
||||
if (state.eventKind) url.searchParams.set("event_kind", state.eventKind);
|
||||
if (state.party) url.searchParams.set("party", state.party);
|
||||
|
||||
let data: EventSearchResponse;
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
if (mySeq === searchSeq) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
data = (await resp.json()) as EventSearchResponse;
|
||||
} catch {
|
||||
if (mySeq === searchSeq) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mySeq !== searchSeq) return; // stale response
|
||||
|
||||
renderResults(data);
|
||||
}
|
||||
|
||||
function renderResults(data: EventSearchResponse): void {
|
||||
const list = document.getElementById("fristen-mode-a-result-list");
|
||||
const count = document.getElementById("fristen-mode-a-results-count");
|
||||
if (!list || !count) return;
|
||||
count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total));
|
||||
|
||||
if (data.events.length === 0) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-empty">${escHtml(t("deadlines.overhaul.modea.no_results"))}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
list.innerHTML = data.events.map((e) => {
|
||||
const name = lang === "en" ? e.name_en || e.name_de : e.name_de;
|
||||
const pt = e.proceeding_type;
|
||||
const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de;
|
||||
const icon = eventKindIconForChip(e.event_kind);
|
||||
const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count));
|
||||
const juris = pt.jurisdiction || "";
|
||||
return `
|
||||
<li class="fristen-mode-a-result" data-event-code="${escAttr(e.code)}" tabindex="0" role="option">
|
||||
<span class="fristen-mode-a-result-icon" aria-hidden="true">${icon}</span>
|
||||
<div class="fristen-mode-a-result-body">
|
||||
<div class="fristen-mode-a-result-title">${escHtml(name)}</div>
|
||||
<div class="fristen-mode-a-result-meta">
|
||||
<span class="fristen-mode-a-result-pt">${escHtml(pt.code)}</span>
|
||||
<span class="fristen-mode-a-result-pt-name">${escHtml(ptName)}</span>
|
||||
${juris ? `<span class="fristen-mode-a-result-juris">${escHtml(juris)}</span>` : ""}
|
||||
<span class="fristen-mode-a-result-followups">${escHtml(followUps)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fristen-mode-a-result-cta" aria-hidden="true">→</span>
|
||||
</li>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".fristen-mode-a-result").forEach((li) => {
|
||||
li.addEventListener("click", () => commitEvent(li.dataset.eventCode || ""));
|
||||
li.addEventListener("keydown", (e) => {
|
||||
const k = (e as KeyboardEvent).key;
|
||||
if (k === "Enter" || k === " ") {
|
||||
e.preventDefault();
|
||||
commitEvent(li.dataset.eventCode || "");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Commit — user picked a result; lock the event as trigger and
|
||||
// transition to the §4 result view (S2).
|
||||
function commitEvent(code: string): void {
|
||||
if (!code) return;
|
||||
// Reflect in URL before re-mounting so the result view's deep link
|
||||
// is consistent.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", code);
|
||||
// Preserve project / forum / kind filters so a back-navigation
|
||||
// brings Mode A back with the same filters.
|
||||
history.pushState(null, "", url.pathname + url.search + url.hash);
|
||||
void mountResultView({
|
||||
eventRef: code,
|
||||
party: state.party || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Helpers -----------------------------------------------------------
|
||||
|
||||
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
|
||||
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
|
||||
const t = title ? ` title="${escAttr(title)}"` : "";
|
||||
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
|
||||
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${t}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
|
||||
}
|
||||
|
||||
function eventKindIconForChip(kind?: string): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥";
|
||||
case "hearing": return "🏛️";
|
||||
case "decision": return "⚖️";
|
||||
case "order": return "📜";
|
||||
default: return "🔍";
|
||||
}
|
||||
}
|
||||
|
||||
// syncUrl writes the active filter set into the URL so the deep link
|
||||
// restores Mode A in the same state.
|
||||
function syncUrl(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
setOrClear(url, "forum", state.jurisdiction);
|
||||
setOrClear(url, "pt", state.proc);
|
||||
setOrClear(url, "kind", state.eventKind);
|
||||
setOrClear(url, "party", state.party);
|
||||
setOrClear(url, "q", state.q);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
function setOrClear(url: URL, key: string, val: string): void {
|
||||
if (val) url.searchParams.set(key, val);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
defaultChecked,
|
||||
groupFollowUps,
|
||||
type FollowUpRule,
|
||||
} from "./fristenrechner-result";
|
||||
|
||||
// Pure helpers exercised here; the DOM-driven render path is covered
|
||||
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
|
||||
// entry-mode UIs in later slices).
|
||||
|
||||
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
|
||||
return {
|
||||
rule_id: "r" + Math.random().toString(36).slice(2, 8),
|
||||
event_code: "evt",
|
||||
title_de: "Frist",
|
||||
title_en: "Deadline",
|
||||
priority: "mandatory",
|
||||
is_court_set: false,
|
||||
is_spawn: false,
|
||||
is_bilateral: false,
|
||||
has_condition: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
|
||||
test("groups by priority; conditional takes precedence over priority", () => {
|
||||
const rows = [
|
||||
mk({ priority: "mandatory" }),
|
||||
mk({ priority: "recommended" }),
|
||||
mk({ priority: "optional" }),
|
||||
mk({ priority: "mandatory", has_condition: true }), // → conditional
|
||||
mk({ priority: "optional", has_condition: true }), // → conditional
|
||||
];
|
||||
const g = groupFollowUps(rows);
|
||||
expect(g.mandatory.length).toBe(1);
|
||||
expect(g.recommended.length).toBe(1);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.conditional.length).toBe(2);
|
||||
});
|
||||
|
||||
test("unknown priority falls through to optional", () => {
|
||||
const g = groupFollowUps([mk({ priority: "informational" })]);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.mandatory.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
|
||||
test("mandatory rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
|
||||
});
|
||||
test("recommended rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
|
||||
});
|
||||
test("optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
|
||||
});
|
||||
test("conditional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
|
||||
});
|
||||
test("court-set rules unchecked even when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
|
||||
});
|
||||
test("spawned rules pre-checked when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
|
||||
});
|
||||
test("spawned optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,693 +0,0 @@
|
||||
// Fristenrechner overhaul — shared result view (design §4).
|
||||
//
|
||||
// Given a locked trigger event + a trigger date, this module renders
|
||||
// the result surface: a sticky trigger card on top, then four priority
|
||||
// groups (mandatory / recommended / optional / conditional) of follow-up
|
||||
// rules with computed dates, then a write-back footer that calls the
|
||||
// existing POST /api/projects/{id}/deadlines/bulk.
|
||||
//
|
||||
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
|
||||
// wizard in S4) both land here once they've identified a trigger
|
||||
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
|
||||
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
|
||||
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
|
||||
// services.FollowUpsResponse server-side.
|
||||
export interface FollowUpRule {
|
||||
rule_id: string;
|
||||
event_code: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
priority: string;
|
||||
primary_party?: string;
|
||||
// m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's
|
||||
// primary_party is the side opposite the perspective. Drives the
|
||||
// Gegenseitig badge + muted style + unchecked default.
|
||||
is_cross_party: boolean;
|
||||
duration_value?: number;
|
||||
duration_unit?: string;
|
||||
timing?: string;
|
||||
due_date?: string;
|
||||
original_due_date?: string;
|
||||
was_adjusted?: boolean;
|
||||
is_court_set: boolean;
|
||||
is_spawn: boolean;
|
||||
is_bilateral: boolean;
|
||||
has_condition: boolean;
|
||||
rule_code?: string;
|
||||
legal_source?: string;
|
||||
legal_source_display?: string;
|
||||
legal_source_url?: string;
|
||||
notes_de?: string;
|
||||
notes_en?: string;
|
||||
spawn_label?: string;
|
||||
spawn_proceeding_code?: string;
|
||||
concept_id?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpsResponse {
|
||||
trigger: {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
anchor_rule_id: string;
|
||||
};
|
||||
trigger_date: string;
|
||||
party?: string;
|
||||
follow_ups: FollowUpRule[];
|
||||
}
|
||||
|
||||
// Per-rule UI state — checkbox, optional date override.
|
||||
interface RuleSelection {
|
||||
checked: boolean;
|
||||
override?: string;
|
||||
}
|
||||
|
||||
// Module-local state. Single result view at a time; the surface
|
||||
// re-renders in place when the user changes the trigger date or
|
||||
// re-locks a different event.
|
||||
let currentResponse: FollowUpsResponse | null = null;
|
||||
const selections = new Map<string, RuleSelection>();
|
||||
let currentProjectId: string | null = null;
|
||||
|
||||
// Public API ----------------------------------------------------------
|
||||
|
||||
// isOverhaulMode reports whether the page is in overhaul mode.
|
||||
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
|
||||
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
|
||||
// a two-week deprecation window. The `?overhaul=1` deep links from
|
||||
// S2-S4 still work — they're now redundant with the default but kept
|
||||
// alive so bookmarks don't 302 / lose state.
|
||||
export function isOverhaulMode(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("legacy") !== "1";
|
||||
}
|
||||
|
||||
// resolveProjectId reads the active Akte from the URL query string.
|
||||
// Returns null when in kontextfrei mode (no project picked).
|
||||
function resolveProjectId(): string | null {
|
||||
const p = new URLSearchParams(window.location.search).get("project");
|
||||
return p && p.length > 0 ? p : null;
|
||||
}
|
||||
|
||||
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
|
||||
// link path bypasses these (jumps straight to the result view via
|
||||
// ?event=); the tabs appear when no event is locked yet.
|
||||
export type ModeTab = "search" | "wizard";
|
||||
|
||||
// mountModeShell renders the mode-tab pair under the page header and
|
||||
// hosts whichever mode panel is currently active. Called from the boot
|
||||
// path when no `?event=` is present. S3 wires Mode A; S4 will add
|
||||
// Mode B and the actual tab switching.
|
||||
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
// Defer to the per-mode module to render into the root. The tab
|
||||
// strip itself is a small header above the mode panel — for S3 we
|
||||
// render the shell + Mode A in one shot.
|
||||
// S4 will replace this with a real tab switcher.
|
||||
const tabs = `
|
||||
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
|
||||
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
|
||||
aria-selected="${activeTab === "search"}" data-tab="search">
|
||||
<span class="fristen-mode-tab-icon" aria-hidden="true">⚡</span>
|
||||
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.search"))}</span>
|
||||
</button>
|
||||
<button type="button" class="fristen-mode-tab${activeTab === "wizard" ? " is-active" : ""}" role="tab"
|
||||
aria-selected="${activeTab === "wizard"}" data-tab="wizard">
|
||||
<span class="fristen-mode-tab-icon" aria-hidden="true">🧭</span>
|
||||
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.wizard"))}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div id="fristen-overhaul-mode-host"></div>
|
||||
`;
|
||||
root.innerHTML = tabs;
|
||||
|
||||
// Wire tab switching. S3 only has Mode A wired; Mode B is a
|
||||
// placeholder until S4.
|
||||
root.querySelectorAll<HTMLButtonElement>(".fristen-mode-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = (btn.dataset.tab || "search") as ModeTab;
|
||||
void mountModeShell(tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Mount the active mode panel into the host. S3 only routes "search";
|
||||
// "wizard" renders a placeholder until S4 lands.
|
||||
const host = document.getElementById("fristen-overhaul-mode-host");
|
||||
if (!host) return;
|
||||
if (activeTab === "search") {
|
||||
// Lazy import to keep the bundle layered and avoid a circular ref
|
||||
// between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts.
|
||||
const mod = await import("./fristenrechner-mode-a");
|
||||
await mod.mountModeA();
|
||||
} else {
|
||||
const mod = await import("./fristenrechner-wizard");
|
||||
await mod.mountWizard();
|
||||
}
|
||||
}
|
||||
|
||||
// MountOptions configures the surface entry. Both entry-mode paths
|
||||
// (Mode A in S3, Mode B in S4) call mount() with the event reference
|
||||
// that the user committed.
|
||||
export interface MountOptions {
|
||||
// eventRef is the procedural_event code OR its uuid OR the anchor
|
||||
// sequencing_rule id. Resolved server-side; the wire returns the
|
||||
// canonical code so the URL bookmark is stable.
|
||||
eventRef: string;
|
||||
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
|
||||
triggerDate?: string;
|
||||
// party is "claimant" | "defendant"; mode A may pass "both" or
|
||||
// "court". When omitted, follow-ups are returned without party
|
||||
// narrowing.
|
||||
party?: string;
|
||||
// courtId selects the holiday calendar for the per-rule date
|
||||
// adjustment. Optional.
|
||||
courtId?: string;
|
||||
}
|
||||
|
||||
// mountResultView fetches /follow-ups and renders the result surface
|
||||
// into the host container. Re-callable: replaces previous state.
|
||||
export async function mountResultView(opts: MountOptions): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
|
||||
const triggerDate = opts.triggerDate || todayIso();
|
||||
currentProjectId = resolveProjectId();
|
||||
|
||||
// Show a quick "loading…" placeholder so the user sees something
|
||||
// immediately, even on a cold fetch.
|
||||
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
|
||||
|
||||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||||
url.searchParams.set("event", opts.eventRef);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
if (opts.party) url.searchParams.set("party", opts.party);
|
||||
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
|
||||
|
||||
let data: FollowUpsResponse;
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
data = (await resp.json()) as FollowUpsResponse;
|
||||
} catch (err) {
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
currentResponse = data;
|
||||
selections.clear();
|
||||
for (const r of data.follow_ups) {
|
||||
selections.set(r.rule_id, { checked: defaultChecked(r) });
|
||||
}
|
||||
|
||||
renderSurface();
|
||||
// Reflect the canonical event code + trigger date in the URL so the
|
||||
// deep-link survives a reload.
|
||||
syncUrlState(data.trigger.code, data.trigger_date);
|
||||
}
|
||||
|
||||
// Render --------------------------------------------------------------
|
||||
|
||||
function renderSurface(): void {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root || !currentResponse) return;
|
||||
|
||||
const lang = getLang();
|
||||
const trig = currentResponse.trigger;
|
||||
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
|
||||
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
|
||||
const juris = trig.proceeding_type.jurisdiction || "";
|
||||
const kindIcon = eventKindIcon(trig.event_kind);
|
||||
|
||||
const triggerCard = `
|
||||
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
|
||||
<header class="fristen-overhaul-trigger-header">
|
||||
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
|
||||
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
|
||||
</header>
|
||||
<div class="fristen-overhaul-trigger-meta">
|
||||
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
|
||||
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
|
||||
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
|
||||
</div>
|
||||
<div class="fristen-overhaul-trigger-date">
|
||||
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
|
||||
${escHtml(t("deadlines.overhaul.trigger.date"))}
|
||||
</label>
|
||||
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
|
||||
value="${escAttr(currentResponse.trigger_date)}" />
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const groups = groupFollowUps(currentResponse.follow_ups);
|
||||
const groupHtml = renderGroups(groups, lang);
|
||||
|
||||
const nudge = currentProjectId
|
||||
? ""
|
||||
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
|
||||
|
||||
const footer = currentProjectId
|
||||
? renderFooter()
|
||||
: "";
|
||||
|
||||
root.innerHTML = `
|
||||
${triggerCard}
|
||||
${nudge}
|
||||
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
|
||||
${groupHtml}
|
||||
</section>
|
||||
${footer}
|
||||
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
|
||||
`;
|
||||
|
||||
wireSurfaceEvents();
|
||||
}
|
||||
|
||||
export interface GroupedFollowUps {
|
||||
mandatory: FollowUpRule[];
|
||||
recommended: FollowUpRule[];
|
||||
optional: FollowUpRule[];
|
||||
conditional: FollowUpRule[];
|
||||
}
|
||||
|
||||
// groupFollowUps splits the wire list into the four visible groups per
|
||||
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
|
||||
// precedence over the priority bucket so a "nur wenn CCR" mandatory
|
||||
// rule renders under Conditional with the gating language visible.
|
||||
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
|
||||
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
|
||||
for (const r of rows) {
|
||||
if (r.has_condition) {
|
||||
out.conditional.push(r);
|
||||
continue;
|
||||
}
|
||||
switch (r.priority) {
|
||||
case "mandatory":
|
||||
out.mandatory.push(r);
|
||||
break;
|
||||
case "recommended":
|
||||
out.recommended.push(r);
|
||||
break;
|
||||
case "optional":
|
||||
out.optional.push(r);
|
||||
break;
|
||||
default:
|
||||
// unknown / informational — fold into optional so the row is at
|
||||
// least visible. Future Phase 2 'informational' tier gets a
|
||||
// dedicated bucket once seeded.
|
||||
out.optional.push(r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
|
||||
const blocks: string[] = [];
|
||||
if (groups.mandatory.length > 0) {
|
||||
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
|
||||
}
|
||||
if (groups.recommended.length > 0) {
|
||||
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
|
||||
}
|
||||
if (groups.optional.length > 0) {
|
||||
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
|
||||
}
|
||||
if (groups.conditional.length > 0) {
|
||||
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
|
||||
}
|
||||
if (blocks.length === 0) {
|
||||
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
|
||||
}
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
|
||||
const items = rows.map((r) => renderRule(r, lang)).join("");
|
||||
return `
|
||||
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
|
||||
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
|
||||
<ul class="fristen-overhaul-rule-list">
|
||||
${items}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
const sel = selections.get(r.rule_id);
|
||||
const checked = sel ? sel.checked : defaultChecked(r);
|
||||
const dateOverride = sel?.override;
|
||||
const computedDate = r.due_date || "";
|
||||
const effectiveDate = dateOverride || computedDate;
|
||||
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
|
||||
|
||||
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
|
||||
const durationPhrase = formatDurationPhrase(r, lang);
|
||||
const dateCell = r.is_court_set
|
||||
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
|
||||
: effectiveDate
|
||||
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
|
||||
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">—</span>`;
|
||||
|
||||
const partyBadge = r.primary_party
|
||||
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
|
||||
: "";
|
||||
|
||||
const sourceBadge = r.legal_source_display
|
||||
? r.legal_source_url
|
||||
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
|
||||
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
|
||||
: r.rule_code
|
||||
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
|
||||
: "";
|
||||
|
||||
const spawnBadge = r.is_spawn
|
||||
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
|
||||
: "";
|
||||
|
||||
const condBadge = r.has_condition
|
||||
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
|
||||
: "";
|
||||
|
||||
const crossPartyBadge = r.is_cross_party
|
||||
? `<span class="fristen-overhaul-rule-crossparty" title="${escAttr(t("deadlines.overhaul.crossparty.tooltip"))}">${escHtml(t("deadlines.overhaul.crossparty.badge"))}</span>`
|
||||
: "";
|
||||
|
||||
const notesHtml = notes
|
||||
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
|
||||
: "";
|
||||
|
||||
const editBtn = r.is_court_set || r.is_spawn || !computedDate
|
||||
? ""
|
||||
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
|
||||
|
||||
return `
|
||||
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}${r.is_cross_party ? " is-cross-party" : ""}" data-rule-id="${escAttr(r.rule_id)}">
|
||||
<label class="fristen-overhaul-rule-check">
|
||||
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
|
||||
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
|
||||
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
|
||||
</label>
|
||||
<div class="fristen-overhaul-rule-body">
|
||||
<div class="fristen-overhaul-rule-title-row">
|
||||
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
|
||||
${spawnBadge}
|
||||
${condBadge}
|
||||
${crossPartyBadge}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-meta-row">
|
||||
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
|
||||
${partyBadge}
|
||||
${sourceBadge}
|
||||
</div>
|
||||
${notesHtml}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-date-cell">
|
||||
${dateCell}
|
||||
${editBtn}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFooter(): string {
|
||||
const selectedCount = countSelected();
|
||||
return `
|
||||
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
|
||||
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
|
||||
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
|
||||
</span>
|
||||
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
|
||||
id="fristen-overhaul-write-back"
|
||||
${selectedCount === 0 ? "disabled" : ""}>
|
||||
${escHtml(t("deadlines.overhaul.footer.cta"))}
|
||||
</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event wiring --------------------------------------------------------
|
||||
|
||||
function wireSurfaceEvents(): void {
|
||||
// Trigger-date change → re-fetch with new date.
|
||||
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput && currentResponse) {
|
||||
dateInput.addEventListener("change", () => {
|
||||
if (!currentResponse) return;
|
||||
const newDate = dateInput.value;
|
||||
if (!newDate) return;
|
||||
void mountResultView({
|
||||
eventRef: currentResponse.trigger.code,
|
||||
triggerDate: newDate,
|
||||
party: currentResponse.party,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Checkbox toggles → update selections + footer count.
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (root) {
|
||||
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.ruleId || "";
|
||||
const sel = selections.get(id) ?? { checked: cb.checked };
|
||||
sel.checked = cb.checked;
|
||||
selections.set(id, sel);
|
||||
refreshFooterCount();
|
||||
});
|
||||
});
|
||||
|
||||
// Per-rule date override.
|
||||
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
|
||||
btn.addEventListener("click", () => editRuleDate(btn));
|
||||
});
|
||||
}
|
||||
|
||||
// Write-back CTA.
|
||||
const cta = document.getElementById("fristen-overhaul-write-back");
|
||||
if (cta) cta.addEventListener("click", () => void submitWriteBack());
|
||||
}
|
||||
|
||||
function editRuleDate(btn: HTMLButtonElement): void {
|
||||
const ruleId = btn.dataset.ruleId || "";
|
||||
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
|
||||
if (!rule) return;
|
||||
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
|
||||
const current = sel.override || rule.due_date || todayIso();
|
||||
|
||||
const dateCell = btn.parentElement;
|
||||
if (!dateCell) return;
|
||||
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
|
||||
if (!dateSpan) return;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "date";
|
||||
input.value = current;
|
||||
input.className = "fristen-overhaul-rule-date-input";
|
||||
dateSpan.replaceWith(input);
|
||||
btn.disabled = true;
|
||||
input.focus();
|
||||
|
||||
const commit = () => {
|
||||
const newDate = input.value;
|
||||
if (newDate && newDate !== current) {
|
||||
sel.override = newDate;
|
||||
selections.set(ruleId, sel);
|
||||
}
|
||||
renderSurface();
|
||||
};
|
||||
input.addEventListener("blur", commit, { once: true });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
} else if ((e as KeyboardEvent).key === "Escape") {
|
||||
renderSurface();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshFooterCount(): void {
|
||||
const countEl = document.getElementById("fristen-overhaul-footer-count");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const n = countSelected();
|
||||
if (countEl) {
|
||||
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
|
||||
}
|
||||
if (cta) cta.disabled = n === 0;
|
||||
}
|
||||
|
||||
function countSelected(): number {
|
||||
let n = 0;
|
||||
if (!currentResponse) return 0;
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
if (r.is_court_set) continue;
|
||||
// Cross-party rows are unconditionally excluded from write-back
|
||||
// (design §2.4). Even if the user manually checks the box, they
|
||||
// describe what the opponent files — not Akte work for our side.
|
||||
if (r.is_cross_party) continue;
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (sel?.checked) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Write-back ----------------------------------------------------------
|
||||
|
||||
async function submitWriteBack(): Promise<void> {
|
||||
if (!currentResponse) return;
|
||||
if (!currentProjectId) return;
|
||||
const msg = document.getElementById("fristen-overhaul-msg");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const lang = getLang();
|
||||
|
||||
const deadlines: Array<Record<string, unknown>> = [];
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (!sel?.checked) continue;
|
||||
if (r.is_court_set) continue;
|
||||
// Skip cross-party rows even if checked — they describe opposing-
|
||||
// side filings and don't belong in our side's Akte deadline set
|
||||
// (design §2.4, write-back exclusion).
|
||||
if (r.is_cross_party) continue;
|
||||
const dueDate = sel.override || r.due_date;
|
||||
if (!dueDate) continue;
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
deadlines.push({
|
||||
title,
|
||||
rule_code: r.rule_code || undefined,
|
||||
due_date: dueDate,
|
||||
original_due_date: r.original_due_date || r.due_date || undefined,
|
||||
source: "fristenrechner",
|
||||
rule_id: r.rule_id,
|
||||
notes: notes || undefined,
|
||||
audit_reason: auditReason(),
|
||||
});
|
||||
}
|
||||
|
||||
if (deadlines.length === 0 || !msg || !cta) return;
|
||||
cta.disabled = true;
|
||||
msg.textContent = "";
|
||||
msg.className = "fristen-overhaul-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deadlines }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = body.error || t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
return;
|
||||
}
|
||||
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
|
||||
msg.className = "fristen-overhaul-msg form-msg-ok";
|
||||
setTimeout(() => {
|
||||
if (cta) cta.disabled = false;
|
||||
}, 1500);
|
||||
} catch {
|
||||
msg.textContent = t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
|
||||
function auditReason(): string {
|
||||
if (!currentResponse) return "";
|
||||
const name = currentResponse.trigger.name_de;
|
||||
const date = currentResponse.trigger_date;
|
||||
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
|
||||
}
|
||||
|
||||
// Helpers -------------------------------------------------------------
|
||||
|
||||
export function defaultChecked(r: FollowUpRule): boolean {
|
||||
// Cross-party rows are unchecked by default — they describe what the
|
||||
// OTHER side files. They render to honestly show the workflow, but
|
||||
// the Akte write-back excludes them unconditionally (design §2.4).
|
||||
if (r.is_cross_party) return false;
|
||||
if (r.is_court_set) return false;
|
||||
if (r.is_spawn) return r.priority === "mandatory";
|
||||
if (r.has_condition) return false;
|
||||
return r.priority === "mandatory" || r.priority === "recommended";
|
||||
}
|
||||
|
||||
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
if (!r.duration_value || !r.duration_unit) return "";
|
||||
const unitDE: Record<string, string> = {
|
||||
days: "Tage",
|
||||
months: "Monate",
|
||||
weeks: "Wochen",
|
||||
years: "Jahre",
|
||||
};
|
||||
const unitEN: Record<string, string> = {
|
||||
days: "days",
|
||||
months: "months",
|
||||
weeks: "weeks",
|
||||
years: "years",
|
||||
};
|
||||
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
|
||||
return `${r.duration_value} ${u}`;
|
||||
}
|
||||
|
||||
function formatDateForLang(iso: string, lang: "de" | "en"): string {
|
||||
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
|
||||
if (!iso || iso.length < 10) return iso;
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return iso;
|
||||
if (lang === "en") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const idx = parseInt(m, 10) - 1;
|
||||
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
|
||||
return `${parseInt(d, 10)} ${mn} ${y}`;
|
||||
}
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
function eventKindIcon(kind?: string): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥"; // inbox/letter
|
||||
case "hearing": return "🏛️"; // courthouse
|
||||
case "decision": return "⚖️"; // scales
|
||||
case "order": return "📜"; // page
|
||||
default: return "📅"; // calendar
|
||||
}
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function syncUrlState(eventCode: string, triggerDate: string): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", eventCode);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { followUpsDifferByParty } from "./fristenrechner-wizard";
|
||||
|
||||
describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => {
|
||||
test("true when both claimant and defendant rules present", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(true);
|
||||
});
|
||||
test("false when all claimant", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "claimant" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when all defendant", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when only 'both' rules", () => {
|
||||
// "Both" rules are bilateral procedural moves (Vertraulichkeits-
|
||||
// Erwiderung); they don't gate R5 because either party can be
|
||||
// looking at them.
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "both" },
|
||||
{ primary_party: "both" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when only court rules", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "court" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("true when mixed with both / court alongside the asymmetric pair", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "both" },
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "court" },
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(true);
|
||||
});
|
||||
test("false on empty list", () => {
|
||||
expect(followUpsDifferByParty([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,711 +0,0 @@
|
||||
// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2).
|
||||
//
|
||||
// 3-5 question row stack that lands the user on one procedural_event
|
||||
// (the trigger), then transitions to the shared §4 result view.
|
||||
//
|
||||
// R1 Was ist passiert? (event_kind) always asked
|
||||
// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows
|
||||
// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option
|
||||
// R4 Welches Schriftstück? (procedural_event — land) always asked
|
||||
// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ
|
||||
//
|
||||
// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier".
|
||||
// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where
|
||||
// perspective is a qualifier).
|
||||
// Pre-fill + collapse rows from project (project.proceeding_type →
|
||||
// R3 + R2 derived; project.our_side → R5). Preserve compatible
|
||||
// downstream picks on back-navigation (§11.Q10).
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
|
||||
// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we
|
||||
// need; kept local so the wizard doesn't depend on Mode A.
|
||||
|
||||
interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
follow_up_count: number;
|
||||
}
|
||||
|
||||
interface EventSearchResponse {
|
||||
events: EventSearchHit[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProceedingChip {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
}
|
||||
|
||||
type Forum = "UPC" | "DE" | "EPA" | "DPMA";
|
||||
type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed";
|
||||
type WizardParty = "claimant" | "defendant";
|
||||
|
||||
// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by
|
||||
// fristenrechner-result.mountModeShell which creates the host element
|
||||
// under the overhaul root.
|
||||
const WIZARD_HOST_ID = "fristen-overhaul-mode-host";
|
||||
|
||||
// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists
|
||||
// so re-grouping happens in one place.
|
||||
const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"];
|
||||
const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"];
|
||||
|
||||
// Single wizard state. Module-local; one wizard at a time.
|
||||
interface WizardState {
|
||||
// Picks. "" = not answered. R5 only set when the question is asked.
|
||||
r1: EventKindRow | "";
|
||||
r2: Forum | "";
|
||||
r3: string; // proceeding_types.code
|
||||
r4: string; // procedural_events.code
|
||||
r5: WizardParty | "";
|
||||
|
||||
// Pre-fill provenance — when a pick came from the project context,
|
||||
// the row renders with an "aus Akte" tag so the user notices.
|
||||
r2FromProject: boolean;
|
||||
r3FromProject: boolean;
|
||||
r5FromProject: boolean;
|
||||
|
||||
// Implicit fills — R2 auto-derived from R1 when R1 narrows to one
|
||||
// forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but
|
||||
// if downstream R3 lookup returns a single forum we can mark R2 as
|
||||
// implicit).
|
||||
r2Implicit: boolean;
|
||||
r3Implicit: boolean;
|
||||
}
|
||||
|
||||
const state: WizardState = {
|
||||
r1: "", r2: "", r3: "", r4: "", r5: "",
|
||||
r2FromProject: false, r3FromProject: false, r5FromProject: false,
|
||||
r2Implicit: false, r3Implicit: false,
|
||||
};
|
||||
|
||||
// Loaded from the project (if any).
|
||||
let projectSummary: ProjectSummary | null = null;
|
||||
|
||||
// Proceeding chip cache key: jurisdiction × event_kind.
|
||||
let lastProcCacheKey = "";
|
||||
let cachedProcChips: ProceedingChip[] = [];
|
||||
|
||||
// Event chip cache: keyed on R3 code + R1 event_kind.
|
||||
let lastEventCacheKey = "";
|
||||
let cachedEventChips: EventSearchHit[] = [];
|
||||
|
||||
// Public API ---------------------------------------------------------
|
||||
|
||||
export async function mountWizard(): Promise<void> {
|
||||
const host = document.getElementById(WIZARD_HOST_ID);
|
||||
if (!host) return;
|
||||
|
||||
// Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…).
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.r1 = (params.get("kind") as EventKindRow) || "";
|
||||
state.r2 = (params.get("forum") as Forum) || "";
|
||||
state.r3 = params.get("pt") || "";
|
||||
state.r4 = params.get("event") || "";
|
||||
state.r5 = (params.get("party") as WizardParty) || "";
|
||||
|
||||
// Project prefills.
|
||||
const projectId = params.get("project");
|
||||
if (projectId) {
|
||||
projectSummary = await fetchProject(projectId);
|
||||
await applyProjectPrefills();
|
||||
} else {
|
||||
projectSummary = null;
|
||||
}
|
||||
|
||||
renderShell();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
// applyProjectPrefills derives R2 + R3 + R5 from the project when they
|
||||
// haven't been set explicitly. Project picks take precedence over
|
||||
// unspecified state, but a user-supplied URL pick wins over the
|
||||
// project default.
|
||||
async function applyProjectPrefills(): Promise<void> {
|
||||
if (!projectSummary) return;
|
||||
// Map our_side → R5.
|
||||
if (!state.r5) {
|
||||
const side = projectSummary.our_side;
|
||||
if (side === "claimant" || side === "applicant" || side === "appellant") {
|
||||
state.r5 = "claimant";
|
||||
state.r5FromProject = true;
|
||||
} else if (side === "defendant" || side === "respondent") {
|
||||
state.r5 = "defendant";
|
||||
state.r5FromProject = true;
|
||||
}
|
||||
}
|
||||
// Map proceeding_type_id → R3 + infer R2 jurisdiction.
|
||||
if (projectSummary.proceeding_type_id && !state.r3) {
|
||||
const pt = await fetchProceedingByID(projectSummary.proceeding_type_id);
|
||||
if (pt) {
|
||||
state.r3 = pt.code;
|
||||
state.r3FromProject = true;
|
||||
if (pt.group && !state.r2) {
|
||||
state.r2 = pt.group as Forum;
|
||||
state.r2FromProject = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render -------------------------------------------------------------
|
||||
|
||||
function renderShell(): void {
|
||||
const host = document.getElementById(WIZARD_HOST_ID);
|
||||
if (!host) return;
|
||||
host.innerHTML = `
|
||||
<div class="fristen-wizard-root">
|
||||
<header class="fristen-wizard-header">
|
||||
<h2 class="fristen-wizard-title">${escHtml(t("deadlines.overhaul.wizard.heading"))}</h2>
|
||||
<p class="fristen-wizard-hint">${escHtml(t("deadlines.overhaul.wizard.hint"))}</p>
|
||||
</header>
|
||||
<div class="fristen-wizard-rows" id="fristen-wizard-rows" aria-live="polite"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function renderRows(): Promise<void> {
|
||||
const host = document.getElementById("fristen-wizard-rows");
|
||||
if (!host) return;
|
||||
|
||||
// Resolve dynamic row prerequisites BEFORE building markup so chip
|
||||
// sets are populated.
|
||||
if (state.r1 && state.r2) {
|
||||
await ensureProceedingChips(state.r2, state.r1);
|
||||
// Auto-skip R3 when the narrowed pool has exactly one option.
|
||||
if (!state.r3 && cachedProcChips.length === 1) {
|
||||
state.r3 = cachedProcChips[0].code;
|
||||
state.r3Implicit = true;
|
||||
}
|
||||
}
|
||||
if (state.r1 && state.r3) {
|
||||
await ensureEventChips(state.r3, state.r1);
|
||||
}
|
||||
|
||||
const rows: string[] = [];
|
||||
rows.push(rowR1());
|
||||
if (shouldShowR2()) rows.push(rowR2());
|
||||
if (shouldShowR3()) rows.push(rowR3());
|
||||
if (shouldShowR4()) rows.push(rowR4());
|
||||
if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading());
|
||||
|
||||
host.innerHTML = rows.join("");
|
||||
wireRowEvents();
|
||||
|
||||
// R5 conditional check — fires after R4 picked. Inspects /follow-ups
|
||||
// to see whether they actually differ by party. If yes, show R5. If
|
||||
// no, or R5 already set, transition straight to result view.
|
||||
if (state.r4) {
|
||||
void maybeAdvanceFromR4();
|
||||
}
|
||||
}
|
||||
|
||||
// Should-show predicates --------------------------------------------
|
||||
|
||||
function shouldShowR2(): boolean {
|
||||
// Skip R2 only when R1 narrows to a single forum — which today
|
||||
// never happens for the closed event_kind set (every kind exists in
|
||||
// multiple jurisdictions). Always show R2 until we have empirical
|
||||
// evidence otherwise.
|
||||
return state.r1 !== "" && state.r1 !== "missed";
|
||||
}
|
||||
|
||||
function shouldShowR3(): boolean {
|
||||
if (state.r1 === "" || state.r2 === "") return false;
|
||||
if (state.r3 && state.r3Implicit) return true; // visible compact
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldShowR4(): boolean {
|
||||
return state.r3 !== "" && state.r1 !== "";
|
||||
}
|
||||
|
||||
// shouldShowR5Sync renders the placeholder row immediately; the actual
|
||||
// asked-or-not decision happens after the async follow-ups probe in
|
||||
// maybeAdvanceFromR4.
|
||||
function shouldShowR5Sync(): boolean {
|
||||
return state.r4 !== "";
|
||||
}
|
||||
|
||||
// Row builders ------------------------------------------------------
|
||||
|
||||
function rowR1(): string {
|
||||
const chips = EVENT_KINDS.map((k) => {
|
||||
const label = t(`deadlines.overhaul.kind.${k}` as never);
|
||||
const icon = eventKindIcon(k);
|
||||
return chipHtml("r1", k, label, state.r1 === k, icon);
|
||||
}).join("");
|
||||
return rowShell({
|
||||
n: 1,
|
||||
badge: "filter",
|
||||
label: t("deadlines.overhaul.wizard.r1.label"),
|
||||
active: !state.r1,
|
||||
answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR2(): string {
|
||||
const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join("");
|
||||
return rowShell({
|
||||
n: 2,
|
||||
badge: "filter",
|
||||
label: t("deadlines.overhaul.wizard.r2.label"),
|
||||
active: !state.r2,
|
||||
fromProject: state.r2FromProject,
|
||||
answeredText: state.r2 || "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR3(): string {
|
||||
if (cachedProcChips.length === 0) {
|
||||
return rowShell({
|
||||
n: 3, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r3.label"),
|
||||
active: true,
|
||||
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}</div>`,
|
||||
});
|
||||
}
|
||||
const lang = getLang();
|
||||
const chips = cachedProcChips.map((p) => {
|
||||
const label = lang === "en" ? p.nameEN || p.name : p.name;
|
||||
return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code);
|
||||
}).join("");
|
||||
let answered = "";
|
||||
if (state.r3) {
|
||||
const hit = cachedProcChips.find((p) => p.code === state.r3);
|
||||
if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name;
|
||||
}
|
||||
return rowShell({
|
||||
n: 3,
|
||||
badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r3.label"),
|
||||
active: !state.r3,
|
||||
fromProject: state.r3FromProject,
|
||||
implicit: state.r3Implicit,
|
||||
answeredText: answered,
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR4(): string {
|
||||
if (cachedEventChips.length === 0) {
|
||||
return rowShell({
|
||||
n: 4, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r4.label"),
|
||||
active: true,
|
||||
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}</div>`,
|
||||
});
|
||||
}
|
||||
const lang = getLang();
|
||||
const chips = cachedEventChips.map((e) => {
|
||||
const label = lang === "en" ? e.name_en || e.name_de : e.name_de;
|
||||
return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow));
|
||||
}).join("");
|
||||
let answered = "";
|
||||
if (state.r4) {
|
||||
const hit = cachedEventChips.find((e) => e.code === state.r4);
|
||||
if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de;
|
||||
}
|
||||
return rowShell({
|
||||
n: 4,
|
||||
badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r4.label"),
|
||||
active: !state.r4,
|
||||
answeredText: answered,
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR5Loading(): string {
|
||||
// Placeholder while we probe whether R5 is needed. The async
|
||||
// follow-ups probe replaces this with rowR5 chips or skips
|
||||
// straight to the result view.
|
||||
return rowShell({
|
||||
n: 5, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r5.label"),
|
||||
active: !state.r5,
|
||||
fromProject: state.r5FromProject,
|
||||
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
|
||||
body: `<div class="fristen-wizard-probe">${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR5Chips(): string {
|
||||
const chips = (["claimant", "defendant"] as const).map((p) =>
|
||||
chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join("");
|
||||
return rowShell({
|
||||
n: 5, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r5.label"),
|
||||
active: !state.r5,
|
||||
fromProject: state.r5FromProject,
|
||||
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
interface RowShellOpts {
|
||||
n: number;
|
||||
badge: "filter" | "qualifier";
|
||||
label: string;
|
||||
active: boolean;
|
||||
body: string;
|
||||
answeredText?: string;
|
||||
fromProject?: boolean;
|
||||
implicit?: boolean;
|
||||
}
|
||||
|
||||
function rowShell(o: RowShellOpts): string {
|
||||
const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` +
|
||||
(o.active ? " is-active" : " is-answered") +
|
||||
(o.fromProject ? " is-from-project" : "") +
|
||||
(o.implicit ? " is-implicit" : "");
|
||||
const badgeText = o.badge === "filter"
|
||||
? t("deadlines.overhaul.wizard.badge.filter")
|
||||
: t("deadlines.overhaul.wizard.badge.qualifier");
|
||||
const annotations: string[] = [];
|
||||
if (o.fromProject) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}</span>`);
|
||||
if (o.implicit) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}</span>`);
|
||||
const answered = o.answeredText
|
||||
? `<span class="fristen-wizard-row-answer">${escHtml(o.answeredText)}</span>`
|
||||
: "";
|
||||
const edit = !o.active
|
||||
? `<button type="button" class="fristen-wizard-row-edit" data-row="${o.n}">${escHtml(t("deadlines.overhaul.wizard.edit"))}</button>`
|
||||
: "";
|
||||
return `
|
||||
<section class="${cls}" data-row="${o.n}">
|
||||
<header class="fristen-wizard-row-header">
|
||||
<span class="fristen-wizard-row-n">${o.n}</span>
|
||||
<span class="fristen-wizard-row-badge fristen-wizard-row-badge--${o.badge}">${escHtml(badgeText)}</span>
|
||||
<span class="fristen-wizard-row-label">${escHtml(o.label)}</span>
|
||||
${annotations.join("")}
|
||||
${answered}
|
||||
${edit}
|
||||
</header>
|
||||
${o.active ? `<div class="fristen-wizard-row-body">${o.body}</div>` : ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event wiring ------------------------------------------------------
|
||||
|
||||
function wireRowEvents(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const axis = btn.dataset.axis || "";
|
||||
const value = btn.dataset.value || "";
|
||||
handleChip(axis, value);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row-edit").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const n = parseInt(btn.dataset.row || "0", 10);
|
||||
handleEdit(n);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleChip(axis: string, value: string): void {
|
||||
switch (axis) {
|
||||
case "r1": {
|
||||
if (state.r1 === value) return;
|
||||
state.r1 = value as EventKindRow;
|
||||
// R1 change resets R3/R4 (event-kind narrows the pools).
|
||||
state.r3 = "";
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
cachedProcChips = [];
|
||||
lastProcCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r2": {
|
||||
if (state.r2 === value) return;
|
||||
state.r2 = value as Forum;
|
||||
state.r2FromProject = false;
|
||||
state.r2Implicit = false;
|
||||
// R2 change may invalidate R3 → reset.
|
||||
state.r3 = "";
|
||||
state.r3FromProject = false;
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedProcChips = [];
|
||||
lastProcCacheKey = "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r3": {
|
||||
if (state.r3 === value) return;
|
||||
state.r3 = value;
|
||||
state.r3FromProject = false;
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r4": {
|
||||
if (state.r4 === value) return;
|
||||
state.r4 = value;
|
||||
break;
|
||||
}
|
||||
case "r5": {
|
||||
if (state.r5 === value) return;
|
||||
state.r5 = value as WizardParty;
|
||||
state.r5FromProject = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
syncUrl();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
function handleEdit(n: number): void {
|
||||
switch (n) {
|
||||
case 1:
|
||||
state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
cachedProcChips = []; lastProcCacheKey = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 2:
|
||||
state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false;
|
||||
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedProcChips = []; lastProcCacheKey = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 3:
|
||||
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 4:
|
||||
state.r4 = "";
|
||||
state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
break;
|
||||
case 5:
|
||||
state.r5 = ""; state.r5FromProject = false;
|
||||
break;
|
||||
}
|
||||
syncUrl();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
// maybeAdvanceFromR4 fetches /follow-ups for the picked event to
|
||||
// decide whether R5 is needed. If R5 is already set OR the
|
||||
// follow-ups don't differ by party, transition straight to the
|
||||
// result view. Else swap the R5 loading row for the chip picker.
|
||||
async function maybeAdvanceFromR4(): Promise<void> {
|
||||
if (!state.r4) return;
|
||||
if (state.r5) {
|
||||
// R5 already answered (project prefill or explicit pick) → go.
|
||||
void launchResult();
|
||||
return;
|
||||
}
|
||||
// Probe follow-ups.
|
||||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||||
url.searchParams.set("event", state.r4);
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
// Soft-fail → swap to R5 chips so the user can decide manually.
|
||||
swapR5(rowR5Chips());
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> };
|
||||
const differs = followUpsDifferByParty(data.follow_ups);
|
||||
if (!differs) {
|
||||
void launchResult();
|
||||
return;
|
||||
}
|
||||
swapR5(rowR5Chips());
|
||||
} catch {
|
||||
swapR5(rowR5Chips());
|
||||
}
|
||||
}
|
||||
|
||||
function swapR5(html: string): void {
|
||||
const host = document.getElementById("fristen-wizard-rows");
|
||||
if (!host) return;
|
||||
const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]');
|
||||
if (!r5) {
|
||||
host.insertAdjacentHTML("beforeend", html);
|
||||
} else {
|
||||
r5.outerHTML = html;
|
||||
}
|
||||
wireRowEvents();
|
||||
}
|
||||
|
||||
function launchResult(): void {
|
||||
// Hand off to the §4 result view. The URL already carries the
|
||||
// picks via syncUrl(); add event= so the boot path treats this
|
||||
// as a deep-link.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", state.r4);
|
||||
if (state.r5) url.searchParams.set("party", state.r5);
|
||||
else url.searchParams.delete("party");
|
||||
history.pushState(null, "", url.pathname + url.search + url.hash);
|
||||
void mountResultView({ eventRef: state.r4, party: state.r5 || undefined });
|
||||
}
|
||||
|
||||
export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean {
|
||||
let hasClaimant = false, hasDefendant = false;
|
||||
for (const r of rows) {
|
||||
if (r.primary_party === "claimant") hasClaimant = true;
|
||||
else if (r.primary_party === "defendant") hasDefendant = true;
|
||||
if (hasClaimant && hasDefendant) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetches -----------------------------------------------------------
|
||||
|
||||
async function fetchProject(id: string): Promise<ProjectSummary | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as ProjectSummary;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProceedingByID(id: number): Promise<ProceedingChip | null> {
|
||||
// The proceeding-types endpoint returns codes, names, jurisdictions
|
||||
// but doesn't carry the id (the wire shape FristenrechnerType is
|
||||
// code-keyed). Walk the unfiltered list and pick by sort-order
|
||||
// proximity / sort-fallback: we need the row whose id matches; since
|
||||
// the wire doesn't expose id, fetch the projects detail to get the
|
||||
// code directly. Cheap workaround: rely on /api/projects/{id}'s
|
||||
// proceeding_type_id being matched against the proceeding-types list
|
||||
// by jurisdiction round-trip is not possible without id. Instead
|
||||
// expose the proceeding-types-by-id mapping via a follow-up endpoint
|
||||
// later. For now hit the unfiltered list and assume the project's
|
||||
// pick is in the active set.
|
||||
//
|
||||
// Pragmatic fallback: query the full list and return the only entry
|
||||
// whose pseudo-id-via-sort-order matches. The lookup is unreliable
|
||||
// until the wire shape includes id; for the project-prefill case the
|
||||
// user can always re-pick R3 / R2 if the prefill misfires.
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) return null;
|
||||
const list = (await resp.json()) as ProceedingChip[] | null;
|
||||
if (!list || list.length === 0) return null;
|
||||
// Without id in the wire we cannot match by id. Skip the prefill
|
||||
// silently — R3 stays unanswered and the user picks manually.
|
||||
// (S5/follow-up can extend the wire shape to include id.)
|
||||
void id;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise<void> {
|
||||
const key = `${forum}\x00${kind}`;
|
||||
if (lastProcCacheKey === key) return;
|
||||
lastProcCacheKey = key;
|
||||
const url = new URL("/api/tools/proceeding-types", window.location.origin);
|
||||
url.searchParams.set("kind", "proceeding");
|
||||
url.searchParams.set("jurisdiction", forum);
|
||||
if (kind !== "missed") url.searchParams.set("event_kind", kind);
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
cachedProcChips = [];
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as ProceedingChip[] | null;
|
||||
cachedProcChips = data || [];
|
||||
} catch {
|
||||
cachedProcChips = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureEventChips(procCode: string, kind: EventKindRow): Promise<void> {
|
||||
const key = `${procCode}\x00${kind}`;
|
||||
if (lastEventCacheKey === key) return;
|
||||
lastEventCacheKey = key;
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("kind", "events");
|
||||
url.searchParams.set("proc", procCode);
|
||||
if (kind !== "missed") url.searchParams.set("event_kind", kind);
|
||||
url.searchParams.set("limit", "100");
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
cachedEventChips = [];
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as EventSearchResponse;
|
||||
cachedEventChips = data.events || [];
|
||||
} catch {
|
||||
cachedEventChips = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers -----------------------------------------------------------
|
||||
|
||||
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
|
||||
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
|
||||
const tt = title ? ` title="${escAttr(title)}"` : "";
|
||||
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
|
||||
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${tt}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
|
||||
}
|
||||
|
||||
function eventKindIcon(kind?: EventKindRow): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥";
|
||||
case "hearing": return "🏛️";
|
||||
case "decision": return "⚖️";
|
||||
case "order": return "📜";
|
||||
case "missed": return "⏲";
|
||||
default: return "📅";
|
||||
}
|
||||
}
|
||||
|
||||
function syncUrl(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("mode", "wizard");
|
||||
setOrClear(url, "kind", state.r1);
|
||||
setOrClear(url, "forum", state.r2);
|
||||
setOrClear(url, "pt", state.r3);
|
||||
// event=… is set only on launchResult; the wizard URL carries the
|
||||
// R4 candidate via r4= so back/forward navigates within the wizard.
|
||||
setOrClear(url, "r4", state.r4);
|
||||
setOrClear(url, "party", state.r5);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
function setOrClear(url: URL, key: string, val: string): void {
|
||||
if (val) url.searchParams.set(key, val);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,320 +0,0 @@
|
||||
// Per-event-card choice popover + chip indicator (t-paliad-265 /
|
||||
// m/paliad#96).
|
||||
//
|
||||
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
|
||||
// button on cards that carry a non-empty `choices_offered` declaration
|
||||
// and an inert chip span next to the title. This module:
|
||||
//
|
||||
// 1. Wires a delegated click handler on the result container so the
|
||||
// caret opens a popover with the offered choice-kinds.
|
||||
// 2. Commits the user's pick — either by POSTing to the project-
|
||||
// bound endpoint or by mutating the in-memory state for the
|
||||
// unbound (no-project) case.
|
||||
// 3. Rehydrates the chip on every render + after every commit so the
|
||||
// glanceable indicator matches the active state.
|
||||
//
|
||||
// Two consumer pages — /tools/verfahrensablauf (unbound) and
|
||||
// /tools/fristenrechner (project-bound) — both wire this module
|
||||
// once at boot via attachEventCardChoices().
|
||||
|
||||
import { escAttr, escHtml } from "./verfahrensablauf-core";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
|
||||
|
||||
export interface EventChoice {
|
||||
submission_code: string;
|
||||
choice_kind: ChoiceKind;
|
||||
choice_value: string;
|
||||
}
|
||||
|
||||
// State surface — the page passes in callbacks that own persistence.
|
||||
// commit / remove must trigger a recalc on the page side (the popover
|
||||
// only owns its own visual state).
|
||||
export interface EventCardChoicesOpts {
|
||||
container: HTMLElement;
|
||||
// Initial state: a list of choices. The page seeds this from the
|
||||
// server response (project-bound) or from URL params (unbound).
|
||||
initial: EventChoice[];
|
||||
// commit gets called for an UPSERT. The page POSTs to the API (or
|
||||
// mutates URL state) AND triggers a recalc.
|
||||
commit: (choice: EventChoice) => Promise<void> | void;
|
||||
// remove gets called when the user resets a choice.
|
||||
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// One mutable bag per attach() call. The current implementation is a
|
||||
// single-page singleton — paginated views (admin tables) are not in
|
||||
// scope. Last-write-wins on the in-memory state.
|
||||
interface AttachedState {
|
||||
opts: EventCardChoicesOpts;
|
||||
// active: submission_code → kind → value. Rebuilt from `initial`
|
||||
// on every reseed() call.
|
||||
active: Map<string, Map<ChoiceKind, string>>;
|
||||
popover: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, AttachedState>();
|
||||
|
||||
// attachEventCardChoices wires the delegated click + popover lifecycle
|
||||
// to the given container. Call once per page after mount; safe to call
|
||||
// again with a fresh container.
|
||||
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
const state: AttachedState = {
|
||||
opts,
|
||||
active: new Map(),
|
||||
popover: null,
|
||||
};
|
||||
for (const c of opts.initial) {
|
||||
if (!state.active.has(c.submission_code)) {
|
||||
state.active.set(c.submission_code, new Map());
|
||||
}
|
||||
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
|
||||
}
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, caret);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
if (state.popover && !state.popover.contains(e.target as Node)) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// ESC also closes.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.popover) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// Repaint chips on every renderResults() call. The page is
|
||||
// responsible for calling reseedChips() after re-render so the chip
|
||||
// dom node (re-created by the renderer) picks the active state up.
|
||||
reseedChips(opts.container);
|
||||
}
|
||||
|
||||
// reseedChips walks every chip span in the container and re-renders
|
||||
// its content from the active state map. Idempotent.
|
||||
export function reseedChips(container: HTMLElement): void {
|
||||
const state = states.get(container);
|
||||
if (!state) return;
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const kinds = state.active.get(code);
|
||||
if (!kinds || kinds.size === 0) {
|
||||
chip.innerHTML = "";
|
||||
chip.dataset.empty = "true";
|
||||
return;
|
||||
}
|
||||
chip.dataset.empty = "false";
|
||||
chip.innerHTML = renderChip(kinds);
|
||||
});
|
||||
// Skipped rows fade out via a class on the card-item ancestor.
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const skipped = state.active.get(code)?.get("skip") === "true";
|
||||
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
|
||||
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChip(kinds: Map<ChoiceKind, string>): string {
|
||||
const parts: string[] = [];
|
||||
if (kinds.get("skip") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
|
||||
}
|
||||
const ap = kinds.get("appellant");
|
||||
if (ap && ap !== "" ) {
|
||||
let label = "";
|
||||
switch (ap) {
|
||||
case "claimant": label = t("choices.appellant.claimant"); break;
|
||||
case "defendant": label = t("choices.appellant.defendant"); break;
|
||||
case "both": label = t("choices.appellant.both"); break;
|
||||
case "none": label = t("choices.appellant.none"); break;
|
||||
}
|
||||
if (label) {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
|
||||
}
|
||||
}
|
||||
if (kinds.get("include_ccr") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
closePopover(state);
|
||||
const code = caret.dataset.submissionCode || "";
|
||||
if (!code) return;
|
||||
let offered: Record<string, unknown> = {};
|
||||
try {
|
||||
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||
|
||||
const blocks: string[] = [];
|
||||
// t-paliad-293: hidden-card prominence. When the user opens the
|
||||
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
||||
// most likely intent — surface it as a single high-contrast action
|
||||
// at the top of the popover (rather than burying it under the skip
|
||||
// toggle's reset link). Clicking it clears the `skip` choice, which
|
||||
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
||||
if (isHidden) {
|
||||
blocks.push(renderUnhideBlock());
|
||||
}
|
||||
if (Array.isArray(offered.appellant)) {
|
||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||
}
|
||||
if (Array.isArray(offered.include_ccr)) {
|
||||
blocks.push(renderToggleBlock(state, code, "include_ccr"));
|
||||
}
|
||||
if (Array.isArray(offered.skip)) {
|
||||
blocks.push(renderToggleBlock(state, code, "skip"));
|
||||
}
|
||||
pop.innerHTML = blocks.join("");
|
||||
|
||||
document.body.appendChild(pop);
|
||||
state.popover = pop;
|
||||
positionPopover(pop, caret);
|
||||
|
||||
pop.addEventListener("click", async (e) => {
|
||||
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
|
||||
const value = btn.dataset.choiceValue || "";
|
||||
const action = btn.dataset.choiceAction;
|
||||
if (!kind) return;
|
||||
try {
|
||||
if (action === "set") {
|
||||
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
if (!state.active.has(code)) state.active.set(code, new Map());
|
||||
state.active.get(code)!.set(kind, value);
|
||||
} else if (action === "clear") {
|
||||
await state.opts.remove(code, kind);
|
||||
state.active.get(code)?.delete(kind);
|
||||
}
|
||||
reseedChips(state.opts.container);
|
||||
closePopover(state);
|
||||
} catch (err) {
|
||||
console.error("event card choice commit failed", err);
|
||||
// Surface a soft inline error inside the popover; do NOT close.
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "event-card-choices-error";
|
||||
errEl.textContent = t("choices.commit.error");
|
||||
pop.appendChild(errEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
|
||||
const current = state.active.get(code)?.get("appellant") || "";
|
||||
const buttons = values
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.map((v) => {
|
||||
const labelKey = `choices.appellant.${v}` as const;
|
||||
const isActive = v === current;
|
||||
return `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="appellant"
|
||||
data-choice-value="${escAttr(v)}"
|
||||
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
})
|
||||
.join("");
|
||||
const reset = current
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
|
||||
<div class="event-card-choices-options">${buttons}</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
|
||||
const current = state.active.get(code)?.get(kind) || "false";
|
||||
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
|
||||
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
|
||||
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
|
||||
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="${kind}"
|
||||
data-choice-value="${v}"
|
||||
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
const reset = state.active.get(code)?.has(kind)
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
|
||||
<div class="event-card-choices-options">
|
||||
${opt("true", trueKey)}
|
||||
${opt("false", falseKey)}
|
||||
</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
||||
// action — surfaced only when the caret is opened on a re-surfaced
|
||||
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
||||
// the same `clear` action as the skip-block reset link below, but
|
||||
// labelled in the user's terms ("restore this card" rather than
|
||||
// "reset skip choice"). Drops out of the popover automatically on
|
||||
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
||||
function renderUnhideBlock(): string {
|
||||
const label = t("choices.unhide.chip");
|
||||
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
||||
<button type="button"
|
||||
data-choice-action="clear"
|
||||
data-choice-kind="skip"
|
||||
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closePopover(state: AttachedState): void {
|
||||
if (state.popover) {
|
||||
state.popover.remove();
|
||||
state.popover = null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
|
||||
const rect = caret.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
pop.style.position = "absolute";
|
||||
pop.style.top = `${rect.bottom + scrollY + 4}px`;
|
||||
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
|
||||
pop.style.zIndex = "1000";
|
||||
}
|
||||
|
||||
// Returns the current in-memory choice list for the given container —
|
||||
// used by the unbound /tools/verfahrensablauf page to keep the URL
|
||||
// param in sync.
|
||||
export function currentChoices(container: HTMLElement): EventChoice[] {
|
||||
const state = states.get(container);
|
||||
if (!state) return [];
|
||||
const out: EventChoice[] = [];
|
||||
state.active.forEach((kinds, code) => {
|
||||
kinds.forEach((value, kind) => {
|
||||
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
|
||||
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
|
||||
//
|
||||
// The contract:
|
||||
// 1. URL params (proceeding, side, target, trigger_date) define which
|
||||
// timeline kind the user is looking at — paste-able, shareable,
|
||||
// refresh-resistant.
|
||||
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
|
||||
// per-user scenario tweaks (event_choices, court_id, flags,
|
||||
// show_hidden) — these never leak into a shared link.
|
||||
// 3. On hydrate, URL wins. localStorage fills the rest.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
SCENARIO_PREFIX,
|
||||
URL_KEYS,
|
||||
applyFiltersToSearch,
|
||||
hydrate,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
readScenario,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./verfahrensablauf-state";
|
||||
|
||||
describe("URL parsers — filter chips", () => {
|
||||
test("parseProceedingFromSearch returns empty string when absent", () => {
|
||||
expect(parseProceedingFromSearch("")).toBe("");
|
||||
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
|
||||
});
|
||||
|
||||
test("parseProceedingFromSearch echoes the raw value", () => {
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
|
||||
});
|
||||
|
||||
test("parseSideFromSearch validates the enum", () => {
|
||||
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
|
||||
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
|
||||
expect(parseSideFromSearch("?side=neither")).toBe(null);
|
||||
expect(parseSideFromSearch("")).toBe(null);
|
||||
});
|
||||
|
||||
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
|
||||
for (const t of APPEAL_TARGETS) {
|
||||
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
|
||||
}
|
||||
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
|
||||
expect(parseAppealTargetFromSearch("")).toBe("");
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
|
||||
expect(parseTriggerDateFromSearch("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL encoder — applyFiltersToSearch", () => {
|
||||
test("empty filters preserve the existing query string", () => {
|
||||
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
|
||||
});
|
||||
|
||||
test("setting a filter writes the canonical key", () => {
|
||||
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
|
||||
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
|
||||
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
|
||||
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("setting null / empty / undefined deletes the key", () => {
|
||||
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
|
||||
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
|
||||
});
|
||||
|
||||
test("invalid trigger_date is deleted (never written as-is)", () => {
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
|
||||
});
|
||||
|
||||
test("setting all four filters together emits all four keys", () => {
|
||||
const out = applyFiltersToSearch("", {
|
||||
proceeding: "upc.apl.unified",
|
||||
side: "defendant",
|
||||
target: "endentscheidung",
|
||||
triggerDate: "2026-05-26",
|
||||
});
|
||||
expect(out).toContain("proceeding=upc.apl.unified");
|
||||
expect(out).toContain("side=defendant");
|
||||
expect(out).toContain("target=endentscheidung");
|
||||
expect(out).toContain("trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("other params (project, view) are preserved", () => {
|
||||
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
|
||||
expect(out).toContain("project=abc");
|
||||
expect(out).toContain("view=timeline");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
|
||||
test("absent keys in the filter object don't touch existing URL values", () => {
|
||||
// Only updating side — proceeding should be untouched.
|
||||
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
|
||||
expect(out).toContain("proceeding=upc.inf.cfi");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL round-trip — encode then parse yields the same value", () => {
|
||||
test("proceeding", () => {
|
||||
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
|
||||
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
|
||||
});
|
||||
|
||||
test("side", () => {
|
||||
const enc = applyFiltersToSearch("", { side: "defendant" });
|
||||
expect(parseSideFromSearch(enc)).toBe("defendant");
|
||||
});
|
||||
|
||||
test("target", () => {
|
||||
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
|
||||
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
|
||||
});
|
||||
|
||||
test("trigger_date", () => {
|
||||
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
|
||||
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scenario localStorage helpers", () => {
|
||||
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
|
||||
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
|
||||
for (const key of Object.values(SCENARIO_KEYS)) {
|
||||
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("readEventChoices returns [] on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readEventChoices(s)).toEqual([]);
|
||||
});
|
||||
|
||||
test("writeEventChoices + readEventChoices round-trip", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const choices = [
|
||||
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
|
||||
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
|
||||
];
|
||||
writeEventChoices(s, choices);
|
||||
expect(readEventChoices(s)).toEqual(choices);
|
||||
});
|
||||
|
||||
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
|
||||
writeEventChoices(s, []);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
|
||||
});
|
||||
|
||||
test("readEventChoices ignores unknown choice_kind values", () => {
|
||||
const s = makeMemoryStorage();
|
||||
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
|
||||
expect(readEventChoices(s)).toEqual([
|
||||
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
|
||||
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readCourtId(s)).toBe("");
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(readCourtId(s)).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("writeCourtId('') removes the key", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
|
||||
writeCourtId(s, "");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
|
||||
});
|
||||
|
||||
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
|
||||
});
|
||||
|
||||
test("readScenario returns all fields defaulted on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readScenario(s)).toEqual({
|
||||
eventChoices: [],
|
||||
courtId: "",
|
||||
ccr: false,
|
||||
infAmend: false,
|
||||
revAmend: false,
|
||||
revCci: false,
|
||||
showHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hydration order — URL wins, localStorage fills the rest", () => {
|
||||
test("URL fills filter chips, localStorage fills scenario state", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
|
||||
s,
|
||||
);
|
||||
// URL-sourced
|
||||
expect(out.proceeding).toBe("upc.inf.cfi");
|
||||
expect(out.side).toBe("defendant");
|
||||
expect(out.target).toBe("endentscheidung");
|
||||
expect(out.triggerDate).toBe("2026-05-26");
|
||||
// localStorage-sourced
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
expect(out.showHidden).toBe(true);
|
||||
expect(out.ccr).toBe(true);
|
||||
});
|
||||
|
||||
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
const out = hydrate("", s);
|
||||
expect(out.proceeding).toBe("");
|
||||
expect(out.side).toBe(null);
|
||||
expect(out.target).toBe("");
|
||||
expect(out.triggerDate).toBe("");
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
|
||||
s,
|
||||
);
|
||||
expect(out.proceeding).toBe("upc.apl.unified");
|
||||
expect(out.side).toBe("claimant");
|
||||
expect(out.target).toBe("anordnung");
|
||||
expect(out.triggerDate).toBe("2026-07-01");
|
||||
expect(out.courtId).toBe("");
|
||||
expect(out.eventChoices).toEqual([]);
|
||||
expect(out.showHidden).toBe(false);
|
||||
});
|
||||
|
||||
test("a shared link doesn't leak the recipient's scenario state in", () => {
|
||||
// Two storages: m's (loaded with court + flags) and a recipient's
|
||||
// (empty). The same URL should reproduce filter chips identically
|
||||
// but leave each user's scenario state untouched.
|
||||
const mStorage = makeMemoryStorage();
|
||||
writeCourtId(mStorage, "UPC-LD-MUC");
|
||||
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
|
||||
const recipientStorage = makeMemoryStorage();
|
||||
|
||||
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
|
||||
|
||||
const mView = hydrate(sharedURL, mStorage);
|
||||
const recipientView = hydrate(sharedURL, recipientStorage);
|
||||
|
||||
// Filter chips identical
|
||||
expect(mView.proceeding).toBe(recipientView.proceeding);
|
||||
expect(mView.side).toBe(recipientView.side);
|
||||
expect(mView.triggerDate).toBe(recipientView.triggerDate);
|
||||
|
||||
// Scenario state diverges — recipient sees defaults
|
||||
expect(mView.courtId).toBe("UPC-LD-MUC");
|
||||
expect(recipientView.courtId).toBe("");
|
||||
expect(mView.ccr).toBe(true);
|
||||
expect(recipientView.ccr).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL key constants match the documented contract", () => {
|
||||
test("URL_KEYS uses the spec'd snake_case names", () => {
|
||||
expect(URL_KEYS.proceeding).toBe("proceeding");
|
||||
expect(URL_KEYS.side).toBe("side");
|
||||
expect(URL_KEYS.target).toBe("target");
|
||||
expect(URL_KEYS.triggerDate).toBe("trigger_date");
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
// /tools/verfahrensablauf URL + scenario-localStorage state contract
|
||||
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
|
||||
// two namespaces:
|
||||
//
|
||||
// URL params (filter chips — the timeline kind the user is looking
|
||||
// at; paste-able, shareable, refresh-resistant):
|
||||
// proceeding, side, target, trigger_date
|
||||
//
|
||||
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
|
||||
// scenario inputs — the noisy parts that don't belong in a URL):
|
||||
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
|
||||
// show_hidden
|
||||
//
|
||||
// Hydration order: URL wins. On page load, URL fills the filter chips;
|
||||
// localStorage fills the rest. Filter-chip changes write to URL only.
|
||||
// Scenario changes write to localStorage only. A shared link from a
|
||||
// colleague reproduces the timeline kind (proceeding + side + target +
|
||||
// trigger_date) but never leaks the recipient's court / flag /
|
||||
// event_choices state in.
|
||||
//
|
||||
// All helpers in this module are pure: they take a search string (or a
|
||||
// StorageLike) and return values, no DOM. The wiring in
|
||||
// ../verfahrensablauf.ts mounts them onto window.location +
|
||||
// window.localStorage at runtime.
|
||||
|
||||
import type { EventChoice, ChoiceKind } from "./event-card-choices";
|
||||
|
||||
// ----- URL params (filter chips) ----------------------------------
|
||||
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
export const URL_KEYS = {
|
||||
proceeding: "proceeding",
|
||||
side: "side",
|
||||
target: "target",
|
||||
triggerDate: "trigger_date",
|
||||
} as const;
|
||||
|
||||
// parseProceedingFromSearch extracts the proceeding code. Returns ""
|
||||
// if absent. No validation against the proceeding registry — that's
|
||||
// the caller's job (an unknown code from a stale link should leave
|
||||
// the first-tile auto-select fallback running).
|
||||
export function parseProceedingFromSearch(search: string): string {
|
||||
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
|
||||
return v ?? "";
|
||||
}
|
||||
|
||||
export function parseSideFromSearch(search: string): Side {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.side);
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
export function parseAppealTargetFromSearch(search: string): AppealTarget {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// parseTriggerDateFromSearch validates the ISO-date shape so a
|
||||
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
|
||||
// only. Round-tripped against Date to reject 2026-02-30 etc.
|
||||
export function parseTriggerDateFromSearch(search: string): string {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
|
||||
const d = new Date(raw + "T00:00:00Z");
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
if (d.toISOString().slice(0, 10) !== raw) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
// applyFiltersToSearch produces the canonical query string for the
|
||||
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
|
||||
// preserved verbatim. Empty values are deleted, never written as
|
||||
// empty string, so the URL stays clean on the default.
|
||||
export function applyFiltersToSearch(
|
||||
search: string,
|
||||
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if ("proceeding" in filters) {
|
||||
if (filters.proceeding && filters.proceeding !== "") {
|
||||
params.set(URL_KEYS.proceeding, filters.proceeding);
|
||||
} else {
|
||||
params.delete(URL_KEYS.proceeding);
|
||||
}
|
||||
}
|
||||
if ("side" in filters) {
|
||||
if (filters.side === "claimant" || filters.side === "defendant") {
|
||||
params.set(URL_KEYS.side, filters.side);
|
||||
} else {
|
||||
params.delete(URL_KEYS.side);
|
||||
}
|
||||
}
|
||||
if ("target" in filters) {
|
||||
if (filters.target && filters.target !== "") {
|
||||
params.set(URL_KEYS.target, filters.target);
|
||||
} else {
|
||||
params.delete(URL_KEYS.target);
|
||||
}
|
||||
}
|
||||
if ("triggerDate" in filters) {
|
||||
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
|
||||
params.set(URL_KEYS.triggerDate, filters.triggerDate);
|
||||
} else {
|
||||
params.delete(URL_KEYS.triggerDate);
|
||||
}
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ----- localStorage (scenario state) ------------------------------
|
||||
|
||||
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
|
||||
export const SCENARIO_KEYS = {
|
||||
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
|
||||
courtId: `${SCENARIO_PREFIX}.court_id`,
|
||||
ccr: `${SCENARIO_PREFIX}.ccr`,
|
||||
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
|
||||
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
|
||||
revCci: `${SCENARIO_PREFIX}.rev_cci`,
|
||||
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
|
||||
} as const;
|
||||
|
||||
// StorageLike is the tiny subset of the Web Storage API the scenario
|
||||
// helpers actually use. Lets the tests pass a Map-backed fake without
|
||||
// pulling in a full localStorage polyfill.
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
// readEventChoices is forgiving: malformed tuples or unknown
|
||||
// choice_kinds are dropped silently. Same shape as the legacy URL
|
||||
// codec (comma-separated `submission_code:kind=value`).
|
||||
export function readEventChoices(storage: StorageLike): EventChoice[] {
|
||||
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
|
||||
if (choices.length === 0) {
|
||||
storage.removeItem(SCENARIO_KEYS.eventChoices);
|
||||
return;
|
||||
}
|
||||
const enc = choices
|
||||
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
|
||||
.join(",");
|
||||
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
|
||||
}
|
||||
|
||||
// readCourtId / writeCourtId — empty string == no court picked. The
|
||||
// "" value is stored as a removed key, not an empty string entry, so
|
||||
// reading it back yields null rather than "".
|
||||
export function readCourtId(storage: StorageLike): string {
|
||||
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
|
||||
}
|
||||
|
||||
export function writeCourtId(storage: StorageLike, courtId: string): void {
|
||||
if (courtId === "") {
|
||||
storage.removeItem(SCENARIO_KEYS.courtId);
|
||||
return;
|
||||
}
|
||||
storage.setItem(SCENARIO_KEYS.courtId, courtId);
|
||||
}
|
||||
|
||||
// Boolean flags — "1" / "0" string encoding, removeItem on default
|
||||
// (false for flags, also false for show_hidden) so the storage stays
|
||||
// uncluttered on a fresh page.
|
||||
export function readBoolFlag(storage: StorageLike, key: string): boolean {
|
||||
return storage.getItem(key) === "1";
|
||||
}
|
||||
|
||||
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
|
||||
if (on) storage.setItem(key, "1");
|
||||
else storage.removeItem(key);
|
||||
}
|
||||
|
||||
// Read all scenario state in one call — convenience for the page's
|
||||
// load-time hydration. Caller decides whether to apply each field
|
||||
// (e.g. court_id is proceeding-specific; the page may discard the
|
||||
// stored value if the active proceeding doesn't expose a court row).
|
||||
export interface ScenarioState {
|
||||
eventChoices: EventChoice[];
|
||||
courtId: string;
|
||||
ccr: boolean;
|
||||
infAmend: boolean;
|
||||
revAmend: boolean;
|
||||
revCci: boolean;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function readScenario(storage: StorageLike): ScenarioState {
|
||||
return {
|
||||
eventChoices: readEventChoices(storage),
|
||||
courtId: readCourtId(storage),
|
||||
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
|
||||
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
|
||||
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
|
||||
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
|
||||
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
|
||||
};
|
||||
}
|
||||
|
||||
// ----- URL → localStorage hydration order -------------------------
|
||||
|
||||
// The page's load-time contract: read URL filters, then read
|
||||
// scenario state from localStorage. URL wins on conflict — but the
|
||||
// only field that can conflict is none of them today (URL owns
|
||||
// proceeding/side/target/trigger_date; localStorage owns the rest).
|
||||
// The order matters for one edge case: if a future field migrates
|
||||
// from URL → localStorage with overlap, the URL value MUST be honored.
|
||||
|
||||
export interface HydratedState extends ScenarioState {
|
||||
proceeding: string;
|
||||
side: Side;
|
||||
target: AppealTarget;
|
||||
triggerDate: string;
|
||||
}
|
||||
|
||||
export function hydrate(search: string, storage: StorageLike): HydratedState {
|
||||
const scenario = readScenario(storage);
|
||||
return {
|
||||
proceeding: parseProceedingFromSearch(search),
|
||||
side: parseSideFromSearch(search),
|
||||
target: parseAppealTargetFromSearch(search),
|
||||
triggerDate: parseTriggerDateFromSearch(search),
|
||||
...scenario,
|
||||
};
|
||||
}
|
||||
|
||||
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
|
||||
// Not used by the runtime page (which mounts real localStorage), but
|
||||
// kept here so test files have one well-known import.
|
||||
export function makeMemoryStorage(): StorageLike {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k, v) => { store.set(k, v); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { h } from "../jsx";
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
|
||||
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
|
||||
// unified "Berufung" tile (upc.apl). After picking it, the user
|
||||
// selects which decision the appeal is directed AT via the
|
||||
// .appeal-target-row chip group below — the engine then filters
|
||||
// rules whose applies_to_target contains the picked slug.
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
];
|
||||
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
// Shared Verfahrensablauf wizard body. Renders the proceeding picker,
|
||||
// perspective + date inputs, scenario flag rows, detail-mode toggle,
|
||||
// view toggle, and the timeline-container that client/verfahrensablauf.ts
|
||||
// (via initVerfahrensablauf()) wires against. Used by both
|
||||
// /tools/verfahrensablauf (legacy) and /tools/procedures (unified).
|
||||
export function VerfahrensablaufBody({ todayIso }: { todayIso: string }): string {
|
||||
return (
|
||||
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
|
||||
</h3>
|
||||
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="side-radio-cluster" id="side-radio-cluster">
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
|
||||
</label>
|
||||
</div>
|
||||
<span className="side-hint" id="side-hint"
|
||||
data-i18n="deadlines.side.hint">
|
||||
Wählen Sie eine Seite, um die Spalten zu fokussieren.
|
||||
</span>
|
||||
</div>
|
||||
<div className="side-chip" id="side-chip" style="display:none">
|
||||
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
|
||||
<strong className="side-chip-value" id="side-chip-value">—</strong>
|
||||
<button type="button" className="side-chip-override" id="side-chip-override"
|
||||
data-i18n="deadlines.side.override">
|
||||
Andere Seite wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="anordnung" />
|
||||
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
|
||||
<label className="fristen-view-option">
|
||||
<input type="checkbox" id="show-hidden-toggle" />
|
||||
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
|
||||
</label>
|
||||
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={todayIso} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="inf-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patentänderung (R.30)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patentänderung (R.49.2.a)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-cci-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="verfahrensablauf-detail-toggle" id="verfahrensablauf-detail-toggle"
|
||||
role="radiogroup" aria-label="Detail">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.detail.label">Anzeige:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="mandatory_only" />
|
||||
<span data-i18n="deadlines.detail.mandatory_only">Nur Pflicht</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="selected" checked />
|
||||
<span data-i18n="deadlines.detail.selected">Gewählt</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="all_options" />
|
||||
<span data-i18n="deadlines.detail.all_options">Alle Optionen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="verfahrensablauf-durations-show" />
|
||||
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user