feat(fristenrechner): Slice S4 — Mode B wizard (m/paliad#146)
Mode B "🧭 Geführt" — the guided 3-5 row wizard defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §3.2. Lands the
user on a single procedural_event (the trigger), then transitions
to the shared §4 result view.
Frontend:
* `fristenrechner-wizard.ts` — row stack with R1..R5:
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
narrowed pool has 1 option)
R4 Welches Schriftstück? (procedural_event, landing)
R5 Welche Seite vertreten Sie? (party, only when follow-ups
differ by primary_party)
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.
* Project prefill — derives R3 + R2 jurisdiction from
project.proceeding_type, R5 from project.our_side. Annotates
pre-filled rows with "aus Akte" tag and implicit rows with
"implizit" tag per §11.Q10 ("erhalten" annotation when a pick is
carried across an upstream change).
* R4-to-result transition — after R4 the wizard fetches /follow-
ups (no dates) to inspect primary_party variance. If both
claimant and defendant rules exist AND R5 isn't already set,
swaps the loading row for the R5 chip picker. Otherwise jumps
straight to mountResultView.
* URL state — `?mode=wizard&kind=…&forum=…&pt=…&r4=…&party=…`
keeps deep-link / back-nav consistent (the launchResult step
sets `event=` so the result view picks up).
* `fristenrechner-result.ts` mountModeShell now dispatches the
"wizard" tab to the wizard module (was a coming-soon
placeholder).
* 18 i18n keys added (DE + EN parity), 145-line CSS block for the
wizard row stack with Filter / Qualifier badge styling and
"aus Akte" annotation chip.
Backend:
* `ProceedingListOptions.EventKind` adds an EXISTS subquery
filter on `paliad.sequencing_rules` ⨯ `paliad.procedural_events`
so Mode B R3 chips only show proceedings whose event roster
contains at least one event of the requested kind (design
§6.3). Endpoint param: `event_kind=` on
/api/tools/proceeding-types.
Test updates:
* `TestListProceedings` switched from SKIP-when-column-missing to
asserting the live filter — mig 153 has landed, `kind` column
is in place. New subtests: kind=proceeding includes
upc.inf.cfi and excludes the phase row upc.cfi.interim;
event_kind=filing narrows to proceedings with filing events.
* `fristenrechner-wizard.test.ts` covers
`followUpsDifferByParty` — the R5 trigger predicate. 7 cases:
asymmetric → true; uniform / both / court / empty → false.
Verified — bun build clean (2971 i18n keys), 256 frontend tests
pass (incl. 7 new), go build + vet clean, live-DB
TestListProceedings passes all 6 subtests against mig 153 data.
This commit is contained in:
@@ -147,7 +147,8 @@ export async function mountModeShell(activeTab: ModeTab): Promise<void> {
|
||||
const mod = await import("./fristenrechner-mode-a");
|
||||
await mod.mountModeA();
|
||||
} else {
|
||||
host.innerHTML = `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.wizard.coming_soon"))}</div>`;
|
||||
const mod = await import("./fristenrechner-wizard");
|
||||
await mod.mountWizard();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
frontend/src/client/fristenrechner-wizard.test.ts
Normal file
47
frontend/src/client/fristenrechner-wizard.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { followUpsDifferByParty } from "./fristenrechner-wizard";
|
||||
|
||||
describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => {
|
||||
test("true when both claimant and defendant rules present", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(true);
|
||||
});
|
||||
test("false when all claimant", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "claimant" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when all defendant", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when only 'both' rules", () => {
|
||||
// "Both" rules are bilateral procedural moves (Vertraulichkeits-
|
||||
// Erwiderung); they don't gate R5 because either party can be
|
||||
// looking at them.
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "both" },
|
||||
{ primary_party: "both" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when only court rules", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "court" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("true when mixed with both / court alongside the asymmetric pair", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "both" },
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "court" },
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(true);
|
||||
});
|
||||
test("false on empty list", () => {
|
||||
expect(followUpsDifferByParty([])).toBe(false);
|
||||
});
|
||||
});
|
||||
711
frontend/src/client/fristenrechner-wizard.ts
Normal file
711
frontend/src/client/fristenrechner-wizard.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1065,6 +1065,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.overhaul.kind.hearing": "Termin",
|
||||
"deadlines.overhaul.kind.decision": "Entscheidung",
|
||||
"deadlines.overhaul.kind.order": "Verf\u00fcgung",
|
||||
"deadlines.overhaul.kind.missed": "Frist vers\u00e4umt",
|
||||
|
||||
// Fristenrechner overhaul Mode B \u2014 gef\u00fchrter Wizard (S4, design \u00a73.2).
|
||||
"deadlines.overhaul.wizard.heading": "Gef\u00fchrter Modus",
|
||||
"deadlines.overhaul.wizard.hint": "Beantworte die Fragen oben nach unten \u2014 der Wizard landet auf einem Trigger-Ereignis und zeigt die Folge-Fristen.",
|
||||
"deadlines.overhaul.wizard.r1.label": "Was ist passiert?",
|
||||
"deadlines.overhaul.wizard.r2.label": "Vor welchem Gericht?",
|
||||
"deadlines.overhaul.wizard.r3.label": "In welchem Verfahren?",
|
||||
"deadlines.overhaul.wizard.r3.empty": "Kein Verfahren mit diesem Ereignistyp im gew\u00e4hlten Forum.",
|
||||
"deadlines.overhaul.wizard.r4.label": "Welches Schriftst\u00fcck / welcher Termin?",
|
||||
"deadlines.overhaul.wizard.r4.empty": "Keine Ereignisse zu dieser Auswahl.",
|
||||
"deadlines.overhaul.wizard.r5.label": "Welche Seite vertreten Sie?",
|
||||
"deadlines.overhaul.wizard.r5.probing": "Pr\u00fcfe, ob die Folge-Fristen seitenabh\u00e4ngig sind\u2026",
|
||||
"deadlines.overhaul.wizard.badge.filter": "Filter",
|
||||
"deadlines.overhaul.wizard.badge.qualifier": "Qualifier",
|
||||
"deadlines.overhaul.wizard.edit": "\u00e4ndern",
|
||||
"deadlines.overhaul.wizard.anno.from_project": "aus Akte",
|
||||
"deadlines.overhaul.wizard.anno.implicit": "implizit",
|
||||
|
||||
// Office labels (shared)
|
||||
"office.munich": "M\u00fcnchen",
|
||||
@@ -4233,6 +4251,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.overhaul.kind.hearing": "Hearing",
|
||||
"deadlines.overhaul.kind.decision": "Decision",
|
||||
"deadlines.overhaul.kind.order": "Order",
|
||||
"deadlines.overhaul.kind.missed": "Missed deadline",
|
||||
|
||||
// Fristenrechner overhaul Mode B — guided wizard (S4, design §3.2).
|
||||
"deadlines.overhaul.wizard.heading": "Guided mode",
|
||||
"deadlines.overhaul.wizard.hint": "Answer top-down — the wizard lands on a trigger event and shows the follow-up deadlines.",
|
||||
"deadlines.overhaul.wizard.r1.label": "What happened?",
|
||||
"deadlines.overhaul.wizard.r2.label": "Before which forum?",
|
||||
"deadlines.overhaul.wizard.r3.label": "In which proceeding?",
|
||||
"deadlines.overhaul.wizard.r3.empty": "No proceeding with this event kind in the chosen forum.",
|
||||
"deadlines.overhaul.wizard.r4.label": "Which document / which hearing?",
|
||||
"deadlines.overhaul.wizard.r4.empty": "No events for this selection.",
|
||||
"deadlines.overhaul.wizard.r5.label": "Which party do you represent?",
|
||||
"deadlines.overhaul.wizard.r5.probing": "Checking whether follow-ups depend on the side…",
|
||||
"deadlines.overhaul.wizard.badge.filter": "Filter",
|
||||
"deadlines.overhaul.wizard.badge.qualifier": "Qualifier",
|
||||
"deadlines.overhaul.wizard.edit": "edit",
|
||||
"deadlines.overhaul.wizard.anno.from_project": "from project",
|
||||
"deadlines.overhaul.wizard.anno.implicit": "implicit",
|
||||
|
||||
// Office labels (shared)
|
||||
"office.munich": "Munich",
|
||||
|
||||
@@ -1391,6 +1391,7 @@ export type I18nKey =
|
||||
| "deadlines.overhaul.kind.decision"
|
||||
| "deadlines.overhaul.kind.filing"
|
||||
| "deadlines.overhaul.kind.hearing"
|
||||
| "deadlines.overhaul.kind.missed"
|
||||
| "deadlines.overhaul.kind.order"
|
||||
| "deadlines.overhaul.load_error"
|
||||
| "deadlines.overhaul.loading"
|
||||
@@ -1424,7 +1425,22 @@ export type I18nKey =
|
||||
| "deadlines.overhaul.spawn.tooltip"
|
||||
| "deadlines.overhaul.trigger.date"
|
||||
| "deadlines.overhaul.trigger.label"
|
||||
| "deadlines.overhaul.wizard.anno.from_project"
|
||||
| "deadlines.overhaul.wizard.anno.implicit"
|
||||
| "deadlines.overhaul.wizard.badge.filter"
|
||||
| "deadlines.overhaul.wizard.badge.qualifier"
|
||||
| "deadlines.overhaul.wizard.coming_soon"
|
||||
| "deadlines.overhaul.wizard.edit"
|
||||
| "deadlines.overhaul.wizard.heading"
|
||||
| "deadlines.overhaul.wizard.hint"
|
||||
| "deadlines.overhaul.wizard.r1.label"
|
||||
| "deadlines.overhaul.wizard.r2.label"
|
||||
| "deadlines.overhaul.wizard.r3.empty"
|
||||
| "deadlines.overhaul.wizard.r3.label"
|
||||
| "deadlines.overhaul.wizard.r4.empty"
|
||||
| "deadlines.overhaul.wizard.r4.label"
|
||||
| "deadlines.overhaul.wizard.r5.label"
|
||||
| "deadlines.overhaul.wizard.r5.probing"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
| "deadlines.party.claimant"
|
||||
|
||||
@@ -19536,3 +19536,148 @@ a.fristen-overhaul-rule-source {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Fristenrechner overhaul Mode B — wizard (t-paliad-323 S4) ======
|
||||
*
|
||||
* 3-5 row stack landing on a procedural_event. Row badge "Filter" vs
|
||||
* "Qualifier" per m §11.Q3; "aus Akte" / "implizit" annotations per
|
||||
* §11.Q10 (preserve compatible downstream picks on back-nav).
|
||||
* ==================================================================== */
|
||||
|
||||
.fristen-wizard-root {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0d4;
|
||||
border-radius: 0.7rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-header {
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.15rem;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.fristen-wizard-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.fristen-wizard-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-row {
|
||||
background: #fafaf6;
|
||||
border: 1px solid #e0e0d4;
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-row.is-active {
|
||||
border-color: #b1c468;
|
||||
box-shadow: 0 0 0 2px rgba(198, 244, 28, 0.15);
|
||||
}
|
||||
|
||||
.fristen-wizard-row.is-from-project {
|
||||
background: #f4f8e0;
|
||||
}
|
||||
|
||||
.fristen-wizard-row.is-implicit {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 50%;
|
||||
background: #c6f41c;
|
||||
color: #2a2a2a;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 0.35rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-badge--filter { background: #d6e4f0; color: #1c4567; }
|
||||
.fristen-wizard-row-badge--qualifier { background: #f5e1a8; color: #6c4905; }
|
||||
|
||||
.fristen-wizard-row-label {
|
||||
font-weight: 500;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-anno {
|
||||
font-size: 0.75rem;
|
||||
color: #5d6e2a;
|
||||
background: #ebf4c9;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-answer {
|
||||
margin-left: auto;
|
||||
color: #1f1f1f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-edit {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #4a6f1f;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-edit:hover {
|
||||
background: #eef4dd;
|
||||
}
|
||||
|
||||
.fristen-wizard-row-body {
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.fristen-wizard-empty,
|
||||
.fristen-wizard-probe {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.fristen-wizard-row-answer {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@ func handleProceedingTypes(w http.ResponseWriter, r *http.Request) {
|
||||
opts := services.ProceedingListOptions{
|
||||
Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))),
|
||||
Kind: strings.TrimSpace(r.URL.Query().Get("kind")),
|
||||
EventKind: strings.TrimSpace(r.URL.Query().Get("event_kind")),
|
||||
}
|
||||
types, err := dbSvc.fristenrechner.ListProceedings(r.Context(), opts)
|
||||
if err != nil {
|
||||
|
||||
@@ -104,6 +104,14 @@ type ProceedingListOptions struct {
|
||||
// Sequenced per docs/design-proceeding-types-taxonomy-2026-05-26.md
|
||||
// §7 option (c): mig 153 merges to main before the S3 PR ships.
|
||||
Kind string
|
||||
// EventKind narrows to proceedings that have at least one published
|
||||
// sequencing rule anchored on a procedural event of the requested
|
||||
// kind ("filing" | "hearing" | "decision" | "order"). Powers the
|
||||
// Fristenrechner overhaul Mode B R3 wizard row (§3.2): after R1
|
||||
// picks an event_kind, R3 should only chip proceedings whose event
|
||||
// roster contains at least one event of that kind. Empty = no
|
||||
// event-kind narrowing.
|
||||
EventKind string
|
||||
}
|
||||
|
||||
// ListProceedings returns the proceeding_types chips the Fristenrechner
|
||||
@@ -127,6 +135,16 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
if opts.Kind != "" {
|
||||
add("kind = $%d", opts.Kind)
|
||||
}
|
||||
if opts.EventKind != "" {
|
||||
add(`EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.proceeding_type_id = paliad.proceeding_types.id
|
||||
AND sr.is_active = true AND sr.lifecycle_state = 'published'
|
||||
AND pe.is_active = true AND pe.lifecycle_state = 'published'
|
||||
AND pe.event_kind = $%d
|
||||
)`, opts.EventKind)
|
||||
}
|
||||
query := `SELECT code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
|
||||
@@ -99,20 +99,7 @@ func TestListProceedings(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Kind filter requires mig 153. Skip when the column is missing so
|
||||
// pre-mig CI / pre-mig laptop runs stay green.
|
||||
t.Run("kind=proceeding narrows when mig 153 has landed", func(t *testing.T) {
|
||||
var hasKind bool
|
||||
if err := pool.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='paliad' AND table_name='proceeding_types' AND column_name='kind'
|
||||
)`).Scan(&hasKind); err != nil {
|
||||
t.Fatalf("kind column probe: %v", err)
|
||||
}
|
||||
if !hasKind {
|
||||
t.Skip("paliad.proceeding_types.kind not present yet — skip until mig 153 lands")
|
||||
}
|
||||
t.Run("kind=proceeding narrows to primary proceedings only", func(t *testing.T) {
|
||||
got, err := fr.ListProceedings(ctx, ProceedingListOptions{Kind: "proceeding"})
|
||||
if err != nil {
|
||||
t.Fatalf("list proceedings kind=proceeding: %v", err)
|
||||
@@ -120,5 +107,50 @@ func TestListProceedings(t *testing.T) {
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected non-empty primary-proceeding list")
|
||||
}
|
||||
// upc.inf.cfi is unambiguously a primary proceeding — must
|
||||
// survive the filter.
|
||||
found := false
|
||||
for _, p := range got {
|
||||
if p.Code == "upc.inf.cfi" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("upc.inf.cfi missing from kind=proceeding list")
|
||||
}
|
||||
// upc.cfi.interim is the canonical phase row (per mig 153 +
|
||||
// taxonomy doc §0.4 Group B) — must NOT appear.
|
||||
for _, p := range got {
|
||||
if p.Code == "upc.cfi.interim" {
|
||||
t.Errorf("phase row upc.cfi.interim leaked into kind=proceeding")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("event_kind=filing narrows to proceedings with filing events", func(t *testing.T) {
|
||||
got, err := fr.ListProceedings(ctx, ProceedingListOptions{
|
||||
Jurisdiction: "UPC",
|
||||
Kind: "proceeding",
|
||||
EventKind: "filing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list proceedings UPC+filing: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected UPC proceedings with filing events")
|
||||
}
|
||||
// upc.inf.cfi has at least one rule anchored on a filing event
|
||||
// (Klageerhebung, SoD, etc.) — must survive.
|
||||
found := false
|
||||
for _, p := range got {
|
||||
if p.Code == "upc.inf.cfi" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("upc.inf.cfi missing from UPC + event_kind=filing list")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user