712 lines
24 KiB
TypeScript
712 lines
24 KiB
TypeScript
// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2).
|
||
//
|
||
// 3-5 question row stack that lands the user on one procedural_event
|
||
// (the trigger), then transitions to the shared §4 result view.
|
||
//
|
||
// R1 Was ist passiert? (event_kind) always asked
|
||
// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows
|
||
// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option
|
||
// R4 Welches Schriftstück? (procedural_event — land) always asked
|
||
// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ
|
||
//
|
||
// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier".
|
||
// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where
|
||
// perspective is a qualifier).
|
||
// Pre-fill + collapse rows from project (project.proceeding_type →
|
||
// R3 + R2 derived; project.our_side → R5). Preserve compatible
|
||
// downstream picks on back-navigation (§11.Q10).
|
||
|
||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||
import { getLang, t, tDyn } from "./i18n";
|
||
import { mountResultView } from "./fristenrechner-result";
|
||
|
||
// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we
|
||
// need; kept local so the wizard doesn't depend on Mode A.
|
||
|
||
interface EventSearchHit {
|
||
id: string;
|
||
code: string;
|
||
name_de: string;
|
||
name_en: string;
|
||
event_kind?: string;
|
||
proceeding_type: {
|
||
id: number;
|
||
code: string;
|
||
name_de: string;
|
||
name_en: string;
|
||
jurisdiction?: string;
|
||
};
|
||
follow_up_count: number;
|
||
}
|
||
|
||
interface EventSearchResponse {
|
||
events: EventSearchHit[];
|
||
total: number;
|
||
}
|
||
|
||
interface ProceedingChip {
|
||
code: string;
|
||
name: string;
|
||
nameEN: string;
|
||
group: string;
|
||
}
|
||
|
||
interface ProjectSummary {
|
||
id: string;
|
||
proceeding_type_id?: number | null;
|
||
our_side?: string | null;
|
||
}
|
||
|
||
type Forum = "UPC" | "DE" | "EPA" | "DPMA";
|
||
type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed";
|
||
type WizardParty = "claimant" | "defendant";
|
||
|
||
// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by
|
||
// fristenrechner-result.mountModeShell which creates the host element
|
||
// under the overhaul root.
|
||
const WIZARD_HOST_ID = "fristen-overhaul-mode-host";
|
||
|
||
// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists
|
||
// so re-grouping happens in one place.
|
||
const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"];
|
||
const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"];
|
||
|
||
// Single wizard state. Module-local; one wizard at a time.
|
||
interface WizardState {
|
||
// Picks. "" = not answered. R5 only set when the question is asked.
|
||
r1: EventKindRow | "";
|
||
r2: Forum | "";
|
||
r3: string; // proceeding_types.code
|
||
r4: string; // procedural_events.code
|
||
r5: WizardParty | "";
|
||
|
||
// Pre-fill provenance — when a pick came from the project context,
|
||
// the row renders with an "aus Akte" tag so the user notices.
|
||
r2FromProject: boolean;
|
||
r3FromProject: boolean;
|
||
r5FromProject: boolean;
|
||
|
||
// Implicit fills — R2 auto-derived from R1 when R1 narrows to one
|
||
// forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but
|
||
// if downstream R3 lookup returns a single forum we can mark R2 as
|
||
// implicit).
|
||
r2Implicit: boolean;
|
||
r3Implicit: boolean;
|
||
}
|
||
|
||
const state: WizardState = {
|
||
r1: "", r2: "", r3: "", r4: "", r5: "",
|
||
r2FromProject: false, r3FromProject: false, r5FromProject: false,
|
||
r2Implicit: false, r3Implicit: false,
|
||
};
|
||
|
||
// Loaded from the project (if any).
|
||
let projectSummary: ProjectSummary | null = null;
|
||
|
||
// Proceeding chip cache key: jurisdiction × event_kind.
|
||
let lastProcCacheKey = "";
|
||
let cachedProcChips: ProceedingChip[] = [];
|
||
|
||
// Event chip cache: keyed on R3 code + R1 event_kind.
|
||
let lastEventCacheKey = "";
|
||
let cachedEventChips: EventSearchHit[] = [];
|
||
|
||
// Public API ---------------------------------------------------------
|
||
|
||
export async function mountWizard(): Promise<void> {
|
||
const host = document.getElementById(WIZARD_HOST_ID);
|
||
if (!host) return;
|
||
|
||
// Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…).
|
||
const params = new URLSearchParams(window.location.search);
|
||
state.r1 = (params.get("kind") as EventKindRow) || "";
|
||
state.r2 = (params.get("forum") as Forum) || "";
|
||
state.r3 = params.get("pt") || "";
|
||
state.r4 = params.get("event") || "";
|
||
state.r5 = (params.get("party") as WizardParty) || "";
|
||
|
||
// Project prefills.
|
||
const projectId = params.get("project");
|
||
if (projectId) {
|
||
projectSummary = await fetchProject(projectId);
|
||
await applyProjectPrefills();
|
||
} else {
|
||
projectSummary = null;
|
||
}
|
||
|
||
renderShell();
|
||
void renderRows();
|
||
}
|
||
|
||
// applyProjectPrefills derives R2 + R3 + R5 from the project when they
|
||
// haven't been set explicitly. Project picks take precedence over
|
||
// unspecified state, but a user-supplied URL pick wins over the
|
||
// project default.
|
||
async function applyProjectPrefills(): Promise<void> {
|
||
if (!projectSummary) return;
|
||
// Map our_side → R5.
|
||
if (!state.r5) {
|
||
const side = projectSummary.our_side;
|
||
if (side === "claimant" || side === "applicant" || side === "appellant") {
|
||
state.r5 = "claimant";
|
||
state.r5FromProject = true;
|
||
} else if (side === "defendant" || side === "respondent") {
|
||
state.r5 = "defendant";
|
||
state.r5FromProject = true;
|
||
}
|
||
}
|
||
// Map proceeding_type_id → R3 + infer R2 jurisdiction.
|
||
if (projectSummary.proceeding_type_id && !state.r3) {
|
||
const pt = await fetchProceedingByID(projectSummary.proceeding_type_id);
|
||
if (pt) {
|
||
state.r3 = pt.code;
|
||
state.r3FromProject = true;
|
||
if (pt.group && !state.r2) {
|
||
state.r2 = pt.group as Forum;
|
||
state.r2FromProject = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Render -------------------------------------------------------------
|
||
|
||
function renderShell(): void {
|
||
const host = document.getElementById(WIZARD_HOST_ID);
|
||
if (!host) return;
|
||
host.innerHTML = `
|
||
<div class="fristen-wizard-root">
|
||
<header class="fristen-wizard-header">
|
||
<h2 class="fristen-wizard-title">${escHtml(t("deadlines.overhaul.wizard.heading"))}</h2>
|
||
<p class="fristen-wizard-hint">${escHtml(t("deadlines.overhaul.wizard.hint"))}</p>
|
||
</header>
|
||
<div class="fristen-wizard-rows" id="fristen-wizard-rows" aria-live="polite"></div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function renderRows(): Promise<void> {
|
||
const host = document.getElementById("fristen-wizard-rows");
|
||
if (!host) return;
|
||
|
||
// Resolve dynamic row prerequisites BEFORE building markup so chip
|
||
// sets are populated.
|
||
if (state.r1 && state.r2) {
|
||
await ensureProceedingChips(state.r2, state.r1);
|
||
// Auto-skip R3 when the narrowed pool has exactly one option.
|
||
if (!state.r3 && cachedProcChips.length === 1) {
|
||
state.r3 = cachedProcChips[0].code;
|
||
state.r3Implicit = true;
|
||
}
|
||
}
|
||
if (state.r1 && state.r3) {
|
||
await ensureEventChips(state.r3, state.r1);
|
||
}
|
||
|
||
const rows: string[] = [];
|
||
rows.push(rowR1());
|
||
if (shouldShowR2()) rows.push(rowR2());
|
||
if (shouldShowR3()) rows.push(rowR3());
|
||
if (shouldShowR4()) rows.push(rowR4());
|
||
if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading());
|
||
|
||
host.innerHTML = rows.join("");
|
||
wireRowEvents();
|
||
|
||
// R5 conditional check — fires after R4 picked. Inspects /follow-ups
|
||
// to see whether they actually differ by party. If yes, show R5. If
|
||
// no, or R5 already set, transition straight to result view.
|
||
if (state.r4) {
|
||
void maybeAdvanceFromR4();
|
||
}
|
||
}
|
||
|
||
// Should-show predicates --------------------------------------------
|
||
|
||
function shouldShowR2(): boolean {
|
||
// Skip R2 only when R1 narrows to a single forum — which today
|
||
// never happens for the closed event_kind set (every kind exists in
|
||
// multiple jurisdictions). Always show R2 until we have empirical
|
||
// evidence otherwise.
|
||
return state.r1 !== "" && state.r1 !== "missed";
|
||
}
|
||
|
||
function shouldShowR3(): boolean {
|
||
if (state.r1 === "" || state.r2 === "") return false;
|
||
if (state.r3 && state.r3Implicit) return true; // visible compact
|
||
return true;
|
||
}
|
||
|
||
function shouldShowR4(): boolean {
|
||
return state.r3 !== "" && state.r1 !== "";
|
||
}
|
||
|
||
// shouldShowR5Sync renders the placeholder row immediately; the actual
|
||
// asked-or-not decision happens after the async follow-ups probe in
|
||
// maybeAdvanceFromR4.
|
||
function shouldShowR5Sync(): boolean {
|
||
return state.r4 !== "";
|
||
}
|
||
|
||
// Row builders ------------------------------------------------------
|
||
|
||
function rowR1(): string {
|
||
const chips = EVENT_KINDS.map((k) => {
|
||
const label = t(`deadlines.overhaul.kind.${k}` as never);
|
||
const icon = eventKindIcon(k);
|
||
return chipHtml("r1", k, label, state.r1 === k, icon);
|
||
}).join("");
|
||
return rowShell({
|
||
n: 1,
|
||
badge: "filter",
|
||
label: t("deadlines.overhaul.wizard.r1.label"),
|
||
active: !state.r1,
|
||
answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "",
|
||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||
});
|
||
}
|
||
|
||
function rowR2(): string {
|
||
const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join("");
|
||
return rowShell({
|
||
n: 2,
|
||
badge: "filter",
|
||
label: t("deadlines.overhaul.wizard.r2.label"),
|
||
active: !state.r2,
|
||
fromProject: state.r2FromProject,
|
||
answeredText: state.r2 || "",
|
||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||
});
|
||
}
|
||
|
||
function rowR3(): string {
|
||
if (cachedProcChips.length === 0) {
|
||
return rowShell({
|
||
n: 3, badge: "qualifier",
|
||
label: t("deadlines.overhaul.wizard.r3.label"),
|
||
active: true,
|
||
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}</div>`,
|
||
});
|
||
}
|
||
const lang = getLang();
|
||
const chips = cachedProcChips.map((p) => {
|
||
const label = lang === "en" ? p.nameEN || p.name : p.name;
|
||
return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code);
|
||
}).join("");
|
||
let answered = "";
|
||
if (state.r3) {
|
||
const hit = cachedProcChips.find((p) => p.code === state.r3);
|
||
if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name;
|
||
}
|
||
return rowShell({
|
||
n: 3,
|
||
badge: "qualifier",
|
||
label: t("deadlines.overhaul.wizard.r3.label"),
|
||
active: !state.r3,
|
||
fromProject: state.r3FromProject,
|
||
implicit: state.r3Implicit,
|
||
answeredText: answered,
|
||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||
});
|
||
}
|
||
|
||
function rowR4(): string {
|
||
if (cachedEventChips.length === 0) {
|
||
return rowShell({
|
||
n: 4, badge: "qualifier",
|
||
label: t("deadlines.overhaul.wizard.r4.label"),
|
||
active: true,
|
||
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}</div>`,
|
||
});
|
||
}
|
||
const lang = getLang();
|
||
const chips = cachedEventChips.map((e) => {
|
||
const label = lang === "en" ? e.name_en || e.name_de : e.name_de;
|
||
return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow));
|
||
}).join("");
|
||
let answered = "";
|
||
if (state.r4) {
|
||
const hit = cachedEventChips.find((e) => e.code === state.r4);
|
||
if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de;
|
||
}
|
||
return rowShell({
|
||
n: 4,
|
||
badge: "qualifier",
|
||
label: t("deadlines.overhaul.wizard.r4.label"),
|
||
active: !state.r4,
|
||
answeredText: answered,
|
||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||
});
|
||
}
|
||
|
||
function rowR5Loading(): string {
|
||
// Placeholder while we probe whether R5 is needed. The async
|
||
// follow-ups probe replaces this with rowR5 chips or skips
|
||
// straight to the result view.
|
||
return rowShell({
|
||
n: 5, badge: "qualifier",
|
||
label: t("deadlines.overhaul.wizard.r5.label"),
|
||
active: !state.r5,
|
||
fromProject: state.r5FromProject,
|
||
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
|
||
body: `<div class="fristen-wizard-probe">${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}</div>`,
|
||
});
|
||
}
|
||
|
||
function rowR5Chips(): string {
|
||
const chips = (["claimant", "defendant"] as const).map((p) =>
|
||
chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join("");
|
||
return rowShell({
|
||
n: 5, badge: "qualifier",
|
||
label: t("deadlines.overhaul.wizard.r5.label"),
|
||
active: !state.r5,
|
||
fromProject: state.r5FromProject,
|
||
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
|
||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||
});
|
||
}
|
||
|
||
interface RowShellOpts {
|
||
n: number;
|
||
badge: "filter" | "qualifier";
|
||
label: string;
|
||
active: boolean;
|
||
body: string;
|
||
answeredText?: string;
|
||
fromProject?: boolean;
|
||
implicit?: boolean;
|
||
}
|
||
|
||
function rowShell(o: RowShellOpts): string {
|
||
const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` +
|
||
(o.active ? " is-active" : " is-answered") +
|
||
(o.fromProject ? " is-from-project" : "") +
|
||
(o.implicit ? " is-implicit" : "");
|
||
const badgeText = o.badge === "filter"
|
||
? t("deadlines.overhaul.wizard.badge.filter")
|
||
: t("deadlines.overhaul.wizard.badge.qualifier");
|
||
const annotations: string[] = [];
|
||
if (o.fromProject) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}</span>`);
|
||
if (o.implicit) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}</span>`);
|
||
const answered = o.answeredText
|
||
? `<span class="fristen-wizard-row-answer">${escHtml(o.answeredText)}</span>`
|
||
: "";
|
||
const edit = !o.active
|
||
? `<button type="button" class="fristen-wizard-row-edit" data-row="${o.n}">${escHtml(t("deadlines.overhaul.wizard.edit"))}</button>`
|
||
: "";
|
||
return `
|
||
<section class="${cls}" data-row="${o.n}">
|
||
<header class="fristen-wizard-row-header">
|
||
<span class="fristen-wizard-row-n">${o.n}</span>
|
||
<span class="fristen-wizard-row-badge fristen-wizard-row-badge--${o.badge}">${escHtml(badgeText)}</span>
|
||
<span class="fristen-wizard-row-label">${escHtml(o.label)}</span>
|
||
${annotations.join("")}
|
||
${answered}
|
||
${edit}
|
||
</header>
|
||
${o.active ? `<div class="fristen-wizard-row-body">${o.body}</div>` : ""}
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
// Event wiring ------------------------------------------------------
|
||
|
||
function wireRowEvents(): void {
|
||
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const axis = btn.dataset.axis || "";
|
||
const value = btn.dataset.value || "";
|
||
handleChip(axis, value);
|
||
});
|
||
});
|
||
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row-edit").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const n = parseInt(btn.dataset.row || "0", 10);
|
||
handleEdit(n);
|
||
});
|
||
});
|
||
}
|
||
|
||
function handleChip(axis: string, value: string): void {
|
||
switch (axis) {
|
||
case "r1": {
|
||
if (state.r1 === value) return;
|
||
state.r1 = value as EventKindRow;
|
||
// R1 change resets R3/R4 (event-kind narrows the pools).
|
||
state.r3 = "";
|
||
state.r3Implicit = false;
|
||
state.r4 = "";
|
||
state.r5 = state.r5FromProject ? state.r5 : "";
|
||
cachedEventChips = [];
|
||
lastEventCacheKey = "";
|
||
cachedProcChips = [];
|
||
lastProcCacheKey = "";
|
||
break;
|
||
}
|
||
case "r2": {
|
||
if (state.r2 === value) return;
|
||
state.r2 = value as Forum;
|
||
state.r2FromProject = false;
|
||
state.r2Implicit = false;
|
||
// R2 change may invalidate R3 → reset.
|
||
state.r3 = "";
|
||
state.r3FromProject = false;
|
||
state.r3Implicit = false;
|
||
state.r4 = "";
|
||
cachedProcChips = [];
|
||
lastProcCacheKey = "";
|
||
cachedEventChips = [];
|
||
lastEventCacheKey = "";
|
||
break;
|
||
}
|
||
case "r3": {
|
||
if (state.r3 === value) return;
|
||
state.r3 = value;
|
||
state.r3FromProject = false;
|
||
state.r3Implicit = false;
|
||
state.r4 = "";
|
||
cachedEventChips = [];
|
||
lastEventCacheKey = "";
|
||
break;
|
||
}
|
||
case "r4": {
|
||
if (state.r4 === value) return;
|
||
state.r4 = value;
|
||
break;
|
||
}
|
||
case "r5": {
|
||
if (state.r5 === value) return;
|
||
state.r5 = value as WizardParty;
|
||
state.r5FromProject = false;
|
||
break;
|
||
}
|
||
}
|
||
syncUrl();
|
||
void renderRows();
|
||
}
|
||
|
||
function handleEdit(n: number): void {
|
||
switch (n) {
|
||
case 1:
|
||
state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : "";
|
||
cachedProcChips = []; lastProcCacheKey = "";
|
||
cachedEventChips = []; lastEventCacheKey = "";
|
||
break;
|
||
case 2:
|
||
state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false;
|
||
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
|
||
state.r4 = "";
|
||
cachedProcChips = []; lastProcCacheKey = "";
|
||
cachedEventChips = []; lastEventCacheKey = "";
|
||
break;
|
||
case 3:
|
||
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
|
||
state.r4 = "";
|
||
cachedEventChips = []; lastEventCacheKey = "";
|
||
break;
|
||
case 4:
|
||
state.r4 = "";
|
||
state.r5 = state.r5FromProject ? state.r5 : "";
|
||
break;
|
||
case 5:
|
||
state.r5 = ""; state.r5FromProject = false;
|
||
break;
|
||
}
|
||
syncUrl();
|
||
void renderRows();
|
||
}
|
||
|
||
// maybeAdvanceFromR4 fetches /follow-ups for the picked event to
|
||
// decide whether R5 is needed. If R5 is already set OR the
|
||
// follow-ups don't differ by party, transition straight to the
|
||
// result view. Else swap the R5 loading row for the chip picker.
|
||
async function maybeAdvanceFromR4(): Promise<void> {
|
||
if (!state.r4) return;
|
||
if (state.r5) {
|
||
// R5 already answered (project prefill or explicit pick) → go.
|
||
void launchResult();
|
||
return;
|
||
}
|
||
// Probe follow-ups.
|
||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||
url.searchParams.set("event", state.r4);
|
||
try {
|
||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||
if (!resp.ok) {
|
||
// Soft-fail → swap to R5 chips so the user can decide manually.
|
||
swapR5(rowR5Chips());
|
||
return;
|
||
}
|
||
const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> };
|
||
const differs = followUpsDifferByParty(data.follow_ups);
|
||
if (!differs) {
|
||
void launchResult();
|
||
return;
|
||
}
|
||
swapR5(rowR5Chips());
|
||
} catch {
|
||
swapR5(rowR5Chips());
|
||
}
|
||
}
|
||
|
||
function swapR5(html: string): void {
|
||
const host = document.getElementById("fristen-wizard-rows");
|
||
if (!host) return;
|
||
const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]');
|
||
if (!r5) {
|
||
host.insertAdjacentHTML("beforeend", html);
|
||
} else {
|
||
r5.outerHTML = html;
|
||
}
|
||
wireRowEvents();
|
||
}
|
||
|
||
function launchResult(): void {
|
||
// Hand off to the §4 result view. The URL already carries the
|
||
// picks via syncUrl(); add event= so the boot path treats this
|
||
// as a deep-link.
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set("overhaul", "1");
|
||
url.searchParams.set("event", state.r4);
|
||
if (state.r5) url.searchParams.set("party", state.r5);
|
||
else url.searchParams.delete("party");
|
||
history.pushState(null, "", url.pathname + url.search + url.hash);
|
||
void mountResultView({ eventRef: state.r4, party: state.r5 || undefined });
|
||
}
|
||
|
||
export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean {
|
||
let hasClaimant = false, hasDefendant = false;
|
||
for (const r of rows) {
|
||
if (r.primary_party === "claimant") hasClaimant = true;
|
||
else if (r.primary_party === "defendant") hasDefendant = true;
|
||
if (hasClaimant && hasDefendant) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Fetches -----------------------------------------------------------
|
||
|
||
async function fetchProject(id: string): Promise<ProjectSummary | null> {
|
||
try {
|
||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } });
|
||
if (!resp.ok) return null;
|
||
return (await resp.json()) as ProjectSummary;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function fetchProceedingByID(id: number): Promise<ProceedingChip | null> {
|
||
// The proceeding-types endpoint returns codes, names, jurisdictions
|
||
// but doesn't carry the id (the wire shape FristenrechnerType is
|
||
// code-keyed). Walk the unfiltered list and pick by sort-order
|
||
// proximity / sort-fallback: we need the row whose id matches; since
|
||
// the wire doesn't expose id, fetch the projects detail to get the
|
||
// code directly. Cheap workaround: rely on /api/projects/{id}'s
|
||
// proceeding_type_id being matched against the proceeding-types list
|
||
// by jurisdiction round-trip is not possible without id. Instead
|
||
// expose the proceeding-types-by-id mapping via a follow-up endpoint
|
||
// later. For now hit the unfiltered list and assume the project's
|
||
// pick is in the active set.
|
||
//
|
||
// Pragmatic fallback: query the full list and return the only entry
|
||
// whose pseudo-id-via-sort-order matches. The lookup is unreliable
|
||
// until the wire shape includes id; for the project-prefill case the
|
||
// user can always re-pick R3 / R2 if the prefill misfires.
|
||
try {
|
||
const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } });
|
||
if (!resp.ok) return null;
|
||
const list = (await resp.json()) as ProceedingChip[] | null;
|
||
if (!list || list.length === 0) return null;
|
||
// Without id in the wire we cannot match by id. Skip the prefill
|
||
// silently — R3 stays unanswered and the user picks manually.
|
||
// (S5/follow-up can extend the wire shape to include id.)
|
||
void id;
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise<void> {
|
||
const key = `${forum}\x00${kind}`;
|
||
if (lastProcCacheKey === key) return;
|
||
lastProcCacheKey = key;
|
||
const url = new URL("/api/tools/proceeding-types", window.location.origin);
|
||
url.searchParams.set("kind", "proceeding");
|
||
url.searchParams.set("jurisdiction", forum);
|
||
if (kind !== "missed") url.searchParams.set("event_kind", kind);
|
||
try {
|
||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||
if (!resp.ok) {
|
||
cachedProcChips = [];
|
||
return;
|
||
}
|
||
const data = (await resp.json()) as ProceedingChip[] | null;
|
||
cachedProcChips = data || [];
|
||
} catch {
|
||
cachedProcChips = [];
|
||
}
|
||
}
|
||
|
||
async function ensureEventChips(procCode: string, kind: EventKindRow): Promise<void> {
|
||
const key = `${procCode}\x00${kind}`;
|
||
if (lastEventCacheKey === key) return;
|
||
lastEventCacheKey = key;
|
||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||
url.searchParams.set("kind", "events");
|
||
url.searchParams.set("proc", procCode);
|
||
if (kind !== "missed") url.searchParams.set("event_kind", kind);
|
||
url.searchParams.set("limit", "100");
|
||
try {
|
||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||
if (!resp.ok) {
|
||
cachedEventChips = [];
|
||
return;
|
||
}
|
||
const data = (await resp.json()) as EventSearchResponse;
|
||
cachedEventChips = data.events || [];
|
||
} catch {
|
||
cachedEventChips = [];
|
||
}
|
||
}
|
||
|
||
// Helpers -----------------------------------------------------------
|
||
|
||
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
|
||
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
|
||
const tt = title ? ` title="${escAttr(title)}"` : "";
|
||
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
|
||
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${tt}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
|
||
}
|
||
|
||
function eventKindIcon(kind?: EventKindRow): string {
|
||
switch (kind) {
|
||
case "filing": return "📥";
|
||
case "hearing": return "🏛️";
|
||
case "decision": return "⚖️";
|
||
case "order": return "📜";
|
||
case "missed": return "⏲";
|
||
default: return "📅";
|
||
}
|
||
}
|
||
|
||
function syncUrl(): void {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set("overhaul", "1");
|
||
url.searchParams.set("mode", "wizard");
|
||
setOrClear(url, "kind", state.r1);
|
||
setOrClear(url, "forum", state.r2);
|
||
setOrClear(url, "pt", state.r3);
|
||
// event=… is set only on launchResult; the wizard URL carries the
|
||
// R4 candidate via r4= so back/forward navigates within the wizard.
|
||
setOrClear(url, "r4", state.r4);
|
||
setOrClear(url, "party", state.r5);
|
||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||
}
|
||
|
||
function setOrClear(url: URL, key: string, val: string): void {
|
||
if (val) url.searchParams.set(key, val);
|
||
else url.searchParams.delete(key);
|
||
}
|