Replaces the misleading Proaktiv/Reaktiv column pair with a static
"Unsere Seite" / "Gericht" / "Gegnerseite" axis ("WE always on the
left", per m's t-paliad-257 ask). The side toggle now drives row
PLACEMENT into the ours/opponent buckets — the column labels stay
truthful regardless of which physical party occupies them.
Old framing lied half the time: Klägerseite is sometimes proactive
(filing the claim) and sometimes reactive (responding to a CCR),
so "Proaktiv (Klägerseite)" was wrong whenever the user's perspective
flipped. New axis is purely positional with semantic labels.
Changes:
- frontend/src/client/views/verfahrensablauf-core.ts:
• ColumnsRow fields proactive/reactive → ours/opponent.
• renderColumnsBody picks static "Unsere Seite" / "Gegnerseite"
labels — no more variant-by-side label keys.
• bucketDeadlinesIntoColumns routes the user's party into `ours`
when opts.side ∈ {"defendant"}; default (null) keeps the legacy
"we are claimant" fallback so claimant-on-left layout survives.
- verfahrensablauf-core.test.ts: rewritten expectations on the new
ours/opponent fields. Added two new tests pinning the WE-on-left
semantics and the side+appellant interaction (side=defendant +
appellant=claimant → "both" collapses into opponent).
- fristenrechner.ts: wires currentPerspective into renderColumnsBody
as `side` so the columns honour the chip-strip perspective.
Without this, a defendant-perspective user would see claimant
filings under the "Unsere Seite" header — the old code didn't
need the wire-up because the labels weren't perspective-aware.
- i18n.ts: replaces deadlines.col.proactive(.defendant) +
deadlines.col.reactive(.claimant) with deadlines.col.ours +
deadlines.col.opponent ("Unsere Seite"/"Client Side",
"Gegnerseite"/"Opponent Side"). Court key unchanged.
- i18n-keys.ts: regenerated key union.
- global.css: .fr-col-proactive/.fr-col-reactive renamed to
.fr-col-ours/.fr-col-opponent.
Out of scope (kept intact):
- Side and appellant URL-state plumbing.
- Appellant selector for Appeal-type proceedings (separate axis).
- Project-default side-from-our_side wiring — /tools/verfahrensablauf
has no project context, and /tools/fristenrechner already does this
via applyOurSidePredefine().
Build: bun run build clean (2794 keys), go build ./... clean.
Tests: 112 frontend tests pass (was 110, +2 new); all Go tests
cached green.
3940 lines
162 KiB
TypeScript
3940 lines
162 KiB
TypeScript
// Fristenrechner client-side logic
|
||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||
//
|
||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
|
||
// date editor) live in `./views/verfahrensablauf-core` and are shared
|
||
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
|
||
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
|
||
// wants.
|
||
|
||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||
import { initSidebar } from "./sidebar";
|
||
import { projectIndent } from "./project-indent";
|
||
import {
|
||
type CalculatedDeadline,
|
||
type DeadlineResponse,
|
||
calculateDeadlines,
|
||
escAttr,
|
||
escHtml,
|
||
formatDate,
|
||
populateCourtPicker as populateCourtPickerCore,
|
||
priorityRendering,
|
||
renderColumnsBody,
|
||
renderTimelineBody,
|
||
wireDateEditClicks,
|
||
} from "./views/verfahrensablauf-core";
|
||
|
||
let lastResponse: DeadlineResponse | null = null;
|
||
|
||
// User overrides for individual rule due-dates (rule.code → YYYY-MM-DD).
|
||
// Set by the click-to-edit affordance on each timeline / column row;
|
||
// posted as `anchorOverrides` to /api/tools/fristenrechner so downstream
|
||
// rules re-anchor on the user's date instead of the calculator's
|
||
// projection. Cleared whenever the trigger changes (proceeding type,
|
||
// trigger date, flag toggle) so a fresh calc starts unanchored.
|
||
const anchorOverrides = new Map<string, string>();
|
||
|
||
function clearAnchorOverrides() {
|
||
anchorOverrides.clear();
|
||
}
|
||
|
||
// Auto-calc plumbing: a sequence counter prevents stale fetches from clobbering
|
||
// fresher results, and a single timer debounces rapid input changes.
|
||
let procCalcSeq = 0;
|
||
let procCalcTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function scheduleProcCalc(delayMs = 200) {
|
||
if (procCalcTimer !== null) clearTimeout(procCalcTimer);
|
||
procCalcTimer = setTimeout(() => {
|
||
procCalcTimer = null;
|
||
void calculate();
|
||
}, delayMs);
|
||
}
|
||
|
||
type ProcedureView = "timeline" | "columns";
|
||
// m's 2026-05-08 18:26 ask: Column view by default, Timeline opt-in.
|
||
// The proactive / court / reactive grid reads more naturally for the
|
||
// HLC team than the single vertical line.
|
||
let procedureView: ProcedureView = "columns";
|
||
|
||
// Notes toggle — off by default; per-rule notes render as a compact
|
||
// ⓘ hover icon. Flipped on, they expand under each card. Choice is
|
||
// localStorage-persisted (paliad.fristen.notes-show key shared with
|
||
// /tools/verfahrensablauf so the preference carries across both).
|
||
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
||
function readNotesPref(): boolean {
|
||
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
||
}
|
||
function writeNotesPref(on: boolean): void {
|
||
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||
}
|
||
let showNotes = readNotesPref();
|
||
|
||
onLangChange(() => {
|
||
if (lastResponse) renderProcedureResults(lastResponse);
|
||
// Update trigger event name if a proceeding is selected
|
||
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
||
if (activeBtn) {
|
||
const name = activeBtn.querySelector("strong")?.textContent || "";
|
||
document.getElementById("trigger-event")!.textContent = name;
|
||
}
|
||
});
|
||
|
||
// formatDate / partyBadge / formatDateSpan / localizeVacationName /
|
||
// localizeWeekday / renderAdjustmentReason / formatAdjustedNote moved to
|
||
// ./views/verfahrensablauf-core so /tools/verfahrensablauf can share them.
|
||
// (t-paliad-179 Slice 1)
|
||
|
||
|
||
// Short date span like "27.7.–28.8." (DE) or "27 Jul – 28 Aug" (EN). Used in
|
||
// the vacation adjustment label, where the explicit weekday + year would
|
||
// just be noise — the surrounding sentence carries the full year via the
|
||
// dueDate / originalDate that the note brackets.
|
||
|
||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||
// vacation"). The Fristenrechner doesn't translate them: they're proper
|
||
// names of court-set closures, not generic strings, and rotating them via
|
||
// i18n.ts duplicates state that should live in the DB. Rename in the seed
|
||
// if the wording needs to change.
|
||
|
||
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
|
||
// (27.7.–28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
|
||
|
||
// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
|
||
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
|
||
// structured reason — keeps older API responses readable.
|
||
|
||
let selectedType = "";
|
||
|
||
function showStep(n: number) {
|
||
for (let i = 1; i <= 3; i++) {
|
||
const el = document.getElementById(`step-${i}`);
|
||
if (el) el.style.display = i <= n ? "block" : "none";
|
||
}
|
||
const resetBtn = document.getElementById("reset-btn")!;
|
||
resetBtn.style.display = n > 1 ? "block" : "none";
|
||
}
|
||
|
||
async function calculate() {
|
||
const seq = ++procCalcSeq;
|
||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
|
||
const triggerDate = dateInput.value;
|
||
if (!triggerDate || !selectedType) return;
|
||
|
||
// Priority date — only meaningful for epa.grant.exa (Art. 93 EPÜ publish-anchor).
|
||
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
|
||
const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : "";
|
||
|
||
// Flags — proceeding-specific checkboxes:
|
||
// upc.inf.cfi: with_ccr (always available); with_amend (nested under
|
||
// with_ccr — R.30 application is only available with a CCR).
|
||
// upc.rev.cfi: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
|
||
// independent gates; both can be on simultaneously.
|
||
// R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18 call): it's
|
||
// an always-available optional submission, surfaced as priority='optional'
|
||
// without a separate checkbox.
|
||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
|
||
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
|
||
const flags: string[] = [];
|
||
if (selectedType === "upc.inf.cfi") {
|
||
if (ccrFlag?.checked) flags.push("with_ccr");
|
||
if (ccrFlag?.checked && infAmendFlag?.checked) flags.push("with_amend");
|
||
}
|
||
if (selectedType === "upc.rev.cfi") {
|
||
if (revAmendFlag?.checked) flags.push("with_amend");
|
||
if (revCciFlag?.checked) flags.push("with_cci");
|
||
}
|
||
|
||
// Forward any user-set per-rule date overrides so downstream rules
|
||
// re-anchor off them. Empty map → omitted from the payload.
|
||
const overrides: Record<string, string> = {};
|
||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||
|
||
// Court picker — only meaningful when the picker row is visible
|
||
// (multi-court proceeding types). When hidden, server resolves the
|
||
// default for the proceeding's jurisdiction.
|
||
const courtPickerRow = document.getElementById("court-picker-row");
|
||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
|
||
? courtPicker.value
|
||
: "";
|
||
|
||
const data = await calculateDeadlines({
|
||
proceedingType: selectedType,
|
||
triggerDate,
|
||
priorityDate,
|
||
flags,
|
||
anchorOverrides: overrides,
|
||
courtId,
|
||
});
|
||
if (seq !== procCalcSeq) return;
|
||
if (!data) return;
|
||
lastResponse = data;
|
||
renderProcedureResults(data);
|
||
showStep(3);
|
||
}
|
||
|
||
interface ProjectOption {
|
||
id: string;
|
||
reference?: string | null;
|
||
title: string;
|
||
path: string;
|
||
// proceeding_type_id is on every paliad.projects row; the JSON
|
||
// already includes it. We just declare the field so the Determinator
|
||
// (Slice 3b) can scope the cascade by the project's jurisdiction
|
||
// without an extra fetch.
|
||
proceeding_type_id?: number | null;
|
||
// our_side carries which side the firm represents on this case
|
||
// project (Client Role; t-paliad-164, widened in t-paliad-222).
|
||
// When a user selects an Akte, the perspective chip pre-locks via
|
||
// ourSideToPerspective(); a small hint above the strip flags the
|
||
// pre-selection and the user can still click another chip to
|
||
// override. NULL/undefined leaves the chip unset (free-pick).
|
||
our_side?:
|
||
| "claimant"
|
||
| "defendant"
|
||
| "applicant"
|
||
| "appellant"
|
||
| "respondent"
|
||
| "third_party"
|
||
| "other"
|
||
| null;
|
||
}
|
||
|
||
async function fetchProjects(): Promise<ProjectOption[]> {
|
||
try {
|
||
const resp = await fetch("/api/projects");
|
||
if (!resp.ok) return [];
|
||
return (await resp.json()) as ProjectOption[];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function ensureSaveModal() {
|
||
if (document.getElementById("frist-save-modal")) return;
|
||
const modal = document.createElement("div");
|
||
modal.id = "frist-save-modal";
|
||
modal.className = "modal-overlay";
|
||
modal.style.display = "none";
|
||
modal.innerHTML = `
|
||
<div class="modal-card">
|
||
<div class="modal-header">
|
||
<h2 data-i18n="deadlines.save.modal.title">${escHtml(t("deadlines.save.modal.title"))}</h2>
|
||
<button class="modal-close" id="frist-save-modal-close" type="button">×</button>
|
||
</div>
|
||
<div class="form-field">
|
||
<label for="frist-save-project" data-i18n="deadlines.save.modal.akte">${escHtml(t("deadlines.save.modal.akte"))}</label>
|
||
<select id="frist-save-project"></select>
|
||
<p class="form-hint" id="frist-save-no-akten" style="display:none">
|
||
<span data-i18n="deadlines.save.modal.no_akten">${escHtml(t("deadlines.save.modal.no_akten"))}</span>
|
||
<a href="/projects/new" data-i18n="deadlines.save.modal.no_akten.link">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>
|
||
</p>
|
||
</div>
|
||
<div class="form-field">
|
||
<p data-i18n="deadlines.save.modal.choose">${escHtml(t("deadlines.save.modal.choose"))}</p>
|
||
<ul class="frist-save-list" id="frist-save-list"></ul>
|
||
<p class="form-hint" data-i18n="deadlines.save.skip_court_set">${escHtml(t("deadlines.save.skip_court_set"))}</p>
|
||
</div>
|
||
<p class="form-msg" id="frist-save-msg"></p>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn-cancel" id="frist-save-cancel" data-i18n="deadlines.save.modal.cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
|
||
<button type="button" class="btn-primary btn-cta-lime" id="frist-save-submit" data-i18n="deadlines.save.modal.submit">${escHtml(t("deadlines.save.modal.submit"))}</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
|
||
modal.addEventListener("click", (e) => {
|
||
if (e.target === e.currentTarget) closeSaveModal();
|
||
});
|
||
document.getElementById("frist-save-modal-close")!.addEventListener("click", closeSaveModal);
|
||
document.getElementById("frist-save-cancel")!.addEventListener("click", closeSaveModal);
|
||
document.getElementById("frist-save-submit")!.addEventListener("click", submitSave);
|
||
}
|
||
|
||
function closeSaveModal() {
|
||
const modal = document.getElementById("frist-save-modal");
|
||
if (modal) modal.style.display = "none";
|
||
}
|
||
|
||
// preselectedProjectId returns the project the user picked in Step 1
|
||
// (if any) so the various save/add flows can default their project
|
||
// pickers to it. Carries through anywhere a "save to Akte" pop-out
|
||
// renders \u2014 preselection is *only* a default; the picker still
|
||
// renders every available project and the user can override.
|
||
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
|
||
// project should be pre-selected" on Add.
|
||
function preselectedProjectId(): string {
|
||
return currentStep1Context.kind === "project" && currentStep1Context.projectId
|
||
? currentStep1Context.projectId
|
||
: "";
|
||
}
|
||
|
||
async function openSaveModal() {
|
||
if (!lastResponse) return;
|
||
ensureSaveModal();
|
||
const projects = await fetchProjects();
|
||
const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
|
||
const noProjects = document.getElementById("frist-save-no-akten")!;
|
||
const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
|
||
|
||
if (projects.length === 0) {
|
||
sel.style.display = "none";
|
||
noProjects.style.display = "";
|
||
submit.disabled = true;
|
||
} else {
|
||
sel.style.display = "";
|
||
noProjects.style.display = "none";
|
||
submit.disabled = false;
|
||
const preselected = preselectedProjectId();
|
||
sel.innerHTML = projects
|
||
.map((p) => {
|
||
const ref = (p.reference || "").trim();
|
||
const indent = projectIndent(p.path);
|
||
const label = ref
|
||
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
|
||
: `${indent}${escHtml(p.title)}`;
|
||
const selected = p.id === preselected ? " selected" : "";
|
||
return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
|
||
})
|
||
.join("");
|
||
if (preselected) sel.value = preselected;
|
||
}
|
||
|
||
const list = document.getElementById("frist-save-list")!;
|
||
list.innerHTML = lastResponse.deadlines
|
||
.map((dl, idx) => {
|
||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||
// Court-determined entries (interim conference, oral hearing, decision,
|
||
// any party=court row) have no calculable date — disable + pre-uncheck
|
||
// so users don't save the trigger-date placeholder as a real deadline.
|
||
const isCourtDetermined = dl.isCourtSet || dl.party === "court";
|
||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: priority drives
|
||
// the save-modal pre-check + the "no save action" notice-card
|
||
// render. priorityRendering falls back to the legacy
|
||
// (isMandatory, isOptional) pair semantic for pre-Slice-8
|
||
// responses; new responses carry `priority` directly.
|
||
const pr = priorityRendering(dl);
|
||
if (pr.hideSave) {
|
||
// informational rules render as notice cards — no checkbox, no
|
||
// save button, distinct visual tier. The 18 F/F filing rules
|
||
// (Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
|
||
// currently fall here once they're flipped to 'informational' by
|
||
// editorial review; today they're 'recommended' so this branch
|
||
// remains exercised only by future rule edits.
|
||
return `<li class="frist-save-row frist-save-row--notice">
|
||
<span class="frist-save-notice-label">${escHtml(t("deadlines.priority.informational.notice_label"))}</span>
|
||
<span class="frist-save-title">${escHtml(dlName)}</span>
|
||
<span class="frist-save-meta">${escHtml(t("deadlines.priority.informational"))}</span>
|
||
</li>`;
|
||
}
|
||
const disabled = isCourtDetermined || !dl.dueDate;
|
||
const checked = !disabled && pr.preChecked;
|
||
// Same direct-vs-indirect split as the timeline date cell —
|
||
// chained court-set rules read as "unbestimmt" rather than
|
||
// "wird vom Gericht bestimmt".
|
||
const courtLabelKey = dl.isCourtSetIndirect ? "deadlines.court.indirect" : "deadlines.court.set";
|
||
const optionalBadge = dl.priority === "optional" && !isCourtDetermined
|
||
? `<span class="frist-save-optional">${escHtml(t("deadlines.optional.badge"))}</span>`
|
||
: "";
|
||
const meta = isCourtDetermined
|
||
? `<span class="frist-save-meta">${escHtml(t(courtLabelKey))}</span>`
|
||
: `<span class="frist-save-meta">${escHtml(formatDate(dl.dueDate))}${optionalBadge}</span>`;
|
||
return `<li class="frist-save-row">
|
||
<label>
|
||
<input type="checkbox" data-idx="${idx}" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
|
||
<span class="frist-save-title">${escHtml(dlName)}</span>
|
||
${meta}
|
||
</label>
|
||
</li>`;
|
||
})
|
||
.join("");
|
||
|
||
document.getElementById("frist-save-msg")!.textContent = "";
|
||
document.getElementById("frist-save-modal")!.style.display = "flex";
|
||
}
|
||
|
||
async function submitSave() {
|
||
if (!lastResponse) return;
|
||
const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
|
||
const projectID = sel.value;
|
||
const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
|
||
const msg = document.getElementById("frist-save-msg")!;
|
||
if (!projectID) return;
|
||
|
||
const checks = document.querySelectorAll<HTMLInputElement>("#frist-save-list input[type=checkbox]");
|
||
const deadlinesPayload: Array<Record<string, unknown>> = [];
|
||
checks.forEach((cb) => {
|
||
if (!cb.checked || cb.disabled) return;
|
||
const idx = Number(cb.dataset.idx);
|
||
const dl = lastResponse!.deadlines[idx];
|
||
if (!dl || !dl.dueDate) return;
|
||
const isEN = getLang() === "en";
|
||
const dlName = isEN ? dl.nameEN : dl.name;
|
||
const dlNotes = isEN ? (dl.notesEN || dl.notes) : dl.notes;
|
||
deadlinesPayload.push({
|
||
title: dlName,
|
||
rule_code: dl.ruleRef || undefined,
|
||
due_date: dl.dueDate,
|
||
original_due_date: dl.originalDate || undefined,
|
||
source: "fristenrechner",
|
||
notes: dlNotes || undefined,
|
||
});
|
||
});
|
||
if (deadlinesPayload.length === 0) return;
|
||
|
||
submit.disabled = true;
|
||
try {
|
||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ deadlines: deadlinesPayload }),
|
||
});
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||
msg.textContent = data.error || t("deadlines.save.error");
|
||
msg.className = "form-msg form-msg-error";
|
||
submit.disabled = false;
|
||
return;
|
||
}
|
||
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
|
||
msg.className = "form-msg form-msg-ok";
|
||
// Re-enable after a short delay so user can read it; modal stays open with the link.
|
||
setTimeout(() => {
|
||
submit.disabled = false;
|
||
}, 1500);
|
||
} catch {
|
||
msg.textContent = t("deadlines.save.error");
|
||
msg.className = "form-msg form-msg-error";
|
||
submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Render the result panel using whichever view is currently active. The
|
||
// timeline view is the historical default; the columns view (t-paliad-127)
|
||
// arranges deadlines into Proactive / Court / Reactive vertical lanes so the
|
||
// reader can see who acts when across the whole proceeding.
|
||
function renderProcedureResults(data: DeadlineResponse) {
|
||
const container = document.getElementById("timeline-container")!;
|
||
const printBtn = document.getElementById("fristen-print-btn")!;
|
||
const saveBtn = document.getElementById("fristen-save-cta") as HTMLButtonElement | null;
|
||
const toggle = document.getElementById("fristen-view-toggle");
|
||
|
||
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
||
const headerHtml = `<div class="timeline-header">
|
||
<strong>${procName}</strong>
|
||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||
</div>`;
|
||
|
||
// Pass the chip-strip perspective through as `side` so the column
|
||
// bucketer keeps the user's own party on the left (Unsere Seite) —
|
||
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
|
||
// was on the defendant side, the new labels demand we route the
|
||
// user's party into the `ours` column.
|
||
const bodyHtml = procedureView === "columns"
|
||
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
|
||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||
|
||
container.innerHTML = headerHtml + bodyHtml;
|
||
printBtn.style.display = "block";
|
||
if (saveBtn) {
|
||
// Ad-hoc explore-mode has no project to save against — show the
|
||
// CTA disabled with a hint so the user understands why the action
|
||
// is blocked (m's 2026-05-08 Slice 1 lock #2). Hiding it would
|
||
// leave the user wondering where the save went.
|
||
saveBtn.style.display = "block";
|
||
if (isAdhocMode()) {
|
||
saveBtn.disabled = true;
|
||
saveBtn.title = t("deadlines.save.cta.adhoc.hint");
|
||
saveBtn.dataset.adhocDisabled = "true";
|
||
} else {
|
||
saveBtn.disabled = false;
|
||
saveBtn.removeAttribute("title");
|
||
delete saveBtn.dataset.adhocDisabled;
|
||
}
|
||
}
|
||
if (toggle) toggle.style.display = "";
|
||
|
||
applyPendingFocus();
|
||
}
|
||
|
||
// onDateEditCommit is the click-to-edit callback handed to the shared
|
||
// wireDateEditClicks() helper: persist the per-rule override (empty value
|
||
// clears it) then recompute so downstream rules re-anchor.
|
||
function onDateEditCommit(ruleCode: string, newValue: string) {
|
||
if (newValue === "") {
|
||
anchorOverrides.delete(ruleCode);
|
||
} else {
|
||
anchorOverrides.set(ruleCode, newValue);
|
||
}
|
||
void calculate();
|
||
}
|
||
|
||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
|
||
// openInlineDateEditor / wireDateEditClicks moved to
|
||
// ./views/verfahrensablauf-core.
|
||
|
||
function reset() {
|
||
selectedType = "";
|
||
lastResponse = null;
|
||
clearAnchorOverrides();
|
||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||
document.getElementById("timeline-container")!.innerHTML = "";
|
||
document.getElementById("fristen-print-btn")!.style.display = "none";
|
||
const saveBtn = document.getElementById("fristen-save-cta");
|
||
if (saveBtn) saveBtn.style.display = "none";
|
||
const toggle = document.getElementById("fristen-view-toggle");
|
||
if (toggle) toggle.style.display = "none";
|
||
setProceedingPickerCollapsed(false);
|
||
showStep(1);
|
||
}
|
||
|
||
// Collapses / expands the four-group proceeding-picker block. Once the
|
||
// user picks, the picker collapses to a one-line summary with a
|
||
// Reselect button so the wizard's vertical real-estate goes to the
|
||
// trigger date + flags + result. m's 2026-05-08 18:26 ask.
|
||
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
|
||
const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
|
||
const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
|
||
const summaryName = document.getElementById("proceeding-summary-name");
|
||
groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
|
||
if (summary) summary.style.display = collapsed ? "" : "none";
|
||
if (summaryName && displayName) summaryName.textContent = displayName;
|
||
}
|
||
|
||
function selectProceeding(btn: HTMLButtonElement) {
|
||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
// Different proceeding tree → previous overrides reference codes that
|
||
// don't exist in the new tree. Clear before the next calc.
|
||
if (selectedType !== btn.dataset.code) clearAnchorOverrides();
|
||
selectedType = btn.dataset.code!;
|
||
|
||
// Update trigger event name
|
||
const name = btn.querySelector("strong")?.textContent || "";
|
||
document.getElementById("trigger-event")!.textContent = name;
|
||
|
||
// Conditional inputs:
|
||
// priority-date → epa.grant.exa
|
||
// ccr-flag → upc.inf.cfi only
|
||
// inf-amend-flag → upc.inf.cfi only, but disabled until ccr-flag is on
|
||
// (R.30 amend only available with a CCR)
|
||
// rev-amend-flag → upc.rev.cfi only
|
||
// rev-cci-flag → upc.rev.cfi only
|
||
const priorityRow = document.getElementById("priority-date-row");
|
||
if (priorityRow) priorityRow.style.display = selectedType === "epa.grant.exa" ? "" : "none";
|
||
const ccrRow = document.getElementById("ccr-flag-row");
|
||
if (ccrRow) ccrRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
|
||
const infAmendRow = document.getElementById("inf-amend-flag-row");
|
||
if (infAmendRow) infAmendRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
|
||
const revAmendRow = document.getElementById("rev-amend-flag-row");
|
||
if (revAmendRow) revAmendRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
|
||
const revCciRow = document.getElementById("rev-cci-flag-row");
|
||
if (revCciRow) revCciRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
|
||
|
||
syncInfAmendEnabled();
|
||
populateCourtPickerCore("court-picker-row", "court-picker", selectedType);
|
||
|
||
// Hide the four group blocks; show the compact summary in their place.
|
||
setProceedingPickerCollapsed(true, name);
|
||
|
||
showStep(2);
|
||
scheduleProcCalc(0);
|
||
}
|
||
|
||
// Court-picker primitives (CourtRow / courtCache / courtTypesFor /
|
||
// defaultCourtFor / fetchCourts / populateCourtPicker) moved to
|
||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||
// is filed within the Defence to CCR). When ccr-flag flips off, also
|
||
// untick inf-amend-flag so the calc payload stays coherent.
|
||
function syncInfAmendEnabled() {
|
||
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||
if (!ccr || !infAmend) return;
|
||
infAmend.disabled = !ccr.checked;
|
||
if (!ccr.checked) infAmend.checked = false;
|
||
}
|
||
|
||
// View toggle wiring. Persist the choice in `?view=…` so reload / share-link
|
||
// restores the same layout.
|
||
function initViewToggle() {
|
||
const toggle = document.getElementById("fristen-view-toggle");
|
||
if (!toggle) return;
|
||
|
||
// Read initial state from URL. Default flipped (m 2026-05-08 18:26):
|
||
// Column is the default; ?view=timeline opts back into the legacy
|
||
// single-column layout. URL only carries the value when it differs
|
||
// from the default so share links stay clean.
|
||
const initial = new URLSearchParams(window.location.search).get("view");
|
||
if (initial === "timeline") procedureView = "timeline";
|
||
|
||
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
||
input.checked = input.value === procedureView;
|
||
input.addEventListener("change", () => {
|
||
if (!input.checked) return;
|
||
procedureView = input.value === "columns" ? "columns" : "timeline";
|
||
const url = new URL(window.location.href);
|
||
if (procedureView === "columns") {
|
||
url.searchParams.delete("view");
|
||
} else {
|
||
url.searchParams.set("view", procedureView);
|
||
}
|
||
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||
if (lastResponse) renderProcedureResults(lastResponse);
|
||
});
|
||
});
|
||
|
||
// Hidden until step 3 renders.
|
||
toggle.style.display = "none";
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initI18n();
|
||
initSidebar();
|
||
|
||
// Proceeding type selection
|
||
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
|
||
btn.addEventListener("click", () => selectProceeding(btn));
|
||
});
|
||
|
||
// Reselect: re-expand the picker without throwing away the rest of
|
||
// the wizard state. Trigger date / flags / result stay visible until
|
||
// the user actually picks a new proceeding type.
|
||
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
|
||
setProceedingPickerCollapsed(false);
|
||
});
|
||
|
||
// Calculate button — manual force-recalc affordance.
|
||
document.getElementById("calculate-btn")!.addEventListener("click", () => scheduleProcCalc(0));
|
||
|
||
// Auto-recalc on input changes. Date `change` covers picker and blur;
|
||
// `input` covers manual typing. The Enter key on the date field bypasses
|
||
// debounce for keyboard-savvy users.
|
||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
|
||
dateInput.addEventListener("change", () => scheduleProcCalc());
|
||
dateInput.addEventListener("input", () => scheduleProcCalc());
|
||
dateInput.addEventListener("keydown", (e) => {
|
||
if ((e as KeyboardEvent).key === "Enter") scheduleProcCalc(0);
|
||
});
|
||
|
||
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
|
||
if (priorityInput) {
|
||
priorityInput.addEventListener("change", () => scheduleProcCalc());
|
||
priorityInput.addEventListener("input", () => scheduleProcCalc());
|
||
}
|
||
|
||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||
if (ccrFlag) ccrFlag.addEventListener("change", () => {
|
||
syncInfAmendEnabled();
|
||
scheduleProcCalc(0);
|
||
});
|
||
|
||
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||
if (infAmendFlag) infAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
|
||
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
|
||
if (revAmendFlag) revAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
|
||
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
|
||
if (revCciFlag) revCciFlag.addEventListener("change", () => scheduleProcCalc(0));
|
||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleProcCalc(0));
|
||
|
||
// Click-to-edit on timeline / column dates: open an inline date input
|
||
// and persist the user's choice as an anchor override so downstream
|
||
// rules re-anchor on the user's date. Delegated on the container so
|
||
// it survives renderProcedureResults() innerHTML rewrites.
|
||
const timelineContainer = document.getElementById("timeline-container");
|
||
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
|
||
|
||
// Reset button
|
||
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
||
|
||
// Print button
|
||
document.getElementById("fristen-print-btn")!.addEventListener("click", () => window.print());
|
||
|
||
// Save-to-Project CTA (Phase E)
|
||
const saveBtn = document.getElementById("fristen-save-cta");
|
||
if (saveBtn) saveBtn.addEventListener("click", openSaveModal);
|
||
|
||
// Notes toggle — restores last preference on load + re-renders when
|
||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||
if (notesShowCb) {
|
||
notesShowCb.checked = showNotes;
|
||
notesShowCb.addEventListener("change", () => {
|
||
showNotes = notesShowCb.checked;
|
||
writeNotesPref(showNotes);
|
||
if (lastResponse) renderProcedureResults(lastResponse);
|
||
});
|
||
}
|
||
|
||
// View toggle (timeline vs. columns layout) for procedure mode.
|
||
initViewToggle();
|
||
|
||
// Tab switching between "Verfahrensablauf" and "Was kommt nach…" modes.
|
||
initModeTabs();
|
||
|
||
// Event-mode wiring (PR-2: youpc-parity trigger-event lookup)
|
||
initEventMode();
|
||
|
||
// Pre-select the first proceeding button so the deadline list renders
|
||
// immediately on page load — no click on "Fristen berechnen" required.
|
||
// This also fires the first auto-calc via scheduleProcCalc().
|
||
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||
if (firstBtn) selectProceeding(firstBtn);
|
||
});
|
||
|
||
// ============================================================================
|
||
// Mode tabs (procedure vs event)
|
||
// ============================================================================
|
||
|
||
function initModeTabs() {
|
||
const tabs = document.querySelectorAll<HTMLButtonElement>(".mode-tab");
|
||
if (tabs.length === 0) return;
|
||
|
||
tabs.forEach((tab) => {
|
||
tab.addEventListener("click", () => {
|
||
const mode = tab.dataset.mode;
|
||
if (!mode) return;
|
||
tabs.forEach((t) => {
|
||
const isActive = t === tab;
|
||
t.classList.toggle("is-active", isActive);
|
||
t.setAttribute("aria-selected", String(isActive));
|
||
});
|
||
document.querySelectorAll<HTMLElement>(".mode-panel").forEach((panel) => {
|
||
panel.hidden = panel.dataset.mode !== mode;
|
||
});
|
||
// Auto-calc on tab activation. Procedure mode self-bootstraps via the
|
||
// pre-selected proceeding button on init, so the only special case is
|
||
// event mode: pick a default trigger event the first time the tab is
|
||
// shown so step 3 isn't empty.
|
||
if (mode === "event") ensureDefaultTriggerEvent();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Event mode: pick a trigger event, see all deadlines that flow from it.
|
||
// Mirrors youpc.org's deadline calculator. Uses /api/tools/trigger-events
|
||
// (list) and /api/tools/event-deadlines (compute).
|
||
// ============================================================================
|
||
|
||
interface TriggerEventSummary {
|
||
id: number;
|
||
code: string;
|
||
name: string;
|
||
name_de: string;
|
||
}
|
||
|
||
interface EventDeadlineResult {
|
||
id: number;
|
||
title: string;
|
||
titleDE: string;
|
||
durationValue: number;
|
||
durationUnit: string;
|
||
timing: string;
|
||
notes?: string;
|
||
notesEN?: string;
|
||
ruleCodes: string[];
|
||
dueDate: string;
|
||
originalDueDate: string;
|
||
wasAdjusted: boolean;
|
||
isComposite?: boolean;
|
||
compositeNote?: string;
|
||
}
|
||
|
||
interface EventCalculateResponse {
|
||
triggerEvent: TriggerEventSummary;
|
||
triggerDate: string;
|
||
deadlines: EventDeadlineResult[];
|
||
}
|
||
|
||
let triggerEvents: TriggerEventSummary[] = [];
|
||
let selectedTrigger: TriggerEventSummary | null = null;
|
||
let lastEventResponse: EventCalculateResponse | null = null;
|
||
|
||
// Auto-calc plumbing for event mode — same shape as procedure mode.
|
||
let eventCalcSeq = 0;
|
||
let eventCalcTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function scheduleEventCalc(delayMs = 200) {
|
||
if (eventCalcTimer !== null) clearTimeout(eventCalcTimer);
|
||
eventCalcTimer = setTimeout(() => {
|
||
eventCalcTimer = null;
|
||
void calculateEvent();
|
||
}, delayMs);
|
||
}
|
||
|
||
function showEventStep(n: number) {
|
||
for (let i = 1; i <= 3; i++) {
|
||
const el = document.getElementById(`event-step-${i}`);
|
||
if (el) el.style.display = i <= n ? "block" : "none";
|
||
}
|
||
const resetBtn = document.getElementById("event-reset-btn");
|
||
if (resetBtn) resetBtn.style.display = n > 1 ? "block" : "none";
|
||
}
|
||
|
||
function eventName(ev: TriggerEventSummary): string {
|
||
// Fall back to English when name_de is empty (default seed has empty DE).
|
||
return getLang() === "de" && ev.name_de ? ev.name_de : ev.name;
|
||
}
|
||
|
||
function deadlineTitle(d: EventDeadlineResult): string {
|
||
return getLang() === "de" && d.titleDE ? d.titleDE : d.title;
|
||
}
|
||
|
||
function unitLabel(unit: string, value: number): string {
|
||
const key = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
|
||
return tDyn(key);
|
||
}
|
||
|
||
function timingLabel(timing: string): string {
|
||
return tDyn(`deadlines.event.timing.${timing}`);
|
||
}
|
||
|
||
function renderEventList(query: string) {
|
||
const list = document.getElementById("event-list");
|
||
if (!list) return;
|
||
const q = query.trim().toLowerCase();
|
||
const matches = q
|
||
? triggerEvents.filter(
|
||
(ev) =>
|
||
ev.name.toLowerCase().includes(q) ||
|
||
(ev.name_de && ev.name_de.toLowerCase().includes(q)) ||
|
||
ev.code.toLowerCase().includes(q),
|
||
)
|
||
: triggerEvents;
|
||
|
||
if (matches.length === 0) {
|
||
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.empty"))}</li>`;
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = matches
|
||
.map(
|
||
(ev) =>
|
||
`<li class="event-list-item" role="option" data-id="${ev.id}" tabindex="0">${escHtml(eventName(ev))}</li>`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
async function loadTriggerEvents() {
|
||
const list = document.getElementById("event-list");
|
||
if (!list) return;
|
||
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.loading"))}</li>`;
|
||
try {
|
||
const resp = await fetch("/api/tools/trigger-events");
|
||
if (!resp.ok) {
|
||
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.error"))}</li>`;
|
||
return;
|
||
}
|
||
triggerEvents = (await resp.json()) as TriggerEventSummary[];
|
||
renderEventList("");
|
||
// If the user already switched to the event tab while the list was
|
||
// loading, pre-select now so they don't see an empty step 1.
|
||
const eventTab = document.getElementById("mode-event-tab");
|
||
if (eventTab?.classList.contains("is-active")) ensureDefaultTriggerEvent();
|
||
} catch {
|
||
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.error"))}</li>`;
|
||
}
|
||
}
|
||
|
||
function selectTriggerEvent(id: number) {
|
||
const ev = triggerEvents.find((e) => e.id === id);
|
||
if (!ev) return;
|
||
selectedTrigger = ev;
|
||
const nameEl = document.getElementById("event-selected-name");
|
||
if (nameEl) nameEl.textContent = eventName(ev);
|
||
showEventStep(2);
|
||
scheduleEventCalc(0);
|
||
}
|
||
|
||
async function calculateEvent() {
|
||
const seq = ++eventCalcSeq;
|
||
if (!selectedTrigger) return;
|
||
const dateInput = document.getElementById("event-date") as HTMLInputElement | null;
|
||
const triggerDate = dateInput?.value;
|
||
if (!triggerDate) return;
|
||
|
||
try {
|
||
const resp = await fetch("/api/tools/event-deadlines", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ triggerEventId: selectedTrigger.id, triggerDate }),
|
||
});
|
||
if (seq !== eventCalcSeq) return;
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
console.error("event API error:", err);
|
||
return;
|
||
}
|
||
const data = (await resp.json()) as EventCalculateResponse;
|
||
if (seq !== eventCalcSeq) return;
|
||
lastEventResponse = data;
|
||
renderEventResults(data);
|
||
showEventStep(3);
|
||
} catch (e) {
|
||
console.error("event fetch error:", e);
|
||
}
|
||
}
|
||
|
||
function renderEventResults(data: EventCalculateResponse) {
|
||
const container = document.getElementById("event-results-container");
|
||
const printBtn = document.getElementById("event-print-btn");
|
||
if (!container) return;
|
||
|
||
if (data.deadlines.length === 0) {
|
||
container.innerHTML = `<p class="event-results-empty">${escHtml(t("deadlines.event.noresults"))}</p>`;
|
||
if (printBtn) printBtn.style.display = "none";
|
||
return;
|
||
}
|
||
|
||
const triggerName = eventName(data.triggerEvent);
|
||
const triggerDate = formatDate(data.triggerDate);
|
||
|
||
const rowsHtml = data.deadlines
|
||
.map((d) => {
|
||
const codes = d.ruleCodes
|
||
.map((c) => `<span class="event-rule-code">${escHtml(c)}</span>`)
|
||
.join(" ");
|
||
const original = d.wasAdjusted
|
||
? `<div class="event-result-adjusted">${escHtml(
|
||
tDyn("deadlines.event.adjusted") + " " + formatDate(d.originalDueDate),
|
||
)}</div>`
|
||
: "";
|
||
const composite = d.isComposite && d.compositeNote
|
||
? `<div class="event-result-composite" title="${escAttr(d.compositeNote)}">${escHtml(t("deadlines.event.composite.label"))} ${escHtml(d.compositeNote)}</div>`
|
||
: "";
|
||
const noteText = getLang() === "en" ? (d.notesEN || d.notes) : d.notes;
|
||
const notes = noteText
|
||
? `<div class="event-result-notes">${escHtml(noteText)}</div>`
|
||
: "";
|
||
return `<li class="event-result-row">
|
||
<div class="event-result-header">
|
||
<span class="event-result-title">${escHtml(deadlineTitle(d))}</span>
|
||
<span class="event-result-date">${escHtml(formatDate(d.dueDate))}</span>
|
||
</div>
|
||
<div class="event-result-meta">
|
||
<span class="event-result-duration">${d.durationValue} ${escHtml(unitLabel(d.durationUnit, d.durationValue))} ${escHtml(timingLabel(d.timing))}</span>
|
||
${codes}
|
||
</div>
|
||
${composite}
|
||
${original}
|
||
${notes}
|
||
</li>`;
|
||
})
|
||
.join("");
|
||
|
||
container.innerHTML = `
|
||
<div class="event-results-header">
|
||
<div><strong>${escHtml(t("deadlines.event.results.trigger"))}</strong> ${escHtml(triggerName)}</div>
|
||
<div><strong>${escHtml(t("deadlines.event.results.date"))}</strong> ${escHtml(triggerDate)}</div>
|
||
</div>
|
||
<ul class="event-result-list">${rowsHtml}</ul>`;
|
||
|
||
if (printBtn) printBtn.style.display = "block";
|
||
}
|
||
|
||
function resetEventMode() {
|
||
selectedTrigger = null;
|
||
lastEventResponse = null;
|
||
const search = document.getElementById("event-search") as HTMLInputElement | null;
|
||
if (search) search.value = "";
|
||
renderEventList("");
|
||
showEventStep(1);
|
||
}
|
||
|
||
function initEventMode() {
|
||
const search = document.getElementById("event-search") as HTMLInputElement | null;
|
||
if (!search) return; // page didn't render the event mode (older bundle)
|
||
|
||
loadTriggerEvents();
|
||
|
||
search.addEventListener("input", () => renderEventList(search.value));
|
||
|
||
const list = document.getElementById("event-list");
|
||
if (list) {
|
||
list.addEventListener("click", (e) => {
|
||
const target = (e.target as HTMLElement).closest<HTMLLIElement>(".event-list-item");
|
||
if (!target) return;
|
||
const id = Number(target.dataset.id);
|
||
if (!Number.isFinite(id)) return;
|
||
selectTriggerEvent(id);
|
||
});
|
||
list.addEventListener("keydown", (e) => {
|
||
const ke = e as KeyboardEvent;
|
||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||
const target = (ke.target as HTMLElement).closest<HTMLLIElement>(".event-list-item");
|
||
if (!target) return;
|
||
ke.preventDefault();
|
||
const id = Number(target.dataset.id);
|
||
if (Number.isFinite(id)) selectTriggerEvent(id);
|
||
});
|
||
}
|
||
|
||
// Event-tab calculate button — manual force-recalc affordance.
|
||
document.getElementById("event-calculate-btn")?.addEventListener("click", () => scheduleEventCalc(0));
|
||
|
||
// Auto-recalc when the user changes the event date.
|
||
const eventDate = document.getElementById("event-date") as HTMLInputElement | null;
|
||
if (eventDate) {
|
||
eventDate.addEventListener("change", () => scheduleEventCalc());
|
||
eventDate.addEventListener("input", () => scheduleEventCalc());
|
||
eventDate.addEventListener("keydown", (e) => {
|
||
if ((e as KeyboardEvent).key === "Enter") scheduleEventCalc(0);
|
||
});
|
||
}
|
||
|
||
document.getElementById("event-reset-btn")?.addEventListener("click", resetEventMode);
|
||
document.getElementById("event-print-btn")?.addEventListener("click", () => window.print());
|
||
}
|
||
|
||
// Pre-select the first trigger event so the "Was kommt nach" tab renders
|
||
// immediately with default selection + today's date. Idempotent — only fires
|
||
// when there's no existing selection. Called both after the event list loads
|
||
// and when the user first activates the event tab, since either one can be
|
||
// the trigger depending on which finishes first.
|
||
function ensureDefaultTriggerEvent() {
|
||
if (selectedTrigger || triggerEvents.length === 0) return;
|
||
selectTriggerEvent(triggerEvents[0].id);
|
||
}
|
||
|
||
// Re-render event results when language flips (titles/notes are bilingual).
|
||
onLangChange(() => {
|
||
if (lastEventResponse) renderEventResults(lastEventResponse);
|
||
if (selectedTrigger) {
|
||
const nameEl = document.getElementById("event-selected-name");
|
||
if (nameEl) nameEl.textContent = eventName(selectedTrigger);
|
||
}
|
||
if (triggerEvents.length > 0) {
|
||
const search = document.getElementById("event-search") as HTMLInputElement | null;
|
||
renderEventList(search?.value || "");
|
||
}
|
||
});
|
||
|
||
// ============================================================================
|
||
// Search bar (t-paliad-131 Phase D) — concept-card UI on top of /api/tools/
|
||
// fristenrechner/search. Augments the proceeding tile grid: type a phrase
|
||
// (Klageerwiderung, RoP 23, § 82, Wiedereinsetzung), see ranked concept
|
||
// cards with one pill per (proceeding × rule) or per cross-cutting trigger.
|
||
// Click a pill → drill into the right calculator mode pre-selected.
|
||
// ============================================================================
|
||
|
||
interface SearchProceeding {
|
||
code: string;
|
||
name_de: string;
|
||
name_en: string;
|
||
jurisdiction: string;
|
||
}
|
||
|
||
interface SearchPillDuration {
|
||
value: number;
|
||
unit: string;
|
||
timing?: string;
|
||
}
|
||
|
||
interface SearchPill {
|
||
kind: "rule" | "trigger";
|
||
rule_id?: string;
|
||
trigger_event_id?: number;
|
||
proceeding?: SearchProceeding;
|
||
rule_local_code: string;
|
||
rule_name_de: string;
|
||
rule_name_en: string;
|
||
legal_source?: string;
|
||
legal_source_display?: string;
|
||
duration?: SearchPillDuration;
|
||
party: string;
|
||
drill_url: string;
|
||
}
|
||
|
||
interface SearchConcept {
|
||
id: string;
|
||
slug: string;
|
||
name_de: string;
|
||
name_en: string;
|
||
description?: string;
|
||
party?: string;
|
||
category: string;
|
||
}
|
||
|
||
interface SearchCard {
|
||
concept: SearchConcept;
|
||
matched_aliases?: string[];
|
||
score: number;
|
||
pills: SearchPill[];
|
||
}
|
||
|
||
interface SearchResponse {
|
||
query: string;
|
||
filters: { party: string | null; proc: string | null; source: string | null };
|
||
cards: SearchCard[];
|
||
total_cards: number;
|
||
total_pills: number;
|
||
}
|
||
|
||
// Debounced search dispatch — see scheduleProcCalc / scheduleEventCalc for
|
||
// the same shape used by the existing modes.
|
||
let searchSeq = 0;
|
||
let searchDebounce: number | undefined;
|
||
function scheduleSearch(delayMs = 180) {
|
||
if (searchDebounce !== undefined) clearTimeout(searchDebounce);
|
||
searchDebounce = window.setTimeout(runSearch, delayMs);
|
||
}
|
||
|
||
async function runSearch() {
|
||
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
|
||
const results = document.getElementById("fristen-search-results") as HTMLDivElement | null;
|
||
const clearBtn = document.getElementById("fristen-search-clear") as HTMLButtonElement | null;
|
||
if (!input || !results) return;
|
||
|
||
const q = input.value.trim();
|
||
// URL state — single source of truth for shareable searches. Strip the
|
||
// param when the input is empty so the URL stays clean.
|
||
syncSearchURL(q);
|
||
if (clearBtn) clearBtn.hidden = q.length === 0;
|
||
|
||
if (q === "") {
|
||
results.innerHTML = "";
|
||
results.classList.remove("is-loading", "is-empty", "is-no-hits");
|
||
return;
|
||
}
|
||
|
||
results.classList.remove("is-empty", "is-no-hits");
|
||
results.classList.add("is-loading");
|
||
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
|
||
|
||
const seq = ++searchSeq;
|
||
let resp: Response;
|
||
try {
|
||
{
|
||
const searchURL = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||
searchURL.searchParams.set("q", q);
|
||
searchURL.searchParams.set("limit", "12");
|
||
const forums = getActiveForumsParam();
|
||
if (forums) searchURL.searchParams.set("forum", forums);
|
||
resp = await fetch(searchURL.toString(), { credentials: "same-origin" });
|
||
}
|
||
} catch {
|
||
if (seq !== searchSeq) return;
|
||
results.classList.remove("is-loading");
|
||
results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
|
||
return;
|
||
}
|
||
if (seq !== searchSeq) return;
|
||
results.classList.remove("is-loading");
|
||
if (!resp.ok) {
|
||
results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
|
||
return;
|
||
}
|
||
const data = (await resp.json()) as SearchResponse;
|
||
if (seq !== searchSeq) return;
|
||
renderSearchResults(data);
|
||
}
|
||
|
||
function renderSearchResults(data: SearchResponse) {
|
||
renderSearchResultsInto("fristen-search-results", data);
|
||
}
|
||
|
||
// renderSearchResultsInto writes a SearchResponse into the named
|
||
// container. Used both by the B2 search bar (target: fristen-search-results)
|
||
// and by the B1 decision tree (target: fristen-b1-results, t-paliad-134).
|
||
function renderSearchResultsInto(containerId: string, data: SearchResponse) {
|
||
const results = document.getElementById(containerId);
|
||
if (!results) return;
|
||
|
||
if (data.cards.length === 0) {
|
||
results.classList.add("is-no-hits");
|
||
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.no_hits"))}</div>`;
|
||
return;
|
||
}
|
||
results.classList.remove("is-no-hits");
|
||
|
||
const lang = getLang();
|
||
const countLabel = data.total_cards === 1
|
||
? t("deadlines.search.results.count_one")
|
||
: t("deadlines.search.results.count").replace("{n}", String(data.total_cards));
|
||
|
||
const cardsHtml = data.cards.map((c) => renderConceptCard(c, lang)).join("");
|
||
|
||
results.innerHTML = `
|
||
<div class="fristen-search-summary">${escHtml(countLabel)}</div>
|
||
<div class="fristen-search-cards">${cardsHtml}</div>`;
|
||
}
|
||
|
||
// wirePillClicks attaches the v4 card-click → inline calc-panel handler
|
||
// to a results container. Idempotent across re-renders because the
|
||
// listener lives on the container, not on individual pill anchors.
|
||
//
|
||
// v4 (t-paliad-136 Phase B): the primary click action on ANY card —
|
||
// header, pill, or body — expands the card with an inline calc panel
|
||
// (trigger-date input + flag checkboxes + computed deadline + add-to-
|
||
// project CTA). Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve
|
||
// the legacy drill-to-Pathway-A semantics via the `<a href="…">`
|
||
// fallback (browser default new-tab behaviour). Trigger pills still
|
||
// drill to the youpc-style trigger-event picker on plain click — those
|
||
// concepts (Wiedereinsetzung, Weiterbehandlung) don't have a single
|
||
// rule to compute against, so the inline calc panel doesn't apply.
|
||
function wirePillClicks(container: HTMLElement) {
|
||
if (container.dataset.pillClicksWired === "1") return;
|
||
container.dataset.pillClicksWired = "1";
|
||
container.addEventListener("click", (e) => {
|
||
// Clicks inside an open calc panel are handled by their own
|
||
// listeners — do not re-trigger the expand/collapse logic.
|
||
if ((e.target as HTMLElement).closest(".fristen-card-calc")) return;
|
||
|
||
const pill = (e.target as HTMLElement).closest<HTMLAnchorElement>(".fristen-pill");
|
||
const card = (e.target as HTMLElement).closest<HTMLElement>(".fristen-card");
|
||
if (!card) return;
|
||
|
||
const me = e as MouseEvent;
|
||
// Modifier-key fallback: let `<a href>` open in a new tab / new
|
||
// window for users who want to deep-link into Pathway A. Don't
|
||
// interfere with normal text selection (no expand on shift-drag).
|
||
if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return;
|
||
|
||
// Trigger pills drill to the trigger-event picker (legacy youpc
|
||
// pathway). The inline calc panel only applies to rule pills.
|
||
if (pill && pill.dataset.kind === "trigger") {
|
||
e.preventDefault();
|
||
const id = Number(pill.dataset.triggerId);
|
||
if (Number.isFinite(id)) drillToTrigger(id);
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
expandCardCalc(card, pill);
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// v4 card-click → inline calc panel (t-paliad-136 Phase B)
|
||
// ============================================================================
|
||
// Click a result card → expand inline → user enters trigger date + flags →
|
||
// server computes one deadline → user can add it to a project.
|
||
//
|
||
// Only one card may be expanded at a time (multiple panels would confuse
|
||
// "which trigger date am I looking at?"). Collapsing happens automatically
|
||
// when another card is clicked, when × is pressed, or when the user clicks
|
||
// outside the panel.
|
||
|
||
interface RuleCalcResponse {
|
||
rule: {
|
||
id: string;
|
||
localCode?: string;
|
||
nameDE: string;
|
||
nameEN: string;
|
||
ruleRef?: string;
|
||
legalSource?: string;
|
||
legalSourceDisplay?: string;
|
||
durationValue: number;
|
||
durationUnit: string;
|
||
party?: string;
|
||
isMandatory: boolean;
|
||
notesDE?: string;
|
||
notesEN?: string;
|
||
};
|
||
proceeding: { code: string; nameDE: string; nameEN: string };
|
||
triggerDate: string;
|
||
originalDate: string;
|
||
dueDate: string;
|
||
wasAdjusted: boolean;
|
||
adjustmentReason?: {
|
||
holidays?: Array<{ name: string; date: string }>;
|
||
upcVacation?: boolean;
|
||
moveToNextWorkday?: boolean;
|
||
};
|
||
isCourtSet: boolean;
|
||
flagsApplied?: string[];
|
||
flagsRequired?: string[];
|
||
}
|
||
|
||
let lastCalcByCard: WeakMap<HTMLElement, RuleCalcResponse> = new WeakMap();
|
||
let calcDebounce: number | undefined;
|
||
let calcSeq = 0;
|
||
|
||
function collapseAnyExpandedCard() {
|
||
document.querySelectorAll<HTMLElement>(".fristen-card.is-expanded").forEach((c) => {
|
||
c.classList.remove("is-expanded");
|
||
c.setAttribute("aria-expanded", "false");
|
||
const panel = c.querySelector<HTMLElement>(".fristen-card-calc");
|
||
if (panel) panel.remove();
|
||
});
|
||
}
|
||
|
||
function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
|
||
// Click a different card → collapse the current one first.
|
||
if (!card.classList.contains("is-expanded")) {
|
||
collapseAnyExpandedCard();
|
||
} else {
|
||
// Already expanded; if the user clicked a different pill, switch
|
||
// selection. If they clicked the body again, do nothing.
|
||
if (autoSelectPill) selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
|
||
return;
|
||
}
|
||
|
||
const payload = card.dataset.cardPayload;
|
||
if (!payload) return;
|
||
let cardData: SearchCard;
|
||
try {
|
||
cardData = JSON.parse(payload) as SearchCard;
|
||
} catch {
|
||
return;
|
||
}
|
||
// Only rule pills are computable. Drop trigger pills from the picker.
|
||
const rulePills = cardData.pills.filter((p) => p.kind === "rule");
|
||
if (rulePills.length === 0) return;
|
||
|
||
card.classList.add("is-expanded");
|
||
card.setAttribute("aria-expanded", "true");
|
||
|
||
// m/paliad#57 part 4: when the user clicked a specific rule pill, the
|
||
// context is already known — the calc panel renders with that pill
|
||
// locked in and no "Which context?" picker. The card's pill list is
|
||
// hidden via CSS while is-expanded so the rules aren't listed twice.
|
||
// When the user clicked the card body (no autoSelectPill), the picker
|
||
// is the primary surface — still no duplicate pill list above it.
|
||
const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
|
||
? rulePills.find((p) =>
|
||
p.proceeding?.code === autoSelectPill.dataset.proc
|
||
&& (autoSelectPill.dataset.focus
|
||
? p.rule_local_code === autoSelectPill.dataset.focus
|
||
: true))
|
||
: undefined;
|
||
|
||
const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
|
||
card.appendChild(panel);
|
||
|
||
scheduleCardCalc(card);
|
||
}
|
||
|
||
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
|
||
const panel = document.createElement("div");
|
||
panel.className = "fristen-card-calc";
|
||
// stopPropagation so clicks inside the panel don't bubble to the
|
||
// card-level expand handler.
|
||
panel.addEventListener("click", (e) => e.stopPropagation());
|
||
panel.addEventListener("keydown", (e) => e.stopPropagation());
|
||
|
||
const lang = getLang();
|
||
const today = new Date().toISOString().split("T")[0];
|
||
|
||
// Picker semantics (m/paliad#57 part 4):
|
||
// - lockedPill set → context known (user clicked a specific
|
||
// rule pill on the card). Render as a
|
||
// hidden input only; the calc panel shows
|
||
// no "Which context?" question. A small
|
||
// "ändern" link reopens the picker fieldset.
|
||
// - rulePills.length <= 1 → only one possible context, never a
|
||
// picker (hidden input carries the data).
|
||
// - otherwise → show the picker as primary surface; the
|
||
// card's pill list is hidden via CSS while
|
||
// the panel is open, so the user isn't
|
||
// asked the same thing twice.
|
||
let pickerHtml: string;
|
||
if (lockedPill) {
|
||
const procName = lockedPill.proceeding
|
||
? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
|
||
: "";
|
||
const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
|
||
const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
|
||
const reopenLabel = t("deadlines.card.calc.pill_picker.change");
|
||
pickerHtml = `<div class="fristen-card-calc-pill-locked">
|
||
<span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
|
||
<span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
|
||
<span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
|
||
${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
|
||
${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
|
||
<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
|
||
</div>`;
|
||
} else if (rulePills.length <= 1) {
|
||
pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
|
||
} else {
|
||
pickerHtml = `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
|
||
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
|
||
${rulePills.map((p, i) => {
|
||
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
|
||
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
|
||
const src = p.legal_source_display || p.legal_source || "";
|
||
return `<label class="fristen-card-calc-pill-option">
|
||
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${i === 0 ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
|
||
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
|
||
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
|
||
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
|
||
</label>`;
|
||
}).join("")}
|
||
</fieldset>`;
|
||
}
|
||
|
||
panel.innerHTML = `
|
||
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
|
||
${pickerHtml}
|
||
<div class="fristen-card-calc-inputs">
|
||
<label class="fristen-card-calc-trigger">
|
||
<span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.trigger.label"))}</span>
|
||
<input type="date" class="fristen-card-calc-trigger-input" value="${escAttr(today)}" />
|
||
</label>
|
||
<div class="fristen-card-calc-flags" hidden></div>
|
||
</div>
|
||
<div class="fristen-card-calc-result" aria-live="polite">
|
||
<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>
|
||
</div>
|
||
<div class="fristen-card-calc-actions">
|
||
<button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add" disabled>${escHtml(t("deadlines.card.calc.add_to_project"))}</button>
|
||
</div>
|
||
<div class="fristen-card-calc-msg" aria-live="polite"></div>
|
||
`;
|
||
|
||
// Wire interactions.
|
||
const close = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-close")!;
|
||
close.addEventListener("click", () => {
|
||
const card = panel.closest<HTMLElement>(".fristen-card");
|
||
if (card) {
|
||
card.classList.remove("is-expanded");
|
||
card.setAttribute("aria-expanded", "false");
|
||
panel.remove();
|
||
}
|
||
});
|
||
|
||
const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
|
||
dateInput.addEventListener("input", () => {
|
||
const card = panel.closest<HTMLElement>(".fristen-card");
|
||
if (card) scheduleCardCalc(card);
|
||
});
|
||
|
||
panel.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
|
||
r.addEventListener("change", () => {
|
||
const card = panel.closest<HTMLElement>(".fristen-card");
|
||
if (card) scheduleCardCalc(card, 0);
|
||
});
|
||
});
|
||
|
||
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!.addEventListener("click", () => {
|
||
const card = panel.closest<HTMLElement>(".fristen-card");
|
||
if (!card) return;
|
||
const last = lastCalcByCard.get(card);
|
||
if (!last) return;
|
||
void addCalcToProject(card, last);
|
||
});
|
||
|
||
// "ändern" — swap the locked-context caption for the full radio
|
||
// picker so the user can change context without collapsing the panel.
|
||
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
|
||
const card = panel.closest<HTMLElement>(".fristen-card");
|
||
const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
|
||
if (!card || !locked) return;
|
||
const fieldset = document.createElement("fieldset");
|
||
fieldset.className = "fristen-card-calc-pill-picker";
|
||
fieldset.setAttribute("role", "radiogroup");
|
||
const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
|
||
const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
|
||
fieldset.innerHTML = `
|
||
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
|
||
${rulePills.map((p, i) => {
|
||
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
|
||
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
|
||
const src = p.legal_source_display || p.legal_source || "";
|
||
const isChecked = (p.proceeding?.code || "") === lockedProc
|
||
&& (p.rule_local_code || "") === lockedFocus;
|
||
return `<label class="fristen-card-calc-pill-option">
|
||
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
|
||
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
|
||
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
|
||
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
|
||
</label>`;
|
||
}).join("")}`;
|
||
locked.replaceWith(fieldset);
|
||
fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
|
||
r.addEventListener("change", () => scheduleCardCalc(card, 0));
|
||
});
|
||
});
|
||
|
||
return panel;
|
||
}
|
||
|
||
function selectCalcPill(card: HTMLElement, proc?: string | null, focus?: string | null) {
|
||
if (!proc) return;
|
||
const radios = card.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]');
|
||
radios.forEach((r) => {
|
||
if (r.dataset.proc === proc && (!focus || r.dataset.focus === focus)) {
|
||
r.checked = true;
|
||
r.dispatchEvent(new Event("change", { bubbles: true }));
|
||
}
|
||
});
|
||
}
|
||
|
||
function scheduleCardCalc(card: HTMLElement, delayMs = 200) {
|
||
if (calcDebounce !== undefined) clearTimeout(calcDebounce);
|
||
calcDebounce = window.setTimeout(() => void runCardCalc(card), delayMs);
|
||
}
|
||
|
||
async function runCardCalc(card: HTMLElement) {
|
||
const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
|
||
if (!panel) return;
|
||
const result = panel.querySelector<HTMLElement>(".fristen-card-calc-result")!;
|
||
const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
|
||
const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
|
||
|
||
const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
|
||
const triggerDate = dateInput.value;
|
||
if (!triggerDate) return;
|
||
|
||
// Resolve currently-selected pill (proc + ruleLocalCode).
|
||
let proc = "";
|
||
let focus = "";
|
||
const checked = panel.querySelector<HTMLInputElement>('input[name="fristen-card-calc-pill"]:checked');
|
||
if (checked) {
|
||
proc = checked.dataset.proc || "";
|
||
focus = checked.dataset.focus || "";
|
||
} else {
|
||
const hidden = panel.querySelector<HTMLInputElement>(".fristen-card-calc-pill-picker");
|
||
if (hidden) {
|
||
proc = hidden.dataset.proc || "";
|
||
focus = hidden.dataset.focus || "";
|
||
}
|
||
}
|
||
if (!proc || !focus) return;
|
||
|
||
// Read flag checkboxes.
|
||
const flags: string[] = [];
|
||
panel.querySelectorAll<HTMLInputElement>('.fristen-card-calc-flags input[type="checkbox"]:checked').forEach((cb) => {
|
||
if (cb.value) flags.push(cb.value);
|
||
});
|
||
|
||
result.innerHTML = `<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>`;
|
||
msgEl.textContent = "";
|
||
addBtn.disabled = true;
|
||
|
||
const seq = ++calcSeq;
|
||
let resp: Response;
|
||
try {
|
||
resp = await fetch("/api/tools/fristenrechner/calculate-rule", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
proceedingCode: proc,
|
||
ruleLocalCode: focus,
|
||
triggerDate,
|
||
flags,
|
||
}),
|
||
});
|
||
} catch {
|
||
if (seq !== calcSeq) return;
|
||
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(t("deadlines.card.calc.result.error"))}</div>`;
|
||
return;
|
||
}
|
||
if (seq !== calcSeq) return;
|
||
if (!resp.ok) {
|
||
const data = (await resp.json().catch(() => ({} as { error?: string }))) as { error?: string };
|
||
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(data.error || t("deadlines.card.calc.result.error"))}</div>`;
|
||
return;
|
||
}
|
||
const calc = (await resp.json()) as RuleCalcResponse;
|
||
if (seq !== calcSeq) return;
|
||
|
||
lastCalcByCard.set(card, calc);
|
||
renderCalcResult(card, calc);
|
||
syncFlagCheckboxes(card, calc);
|
||
addBtn.disabled = calc.isCourtSet || !calc.dueDate;
|
||
if (calc.isCourtSet) {
|
||
addBtn.textContent = t("deadlines.card.calc.add_to_project.disabled");
|
||
} else {
|
||
addBtn.textContent = t("deadlines.card.calc.add_to_project");
|
||
}
|
||
}
|
||
|
||
function syncFlagCheckboxes(card: HTMLElement, calc: RuleCalcResponse) {
|
||
const flagsEl = card.querySelector<HTMLElement>(".fristen-card-calc-flags");
|
||
if (!flagsEl) return;
|
||
const required = calc.flagsRequired || [];
|
||
if (required.length === 0) {
|
||
flagsEl.hidden = true;
|
||
flagsEl.innerHTML = "";
|
||
return;
|
||
}
|
||
flagsEl.hidden = false;
|
||
// Preserve current state when re-rendering: check current DOM for
|
||
// existing checkbox values so user input survives a recalc.
|
||
const existing = new Map<string, boolean>();
|
||
flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
|
||
existing.set(cb.value, cb.checked);
|
||
});
|
||
const labelKey = (flag: string) => {
|
||
const k = `deadlines.card.calc.flag.${flag}`;
|
||
const localised = tDyn(k);
|
||
return localised === k ? flag : localised;
|
||
};
|
||
flagsEl.innerHTML = `
|
||
<span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.flags.label"))}</span>
|
||
${required.map((f) => {
|
||
const checked = existing.get(f) ?? (calc.flagsApplied || []).includes(f);
|
||
return `<label class="fristen-card-calc-flag">
|
||
<input type="checkbox" value="${escAttr(f)}" ${checked ? "checked" : ""} />
|
||
<span>${escHtml(labelKey(f))}</span>
|
||
</label>`;
|
||
}).join("")}
|
||
`;
|
||
flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
|
||
cb.addEventListener("change", () => scheduleCardCalc(card, 0));
|
||
});
|
||
}
|
||
|
||
function renderCalcResult(card: HTMLElement, calc: RuleCalcResponse) {
|
||
const result = card.querySelector<HTMLElement>(".fristen-card-calc-result");
|
||
if (!result) return;
|
||
if (calc.isCourtSet) {
|
||
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-court">${escHtml(t("deadlines.card.calc.result.court_set"))}</div>`;
|
||
return;
|
||
}
|
||
const lang = getLang();
|
||
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
|
||
const durLabel = `${calc.rule.durationValue} ${formatDurationUnit(calc.rule.durationUnit, lang)}`;
|
||
const dueLabel = formatDate(calc.dueDate);
|
||
const fromLabel = formatDate(calc.triggerDate);
|
||
const adjustmentChip = calc.wasAdjusted
|
||
? renderAdjustmentChip(calc, lang)
|
||
: "";
|
||
result.innerHTML = `
|
||
<div class="fristen-card-calc-result-row">
|
||
<span class="fristen-card-calc-result-arrow" aria-hidden="true">►</span>
|
||
<span class="fristen-card-calc-result-due"><strong>${escHtml(dueLabel)}</strong></span>
|
||
<span class="fristen-card-calc-result-detail">(${escHtml(durLabel)} ${escHtml(t("deadlines.card.calc.result.from_trigger"))} ${escHtml(fromLabel)})</span>
|
||
</div>
|
||
${adjustmentChip}
|
||
<div class="fristen-card-calc-result-rule">${escHtml(ruleName)}</div>
|
||
`;
|
||
}
|
||
|
||
function renderAdjustmentChip(calc: RuleCalcResponse, _lang: "de" | "en"): string {
|
||
const reason = calc.adjustmentReason;
|
||
let why = "";
|
||
if (reason && reason.upcVacation) {
|
||
why = "UPC-Sommerferien (27.7.–28.8.)";
|
||
} else if (reason && reason.holidays && reason.holidays.length > 0) {
|
||
why = reason.holidays.map((h) => h.name).join(", ");
|
||
} else {
|
||
why = "Wochenende / Feiertag";
|
||
}
|
||
return `<div class="fristen-card-calc-result-shift">
|
||
⚠ ${escHtml(t("deadlines.card.calc.result.shifted_from"))} <strong>${escHtml(formatDate(calc.originalDate))}</strong>
|
||
${escHtml(t("deadlines.card.calc.result.shifted_because"))} ${escHtml(why)}.
|
||
</div>`;
|
||
}
|
||
|
||
function formatDurationUnit(unit: string, lang: "de" | "en"): string {
|
||
const map: Record<string, { de: string; en: string }> = {
|
||
days: { de: "Tage", en: "days" },
|
||
working_days: { de: "Arbeitstage", en: "working days" },
|
||
weeks: { de: "Wochen", en: "weeks" },
|
||
months: { de: "Monate", en: "months" },
|
||
years: { de: "Jahre", en: "years" },
|
||
};
|
||
return map[unit] ? map[unit][lang] : unit;
|
||
}
|
||
|
||
async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||
const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
|
||
if (!panel) return;
|
||
const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
|
||
const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
|
||
msgEl.textContent = "";
|
||
addBtn.disabled = true;
|
||
|
||
const projects = await fetchProjects();
|
||
if (projects.length === 0) {
|
||
addBtn.disabled = false;
|
||
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.modal.no_akten"))}</span> <a href="/projects/new">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>`;
|
||
return;
|
||
}
|
||
|
||
// Inline picker — render a compact <select> + Confirm button under
|
||
// the result. Keeps the user inside the card; no full modal needed.
|
||
const lang = getLang();
|
||
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
|
||
const dueLabel = formatDate(calc.dueDate);
|
||
const preselected = preselectedProjectId();
|
||
msgEl.innerHTML = `
|
||
<div class="fristen-card-calc-add-picker">
|
||
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
|
||
<select class="fristen-card-calc-add-select">
|
||
${projects.map((p) => {
|
||
const ref = (p.reference || "").trim();
|
||
const indent = projectIndent(p.path);
|
||
const label = ref ? `${indent}${ref} — ${p.title}` : `${indent}${p.title}`;
|
||
const selected = p.id === preselected ? " selected" : "";
|
||
return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
|
||
}).join("")}
|
||
</select>
|
||
</label>
|
||
<button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add-confirm">${escHtml(t("deadlines.save.modal.submit"))}</button>
|
||
<button type="button" class="btn-cancel fristen-card-calc-add-cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
|
||
</div>
|
||
`;
|
||
|
||
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
|
||
if (preselected) sel.value = preselected;
|
||
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
|
||
msgEl.innerHTML = "";
|
||
addBtn.disabled = false;
|
||
});
|
||
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!.addEventListener("click", async () => {
|
||
const projectID = sel.value;
|
||
if (!projectID) return;
|
||
const dlNotes = lang === "en"
|
||
? (calc.rule.notesEN || calc.rule.notesDE)
|
||
: calc.rule.notesDE;
|
||
const payload = {
|
||
deadlines: [{
|
||
title: ruleName,
|
||
rule_code: calc.rule.ruleRef || undefined,
|
||
due_date: calc.dueDate,
|
||
original_due_date: calc.originalDate || undefined,
|
||
// m's Q2 (2026-05-05): use 'fristenrechner' (existing tag), not
|
||
// 'fristenrechner_card'. Audit-log differentiation is not needed.
|
||
source: "fristenrechner",
|
||
notes: dlNotes || undefined,
|
||
}],
|
||
};
|
||
const confirm = msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!;
|
||
confirm.disabled = true;
|
||
try {
|
||
const r = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!r.ok) {
|
||
const data = (await r.json().catch(() => ({}))) as { error?: string };
|
||
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(data.error || t("deadlines.save.error"))}</span>`;
|
||
addBtn.disabled = false;
|
||
return;
|
||
}
|
||
msgEl.innerHTML = `<span class="form-msg form-msg-ok">${escHtml(t("deadlines.save.success"))} (${escHtml(dueLabel)}) <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a></span>`;
|
||
addBtn.disabled = false;
|
||
} catch {
|
||
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.error"))}</span>`;
|
||
addBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Collapse the open card on Escape key for quick keyboard exit.
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key !== "Escape") return;
|
||
const open = document.querySelector<HTMLElement>(".fristen-card.is-expanded");
|
||
if (open) {
|
||
open.classList.remove("is-expanded");
|
||
open.setAttribute("aria-expanded", "false");
|
||
open.querySelector<HTMLElement>(".fristen-card-calc")?.remove();
|
||
}
|
||
});
|
||
|
||
function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
|
||
const name = lang === "en" ? card.concept.name_en : card.concept.name_de;
|
||
const altName = lang === "en" ? card.concept.name_de : card.concept.name_en;
|
||
const aliasLine = card.matched_aliases && card.matched_aliases.length > 0
|
||
? `<div class="fristen-card-aliases"><span class="fristen-card-aliases-label">${escHtml(t("deadlines.search.aliases"))}</span> ${card.matched_aliases.map(escHtml).join(" · ")}</div>`
|
||
: "";
|
||
const desc = card.concept.description ? `<p class="fristen-card-desc">${escHtml(card.concept.description)}</p>` : "";
|
||
|
||
// Split rule pills (have a proceeding) from cross-cutting trigger pills.
|
||
const rulePills = card.pills.filter((p) => p.kind === "rule");
|
||
const triggerPills = card.pills.filter((p) => p.kind === "trigger");
|
||
|
||
const ruleSection = rulePills.length === 0 ? "" : `
|
||
<div class="fristen-card-pills-section fristen-card-pills-section--rules">
|
||
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
|
||
<div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
|
||
</div>`;
|
||
const triggerSection = triggerPills.length === 0 ? "" : `
|
||
<div class="fristen-card-pills-section fristen-card-pills-section--cross">
|
||
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
|
||
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
|
||
</div>`;
|
||
|
||
// v4 (t-paliad-136 Phase B): stash the card payload on the article so
|
||
// expandCardCalc() can read pills + concept name without re-querying.
|
||
// JSON.stringify → escAttr survives htmlentity round-trip.
|
||
const cardPayload = escAttr(JSON.stringify(card));
|
||
|
||
return `
|
||
<article class="fristen-card" data-concept-slug="${escAttr(card.concept.slug)}" data-card-payload="${cardPayload}" tabindex="0" role="button" aria-expanded="false" title="${escAttr(t("deadlines.card.calc.expand_hint"))}">
|
||
<header class="fristen-card-header">
|
||
<h3 class="fristen-card-title">${escHtml(name)}</h3>
|
||
<span class="fristen-card-altname">${escHtml(altName)}</span>
|
||
</header>
|
||
${desc}
|
||
${aliasLine}
|
||
${ruleSection}
|
||
${triggerSection}
|
||
</article>`;
|
||
}
|
||
|
||
function renderPill(pill: SearchPill, lang: "de" | "en"): string {
|
||
const procLabel = pill.proceeding
|
||
? (lang === "en" && pill.proceeding.name_en ? pill.proceeding.name_en : pill.proceeding.name_de)
|
||
: "";
|
||
// t-paliad-134: never fall back to rule_local_code — that's an
|
||
// internal slug like "rev.defence" / "inf.decision" and leaks
|
||
// implementation detail to the UI when legal_source is unset.
|
||
const sourceLabel = pill.legal_source_display || pill.legal_source || "";
|
||
const ruleName = lang === "en" && pill.rule_name_en ? pill.rule_name_en : pill.rule_name_de;
|
||
const partyLabel = partyLabelFor(pill.party);
|
||
|
||
const durationHtml = pill.duration
|
||
? `<span class="fristen-pill-duration">${escHtml(formatDuration(pill.duration, lang))}</span>`
|
||
: "";
|
||
const procHtml = procLabel
|
||
? `<span class="fristen-pill-proc">${escHtml(procLabel)}</span>`
|
||
: `<span class="fristen-pill-proc fristen-pill-proc--cross">${escHtml(t("deadlines.search.pills.cross_cutting"))}</span>`;
|
||
const partyHtml = partyLabel ? `<span class="fristen-pill-party fristen-pill-party--${escAttr(pill.party)}">${escHtml(partyLabel)}</span>` : "";
|
||
const sourceHtml = sourceLabel
|
||
? `<span class="fristen-pill-source">${escHtml(sourceLabel)}</span>`
|
||
: "";
|
||
|
||
// data-* attributes carry everything the click handler needs to drill in
|
||
// without re-parsing JSON. drill_url is the canonical fallback (used when
|
||
// the user middle-clicks / cmd-clicks for a new tab).
|
||
const dataAttrs = [
|
||
`data-kind="${escAttr(pill.kind)}"`,
|
||
pill.proceeding ? `data-proc="${escAttr(pill.proceeding.code)}"` : "",
|
||
pill.rule_local_code ? `data-focus="${escAttr(pill.rule_local_code)}"` : "",
|
||
pill.trigger_event_id !== undefined ? `data-trigger-id="${pill.trigger_event_id}"` : "",
|
||
].filter(Boolean).join(" ");
|
||
|
||
return `
|
||
<a href="${escAttr(pill.drill_url)}" class="fristen-pill" ${dataAttrs}>
|
||
${procHtml}
|
||
<span class="fristen-pill-rule">${escHtml(ruleName)}</span>
|
||
${sourceHtml}
|
||
${durationHtml}
|
||
${partyHtml}
|
||
</a>`;
|
||
}
|
||
|
||
function formatDuration(d: SearchPillDuration, lang: "de" | "en"): string {
|
||
const unitLabels: Record<string, { de: string; en: string }> = {
|
||
days: { de: "Tage", en: "days" },
|
||
working_days: { de: "Arbeitstage", en: "working days" },
|
||
weeks: { de: "Wochen", en: "weeks" },
|
||
months: { de: "Monate", en: "months" },
|
||
years: { de: "Jahre", en: "years" },
|
||
};
|
||
const label = unitLabels[d.unit] ? unitLabels[d.unit][lang] : d.unit;
|
||
return `${d.value} ${label}`;
|
||
}
|
||
|
||
function partyLabelFor(party: string): string {
|
||
switch (party) {
|
||
case "claimant": return t("deadlines.search.party.claimant");
|
||
case "defendant": return t("deadlines.search.party.defendant");
|
||
case "both": return t("deadlines.search.party.both");
|
||
case "court": return t("deadlines.search.party.court");
|
||
default: return party;
|
||
}
|
||
}
|
||
|
||
// ----- Drill-in --------------------------------------------------------------
|
||
|
||
// Pending focus the next renderProcedureResults() will scroll to and
|
||
// highlight. Set by drillToProceeding right before the scheduled calc fires.
|
||
let pendingFocusRule: string | null = null;
|
||
|
||
function applyPendingFocus() {
|
||
if (!pendingFocusRule) return;
|
||
const code = pendingFocusRule;
|
||
pendingFocusRule = null;
|
||
// Wait one frame so the DOM has settled.
|
||
requestAnimationFrame(() => {
|
||
const target = document.querySelector<HTMLElement>(`[data-rule-code="${CSS.escape(code)}"]`);
|
||
if (!target) return;
|
||
const row = target.closest<HTMLElement>(".frist-row, .frist-card, li") || target;
|
||
row.classList.add("fristen-focus-highlight");
|
||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
window.setTimeout(() => row.classList.remove("fristen-focus-highlight"), 2400);
|
||
});
|
||
}
|
||
|
||
function drillToProceeding(procCode: string, focusCode: string | null) {
|
||
// Switch to procedure mode if we're on event mode.
|
||
const procTab = document.getElementById("mode-procedure-tab");
|
||
if (procTab) procTab.click();
|
||
|
||
const btn = document.querySelector<HTMLButtonElement>(`.proceeding-btn[data-code="${procCode}"]`);
|
||
if (!btn) return;
|
||
if (focusCode) pendingFocusRule = focusCode;
|
||
selectProceeding(btn);
|
||
// Scroll the wizard into view so the user sees what just happened.
|
||
document.getElementById("step-2")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
|
||
function drillToTrigger(triggerId: number) {
|
||
// v3 (Phase E): legacy tabs are gone. Show the event panel directly.
|
||
// Triggered from concept-card pill clicks; routes via Pathway A so the
|
||
// Verfahrensablauf user surface stays consistent.
|
||
const procedurePanel = document.getElementById("mode-procedure-panel");
|
||
const eventPanel = document.getElementById("mode-event-panel");
|
||
if (procedurePanel) procedurePanel.hidden = true;
|
||
if (eventPanel) eventPanel.hidden = false;
|
||
// Defer a tick so the panel swap has rendered before we touch state.
|
||
window.setTimeout(() => {
|
||
selectTriggerEvent(triggerId);
|
||
document.getElementById("event-step-2")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}, 0);
|
||
}
|
||
|
||
// ----- URL state -------------------------------------------------------------
|
||
|
||
function syncSearchURL(q: string) {
|
||
const url = new URL(window.location.href);
|
||
if (q === "") url.searchParams.delete("q");
|
||
else url.searchParams.set("q", q);
|
||
// Only push if the URL actually changed — avoids spamming history with
|
||
// identical entries while the user types.
|
||
if (url.toString() !== window.location.href) {
|
||
window.history.replaceState(null, "", url.toString());
|
||
}
|
||
}
|
||
|
||
function readInitialSearchQuery(): string {
|
||
return new URLSearchParams(window.location.search).get("q") || "";
|
||
}
|
||
|
||
// Quick-pick chips (t-paliad-134) carry both DE and EN labels via
|
||
// data-chip-name-de / data-chip-name-en attributes. relabelChips
|
||
// rewrites the visible text to match the active language; chipQueryFor
|
||
// returns the active-language label for use as the search query.
|
||
function relabelChips() {
|
||
const lang = getLang();
|
||
document.querySelectorAll<HTMLButtonElement>(".fristen-search-chip").forEach((chip) => {
|
||
const de = chip.dataset.chipNameDe;
|
||
const en = chip.dataset.chipNameEn;
|
||
if (!de && !en) return; // legacy chip without slug-based labels
|
||
const label = lang === "en" ? (en || de || "") : (de || en || "");
|
||
if (label && chip.textContent?.trim() !== label) {
|
||
chip.textContent = label;
|
||
}
|
||
// data-q kept in sync so existing click paths (e.g. fork-chip path)
|
||
// see the right query string without needing a chip-aware fallback.
|
||
chip.dataset.q = label;
|
||
});
|
||
}
|
||
|
||
function chipQueryFor(chip: HTMLButtonElement): string {
|
||
const lang = getLang();
|
||
const de = chip.dataset.chipNameDe;
|
||
const en = chip.dataset.chipNameEn;
|
||
if (de || en) {
|
||
return lang === "en" ? (en || de || "") : (de || en || "");
|
||
}
|
||
return chip.dataset.q || chip.textContent || "";
|
||
}
|
||
|
||
// ----- Wiring ----------------------------------------------------------------
|
||
|
||
function initSearch() {
|
||
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
|
||
const chips = document.getElementById("fristen-search-chips");
|
||
const results = document.getElementById("fristen-search-results");
|
||
const clearBtn = document.getElementById("fristen-search-clear") as HTMLButtonElement | null;
|
||
if (!input || !chips || !results) return; // older bundle — skip silently
|
||
|
||
// Initial state from URL.
|
||
const initial = readInitialSearchQuery();
|
||
if (initial) {
|
||
input.value = initial;
|
||
if (clearBtn) clearBtn.hidden = false;
|
||
scheduleSearch(0);
|
||
}
|
||
|
||
input.addEventListener("input", () => scheduleSearch());
|
||
input.addEventListener("keydown", (e) => {
|
||
if ((e as KeyboardEvent).key === "Escape") {
|
||
input.value = "";
|
||
scheduleSearch(0);
|
||
} else if ((e as KeyboardEvent).key === "Enter") {
|
||
scheduleSearch(0);
|
||
}
|
||
});
|
||
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener("click", () => {
|
||
input.value = "";
|
||
input.focus();
|
||
scheduleSearch(0);
|
||
});
|
||
}
|
||
|
||
chips.addEventListener("click", (e) => {
|
||
const target = (e.target as HTMLElement).closest<HTMLButtonElement>(".fristen-search-chip");
|
||
if (!target) return;
|
||
// Slug-based chips (t-paliad-134) carry both labels and use the
|
||
// active language. Legacy chips (no slug) fall back to data-q.
|
||
const q = chipQueryFor(target);
|
||
input.value = q;
|
||
input.focus();
|
||
scheduleSearch(0);
|
||
});
|
||
relabelChips();
|
||
|
||
wirePillClicks(results);
|
||
|
||
// Re-render on language flip so card / pill labels follow the active locale.
|
||
onLangChange(() => {
|
||
relabelChips();
|
||
const q = input.value.trim();
|
||
if (q !== "") scheduleSearch(0);
|
||
});
|
||
|
||
// Browser back/forward should restore search state.
|
||
window.addEventListener("popstate", () => {
|
||
const q = readInitialSearchQuery();
|
||
if (q !== input.value) {
|
||
input.value = q;
|
||
scheduleSearch(0);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Wire on DOM ready (the existing DOMContentLoaded handler is already busy;
|
||
// add a lightweight follow-up listener to keep the diff small).
|
||
document.addEventListener("DOMContentLoaded", initSearch);
|
||
|
||
// ============================================================================
|
||
// v3 pathway fork (t-paliad-133)
|
||
// ============================================================================
|
||
// Three-state landing surface: fork (default), Pathway A (Verfahrensablauf —
|
||
// existing wizard), Pathway B (Frist eintragen — search/B1/B2). URL ?path=
|
||
// drives visibility; localStorage remembers the last-used pathway for soft
|
||
// re-entry. ?legacy=1 keeps the pre-v3 layout (no fork) for parity testing
|
||
// during the rollout window.
|
||
|
||
// Pathway values:
|
||
// fork — Step 1 / Step 2 visible (the new front of the funnel)
|
||
// outgoing — Step 3a chooser (File / Draft / Enter) visible
|
||
// a — Pathway A wizard (Verfahrensablauf timeline)
|
||
// b — Pathway B cascade
|
||
type Pathway = "fork" | "outgoing" | "a" | "b";
|
||
type BMode = "tree" | "filter";
|
||
|
||
const PATHWAY_STORAGE_KEY = "paliad.fristen.pathway";
|
||
|
||
function readPathwayFromURL(): Pathway {
|
||
const sp = new URLSearchParams(window.location.search);
|
||
const p = sp.get("path");
|
||
if (p === "a" || p === "b" || p === "outgoing") return p;
|
||
return "fork";
|
||
}
|
||
|
||
function readBModeFromURL(): BMode {
|
||
const sp = new URLSearchParams(window.location.search);
|
||
const m = sp.get("mode");
|
||
if (m === "tree" || m === "filter") return m;
|
||
// Default: tree mode (B1 cascade is the discovery surface; the
|
||
// free-text/filter B2 mode is for power users who already know what
|
||
// they want).
|
||
return "tree";
|
||
}
|
||
|
||
function setPathwayURL(path: Pathway, mode?: BMode, replace = false) {
|
||
const url = new URL(window.location.href);
|
||
if (path === "fork") {
|
||
url.searchParams.delete("path");
|
||
url.searchParams.delete("mode");
|
||
url.searchParams.delete("b1");
|
||
} else {
|
||
url.searchParams.set("path", path);
|
||
if (path === "b" && mode) {
|
||
url.searchParams.set("mode", mode);
|
||
} else {
|
||
url.searchParams.delete("mode");
|
||
}
|
||
}
|
||
if (replace) {
|
||
window.history.replaceState({}, "", url.toString());
|
||
} else {
|
||
window.history.pushState({}, "", url.toString());
|
||
}
|
||
}
|
||
|
||
// resolveDeadlinesNewURL builds the Step 3a "Enter" destination. For a
|
||
// real project: /projects/{id}/deadlines/new. For ad-hoc explore-mode:
|
||
// /deadlines/new (the project picker is in the form itself, but the
|
||
// user has no Akte to attach it to without picking one anew). m's
|
||
// 2026-05-08 spec: this is the manual-entry escape hatch.
|
||
function resolveDeadlinesNewURL(ctx: Step1Context): string {
|
||
if (ctx.kind === "project" && ctx.projectId) {
|
||
return `/projects/${encodeURIComponent(ctx.projectId)}/deadlines/new`;
|
||
}
|
||
return "/deadlines/new";
|
||
}
|
||
|
||
function showPathway(path: Pathway, mode?: BMode) {
|
||
// m's 2026-05-08 18:08 redesign retired the legacy fork; Step 1 and
|
||
// Step 2 sit where it used to live. The "fork" Pathway value now
|
||
// means "show Step 1 + Step 2 (Step 2 visibility gated by whether a
|
||
// project / ad-hoc context is selected — managed by initStep1Step2)";
|
||
// "a" / "b" still drive the existing wizard / cascade shells.
|
||
const step1 = document.getElementById("fristen-step1");
|
||
const step1Summary = document.getElementById("fristen-step1-summary");
|
||
const step2 = document.getElementById("fristen-step2");
|
||
const step3a = document.getElementById("fristen-step3a");
|
||
const a = document.getElementById("fristen-pathway-a");
|
||
const b = document.getElementById("fristen-pathway-b");
|
||
if (!a || !b) return;
|
||
|
||
// Step 1 + 2 stay mounted under "fork". Step 2 visibility is also
|
||
// gated by the Step 1 context state — initStep1Step2 owns the
|
||
// toggle between Step 1 expanded vs collapsed-with-summary, and
|
||
// the "show Step 2" gate. We just hide them wholesale when not on
|
||
// the fork.
|
||
if (step1) step1.style.display = path === "fork" ? "" : "none";
|
||
if (step1Summary) {
|
||
// Summary stays visible from Step 2 onward so the user always
|
||
// sees their selected Akte. Hidden only when no context is set.
|
||
const ctx = readStep1ContextFromURL();
|
||
step1Summary.style.display = (ctx.kind !== "none" && path !== "fork") ? "" : step1Summary.style.display;
|
||
}
|
||
if (step2) step2.hidden = path !== "fork";
|
||
if (step3a) step3a.hidden = path !== "outgoing";
|
||
a.hidden = path !== "a";
|
||
b.hidden = path !== "b";
|
||
|
||
if (path === "b") {
|
||
showBMode(mode || readBModeFromURL());
|
||
}
|
||
}
|
||
|
||
function showBMode(mode: BMode) {
|
||
const tree = document.getElementById("fristen-b1-panel");
|
||
const filter = document.getElementById("fristen-b2-panel");
|
||
if (!tree || !filter) return;
|
||
tree.hidden = mode !== "tree";
|
||
filter.hidden = mode !== "filter";
|
||
|
||
// Trigger tree load on entering tree mode. renderRowStack repaints
|
||
// every row + calls runB1Search() for the current slug. With slug=""
|
||
// this fetches every concept reachable from any leaf (browse-all,
|
||
// t-paliad-134) so the user sees the full landscape before drilling
|
||
// in.
|
||
if (mode === "tree") {
|
||
const stack = document.getElementById("fristen-row-stack");
|
||
if (stack && stack.childElementCount === 0) {
|
||
stack.innerHTML = `<div class="fristen-b1-stub">${escHtml(t("deadlines.search.loading"))}</div>`;
|
||
}
|
||
void loadAndRenderB1();
|
||
}
|
||
}
|
||
|
||
function navigateToPathway(path: Pathway, mode?: BMode) {
|
||
setPathwayURL(path, mode);
|
||
showPathway(path, mode);
|
||
if (path !== "fork") {
|
||
try {
|
||
localStorage.setItem(PATHWAY_STORAGE_KEY, path);
|
||
} catch { /* private mode */ }
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// m's 2026-05-08 18:08 Determinator redesign — Step 1 + Step 2 state
|
||
// ============================================================================
|
||
// Step 1: pick the project (Akte) that scopes everything downstream, OR
|
||
// pick an ad-hoc explore-mode chip (4 jurisdictions). Step 2: choose
|
||
// between outgoing intent (Pathway A / Verfahrensablauf) and incoming
|
||
// event (Pathway B / cascade). Step 3+ stay as today (Pathway A wizard,
|
||
// B1 cascade, B2 search). The legacy "Was möchten Sie tun?" fork is
|
||
// retired; back-buttons inside Pathway A/B return to Step 2.
|
||
|
||
type Step1ContextKind = "project" | "adhoc" | "none";
|
||
type AdhocForum = "upc" | "de" | "epa" | "dpma";
|
||
|
||
interface Step1Context {
|
||
kind: Step1ContextKind;
|
||
projectId?: string;
|
||
project?: ProjectOption;
|
||
adhocForum?: AdhocForum;
|
||
}
|
||
|
||
let currentStep1Context: Step1Context = { kind: "none" };
|
||
let cachedAkten: ProjectOption[] = [];
|
||
|
||
function readStep1ContextFromURL(): Step1Context {
|
||
const sp = new URLSearchParams(window.location.search);
|
||
const project = sp.get("project");
|
||
const adhoc = sp.get("ad_hoc");
|
||
if (project) return { kind: "project", projectId: project };
|
||
if (adhoc === "upc" || adhoc === "de" || adhoc === "epa" || adhoc === "dpma") {
|
||
return { kind: "adhoc", adhocForum: adhoc };
|
||
}
|
||
return { kind: "none" };
|
||
}
|
||
|
||
function writeStep1ContextToURL(ctx: Step1Context, replace = false) {
|
||
const url = new URL(window.location.href);
|
||
if (ctx.kind === "project" && ctx.projectId) {
|
||
url.searchParams.set("project", ctx.projectId);
|
||
url.searchParams.delete("ad_hoc");
|
||
} else if (ctx.kind === "adhoc" && ctx.adhocForum) {
|
||
url.searchParams.set("ad_hoc", ctx.adhocForum);
|
||
url.searchParams.delete("project");
|
||
} else {
|
||
url.searchParams.delete("project");
|
||
url.searchParams.delete("ad_hoc");
|
||
}
|
||
if (replace) window.history.replaceState({}, "", url.toString());
|
||
else window.history.pushState({}, "", url.toString());
|
||
}
|
||
|
||
// isAdhocMode is read by the save-to-project CTA — ad-hoc has no
|
||
// project to save against, so the CTA disables and renders a hint.
|
||
// t-paliad-168: also true when no Step 1 context is set at all (the
|
||
// "Verfahrensablauf einsehen" / sidebar deep-link browse path opens
|
||
// Pathway A without an Akte). In both cases the user has no project
|
||
// to save against; the CTA renders disabled with the same hint.
|
||
function isAdhocMode(): boolean {
|
||
return currentStep1Context.kind === "adhoc" || currentStep1Context.kind === "none";
|
||
}
|
||
|
||
function adhocSummaryLabel(forum: AdhocForum): string {
|
||
switch (forum) {
|
||
case "upc": return "Ad-hoc UPC";
|
||
case "de": return "Ad-hoc DE";
|
||
case "epa": return "Ad-hoc EPA";
|
||
case "dpma": return "Ad-hoc DPMA";
|
||
}
|
||
}
|
||
|
||
function renderAkteList(query: string) {
|
||
const list = document.getElementById("fristen-akte-list");
|
||
if (!list) return;
|
||
const q = query.trim().toLowerCase();
|
||
const filtered = q === ""
|
||
? cachedAkten.slice(0, 12)
|
||
: cachedAkten.filter((p) =>
|
||
(p.title || "").toLowerCase().includes(q) ||
|
||
(p.reference || "").toLowerCase().includes(q));
|
||
if (filtered.length === 0) {
|
||
list.innerHTML = `<li class="fristen-akte-list-empty">${escHtml(t("deadlines.step1.search.empty"))}</li>`;
|
||
return;
|
||
}
|
||
list.innerHTML = filtered.map((p) => {
|
||
const ref = p.reference ? `<span class="fristen-akte-list-ref">${escHtml(p.reference)}</span> · ` : "";
|
||
return `<li><button type="button" class="fristen-akte-list-item" data-project-id="${escAttr(p.id)}">
|
||
${ref}<span class="fristen-akte-list-title">${escHtml(p.title)}</span>
|
||
</button></li>`;
|
||
}).join("");
|
||
list.querySelectorAll<HTMLButtonElement>(".fristen-akte-list-item").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const id = btn.dataset.projectId!;
|
||
const project = cachedAkten.find((p) => p.id === id);
|
||
if (project) selectProject(project);
|
||
});
|
||
});
|
||
}
|
||
|
||
function selectProject(project: ProjectOption) {
|
||
currentStep1Context = { kind: "project", projectId: project.id, project };
|
||
writeStep1ContextToURL(currentStep1Context);
|
||
renderStep1Summary();
|
||
showStep2Card();
|
||
// t-paliad-164: project.our_side predefines the perspective chip.
|
||
// Only fires when the user hasn't already locked a perspective via
|
||
// ?role= in the URL — the URL pick wins because it represents an
|
||
// explicit choice (chip click or shared link).
|
||
applyOurSidePredefine(project, /* replaceURL */ false);
|
||
// Slice 3b: project's proceeding type narrows the B1 cascade if the
|
||
// user reaches it via Step 2 → Etwas ist passiert. Refresh here so
|
||
// a cascade already on screen (rare but possible via popstate) picks
|
||
// up the new narrowing.
|
||
triggerCascadeRefresh();
|
||
}
|
||
|
||
function selectAdhoc(forum: AdhocForum) {
|
||
currentStep1Context = { kind: "adhoc", adhocForum: forum };
|
||
writeStep1ContextToURL(currentStep1Context);
|
||
renderStep1Summary();
|
||
showStep2Card();
|
||
triggerCascadeRefresh();
|
||
}
|
||
|
||
function clearStep1Context() {
|
||
currentStep1Context = { kind: "none" };
|
||
writeStep1ContextToURL(currentStep1Context);
|
||
renderStep1Summary();
|
||
hideStep2Card();
|
||
// t-paliad-180: dropping the project context makes the perspective
|
||
// row revert from is-prefilled to is-answered (the "aus Akte" tag
|
||
// disappears) — repainting the row stack is the only thing needed;
|
||
// we deliberately leave currentPerspective alone, since the user
|
||
// may want to keep their pick when returning to Step 1.
|
||
triggerCascadeRefresh();
|
||
}
|
||
|
||
function renderStep1Summary() {
|
||
const summary = document.getElementById("fristen-step1-summary") as HTMLElement | null;
|
||
const name = document.getElementById("fristen-step1-summary-name");
|
||
const meta = document.getElementById("fristen-step1-summary-meta");
|
||
const step1 = document.getElementById("fristen-step1") as HTMLElement | null;
|
||
if (!summary || !name || !step1) return;
|
||
|
||
if (currentStep1Context.kind === "none") {
|
||
summary.style.display = "none";
|
||
step1.style.display = "";
|
||
return;
|
||
}
|
||
|
||
if (currentStep1Context.kind === "project" && currentStep1Context.project) {
|
||
const p = currentStep1Context.project;
|
||
const ref = p.reference ? p.reference + " · " : "";
|
||
name.textContent = ref + p.title;
|
||
if (meta) meta.textContent = "";
|
||
} else if (currentStep1Context.kind === "adhoc" && currentStep1Context.adhocForum) {
|
||
name.textContent = adhocSummaryLabel(currentStep1Context.adhocForum);
|
||
if (meta) meta.textContent = " · " + t("deadlines.step1.summary.adhoc.suffix");
|
||
}
|
||
summary.style.display = "";
|
||
step1.style.display = "none";
|
||
}
|
||
|
||
function showStep2Card() {
|
||
const step2 = document.getElementById("fristen-step2");
|
||
if (step2) step2.hidden = false;
|
||
}
|
||
|
||
function hideStep2Card() {
|
||
const step2 = document.getElementById("fristen-step2");
|
||
if (step2) step2.hidden = true;
|
||
}
|
||
|
||
function initPathwayFork() {
|
||
// Set chip labels to active language before user sees them.
|
||
relabelChips();
|
||
|
||
// Hydrate Step 1 context from URL first — Step 2 visibility depends
|
||
// on whether a project / ad-hoc chip is already locked in.
|
||
currentStep1Context = readStep1ContextFromURL();
|
||
|
||
// Initial Pathway render. Inherits the URL ?path= semantic — Step 2
|
||
// having been satisfied is implied if path = a / b.
|
||
const initial = readPathwayFromURL();
|
||
const initialMode = readBModeFromURL();
|
||
showPathway(initial, initialMode);
|
||
|
||
// Step 1 summary visibility flows from the context kind.
|
||
renderStep1Summary();
|
||
if (currentStep1Context.kind !== "none") {
|
||
showStep2Card();
|
||
}
|
||
|
||
if (initial !== "fork") {
|
||
try { localStorage.setItem(PATHWAY_STORAGE_KEY, initial); } catch { /* */ }
|
||
}
|
||
|
||
// Step 1 — fetch projects + proceeding-types in parallel. Both are
|
||
// small + cacheable; both are needed before the cascade narrowing
|
||
// can fire correctly. Render the list as soon as projects come in,
|
||
// refresh cascade once the proceeding-types map is also populated.
|
||
void (async () => {
|
||
const [projects] = await Promise.all([
|
||
fetchProjects(),
|
||
loadProceedingTypes(),
|
||
]);
|
||
cachedAkten = projects;
|
||
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
|
||
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
|
||
renderStep1Summary();
|
||
// t-paliad-164: deep-link / refresh path. project loaded async, so
|
||
// the predefine has to wait for cachedAkten. replace=true keeps
|
||
// the URL clean — the user didn't navigate, they just refreshed.
|
||
applyOurSidePredefine(currentStep1Context.project, /* replaceURL */ true);
|
||
}
|
||
renderAkteList("");
|
||
// Cascade may already be on screen if the user landed with
|
||
// ?path=b&project=<uuid>; re-render to apply the now-known forum.
|
||
triggerCascadeRefresh();
|
||
})();
|
||
|
||
const akteSearch = document.getElementById("fristen-akte-search") as HTMLInputElement | null;
|
||
if (akteSearch) {
|
||
akteSearch.addEventListener("input", () => renderAkteList(akteSearch.value));
|
||
}
|
||
|
||
// Ad-hoc chips — explore-mode escape hatch. No DB write; the
|
||
// save-modal CTA disables itself in this state.
|
||
document.querySelectorAll<HTMLButtonElement>(".fristen-adhoc-chip").forEach((chip) => {
|
||
chip.addEventListener("click", () => {
|
||
const forum = chip.dataset.adHoc as AdhocForum | undefined;
|
||
if (forum) selectAdhoc(forum);
|
||
});
|
||
});
|
||
|
||
// t-paliad-180: perspective + inbox chip strips folded into the
|
||
// row stack; click wiring now lives inside renderRowStack(). We
|
||
// still hydrate perspective from URL here so the row stack picks
|
||
// up the correct picked-value on first paint.
|
||
applyPerspective(readPerspectiveFromURL());
|
||
|
||
// t-paliad-180: search escape-hatch (Pathway B Direkt-suchen
|
||
// button) routes to ?mode=filter. Reset link drops the cascade
|
||
// slug back to root, keeping mode / perspective / inbox alone.
|
||
// t-paliad-198 Slice 3: Direkt-suchen icon expands an inline search
|
||
// overlay over the row stack — the legacy ?mode=filter route is kept
|
||
// for deep-link compatibility but is no longer the user-facing path.
|
||
document.getElementById("fristen-row-search-link")?.addEventListener("click", () => {
|
||
setInlineSearchActive(true);
|
||
});
|
||
document.getElementById("fristen-row-search-panel-back")?.addEventListener("click", () => {
|
||
setInlineSearchActive(false);
|
||
});
|
||
document.getElementById("fristen-row-reset")?.addEventListener("click", () => {
|
||
currentActiveRow = null;
|
||
navigateB1("");
|
||
});
|
||
initInlineSearch();
|
||
|
||
// Reselect: drop the locked context, return to Step 1.
|
||
document.getElementById("fristen-step1-summary-reselect")?.addEventListener("click", () => {
|
||
clearStep1Context();
|
||
// Bounce back to fork (Step 1 + 2) so the user sees the picker,
|
||
// even if they were currently in Pathway A or B.
|
||
if (initial !== "fork") {
|
||
navigateToPathway("fork");
|
||
}
|
||
});
|
||
|
||
// Step 2 cards — outgoing (Step 3a chooser) vs incoming (Pathway B
|
||
// cascade). showPathway() owns the actual transition; we just drive
|
||
// it from the action choice.
|
||
document.getElementById("fristen-step2-file")?.addEventListener("click", () => {
|
||
navigateToPathway("outgoing");
|
||
});
|
||
document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
|
||
navigateToPathway("b", "tree");
|
||
});
|
||
// t-paliad-179 Slice 1: the "Verfahrensablauf einsehen" Step 2 card
|
||
// has been retired — the abstract-browse intent lives on its own
|
||
// route at /tools/verfahrensablauf now. No third-card handler here.
|
||
|
||
// Step 3a cards — File / Draft / Enter. File drops into the existing
|
||
// Pathway A wizard; Enter routes to the manual-create form;
|
||
// Draft is a v1 placeholder (button disabled in markup, no handler).
|
||
document.getElementById("fristen-step3a-file")?.addEventListener("click", () => {
|
||
navigateToPathway("a");
|
||
});
|
||
document.getElementById("fristen-step3a-enter")?.addEventListener("click", () => {
|
||
window.location.href = resolveDeadlinesNewURL(currentStep1Context);
|
||
});
|
||
|
||
// Back-from-Pathway buttons return to Step 2 (the new "fork" state).
|
||
// Pathway A's back returns to Step 3a since that's where the user
|
||
// came from in the new flow; pre-Slice-3 muscle memory of Pathway A
|
||
// back going all the way to fork is preserved by clicking back twice.
|
||
document.getElementById("fristen-pathway-a-back")?.addEventListener("click", () => {
|
||
navigateToPathway("outgoing");
|
||
});
|
||
document.getElementById("fristen-pathway-b-back")?.addEventListener("click", () => {
|
||
navigateToPathway("fork");
|
||
});
|
||
document.getElementById("fristen-step3a-back")?.addEventListener("click", () => {
|
||
navigateToPathway("fork");
|
||
});
|
||
|
||
// t-paliad-180: B1/B2 mode toggle moved into the row stack. The
|
||
// mode row's click handler (renderRowStack → handleRowPick) drives
|
||
// setPathwayURL + showBMode now; no more standalone radio.
|
||
|
||
// Quick-pick chips on the Step 2 shortcut row → jump straight to
|
||
// Pathway B + filter mode + prefilled query. Same behaviour as the
|
||
// legacy fork; only the ID's mounting point changed.
|
||
document.querySelectorAll<HTMLButtonElement>("#fristen-fork-chips .fristen-search-chip").forEach((chip) => {
|
||
chip.addEventListener("click", () => {
|
||
const q = chipQueryFor(chip);
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set("path", "b");
|
||
url.searchParams.set("mode", "filter");
|
||
if (q) url.searchParams.set("q", q);
|
||
window.history.pushState({}, "", url.toString());
|
||
showPathway("b", "filter");
|
||
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
|
||
if (input && q) {
|
||
input.value = q;
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
}
|
||
});
|
||
});
|
||
|
||
// Browser back/forward restores pathway + Step 1 context + perspective.
|
||
window.addEventListener("popstate", () => {
|
||
currentStep1Context = readStep1ContextFromURL();
|
||
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
|
||
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
|
||
}
|
||
renderStep1Summary();
|
||
if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
|
||
applyPerspective(readPerspectiveFromURL());
|
||
// t-paliad-180: the "aus Akte" tag is now part of the prefilled
|
||
// row state; buildRowStack derives visibility from
|
||
// project.our_side ↔ currentPerspective inside the renderer, so
|
||
// no separate DOM hint flip is needed on popstate.
|
||
currentActiveRow = null;
|
||
// t-paliad-197: popstate is a fresh navigation — auto-walk should
|
||
// re-evaluate from scratch, no stale "ändern-just-clicked" cap.
|
||
cascadeAutoWalkStopAfter = null;
|
||
const path = readPathwayFromURL();
|
||
const mode = readBModeFromURL();
|
||
showPathway(path, mode);
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", initPathwayFork);
|
||
|
||
// ============================================================================
|
||
// v3 B1 decision tree (t-paliad-133 Phase C)
|
||
// ============================================================================
|
||
// Data-driven cascade: fetch the event-categories tree from
|
||
// GET /api/tools/fristenrechner/event-categories, render the current
|
||
// step's button set, walk down on click, show breadcrumb + reset.
|
||
// Result cards below come from /api/tools/fristenrechner/search with
|
||
// ?event_category_slug= narrowing.
|
||
|
||
interface EventCategoryNode {
|
||
id: string;
|
||
slug: string;
|
||
label_de: string;
|
||
label_en: string;
|
||
description_de?: string;
|
||
description_en?: string;
|
||
step_question_de?: string;
|
||
step_question_en?: string;
|
||
icon?: string;
|
||
sort_order: number;
|
||
is_leaf: boolean;
|
||
// #15 follow-up: coarse forum tags ('upc' | 'de' | 'epa' | 'dpma').
|
||
// Empty / undefined = neutral, node visible from every inbox setting.
|
||
forums?: string[];
|
||
// Slice 3c: party tags ('claimant' | 'defendant' | 'both' | 'court').
|
||
// Empty / undefined = neutral. Hides leaves whose tag doesn't match
|
||
// the active perspective chip.
|
||
party?: string[];
|
||
children?: EventCategoryNode[];
|
||
}
|
||
|
||
let eventCategoryTree: EventCategoryNode[] | null = null;
|
||
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
|
||
|
||
// Top-level cascade roots that represent forward-looking workflows ("I
|
||
// want to file X, what deadlines does my action trigger?") rather than
|
||
// the backward-looking calc the Fristenrechner is built for ("event Y
|
||
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
|
||
// remove these from the "Was ist passiert?" picker — they belong in a
|
||
// future forward-workflow tool, not here. The DB rows stay so that
|
||
// future tool can pick them back up; we just hide them at the UI layer.
|
||
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
|
||
"ich-moechte-einreichen",
|
||
]);
|
||
|
||
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||
if (eventCategoryTree) return eventCategoryTree;
|
||
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
|
||
eventCategoryFetchInflight = (async () => {
|
||
try {
|
||
const r = await fetch("/api/tools/fristenrechner/event-categories");
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
const data = await r.json();
|
||
const raw = (data.tree || []) as EventCategoryNode[];
|
||
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
|
||
return eventCategoryTree;
|
||
} finally {
|
||
eventCategoryFetchInflight = null;
|
||
}
|
||
})();
|
||
return eventCategoryFetchInflight;
|
||
}
|
||
|
||
function readB1PathFromURL(): string {
|
||
return new URLSearchParams(window.location.search).get("b1") || "";
|
||
}
|
||
|
||
function setB1PathInURL(slug: string, replace = false) {
|
||
const url = new URL(window.location.href);
|
||
if (slug) {
|
||
url.searchParams.set("b1", slug);
|
||
} else {
|
||
url.searchParams.delete("b1");
|
||
}
|
||
if (replace) {
|
||
window.history.replaceState({}, "", url.toString());
|
||
} else {
|
||
window.history.pushState({}, "", url.toString());
|
||
}
|
||
}
|
||
|
||
function findNodeBySlug(roots: EventCategoryNode[], slug: string): EventCategoryNode | null {
|
||
for (const root of roots) {
|
||
if (root.slug === slug) return root;
|
||
if (root.children) {
|
||
const inner = findNodeBySlug(root.children, slug);
|
||
if (inner) return inner;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildBreadcrumb(roots: EventCategoryNode[], slug: string): EventCategoryNode[] {
|
||
// Slug is dot-separated; walk down each segment.
|
||
if (!slug) return [];
|
||
const parts = slug.split(".");
|
||
const trail: EventCategoryNode[] = [];
|
||
let scope = roots;
|
||
let cumulative = "";
|
||
for (const seg of parts) {
|
||
cumulative = cumulative ? `${cumulative}.${seg}` : seg;
|
||
const node = scope.find((n) => n.slug === cumulative);
|
||
if (!node) break;
|
||
trail.push(node);
|
||
scope = node.children || [];
|
||
}
|
||
return trail;
|
||
}
|
||
|
||
function nodeLabel(n: EventCategoryNode): string {
|
||
return getLang() === "de" ? n.label_de : n.label_en;
|
||
}
|
||
|
||
function nodeStepQuestion(n: EventCategoryNode): string {
|
||
return getLang() === "de"
|
||
? (n.step_question_de || "")
|
||
: (n.step_question_en || n.step_question_de || "");
|
||
}
|
||
|
||
// t-paliad-180 Slice 1 — row-stack rendering.
|
||
//
|
||
// The Pathway B shell used to layer four functionally different decision
|
||
// surfaces with four different visuals: a radio (mode), two pill strips
|
||
// (perspective + inbox), and a button-grid cascade with a breadcrumb.
|
||
// They're all "narrow the deadline-rule space" steps; the new model
|
||
// renders every one of them as the same `.fristen-row` primitive in a
|
||
// single stack — top-down, persistent, each row showing the question +
|
||
// the picked answer + an inline "ändern" affordance.
|
||
//
|
||
// Slice 1 is visual-only: the narrowing engine (forum + perspective
|
||
// filters) is unchanged. What changes is presentation — the user can
|
||
// finally see their full decision path at a glance, and edit any row
|
||
// without losing the rest. Slice 2 will add project-driven prefills +
|
||
// auto-walk; Slice 3 polishes mobile + the search escape-hatch.
|
||
|
||
// t-paliad-198 Slice 3: mode kind retired — the icon-button at the top
|
||
// of the row stack (`#fristen-row-search-link`) replaces the mode row,
|
||
// toggling an inline search overlay instead of routing to ?mode=filter.
|
||
type RowKind = "perspective" | "inbox" | "cascade";
|
||
type RowState = "active" | "answered" | "prefilled";
|
||
|
||
interface RowOption {
|
||
value: string;
|
||
label: string;
|
||
icon?: string;
|
||
isLeaf?: boolean;
|
||
title?: string;
|
||
}
|
||
|
||
interface RowSpec {
|
||
kind: RowKind;
|
||
rowId: string;
|
||
state: RowState;
|
||
question: string;
|
||
options: RowOption[];
|
||
pickedValue?: string;
|
||
pickedLabel?: string;
|
||
pickedIcon?: string;
|
||
// is-prefilled rows can carry an "aus Akte: <reference>" annotation.
|
||
// Per design §11.2, only the first prefilled row in a stack shows the
|
||
// reference; subsequent ones show the plain tag.
|
||
prefilledReference?: string;
|
||
// Cascade-only: the slug of the ancestor node whose children populate
|
||
// this row's options. Used by the click handler to navigate to a
|
||
// sibling without losing prefix state. Empty string = cascade root.
|
||
cascadeParentSlug?: string;
|
||
// Cascade-only: the slug to navigate to when the user clicks "ändern"
|
||
// on this answered row. Always the row's parent slug (ancestor at
|
||
// depth K-1), which drops descendants and re-actives this depth.
|
||
cascadeRevertSlug?: string;
|
||
// Cascade-only: the depth this row occupies in the post-auto-walk
|
||
// trail (0 = root bucket). Used by handleRowEdit to compute the
|
||
// auto-walk suppression key.
|
||
cascadeDepth?: number;
|
||
}
|
||
|
||
// currentActiveRow tracks which non-cascade row the user has clicked
|
||
// "ändern" on. null = no override; the deepest cascade step is the
|
||
// natural active row. Cleared on every successful pick.
|
||
let currentActiveRow: string | null = null;
|
||
|
||
// cascadeAutoWalkStopAfter caps the auto-walk depth for the next
|
||
// render. Set when the user clicks "ändern" on an auto-walked cascade
|
||
// row at depth K — auto-walk would otherwise re-fire on render and
|
||
// undo the edit. The cap stays in place until the user picks a chip
|
||
// (which clears it via handleRowPick → renderRowStack → cleared by
|
||
// the picker), at which point auto-walk re-engages from the new
|
||
// position. null = no cap (auto-walk runs to first branching point).
|
||
let cascadeAutoWalkStopAfter: number | null = null;
|
||
|
||
// cascadeTooltipDismissedKey backs the "first auto-walk tooltip" flag
|
||
// in localStorage. Once the user dismisses the tooltip, the key stays
|
||
// for future sessions; we only re-prompt if the key gets cleared.
|
||
const cascadeTooltipDismissedKey = "paliad.fristen.cascade.autoWalkTooltipSeen";
|
||
|
||
function perspectiveOptionLabel(value: string): string {
|
||
if (value === "claimant") return t("deadlines.perspective.claimant.short");
|
||
if (value === "defendant") return t("deadlines.perspective.defendant.short");
|
||
return t("deadlines.perspective.both.short");
|
||
}
|
||
|
||
function inboxOptionLabel(value: string): string {
|
||
if (value === "cms") return "CMS";
|
||
if (value === "bea") return "beA";
|
||
if (value === "posteingang") return t("deadlines.inbox.posteingang");
|
||
return t("deadlines.inbox.all");
|
||
}
|
||
|
||
|
||
// Slice 2: cascade-segment ↔ fristenrechner-code bridge. The event_categories
|
||
// taxonomy uses kebab-case segments under the `cms-eingang.*` buckets to
|
||
// represent proceedings (`upc-inf`, `de-bgh-null`, …); paliad.projects
|
||
// binds to fristenrechner codes by id and the lookup yields the
|
||
// lowercase dot-separated taxonomy ratified by mig 096
|
||
// (`upc.inf.cfi`, `de.inf.bgh`, …). The event_categories slugs are NOT
|
||
// renamed by mig 096 — they live in a separate taxonomy and the kebab
|
||
// form is presentation-layer (it appears in URL fragments). This map
|
||
// is the bridge. Any code not in the map degrades to "no proceeding-
|
||
// axis narrowing" — better silent than wrong (design §11.6).
|
||
//
|
||
// upc.ccr.cfi is the illustrative peer added by mig 096; it shares the
|
||
// `upc-inf` kebab segment because rules live on upc.inf.cfi with
|
||
// with_ccr=true (design doc S1, proceeding_mapping.go).
|
||
const fristenrechnerCodeToCascadeSegment: Record<string, string> = {
|
||
"upc.inf.cfi": "upc-inf",
|
||
"upc.ccr.cfi": "upc-inf",
|
||
"upc.rev.cfi": "upc-rev",
|
||
"upc.apl.merits": "upc-app",
|
||
"upc.pi.cfi": "upc-pi",
|
||
"de.inf.lg": "de-inf",
|
||
"de.null.bpatg": "de-null",
|
||
"de.inf.bgh": "de-bgh-inf",
|
||
"de.null.bgh": "de-bgh-null",
|
||
"dpma.opp.dpma": "dpma-opp",
|
||
"dpma.appeal.bgh":"dpma-bgh",
|
||
"epa.opp.opd": "epa-opp",
|
||
"epa.opp.boa": "epa-app",
|
||
};
|
||
|
||
// Set of kebab segments known to be proceeding-axis values. Used to
|
||
// distinguish "this child is a proceeding pick" from "this child is a
|
||
// sender / bucket / Schriftsatz" — proceeding-axis siblings get filtered
|
||
// by project context, others always pass.
|
||
const proceedingCascadeSegments = new Set(Object.values(fristenrechnerCodeToCascadeSegment));
|
||
|
||
function fristenrechnerCodeFromProject(p?: ProjectOption | null): string | null {
|
||
if (!p || p.proceeding_type_id == null) return null;
|
||
return cachedProceedingTypes.get(p.proceeding_type_id) ?? null;
|
||
}
|
||
|
||
function projectCascadeSegment(p?: ProjectOption | null): string | null {
|
||
const code = fristenrechnerCodeFromProject(p);
|
||
if (!code) return null;
|
||
return fristenrechnerCodeToCascadeSegment[code] ?? null;
|
||
}
|
||
|
||
// cascadeChildAllowsProject narrows cascade children to those matching
|
||
// the project's proceeding code along the proceeding axis. Children
|
||
// whose terminal slug segment isn't a known proceeding segment always
|
||
// pass (they're orthogonal axes — sender / bucket / Schriftsatz / …).
|
||
// Without a project context this is a no-op.
|
||
function cascadeChildAllowsProject(child: EventCategoryNode, projectSegment: string | null): boolean {
|
||
if (!projectSegment) return true;
|
||
const terminal = child.slug.split(".").pop() || "";
|
||
if (!proceedingCascadeSegments.has(terminal)) return true;
|
||
return terminal === projectSegment;
|
||
}
|
||
|
||
function filterCascadeChildren(scope: EventCategoryNode[], projectSegment: string | null): EventCategoryNode[] {
|
||
return scope.filter((c) =>
|
||
inboxFilterAllowsForums(c.forums)
|
||
&& perspectiveAllowsParty(c.party)
|
||
&& cascadeChildAllowsProject(c, projectSegment));
|
||
}
|
||
|
||
// projectReferenceLabel returns the short identifier used in the "aus
|
||
// Akte: <reference>" tag. Falls back to a short slice of the UUID when
|
||
// the project has no human-readable reference yet.
|
||
function projectReferenceLabel(p?: ProjectOption | null): string {
|
||
if (!p) return "";
|
||
if (p.reference && p.reference.trim() !== "") return p.reference.trim();
|
||
return p.id ? p.id.slice(0, 8) : "";
|
||
}
|
||
|
||
function buildRowStack(currentSlug: string): RowSpec[] {
|
||
const rows: RowSpec[] = [];
|
||
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
|
||
const hasProject = !!proj;
|
||
const projectForum = hasProject ? forumFromProject(proj) : null;
|
||
const projectSegment = projectCascadeSegment(proj);
|
||
const projectRef = projectReferenceLabel(proj);
|
||
|
||
// Once a single "aus Akte: <reference>" tag has been rendered, subsequent
|
||
// prefilled rows omit the reference (design §11.2) — repeating it on
|
||
// every row is just noise.
|
||
let referenceTagUsed = false;
|
||
const consumeReferenceTag = (): string => {
|
||
if (!projectRef) return "";
|
||
if (referenceTagUsed) return "";
|
||
referenceTagUsed = true;
|
||
return projectRef;
|
||
};
|
||
|
||
// R1 — Perspective. Default null ("Beide") = no filter; the row
|
||
// renders as answered with that label. t-paliad-164 carry-over: a
|
||
// project-bound perspective shows as `is-prefilled` with the "aus Akte"
|
||
// tag in the picked-answer area.
|
||
const perspectiveValue = currentPerspective ?? "";
|
||
const expectedFromAkte = ourSideToPerspective(proj?.our_side);
|
||
const perspectivePrefilled =
|
||
!!(proj && proj.our_side && expectedFromAkte === currentPerspective && currentPerspective !== null);
|
||
rows.push({
|
||
kind: "perspective",
|
||
rowId: "perspective",
|
||
state: currentActiveRow === "perspective"
|
||
? "active"
|
||
: perspectivePrefilled ? "prefilled" : "answered",
|
||
question: t("deadlines.perspective.label"),
|
||
options: [
|
||
{ value: "claimant", label: t("deadlines.perspective.claimant.short"), title: t("deadlines.perspective.claimant.title") },
|
||
{ value: "defendant", label: t("deadlines.perspective.defendant.short"), title: t("deadlines.perspective.defendant.title") },
|
||
{ value: "", label: t("deadlines.perspective.both.short") },
|
||
],
|
||
pickedValue: perspectiveValue,
|
||
pickedLabel: perspectiveOptionLabel(perspectiveValue),
|
||
prefilledReference: perspectivePrefilled ? consumeReferenceTag() : undefined,
|
||
});
|
||
|
||
// R2 — Inbox channel. Hidden entirely when the project's forum is UPC
|
||
// (CMS is the only valid inbox for UPC matters, so the question is
|
||
// moot — design §5 matrix). For non-UPC projects, ad-hoc explore mode,
|
||
// and the no-context case, the row renders as answered with whatever
|
||
// "Wo kam es an?" channel was last picked (default: "Alle").
|
||
const r2Hidden = projectForum === "upc" && currentActiveRow !== "inbox";
|
||
if (!r2Hidden) {
|
||
const inboxValue = currentInboxChannel ?? "";
|
||
rows.push({
|
||
kind: "inbox",
|
||
rowId: "inbox",
|
||
state: currentActiveRow === "inbox" ? "active" : "answered",
|
||
question: t("deadlines.inbox.label"),
|
||
options: [
|
||
{ value: "cms", label: "CMS", title: t("deadlines.inbox.cms.title") },
|
||
{ value: "bea", label: "beA", title: t("deadlines.inbox.bea.title") },
|
||
{ value: "posteingang", label: t("deadlines.inbox.posteingang"), title: t("deadlines.inbox.posteingang.title") },
|
||
{ value: "", label: t("deadlines.inbox.all") },
|
||
],
|
||
pickedValue: inboxValue,
|
||
pickedLabel: inboxOptionLabel(inboxValue),
|
||
});
|
||
}
|
||
|
||
// R3..Rn — Cascade. URL slug = user-picked deepest position. The
|
||
// trail walks the URL slug; each trail node renders as `answered`
|
||
// unless it was the sole option at its parent's filtered scope under
|
||
// a project context — in which case it's `prefilled` (the user
|
||
// effectively had no choice; project context implied this pick).
|
||
//
|
||
// After the trail, the auto-walk extends the stack with `prefilled`
|
||
// rows whenever the remaining scope narrows to a single option under
|
||
// a project context; the walk stops at the first branching point,
|
||
// empty scope, or leaf — which is where the active row goes (if any).
|
||
// Auto-walk does NOT extend the URL — the user-picked slug stays
|
||
// explicit. "Ändern" on an auto-walked row sets `cascadeAutoWalkStopAfter`
|
||
// so the next render shows the active row at that depth.
|
||
const trail = eventCategoryTree
|
||
? buildBreadcrumb(eventCategoryTree, currentSlug)
|
||
: [];
|
||
let parentScope: EventCategoryNode[] = eventCategoryTree || [];
|
||
let parentSlug = "";
|
||
let parentQuestion = t("deadlines.pathway.b.tree.start_question") || "Was ist passiert?";
|
||
|
||
for (let i = 0; i < trail.length; i++) {
|
||
const node = trail[i];
|
||
const filteredSiblings = filterCascadeChildren(parentScope, projectSegment);
|
||
// "Sole option" detection: at this depth, the project-filtered scope
|
||
// contains only this node — the pick was implied by context.
|
||
const wasSoleOption = hasProject && filteredSiblings.length === 1
|
||
&& filteredSiblings[0].slug === node.slug;
|
||
const isPrefilled = wasSoleOption;
|
||
rows.push({
|
||
kind: "cascade",
|
||
rowId: `cascade:${i}`,
|
||
state: isPrefilled ? "prefilled" : "answered",
|
||
question: parentQuestion,
|
||
options: filteredSiblings.map(nodeToRowOption),
|
||
pickedValue: node.slug,
|
||
pickedLabel: nodeLabel(node),
|
||
pickedIcon: node.icon,
|
||
cascadeParentSlug: parentSlug,
|
||
cascadeRevertSlug: parentSlug,
|
||
cascadeDepth: i,
|
||
prefilledReference: isPrefilled ? consumeReferenceTag() : undefined,
|
||
});
|
||
parentScope = node.children || [];
|
||
parentSlug = node.slug;
|
||
parentQuestion = nodeStepQuestion(node) || parentQuestion;
|
||
}
|
||
|
||
// Auto-walk extension + active row. Walk down the cascade as long as
|
||
// the filtered scope narrows to a single option (with a project
|
||
// context that justifies the inference); stop at branching, empty
|
||
// scope, or a leaf. The first branching point becomes the active row;
|
||
// an empty scope or leaf finalises the stack with no active row.
|
||
let walkDepth = trail.length;
|
||
const deepestTrail = trail.length > 0 ? trail[trail.length - 1] : null;
|
||
let walked: EventCategoryNode | null = null;
|
||
|
||
if (!deepestTrail || !deepestTrail.is_leaf) {
|
||
// eslint-disable-next-line no-constant-condition
|
||
while (true) {
|
||
const filtered = filterCascadeChildren(parentScope, projectSegment);
|
||
if (filtered.length === 0) break;
|
||
const canAutoWalk = filtered.length === 1
|
||
&& hasProject
|
||
&& (cascadeAutoWalkStopAfter === null || walkDepth < cascadeAutoWalkStopAfter);
|
||
if (canAutoWalk) {
|
||
const node = filtered[0];
|
||
rows.push({
|
||
kind: "cascade",
|
||
rowId: `cascade:${walkDepth}`,
|
||
state: "prefilled",
|
||
question: parentQuestion,
|
||
options: filtered.map(nodeToRowOption),
|
||
pickedValue: node.slug,
|
||
pickedLabel: nodeLabel(node),
|
||
pickedIcon: node.icon,
|
||
cascadeParentSlug: parentSlug,
|
||
// Revert slug for an auto-walked row is the URL-explicit slug
|
||
// (= the trail tail). ändern on this row should NOT change the
|
||
// URL — it suppresses auto-walk past this depth instead. See
|
||
// handleRowEdit's cascade branch.
|
||
cascadeRevertSlug: parentSlug,
|
||
cascadeDepth: walkDepth,
|
||
prefilledReference: consumeReferenceTag(),
|
||
});
|
||
walked = node;
|
||
parentScope = node.children || [];
|
||
parentSlug = node.slug;
|
||
parentQuestion = nodeStepQuestion(node) || parentQuestion;
|
||
walkDepth += 1;
|
||
if (node.is_leaf) break;
|
||
continue;
|
||
}
|
||
// Branching or no-project — render active row at this depth.
|
||
rows.push({
|
||
kind: "cascade",
|
||
rowId: `cascade:${walkDepth}`,
|
||
state: "active",
|
||
question: parentQuestion,
|
||
options: filtered.map(nodeToRowOption),
|
||
cascadeParentSlug: parentSlug,
|
||
cascadeRevertSlug: parentSlug,
|
||
cascadeDepth: walkDepth,
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Stash the effective deepest slug so renderRowStack can pass it to
|
||
// runB1Search — the result panel narrows on the auto-walked leaf, not
|
||
// the URL slug.
|
||
cascadeEffectiveSlug = walked ? walked.slug : (deepestTrail ? deepestTrail.slug : "");
|
||
|
||
return rows;
|
||
}
|
||
|
||
// cascadeEffectiveSlug is the deepest slug after auto-walk extension —
|
||
// used by runB1Search to narrow the concept-card results to the
|
||
// effective cascade position, not just the URL-explicit slug. Updated
|
||
// by buildRowStack on every render.
|
||
let cascadeEffectiveSlug = "";
|
||
|
||
function nodeToRowOption(c: EventCategoryNode): RowOption {
|
||
return {
|
||
value: c.slug,
|
||
label: nodeLabel(c),
|
||
icon: c.icon,
|
||
isLeaf: c.is_leaf,
|
||
};
|
||
}
|
||
|
||
function rowOptionChipHtml(rowId: string, opt: RowOption, isPicked: boolean): string {
|
||
const cls = [
|
||
"fristen-row-chip",
|
||
opt.isLeaf ? "fristen-row-chip--leaf" : "",
|
||
isPicked ? "is-picked" : "",
|
||
].filter(Boolean).join(" ");
|
||
const titleAttr = opt.title ? ` title="${escAttr(opt.title)}"` : "";
|
||
const iconHtml = opt.icon
|
||
? `<span class="fristen-row-chip-icon" aria-hidden="true">${escHtml(opt.icon)}</span>`
|
||
: "";
|
||
return `<button type="button" class="${cls}" data-row-id="${escAttr(rowId)}"
|
||
data-row-value="${escAttr(opt.value)}"${titleAttr}>
|
||
${iconHtml}<span class="fristen-row-chip-label">${escHtml(opt.label)}</span>
|
||
</button>`;
|
||
}
|
||
|
||
function rowHtml(row: RowSpec, rowNumber: number): string {
|
||
const stateClass = `is-${row.state}`;
|
||
const ariaCurrent = row.state === "active" ? ' aria-current="step"' : "";
|
||
const dataKindAttr = ` data-row-kind="${escAttr(row.kind)}"`;
|
||
const dataIdAttr = ` data-row-id="${escAttr(row.rowId)}"`;
|
||
|
||
if (row.state === "active") {
|
||
const chipsHtml = row.options.map((o) =>
|
||
rowOptionChipHtml(row.rowId, o, o.value === (row.pickedValue ?? ""))).join("");
|
||
return `<div class="fristen-row ${stateClass}"${ariaCurrent}${dataKindAttr}${dataIdAttr}>
|
||
<div class="fristen-row-head">
|
||
<span class="fristen-row-num" aria-hidden="true">${rowNumber}</span>
|
||
<span class="fristen-row-label">${escHtml(row.question)}</span>
|
||
</div>
|
||
<div class="fristen-row-body">${chipsHtml}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// answered + prefilled share the compact head-only layout.
|
||
const iconHtml = row.pickedIcon
|
||
? `<span class="fristen-row-answer-icon" aria-hidden="true">${escHtml(row.pickedIcon)}</span>`
|
||
: "";
|
||
// Prefilled tag carries the project reference on the first prefilled
|
||
// row only (design §11.2 — subsequent rows show the plain "aus Akte"
|
||
// tag). prefilledReference !== "" signals "this row owns the
|
||
// reference"; "" / undefined signals plain tag.
|
||
const prefilledTag = row.state === "prefilled"
|
||
? (row.prefilledReference
|
||
? `<span class="fristen-row-prefilled-tag">
|
||
<span data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>:
|
||
<span class="fristen-row-prefilled-ref">${escHtml(row.prefilledReference)}</span>
|
||
</span>`
|
||
: `<span class="fristen-row-prefilled-tag" data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>`)
|
||
: "";
|
||
return `<div class="fristen-row ${stateClass}"${dataKindAttr}${dataIdAttr}>
|
||
<div class="fristen-row-head">
|
||
<span class="fristen-row-num" aria-hidden="true">${rowNumber}</span>
|
||
<span class="fristen-row-label">${escHtml(row.question)}</span>
|
||
<span class="fristen-row-answer">
|
||
<span class="fristen-row-answer-check" aria-hidden="true">✓</span>
|
||
${iconHtml}<span class="fristen-row-answer-label">${escHtml(row.pickedLabel || "")}</span>
|
||
${prefilledTag}
|
||
</span>
|
||
<button type="button" class="fristen-row-edit" data-row-edit="${escAttr(row.rowId)}">
|
||
<span>${escHtml(t("deadlines.row.edit"))}</span>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderRowStack(currentSlug: string) {
|
||
const stack = document.getElementById("fristen-row-stack");
|
||
if (!stack || !eventCategoryTree) return;
|
||
|
||
const rows = buildRowStack(currentSlug);
|
||
stack.innerHTML = rows.map((r, i) => rowHtml(r, i + 1)).join("");
|
||
maybeShowAutoWalkTooltip(stack, rows);
|
||
// t-paliad-198 Slice 3: bring the active row into view on every
|
||
// render. Particularly important on mobile, where a chip pick may
|
||
// push the next active row below the fold; the helper is a no-op
|
||
// when the row is already in view, so desktop doesn't see jumpy
|
||
// scrolling.
|
||
autoscrollToActiveRow(stack);
|
||
|
||
// Wire chip picks (active rows).
|
||
stack.querySelectorAll<HTMLButtonElement>(".fristen-row-chip").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const rowId = btn.dataset.rowId || "";
|
||
const value = btn.dataset.rowValue || "";
|
||
handleRowPick(rowId, value);
|
||
});
|
||
});
|
||
|
||
// Wire "ändern" affordance + whole-row click on answered/prefilled
|
||
// rows. Whole-row click follows the CLAUDE.md "row-level click handler
|
||
// that skips inner <a>/<button>" pattern — clicking the ändern button
|
||
// is handled by its own listener, so the row-level handler skips it.
|
||
stack.querySelectorAll<HTMLButtonElement>(".fristen-row-edit").forEach((btn) => {
|
||
btn.addEventListener("click", (ev) => {
|
||
ev.stopPropagation();
|
||
const rowId = btn.dataset.rowEdit || "";
|
||
handleRowEdit(rowId);
|
||
});
|
||
});
|
||
stack.querySelectorAll<HTMLDivElement>(".fristen-row.is-answered, .fristen-row.is-prefilled")
|
||
.forEach((row) => {
|
||
row.addEventListener("click", (ev) => {
|
||
const target = ev.target as Element | null;
|
||
if (target && target.closest("a, button")) return;
|
||
const rowId = (row as HTMLElement).dataset.rowId || "";
|
||
handleRowEdit(rowId);
|
||
});
|
||
});
|
||
|
||
runB1Search(cascadeEffectiveSlug);
|
||
}
|
||
|
||
// handleRowPick routes a chip selection in an active row to the right
|
||
// state mutator + URL writer. Cascade picks navigate to the child slug
|
||
// (slugs encode the full path, so cascade depth is reconstructed on
|
||
// every render); the other kinds update their per-kind state + clear
|
||
// the "currently being edited" override.
|
||
function handleRowPick(rowId: string, value: string) {
|
||
// t-paliad-198 Slice 3: any chip pick is also a "user interacted"
|
||
// signal — the auto-walk tooltip's job is done.
|
||
dismissAutoWalkTooltip();
|
||
if (rowId === "perspective") {
|
||
const next: Perspective = value === "claimant" || value === "defendant" ? value : null;
|
||
writePerspectiveToURL(next);
|
||
currentActiveRow = null;
|
||
cascadeAutoWalkStopAfter = null;
|
||
applyPerspective(next);
|
||
return;
|
||
}
|
||
if (rowId === "inbox") {
|
||
const next: InboxChannel = value === "cms" || value === "bea" || value === "posteingang"
|
||
? value : null;
|
||
writeInboxToURL(next);
|
||
currentActiveRow = null;
|
||
cascadeAutoWalkStopAfter = null;
|
||
applyInboxFilter(next);
|
||
applyFineForumsFromInbox(next);
|
||
writeForumsToURL(true);
|
||
void persistInboxPref(next);
|
||
return;
|
||
}
|
||
if (rowId.startsWith("cascade:")) {
|
||
currentActiveRow = null;
|
||
// A new cascade pick clears any auto-walk cap — the user has
|
||
// committed to this branch, so auto-walk can re-engage from here.
|
||
cascadeAutoWalkStopAfter = null;
|
||
navigateB1(value);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// handleRowEdit re-activates an answered row. For non-cascade rows that
|
||
// flips the row to active in-place (toggle via currentActiveRow) so the
|
||
// user can re-pick; the cascade below stays valid. For cascade rows it
|
||
// either drops descendants by navigating to the row's revert slug —
|
||
// matching today's breadcrumb-click semantic — or, when the row was
|
||
// auto-walked, caps the auto-walk depth so the row turns active
|
||
// in-place without changing the URL.
|
||
function handleRowEdit(rowId: string) {
|
||
if (rowId === "perspective" || rowId === "inbox") {
|
||
currentActiveRow = rowId;
|
||
renderRowStack(readB1PathFromURL());
|
||
// t-paliad-198 Slice 3: any explicit ändern click counts as user
|
||
// interaction, so the auto-walk tooltip is no longer needed.
|
||
dismissAutoWalkTooltip();
|
||
return;
|
||
}
|
||
if (rowId.startsWith("cascade:")) {
|
||
const idx = parseInt(rowId.slice("cascade:".length), 10);
|
||
if (!Number.isFinite(idx)) return;
|
||
const urlSlug = readB1PathFromURL();
|
||
const trail = buildBreadcrumb(eventCategoryTree || [], urlSlug);
|
||
if (idx < trail.length) {
|
||
// Row K is within the user-explicit trail — ändern drops to
|
||
// the parent slug, which drops descendants AND clears any
|
||
// auto-walk suppression so the next pick can extend again.
|
||
const revertSlug = idx > 0 ? (trail[idx - 1]?.slug || "") : "";
|
||
currentActiveRow = null;
|
||
cascadeAutoWalkStopAfter = null;
|
||
navigateB1(revertSlug);
|
||
dismissAutoWalkTooltip();
|
||
return;
|
||
}
|
||
// Row K is auto-walked (beyond the URL trail). Suppress auto-walk
|
||
// past depth K so the row materialises as active in the next
|
||
// render — URL stays at the trail end.
|
||
cascadeAutoWalkStopAfter = idx;
|
||
currentActiveRow = null;
|
||
renderRowStack(urlSlug);
|
||
dismissAutoWalkTooltip();
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// t-paliad-198 Slice 3 — inline search overlay + autoscroll-to-active +
|
||
// tooltip polish.
|
||
// ============================================================================
|
||
//
|
||
// Inline search replaces the Slice 1 mode-toggle row: clicking the
|
||
// `🔍 Direkt suchen` icon in the row-stack header expands a search input
|
||
// over the row stack and renders results into the same #fristen-b1-results
|
||
// container the cascade narrows into. ESC inside the input or the back
|
||
// button collapses it. State is ephemeral — no URL change — so a user can
|
||
// step into search to check a name, then return to the cascade with all
|
||
// their picks intact. Deep-link path ?mode=filter still routes to the
|
||
// legacy B2 panel for backwards compatibility, but is no longer exposed
|
||
// in the cascade UI.
|
||
|
||
let inlineSearchActive = false;
|
||
let inlineSearchSeq = 0;
|
||
let inlineSearchDebounce: number | undefined;
|
||
|
||
function setInlineSearchActive(active: boolean) {
|
||
if (inlineSearchActive === active) return;
|
||
inlineSearchActive = active;
|
||
const panel = document.getElementById("fristen-row-search-panel");
|
||
const header = document.getElementById("fristen-row-stack-header");
|
||
const stack = document.getElementById("fristen-row-stack");
|
||
const results = document.getElementById("fristen-b1-results");
|
||
const trigger = document.getElementById("fristen-row-search-link") as HTMLButtonElement | null;
|
||
const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
|
||
if (!panel || !stack) return;
|
||
panel.hidden = !active;
|
||
stack.hidden = active;
|
||
// Reset link belongs to the cascade affordance set; hide while the
|
||
// user is in search mode so the header reads as "← back to tree" only.
|
||
if (header) header.classList.toggle("is-inline-search-active", active);
|
||
if (trigger) trigger.setAttribute("aria-expanded", active ? "true" : "false");
|
||
if (active) {
|
||
if (input) {
|
||
input.focus();
|
||
if (input.value.trim() !== "") {
|
||
scheduleInlineSearch(0);
|
||
} else if (results) {
|
||
// No query yet — clear results so a stale cascade leaf panel
|
||
// doesn't show alongside the search input.
|
||
results.innerHTML = "";
|
||
results.classList.remove("is-loading", "is-no-hits");
|
||
}
|
||
}
|
||
} else {
|
||
// On collapse, restore the cascade results for the current slug
|
||
// so the user picks up exactly where they left off.
|
||
runB1Search(cascadeEffectiveSlug || readB1PathFromURL());
|
||
}
|
||
}
|
||
|
||
function scheduleInlineSearch(delayMs = 180) {
|
||
if (inlineSearchDebounce !== undefined) window.clearTimeout(inlineSearchDebounce);
|
||
inlineSearchDebounce = window.setTimeout(runInlineSearch, delayMs);
|
||
}
|
||
|
||
async function runInlineSearch() {
|
||
const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
|
||
const results = document.getElementById("fristen-b1-results");
|
||
const clearBtn = document.getElementById("fristen-row-search-panel-clear") as HTMLButtonElement | null;
|
||
if (!input || !results) return;
|
||
const q = input.value.trim();
|
||
if (clearBtn) clearBtn.hidden = q.length === 0;
|
||
if (q === "") {
|
||
results.innerHTML = "";
|
||
results.classList.remove("is-loading", "is-no-hits");
|
||
return;
|
||
}
|
||
results.classList.remove("is-no-hits");
|
||
results.classList.add("is-loading");
|
||
if (results.childElementCount === 0) {
|
||
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
|
||
}
|
||
const seq = ++inlineSearchSeq;
|
||
try {
|
||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||
url.searchParams.set("q", q);
|
||
url.searchParams.set("limit", "12");
|
||
const forums = getActiveForumsParam();
|
||
if (forums) url.searchParams.set("forum", forums);
|
||
const r = await fetch(url.toString(), { credentials: "same-origin" });
|
||
if (seq !== inlineSearchSeq) return;
|
||
results.classList.remove("is-loading");
|
||
if (!r.ok) {
|
||
results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
|
||
return;
|
||
}
|
||
const data = (await r.json()) as SearchResponse;
|
||
if (seq !== inlineSearchSeq) return;
|
||
if (!data.cards || data.cards.length === 0) {
|
||
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.no_hits"))}</div>`;
|
||
results.classList.add("is-no-hits");
|
||
return;
|
||
}
|
||
renderSearchResultsInto("fristen-b1-results", data);
|
||
} catch {
|
||
if (seq !== inlineSearchSeq) return;
|
||
results.classList.remove("is-loading");
|
||
results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
|
||
}
|
||
}
|
||
|
||
function initInlineSearch() {
|
||
const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
|
||
const clearBtn = document.getElementById("fristen-row-search-panel-clear") as HTMLButtonElement | null;
|
||
if (input) {
|
||
input.addEventListener("input", () => scheduleInlineSearch());
|
||
input.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Escape") {
|
||
if (input.value.trim() !== "") {
|
||
// First ESC: clear the input; second ESC: close the panel.
|
||
input.value = "";
|
||
scheduleInlineSearch(0);
|
||
return;
|
||
}
|
||
setInlineSearchActive(false);
|
||
}
|
||
});
|
||
}
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener("click", () => {
|
||
if (!input) return;
|
||
input.value = "";
|
||
input.focus();
|
||
scheduleInlineSearch(0);
|
||
});
|
||
}
|
||
}
|
||
|
||
// autoscrollToActiveRow brings the active cascade row into view when a
|
||
// user pick on a higher row causes descendants to drop and the next
|
||
// active row is now below the fold. The 60px headroom matches the
|
||
// pattern used by the Akte picker (Step 1) and other paliad forms so
|
||
// the row's chip body is visible without scrolling further. Desktop +
|
||
// mobile share this behaviour; the visual change is only noticeable on
|
||
// narrow viewports where the row stack scrolls.
|
||
function autoscrollToActiveRow(stack: HTMLElement) {
|
||
const active = stack.querySelector<HTMLElement>(".fristen-row.is-active");
|
||
if (!active) return;
|
||
// Skip if the active row is already fully visible (avoid jumpy
|
||
// scroll-on-every-render on desktop).
|
||
const rect = active.getBoundingClientRect();
|
||
if (rect.top >= 60 && rect.bottom <= window.innerHeight - 16) return;
|
||
const targetY = window.scrollY + rect.top - 60;
|
||
window.scrollTo({ top: Math.max(0, targetY), behavior: "smooth" });
|
||
}
|
||
|
||
// dismissAutoWalkTooltip removes the live tooltip element (if any) and
|
||
// flips the localStorage suppression key so future renders skip it.
|
||
// Called from any meaningful user interaction (chip pick, ändern,
|
||
// dismiss-button) — once the user has touched the cascade they no
|
||
// longer need the inference hint.
|
||
function dismissAutoWalkTooltip() {
|
||
document.querySelector(".fristen-row-autowalk-tip")?.remove();
|
||
try { localStorage.setItem(cascadeTooltipDismissedKey, "1"); } catch { /* private mode */ }
|
||
}
|
||
|
||
// maybeShowAutoWalkTooltip surfaces a one-time hint when ≥ 2 cascade
|
||
// rows render in the prefilled (auto-walked) state. Per design §11.3
|
||
// and Q11 (deferred to v2 nice-to-have), the tooltip only appears the
|
||
// first time the user lands in a multi-row auto-walk — once dismissed,
|
||
// localStorage suppresses it forever. Tooltip is inline (no portal /
|
||
// modal): it injects a small banner above the first prefilled row.
|
||
function maybeShowAutoWalkTooltip(stack: HTMLElement, rows: RowSpec[]) {
|
||
const prefilledCount = rows.filter((r) => r.state === "prefilled" && r.kind === "cascade").length;
|
||
if (prefilledCount < 2) return;
|
||
let dismissed = false;
|
||
try { dismissed = localStorage.getItem(cascadeTooltipDismissedKey) === "1"; } catch { /* private mode */ }
|
||
if (dismissed) return;
|
||
// Inject the tooltip element. It's a sibling of the row stack, slotted
|
||
// just above the first prefilled row via DOM insertion. The
|
||
// `is-entering` class drives the fade-in + slide-down animation in
|
||
// CSS (Slice 3 polish); we remove it on the next animation frame so
|
||
// the CSS transition kicks in.
|
||
const firstPrefilled = stack.querySelector(".fristen-row.is-prefilled");
|
||
if (!firstPrefilled) return;
|
||
const tip = document.createElement("div");
|
||
tip.className = "fristen-row-autowalk-tip is-entering";
|
||
tip.setAttribute("role", "status");
|
||
tip.innerHTML = `
|
||
<span class="fristen-row-autowalk-tip-icon" aria-hidden="true">ⓘ</span>
|
||
<span class="fristen-row-autowalk-tip-text" data-i18n="deadlines.row.autowalk.tooltip">
|
||
${escHtml(t("deadlines.row.autowalk.tooltip"))}
|
||
</span>
|
||
<button type="button" class="fristen-row-autowalk-tip-dismiss"
|
||
aria-label="${escAttr(t("deadlines.row.autowalk.dismiss"))}">
|
||
×
|
||
</button>`;
|
||
// Desktop: tip sits above the prefilled row so it reads as a header.
|
||
// Mobile (<640px): tip sits below the row so the user's eye lands on
|
||
// the row's "aus Akte" tag first and reads the explanation beneath
|
||
// — matches design §7 + the natural reading pattern on small screens.
|
||
const isMobile = window.matchMedia && window.matchMedia("(max-width: 640px)").matches;
|
||
if (isMobile) {
|
||
firstPrefilled.parentElement?.insertBefore(tip, firstPrefilled.nextSibling);
|
||
} else {
|
||
firstPrefilled.parentElement?.insertBefore(tip, firstPrefilled);
|
||
}
|
||
// Trigger the transition: remove the is-entering offset on next frame.
|
||
window.requestAnimationFrame(() => tip.classList.remove("is-entering"));
|
||
tip.querySelector<HTMLButtonElement>(".fristen-row-autowalk-tip-dismiss")?.addEventListener("click", () => {
|
||
dismissAutoWalkTooltip();
|
||
});
|
||
}
|
||
|
||
|
||
// b1SearchSeq guards against out-of-order responses when the user
|
||
// click-cascades faster than the network. Only the latest invocation
|
||
// gets to render its result.
|
||
let b1SearchSeq = 0;
|
||
|
||
async function runB1Search(slug: string) {
|
||
const results = document.getElementById("fristen-b1-results");
|
||
if (!results) return;
|
||
wirePillClicks(results);
|
||
|
||
const seq = ++b1SearchSeq;
|
||
results.classList.add("is-loading");
|
||
results.classList.remove("is-no-hits");
|
||
// Fade-out is CSS-driven via .is-loading; render the spinner-row only
|
||
// when the container is still empty (first paint), otherwise keep the
|
||
// previous cards visible underneath the dimming layer for continuity.
|
||
if (results.childElementCount === 0) {
|
||
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
|
||
}
|
||
|
||
try {
|
||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||
if (slug) {
|
||
url.searchParams.set("event_category_slug", slug);
|
||
} else {
|
||
// No tree node picked yet → show every concept reachable from any
|
||
// leaf (t-paliad-134: full landscape on entry).
|
||
url.searchParams.set("browse", "all");
|
||
}
|
||
const forums = getActiveForumsParam();
|
||
if (forums) url.searchParams.set("forum", forums);
|
||
const r = await fetch(url.toString());
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
const data = await r.json();
|
||
if (seq !== b1SearchSeq) return;
|
||
results.classList.remove("is-loading");
|
||
|
||
if (!data.cards || data.cards.length === 0) {
|
||
// At root we should always have data (event_category_concepts is
|
||
// seeded). At a deeper node, offer to step back. Either way render
|
||
// a friendly empty state.
|
||
const empty = `<div class="fristen-search-status fristen-search-error">
|
||
${escHtml(t("deadlines.pathway.b.tree.empty"))}
|
||
${slug ? `<button type="button" class="fristen-b1-loosen-link" data-action="loosen">
|
||
${escHtml(t("deadlines.pathway.b.tree.step.back"))}
|
||
</button>` : ""}
|
||
</div>`;
|
||
results.innerHTML = empty;
|
||
results.classList.add("is-no-hits");
|
||
if (slug) {
|
||
results.querySelector<HTMLButtonElement>(".fristen-b1-loosen-link")?.addEventListener("click", () => {
|
||
const trail = buildBreadcrumb(eventCategoryTree || [], slug);
|
||
const parent = trail.length > 1 ? trail[trail.length - 2].slug : "";
|
||
navigateB1(parent);
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
renderSearchResultsInto("fristen-b1-results", data);
|
||
} catch (e) {
|
||
if (seq !== b1SearchSeq) return;
|
||
results.classList.remove("is-loading");
|
||
results.innerHTML = `<div class="fristen-search-status fristen-search-error">
|
||
${escHtml(t("deadlines.search.no_hits"))}
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function navigateB1(slug: string) {
|
||
setB1PathInURL(slug);
|
||
renderRowStack(slug);
|
||
}
|
||
|
||
// loadAndRenderB1 fetches the tree (cached after first call) and
|
||
// renders the cascade + result cards at the slug currently in the URL.
|
||
// Module-level so showBMode("tree") can trigger it on Pathway B entry
|
||
// without relying on a synthetic radio-change event.
|
||
async function loadAndRenderB1() {
|
||
try {
|
||
await loadEventCategoryTree();
|
||
renderRowStack(readB1PathFromURL());
|
||
} catch (e) {
|
||
const stack = document.getElementById("fristen-row-stack");
|
||
if (stack) {
|
||
stack.innerHTML = `<div class="fristen-b1-error">${escHtml(t("deadlines.pathway.b.tree.empty"))}</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function initB1Cascade() {
|
||
const panel = document.getElementById("fristen-b1-panel");
|
||
if (!panel) return;
|
||
|
||
// t-paliad-180: mode-radio retired; the row-stack's mode-row click
|
||
// handler drives tree↔filter routing. No standalone change listener
|
||
// needed here — showBMode() triggers loadAndRenderB1 when the
|
||
// pathway enters tree mode.
|
||
|
||
// Initial render if the URL already lands in tree mode.
|
||
const sp = new URLSearchParams(window.location.search);
|
||
if (sp.get("path") === "b" && sp.get("mode") === "tree") {
|
||
loadAndRenderB1();
|
||
}
|
||
|
||
// popstate restores the cascade depth.
|
||
window.addEventListener("popstate", () => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (params.get("path") === "b" && params.get("mode") === "tree") {
|
||
// Always re-render — tree may not have loaded yet on first popstate.
|
||
currentActiveRow = null;
|
||
cascadeAutoWalkStopAfter = null;
|
||
loadAndRenderB1();
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", initB1Cascade);
|
||
|
||
// ============================================================================
|
||
// v3 B2 forum filter (t-paliad-133 Phase D)
|
||
// ============================================================================
|
||
// 10 forum buckets per m's spec lock §10 Q8. Multi-select chips,
|
||
// AND-narrowing: each chip click toggles its membership in the active
|
||
// set; the active set is sent as ?forum=<comma-separated> on every
|
||
// search. Empty set = no filter.
|
||
|
||
const FORUM_BUCKETS: { slug: string; i18nKey: string }[] = [
|
||
{ slug: "upc_cfi", i18nKey: "deadlines.filter.forum.upc_cfi" },
|
||
{ slug: "upc_coa", i18nKey: "deadlines.filter.forum.upc_coa" },
|
||
{ slug: "de_lg", i18nKey: "deadlines.filter.forum.de_lg" },
|
||
{ slug: "de_olg", i18nKey: "deadlines.filter.forum.de_olg" },
|
||
{ slug: "de_bgh", i18nKey: "deadlines.filter.forum.de_bgh" },
|
||
{ slug: "de_bpatg", i18nKey: "deadlines.filter.forum.de_bpatg" },
|
||
{ slug: "epa_grant", i18nKey: "deadlines.filter.forum.epa_grant" },
|
||
{ slug: "epa_opp", i18nKey: "deadlines.filter.forum.epa_opp" },
|
||
{ slug: "epa_appeal", i18nKey: "deadlines.filter.forum.epa_appeal" },
|
||
{ slug: "dpma", i18nKey: "deadlines.filter.forum.dpma" },
|
||
];
|
||
|
||
const activeForums = new Set<string>();
|
||
|
||
function readForumsFromURL(): string[] {
|
||
const sp = new URLSearchParams(window.location.search);
|
||
const raw = sp.get("forum");
|
||
if (!raw) return [];
|
||
return raw.split(",").map((s) => s.trim()).filter((s) => FORUM_BUCKETS.some((b) => b.slug === s));
|
||
}
|
||
|
||
function writeForumsToURL(replace = false) {
|
||
const url = new URL(window.location.href);
|
||
if (activeForums.size === 0) {
|
||
url.searchParams.delete("forum");
|
||
} else {
|
||
url.searchParams.set("forum", Array.from(activeForums).sort().join(","));
|
||
}
|
||
if (replace) {
|
||
window.history.replaceState({}, "", url.toString());
|
||
} else {
|
||
window.history.pushState({}, "", url.toString());
|
||
}
|
||
}
|
||
|
||
function renderForumChips() {
|
||
const container = document.getElementById("fristen-forum-chips");
|
||
const wrapper = document.getElementById("fristen-forum-filter");
|
||
if (!container || !wrapper) return;
|
||
wrapper.hidden = false;
|
||
container.innerHTML = FORUM_BUCKETS.map((b) => {
|
||
const active = activeForums.has(b.slug);
|
||
return `<button type="button" class="fristen-forum-chip${active ? " fristen-forum-chip--active" : ""}"
|
||
data-forum="${escAttr(b.slug)}"
|
||
aria-pressed="${active ? "true" : "false"}">
|
||
${escHtml(t(b.i18nKey))}
|
||
</button>`;
|
||
}).join("");
|
||
container.querySelectorAll<HTMLButtonElement>(".fristen-forum-chip").forEach((chip) => {
|
||
chip.addEventListener("click", () => {
|
||
const slug = chip.dataset.forum || "";
|
||
if (!slug) return;
|
||
if (activeForums.has(slug)) {
|
||
activeForums.delete(slug);
|
||
} else {
|
||
activeForums.add(slug);
|
||
}
|
||
writeForumsToURL();
|
||
renderForumChips();
|
||
reissueSearchWithCurrentFilters();
|
||
});
|
||
});
|
||
}
|
||
|
||
function reissueSearchWithCurrentFilters() {
|
||
// If we're in B1 mode, refresh the current cascade slug's results.
|
||
const sp = new URLSearchParams(window.location.search);
|
||
if (sp.get("mode") === "tree") {
|
||
const slug = sp.get("b1") || "";
|
||
if (slug) {
|
||
runB1Search(slug);
|
||
return;
|
||
}
|
||
}
|
||
// Otherwise re-trigger the B2 search input handler.
|
||
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
|
||
if (input && input.value.trim() !== "") {
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
}
|
||
}
|
||
|
||
function getActiveForumsParam(): string {
|
||
if (activeForums.size === 0) return "";
|
||
return Array.from(activeForums).sort().join(",");
|
||
}
|
||
|
||
function initForumFilter() {
|
||
// Hydrate from URL on first load.
|
||
for (const slug of readForumsFromURL()) {
|
||
activeForums.add(slug);
|
||
}
|
||
renderForumChips();
|
||
|
||
// Restore on browser nav.
|
||
window.addEventListener("popstate", () => {
|
||
activeForums.clear();
|
||
for (const slug of readForumsFromURL()) {
|
||
activeForums.add(slug);
|
||
}
|
||
renderForumChips();
|
||
});
|
||
|
||
// Re-render labels on language change.
|
||
onLangChange(() => renderForumChips());
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", initForumFilter);
|
||
|
||
// ============================================================================
|
||
// m/paliad#15 inbox-channel pre-filter
|
||
// ============================================================================
|
||
// Coarse forum chip on the page header above the pathway fork. Three
|
||
// values: cms (UPC), bea (national-DE), posteingang (national-DE, slower
|
||
// channel — same forums as beA). State priority on hydrate:
|
||
// 1. URL ?inbox=cms|bea|posteingang (per-visit override; lets a
|
||
// colleague share a CMS-narrowed link without flipping anyone's
|
||
// saved preference)
|
||
// 2. /api/me forum_pref (the user's persisted default)
|
||
// 3. unset (no filter; picker shows all groups)
|
||
//
|
||
// On chip click: write URL + PATCH /api/me + filter Pathway A picker.
|
||
// Empty string PATCH body clears the saved preference (matches the
|
||
// EscalationContactID convention in services/user_service.go).
|
||
|
||
type InboxChannel = "cms" | "bea" | "posteingang" | null;
|
||
|
||
const INBOX_CHANNEL_VALUES = new Set<string>(["cms", "bea", "posteingang"]);
|
||
|
||
// currentInboxChannel mirrors the chip's active state so the B1 cascade
|
||
// renderer (which lives in a different section of this file) can ask
|
||
// "which forum is active right now?" without re-deriving from URL on
|
||
// every render. Updated by applyInboxFilter on hydrate / click /
|
||
// popstate.
|
||
let currentInboxChannel: InboxChannel = null;
|
||
|
||
function readInboxFromURL(): InboxChannel {
|
||
const raw = new URLSearchParams(window.location.search).get("inbox");
|
||
return raw && INBOX_CHANNEL_VALUES.has(raw) ? (raw as InboxChannel) : null;
|
||
}
|
||
|
||
function writeInboxToURL(ch: InboxChannel, replace = false) {
|
||
const url = new URL(window.location.href);
|
||
if (ch === null) url.searchParams.delete("inbox");
|
||
else url.searchParams.set("inbox", ch);
|
||
if (replace) window.history.replaceState({}, "", url.toString());
|
||
else window.history.pushState({}, "", url.toString());
|
||
}
|
||
|
||
// inboxChannelToForum collapses the channel onto the coarse forum
|
||
// bucket the proceeding-group filter understands. cms → upc; beA and
|
||
// Posteingang both → de (same set of national-DE proceedings, different
|
||
// inbox name). Null = no filter.
|
||
function inboxChannelToForum(ch: InboxChannel): "upc" | "de" | null {
|
||
if (ch === "cms") return "upc";
|
||
if (ch === "bea" || ch === "posteingang") return "de";
|
||
return null;
|
||
}
|
||
|
||
// inboxToFineForumSlugs maps the inbox channel to the matching subset
|
||
// of the 10-bucket B2 forum filter. CMS narrows to UPC CFI + CoA;
|
||
// beA / Posteingang narrow to all four national-DE buckets (LG / OLG /
|
||
// BGH / BPatG). Null returns an empty list — caller handles the
|
||
// "no inbox set" case explicitly because the meaning differs between
|
||
// hydrate (don't touch fine chips) and click-clear (clear them).
|
||
function inboxToFineForumSlugs(ch: InboxChannel): string[] {
|
||
if (ch === "cms") return ["upc_cfi", "upc_coa"];
|
||
if (ch === "bea" || ch === "posteingang") return ["de_lg", "de_olg", "de_bgh", "de_bpatg"];
|
||
return [];
|
||
}
|
||
|
||
// applyFineForumsFromInbox replaces activeForums with the inbox's
|
||
// implied fine-bucket set and re-renders the B2 chip strip + reissues
|
||
// the active search. Caller decides when to invoke (user click always;
|
||
// hydrate / popstate only when URL ?forum= is empty so an explicit
|
||
// link-share wins over the inbox derivation).
|
||
function applyFineForumsFromInbox(ch: InboxChannel) {
|
||
activeForums.clear();
|
||
for (const slug of inboxToFineForumSlugs(ch)) activeForums.add(slug);
|
||
// renderForumChips guards on missing container, so calling from
|
||
// contexts where Pathway B isn't yet rendered is safe.
|
||
renderForumChips();
|
||
reissueSearchWithCurrentFilters();
|
||
}
|
||
|
||
function applyInboxFilter(ch: InboxChannel) {
|
||
currentInboxChannel = ch;
|
||
|
||
// t-paliad-180: the inbox chip strip is gone — chip state now lives
|
||
// inside the row stack and is repainted on every renderRowStack call.
|
||
// Pathway A's "Verlauf" is intentionally NOT filtered here (m's
|
||
// 2026-05-08 feedback: the chip belongs inside the Determinator, not
|
||
// page-wide). The .proceeding-group [data-forum] attributes stay on
|
||
// the markup as documentation of intent but no longer drive
|
||
// visibility.
|
||
if (eventCategoryTree) {
|
||
renderRowStack(readB1PathFromURL());
|
||
}
|
||
}
|
||
|
||
// Slice 3b: cascade narrowing now flows from THREE inputs, in priority
|
||
// order. Whichever is set first wins.
|
||
//
|
||
// 1. Inbox chip (cms / bea / posteingang) — user-clicked override.
|
||
// Maps to upc / de / de.
|
||
// 2. Ad-hoc chip (Step 1's explore-mode upc / de / epa / dpma).
|
||
// 3. Project context (Step 1's selected Akte → proceeding_type_id →
|
||
// proceeding_types.code → forum prefix).
|
||
//
|
||
// activeForumOnPage() returns the first non-null value or null when
|
||
// nothing is set. inboxFilterAllowsForums consults this so the B1
|
||
// cascade narrows automatically when the user enters Pathway B with a
|
||
// project context — no extra clicks needed. The chip can still
|
||
// override at the top of the B1 panel.
|
||
|
||
let cachedProceedingTypes: Map<number, string> = new Map();
|
||
|
||
async function loadProceedingTypes(): Promise<void> {
|
||
try {
|
||
const resp = await fetch("/api/proceeding-types-db");
|
||
if (!resp.ok) return;
|
||
const list = (await resp.json()) as Array<{ id: number; code: string }>;
|
||
for (const pt of list) {
|
||
cachedProceedingTypes.set(pt.id, pt.code);
|
||
}
|
||
} catch {
|
||
// Anonymous visitor / network blip — leave the map empty; project
|
||
// narrowing falls back to "no opinion" and the cascade stays
|
||
// wide-open.
|
||
}
|
||
}
|
||
|
||
function forumFromProceedingCode(code: string): "upc" | "de" | "epa" | "dpma" | null {
|
||
if (code.startsWith("UPC_")) return "upc";
|
||
if (code.startsWith("DE_")) return "de";
|
||
if (code.startsWith("EPA_") || code.startsWith("EP_")) return "epa";
|
||
if (code.startsWith("DPMA_")) return "dpma";
|
||
return null;
|
||
}
|
||
|
||
function forumFromProject(p?: ProjectOption | null): "upc" | "de" | "epa" | "dpma" | null {
|
||
if (!p || p.proceeding_type_id == null) return null;
|
||
const code = cachedProceedingTypes.get(p.proceeding_type_id);
|
||
return code ? forumFromProceedingCode(code) : null;
|
||
}
|
||
|
||
function activeForumOnPage(): "upc" | "de" | "epa" | "dpma" | null {
|
||
const chipForum = inboxChannelToForum(currentInboxChannel);
|
||
if (chipForum !== null) return chipForum;
|
||
if (currentStep1Context.kind === "adhoc" && currentStep1Context.adhocForum) {
|
||
return currentStep1Context.adhocForum;
|
||
}
|
||
if (currentStep1Context.kind === "project") {
|
||
return forumFromProject(currentStep1Context.project);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// inboxFilterAllowsForums returns true when a node with the given
|
||
// forums tags should be visible. Neutral nodes (forums undefined /
|
||
// empty) are always visible. When no forum is active anywhere on the
|
||
// page, every node is visible.
|
||
function inboxFilterAllowsForums(forums: string[] | undefined): boolean {
|
||
if (!forums || forums.length === 0) return true;
|
||
const active = activeForumOnPage();
|
||
if (active === null) return true;
|
||
return forums.includes(active);
|
||
}
|
||
|
||
// triggerCascadeRefresh re-renders the B1 row stack if the panel is
|
||
// mounted. Call after any change that affects activeForumOnPage() or
|
||
// the perspective filter (row pick, project selection, ad-hoc
|
||
// selection, clear, perspective change).
|
||
function triggerCascadeRefresh() {
|
||
if (eventCategoryTree && document.getElementById("fristen-row-stack")) {
|
||
renderRowStack(readB1PathFromURL());
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Slice 3c: perspective chip (Klägerseite / Beklagtenseite / Beide)
|
||
// ============================================================================
|
||
// Lives at the top of the B1 panel above the inbox-channel chip strip.
|
||
// Filters the cascade by event_categories.party. Default unset →
|
||
// every leaf visible regardless of party.
|
||
//
|
||
// State is URL-only (?role=claimant|defendant). No localStorage —
|
||
// dogfood will tell us whether persistence is wanted.
|
||
|
||
type Perspective = "claimant" | "defendant" | null;
|
||
|
||
let currentPerspective: Perspective = null;
|
||
|
||
function readPerspectiveFromURL(): Perspective {
|
||
const raw = new URLSearchParams(window.location.search).get("role");
|
||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||
}
|
||
|
||
function writePerspectiveToURL(p: Perspective, replace = false) {
|
||
const url = new URL(window.location.href);
|
||
if (p === null) url.searchParams.delete("role");
|
||
else url.searchParams.set("role", p);
|
||
if (replace) window.history.replaceState({}, "", url.toString());
|
||
else window.history.pushState({}, "", url.toString());
|
||
}
|
||
|
||
function applyPerspective(p: Perspective) {
|
||
currentPerspective = p;
|
||
// t-paliad-180: chip state lives inside the row stack now; one refresh
|
||
// repaints the perspective row + re-narrows the cascade options.
|
||
triggerCascadeRefresh();
|
||
}
|
||
|
||
// ourSideToPerspective maps the project-level "Client Role" enum
|
||
// (DB column: our_side) onto the chip-strip Perspective.
|
||
//
|
||
// Per t-paliad-222 (m/paliad#47) the field carries one of seven
|
||
// sub-role values grouped at display time:
|
||
// Active (we initiate) : claimant, applicant, appellant → "claimant"
|
||
// Reactive (we defend) : defendant, respondent → "defendant"
|
||
// Other : third_party, other, NULL → null
|
||
//
|
||
// Legacy 'court' / 'both' values no longer exist in the column
|
||
// (mig 110 backfilled them to NULL); both fall through to the null
|
||
// default arm if a stale value sneaks in.
|
||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||
switch (os) {
|
||
case "claimant":
|
||
case "applicant":
|
||
case "appellant":
|
||
return "claimant";
|
||
case "defendant":
|
||
case "respondent":
|
||
return "defendant";
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// applyOurSidePredefine locks the perspective from project.our_side
|
||
// when the user hasn't already explicitly picked one. The URL is the
|
||
// "explicit pick" signal: if ?role= is present at call time, the user
|
||
// (or a shared link) chose it and we don't overwrite. When we do
|
||
// predefine, we write the same value to the URL so back/forward +
|
||
// refresh round-trip cleanly. t-paliad-180: the "aus Akte" tag now
|
||
// lives inline in the prefilled-row state — no separate hint element.
|
||
//
|
||
// `replaceURL=true` is for the deep-link / refresh path; `false` for
|
||
// in-page project selection so back-button restores the empty state.
|
||
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
|
||
if (!project || !project.our_side) return;
|
||
// URL wins — user has an explicit pick. Don't clobber it.
|
||
if (readPerspectiveFromURL() !== null) return;
|
||
const next = ourSideToPerspective(project.our_side);
|
||
writePerspectiveToURL(next, replaceURL);
|
||
applyPerspective(next);
|
||
}
|
||
|
||
// perspectiveAllowsParty returns true when a node tagged with `party`
|
||
// should be visible under the current perspective. Neutral nodes
|
||
// (party undefined / empty) always pass. "both" matches every
|
||
// perspective; "court" matches every perspective (court actions are
|
||
// neutral to the user's side).
|
||
function perspectiveAllowsParty(party: string[] | undefined): boolean {
|
||
if (!party || party.length === 0) return true;
|
||
if (currentPerspective === null) return true;
|
||
if (party.includes("both") || party.includes("court")) return true;
|
||
return party.includes(currentPerspective);
|
||
}
|
||
|
||
async function persistInboxPref(ch: InboxChannel) {
|
||
// forum_pref="" clears on the server side (NULL in the DB) — matches
|
||
// the EscalationContactID convention in services/user_service.go.
|
||
// Persistence is opportunistic; URL state already won the visit.
|
||
try {
|
||
await fetch("/api/me", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "same-origin",
|
||
body: JSON.stringify({ forum_pref: ch === null ? "" : ch }),
|
||
});
|
||
} catch {
|
||
// Network blip on persist isn't worth blocking the UI; the user can
|
||
// re-pick on the next visit.
|
||
}
|
||
}
|
||
|
||
async function initInboxFilter() {
|
||
// t-paliad-180: the standalone inbox chip strip is retired; inbox
|
||
// state still drives cascade narrowing + B2 fine-bucket sync, just
|
||
// surfaced through the row-stack row now. This init still hydrates
|
||
// from URL / saved preference + wires the popstate restore.
|
||
if (!document.getElementById("fristen-b1-panel")) return;
|
||
|
||
let initial: InboxChannel = readInboxFromURL();
|
||
if (initial === null) {
|
||
try {
|
||
const resp = await fetch("/api/me", { credentials: "same-origin" });
|
||
if (resp.ok) {
|
||
const me = (await resp.json()) as { forum_pref?: string | null };
|
||
if (me.forum_pref && INBOX_CHANNEL_VALUES.has(me.forum_pref)) {
|
||
initial = me.forum_pref as InboxChannel;
|
||
}
|
||
}
|
||
} catch {
|
||
// Anonymous visitor or transient error — leave the chip unset.
|
||
}
|
||
}
|
||
applyInboxFilter(initial);
|
||
|
||
// Sync B2 fine-bucket chips from the inbox on hydrate ONLY when the
|
||
// URL doesn't explicitly carry ?forum=… — an explicit forum= comes
|
||
// from a shared link and should win over the user's saved inbox
|
||
// preference. initForumFilter (which runs first) has already
|
||
// populated activeForums from URL forum=, so we leave it alone here.
|
||
if (initial !== null && readForumsFromURL().length === 0) {
|
||
applyFineForumsFromInbox(initial);
|
||
writeForumsToURL(true);
|
||
}
|
||
|
||
window.addEventListener("popstate", () => {
|
||
const newInbox = readInboxFromURL();
|
||
applyInboxFilter(newInbox);
|
||
// popstate can land on a URL with inbox= but no forum= (the user
|
||
// navigated to a state where derivation should re-apply). Don't
|
||
// touch activeForums when forum= is explicit — initForumFilter's
|
||
// own popstate handler has already loaded it from the URL.
|
||
if (newInbox !== null && readForumsFromURL().length === 0) {
|
||
applyFineForumsFromInbox(newInbox);
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", initInboxFilter);
|
||
|