Compare commits
17 Commits
mai/newton
...
mai/maxwel
| Author | SHA1 | Date | |
|---|---|---|---|
| 9679a98666 | |||
| fcdfba209d | |||
| 3e93e94d10 | |||
| 28ea103260 | |||
| 1c77cb6e67 | |||
| 1f6e586c63 | |||
| a4b865d6bd | |||
| a905911cf4 | |||
| 88c03e922f | |||
| 6bcac2dd20 | |||
| 46dc4ec94b | |||
| 6c1d8cc0cf | |||
| 0c857026a2 | |||
| 3c840c0366 | |||
| 1b4b2e4758 | |||
| b78a984a7c | |||
| 1844df3ae6 |
@@ -248,8 +248,11 @@ func main() {
|
||||
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
|
||||
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
|
||||
// CRUD over the new normalised scenarios + scenario_proceedings
|
||||
// + scenario_events + scenario_shares tables.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool),
|
||||
// + scenario_events + scenario_shares tables. B4 adds the
|
||||
// Akte-mode dual-write: project-backed scenarios write through
|
||||
// to paliad.projects.scenario_flags + paliad.deadlines via the
|
||||
// injected project + scenarioFlags services.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc)),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
262
frontend/src/client/builder-akte.ts
Normal file
262
frontend/src/client/builder-akte.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
// Akte-mode wiring for the Litigation Builder (m/paliad#153 B4,
|
||||
// t-paliad-347).
|
||||
//
|
||||
// PRD §2.3 + §3.1 + §3.2: the page-header Akte picker lists every
|
||||
// project (`type='case'`) the user can see. Picking one POSTs to
|
||||
// /api/builder/scenarios/from-project, which mints a project-backed
|
||||
// scenario (origin_project_id pinned) seeded with the project's
|
||||
// proceeding + scenario_flags + completed deadlines. Subsequent
|
||||
// builder edits dual-write through to paliad.deadlines + projects.
|
||||
// scenario_flags via the server-side dual-write hooks.
|
||||
//
|
||||
// The picker is its own module so the builder.ts orchestrator only
|
||||
// has to expose two hooks:
|
||||
//
|
||||
// - `onProjectChosen(projectId)` — called when the user picks a
|
||||
// project. Builder calls the from-project endpoint and loads the
|
||||
// returned scenario.
|
||||
// - `setSelectedProject(scenario)` — called after a scenario loads
|
||||
// so the picker reflects the current Akte (or "— ohne —" for
|
||||
// kontextfrei scenarios).
|
||||
//
|
||||
// Cross-surface scenario-flag-changed (mig 154 ssoT, m/paliad#149):
|
||||
// the builder listens to the existing CustomEvent so any peer surface
|
||||
// that PATCHes /api/projects/{id}/scenario-flags triggers a re-fetch
|
||||
// on the builder's active proceeding when the projectId matches the
|
||||
// scenario's origin_project_id. The dispatch direction is already
|
||||
// covered by patchScenarioFlags inside scenario-flags.ts — the
|
||||
// builder's own PATCH /api/projects/.../scenario-flags goes through
|
||||
// that helper so peer surfaces stay in sync without a separate dispatch.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface AkteProjectMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
}
|
||||
|
||||
export type OnProjectChosen = (projectId: string) => void | Promise<void>;
|
||||
|
||||
interface State {
|
||||
projects: AkteProjectMeta[];
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
projects: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// fetchAkteProjects pulls every type=case project the caller can see.
|
||||
// Visibility is enforced by /api/projects via the project_teams /
|
||||
// can_see_project predicate. We filter client-side to projects with a
|
||||
// proceeding_type_id — those are the ones the builder can render. We
|
||||
// don't filter server-side because /api/projects' filter param doesn't
|
||||
// accept proceeding_type_id_not_null and round-tripping for that one
|
||||
// reason isn't worth a new endpoint.
|
||||
export async function fetchAkteProjects(): Promise<AkteProjectMeta[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=case", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: /api/projects", resp.status);
|
||||
return [];
|
||||
}
|
||||
const rows = (await resp.json()) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
status?: string;
|
||||
}>;
|
||||
return rows
|
||||
.filter((r) => r.proceeding_type_id != null && (r.status ?? "active") === "active")
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
reference: r.reference ?? null,
|
||||
case_number: r.case_number ?? null,
|
||||
proceeding_type_id: r.proceeding_type_id ?? null,
|
||||
our_side: r.our_side ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("builder-akte: fetch projects failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// formatProjectLabel renders the dropdown row for a project. Reference
|
||||
// + title are the primary anchors; the case_number tail disambiguates
|
||||
// when two cases share a reference family.
|
||||
function formatProjectLabel(p: AkteProjectMeta): string {
|
||||
const parts: string[] = [];
|
||||
if (p.reference) parts.push(p.reference);
|
||||
parts.push(p.title);
|
||||
if (p.case_number) parts.push("(" + p.case_number + ")");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// renderAktePicker fills the existing <select id="builder-akte-picker">
|
||||
// with the project list + a "— ohne —" sentinel. Idempotent.
|
||||
function renderAktePicker(selectedId: string | null): void {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const none = t("builder.akte.none");
|
||||
const opts: string[] = [`<option value="" data-i18n="builder.akte.none">${escHtml(none)}</option>`];
|
||||
for (const p of state.projects) {
|
||||
const selected = p.id === selectedId ? " selected" : "";
|
||||
opts.push(
|
||||
`<option value="${escAttr(p.id)}"${selected}>${escHtml(formatProjectLabel(p))}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
}
|
||||
|
||||
// mountAktePicker is the entry point. It fetches the project list once,
|
||||
// wires the dropdown change event to the supplied callback, and
|
||||
// returns a controller exposing setSelectedProject so the builder can
|
||||
// keep the picker reflective of the active scenario's Akte.
|
||||
//
|
||||
// The picker re-enables itself the moment projects load. While
|
||||
// loading, the existing `disabled` attribute (set in procedures.tsx)
|
||||
// stays so users don't pick during the fetch — but if the user lands
|
||||
// on the page after the catalog is cached this is essentially
|
||||
// instantaneous.
|
||||
export interface AktePickerHandle {
|
||||
setSelectedProject: (projectId: string | null) => void;
|
||||
isAkteMode: () => boolean;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function mountAktePicker(onChosen: OnProjectChosen): Promise<AktePickerHandle> {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) {
|
||||
return {
|
||||
setSelectedProject: () => {},
|
||||
isAkteMode: () => false,
|
||||
reload: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// First load — fill the dropdown, enable it, wire change.
|
||||
state.projects = await fetchAkteProjects();
|
||||
state.loaded = true;
|
||||
renderAktePicker(null);
|
||||
sel.disabled = false;
|
||||
|
||||
sel.addEventListener("change", () => {
|
||||
const id = sel.value;
|
||||
if (!id) {
|
||||
// "— ohne —" reset is intentional; the builder treats this as
|
||||
// "leave the current scenario alone, just clear the picker".
|
||||
// Switching the active scenario to a non-Akte one happens via
|
||||
// the scenario picker, not by clicking the empty Akte option.
|
||||
return;
|
||||
}
|
||||
void onChosen(id);
|
||||
});
|
||||
|
||||
return {
|
||||
setSelectedProject: (projectId: string | null) => {
|
||||
const next = projectId ?? "";
|
||||
// Renderless quick-sync when the option is present; otherwise
|
||||
// re-render so the option appears (covers freshly created
|
||||
// projects since this picker last loaded).
|
||||
const optEl = sel.querySelector<HTMLOptionElement>(`option[value="${cssEscape(next)}"]`);
|
||||
if (next && !optEl) {
|
||||
renderAktePicker(next);
|
||||
} else {
|
||||
sel.value = next;
|
||||
}
|
||||
},
|
||||
isAkteMode: () => sel.value !== "",
|
||||
reload: async () => {
|
||||
state.projects = await fetchAkteProjects();
|
||||
renderAktePicker(sel.value || null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// createScenarioFromProject posts to the B4 entry point. Returns the
|
||||
// new scenario's deep payload on success (id + proceedings + events),
|
||||
// null on failure. Caller is expected to load the returned scenario
|
||||
// via the builder's existing fetchScenarioDeep / state.active path.
|
||||
export async function createScenarioFromProject(projectId: string): Promise<{ id: string } | null> {
|
||||
try {
|
||||
const resp = await fetch("/api/builder/scenarios/from-project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ project_id: projectId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: from-project", resp.status, await resp.text().catch(() => ""));
|
||||
return null;
|
||||
}
|
||||
const out = await resp.json();
|
||||
return out && typeof out.id === "string" ? { id: out.id } : null;
|
||||
} catch (e) {
|
||||
console.error("builder-akte: from-project failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// renderAkteBanner toggles the "Aus Akte: <code>" badge next to the
|
||||
// scenario picker. The badge is a <span class="builder-akte-banner">
|
||||
// inserted/removed by this helper; CSS gives it a lime tint to match
|
||||
// the Akte affordance throughout the app. Pass `null` (or omit
|
||||
// projectId) to hide.
|
||||
export function renderAkteBanner(projectId: string | null): void {
|
||||
const host = document.querySelector(".builder-pageheader") as HTMLElement | null;
|
||||
if (!host) return;
|
||||
let badge = document.getElementById("builder-akte-banner");
|
||||
if (!projectId) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
const meta = state.projects.find((p) => p.id === projectId);
|
||||
const label = meta ? formatProjectLabel(meta) : projectId.slice(0, 8);
|
||||
const text =
|
||||
t("builder.akte.banner.prefix") + " " + label;
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.id = "builder-akte-banner";
|
||||
badge.className = "builder-akte-banner";
|
||||
badge.setAttribute("role", "note");
|
||||
host.appendChild(badge);
|
||||
}
|
||||
badge.textContent = text;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape is a small fallback for browsers that don't yet expose
|
||||
// CSS.escape. UUIDs only contain [0-9a-f-] so even the naïve replacer
|
||||
// keeps us safe; the function exists to make intent obvious.
|
||||
function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof (CSS as { escape?: (s: string) => string }).escape === "function") {
|
||||
return (CSS as { escape: (s: string) => string }).escape(s);
|
||||
}
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||
}
|
||||
147
frontend/src/client/builder-picker.ts
Normal file
147
frontend/src/client/builder-picker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Add-proceeding inline picker for the Litigation Builder.
|
||||
//
|
||||
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
|
||||
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
|
||||
// gates the Verfahren chip row, click → callback. Designed for B1's
|
||||
// single-triplet flow and B2's multi-triplet stacking with no shape
|
||||
// change between slices.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ProceedingTypeMeta {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
// group / jurisdiction. The proceeding-types API returns "UPC" /
|
||||
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
|
||||
// only renders UPC.
|
||||
group?: string;
|
||||
jurisdiction?: string;
|
||||
}
|
||||
|
||||
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
|
||||
|
||||
let activePopover: HTMLElement | null = null;
|
||||
|
||||
export function mountAddProceedingPicker(
|
||||
anchor: HTMLElement,
|
||||
types: ProceedingTypeMeta[],
|
||||
onPick: OnPick,
|
||||
): void {
|
||||
closeActive();
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "builder-picker-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("builder.picker.aria"));
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "builder-picker-header";
|
||||
header.innerHTML = `
|
||||
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
|
||||
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
|
||||
`;
|
||||
pop.appendChild(header);
|
||||
|
||||
// Forum row — UPC only for v1. Disabled chips render greyed.
|
||||
const forumRow = document.createElement("div");
|
||||
forumRow.className = "builder-picker-row";
|
||||
forumRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
|
||||
<div class="builder-picker-chips">
|
||||
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
|
||||
</div>
|
||||
`;
|
||||
pop.appendChild(forumRow);
|
||||
|
||||
const procRow = document.createElement("div");
|
||||
procRow.className = "builder-picker-row";
|
||||
procRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
|
||||
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
|
||||
`;
|
||||
pop.appendChild(procRow);
|
||||
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "builder-picker-empty";
|
||||
empty.hidden = true;
|
||||
empty.textContent = t("builder.picker.empty");
|
||||
pop.appendChild(empty);
|
||||
|
||||
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
|
||||
const lang = document.documentElement.lang === "en" ? "en" : "de";
|
||||
for (const meta of types) {
|
||||
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-picker-chip builder-picker-chip--proc";
|
||||
chip.setAttribute("data-code", meta.code);
|
||||
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
|
||||
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
|
||||
chip.addEventListener("click", () => {
|
||||
closeActive();
|
||||
void onPick(meta);
|
||||
});
|
||||
procHost.appendChild(chip);
|
||||
}
|
||||
if (types.length === 0) empty.hidden = false;
|
||||
|
||||
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
|
||||
closeActive();
|
||||
});
|
||||
|
||||
// Position the popover under the anchor button.
|
||||
positionUnder(pop, anchor);
|
||||
document.body.appendChild(pop);
|
||||
activePopover = pop;
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
document.addEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
pop.style.position = "absolute";
|
||||
const top = rect.bottom + window.scrollY + 6;
|
||||
// Default left = anchor's left; clamp so popover stays in viewport.
|
||||
const left = Math.max(8, rect.left + window.scrollX);
|
||||
pop.style.top = `${top}px`;
|
||||
pop.style.left = `${left}px`;
|
||||
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
|
||||
pop.style.zIndex = "60";
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!activePopover) return;
|
||||
const target = ev.target as Node;
|
||||
if (activePopover.contains(target)) return;
|
||||
closeActive();
|
||||
}
|
||||
|
||||
function onEscape(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") closeActive();
|
||||
}
|
||||
|
||||
function closeActive(): void {
|
||||
if (activePopover) {
|
||||
activePopover.remove();
|
||||
activePopover = null;
|
||||
}
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
document.removeEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
412
frontend/src/client/builder-search.ts
Normal file
412
frontend/src/client/builder-search.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
// Universal search dropdown for the Litigation Builder (m/paliad#153 B3).
|
||||
//
|
||||
// PRD §2.2 + §3.1 + §6.3: the page-header search box ("Suche") drives
|
||||
// a typed dropdown returning grouped event / scenario / project hits.
|
||||
// Picking an event lands the user on a scratch scenario with one
|
||||
// triplet anchored on that event's proceeding type. Picking a scenario
|
||||
// loads it; picking a project (Akte) is deferred to B4 (the dropdown
|
||||
// row renders but pick falls through to a console hint until B4 wires
|
||||
// project-backed scenarios).
|
||||
//
|
||||
// The controller is owned by builder.ts; this module exports
|
||||
// `mountBuilderSearch` which wires the input + dropdown lifecycle and
|
||||
// invokes the supplied callbacks. No module-level state — re-mounting
|
||||
// is safe.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string | null;
|
||||
primary_party?: string | null;
|
||||
anchor_rule_id: string;
|
||||
follow_up_count: number;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScenarioSearchHit {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectSearchHit {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
client_number?: string | null;
|
||||
}
|
||||
|
||||
export interface UniversalSearchResponse {
|
||||
query: string;
|
||||
events: EventSearchHit[];
|
||||
scenarios: ScenarioSearchHit[];
|
||||
projects: ProjectSearchHit[];
|
||||
counts: { events: number; scenarios: number; projects: number };
|
||||
}
|
||||
|
||||
export interface BuilderSearchCallbacks {
|
||||
onPickEvent: (hit: EventSearchHit) => void | Promise<void>;
|
||||
onPickScenario: (hit: ScenarioSearchHit) => void | Promise<void>;
|
||||
onPickProject?: (hit: ProjectSearchHit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
input: HTMLInputElement;
|
||||
dropdown: HTMLElement;
|
||||
open: boolean;
|
||||
abort: AbortController | null;
|
||||
debounceTimer: number | null;
|
||||
lang: "de" | "en";
|
||||
}
|
||||
|
||||
let active: Controller | null = null;
|
||||
|
||||
// mountBuilderSearch wires the universal search behavior onto an
|
||||
// existing <input>. Idempotent — re-calling tears down the previous
|
||||
// dropdown and rebinds. Returns a controller exposing focus() so the
|
||||
// entry-mode toggle in builder.ts can land on the search input.
|
||||
export function mountBuilderSearch(
|
||||
input: HTMLInputElement,
|
||||
cb: BuilderSearchCallbacks,
|
||||
): { focus: () => void; close: () => void } {
|
||||
teardown();
|
||||
|
||||
const lang: "de" | "en" = document.documentElement.lang === "en" ? "en" : "de";
|
||||
|
||||
// Single dropdown container, anchored under the input. Positioned
|
||||
// absolutely so it floats above the canvas without reflowing layout.
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "builder-search-dropdown";
|
||||
dropdown.setAttribute("role", "listbox");
|
||||
dropdown.hidden = true;
|
||||
document.body.appendChild(dropdown);
|
||||
|
||||
active = {
|
||||
input,
|
||||
dropdown,
|
||||
open: false,
|
||||
abort: null,
|
||||
debounceTimer: null,
|
||||
lang,
|
||||
};
|
||||
|
||||
input.addEventListener("input", onInput);
|
||||
input.addEventListener("focus", onFocus);
|
||||
input.addEventListener("keydown", onKeydown);
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
window.addEventListener("resize", reposition);
|
||||
window.addEventListener("scroll", reposition, true);
|
||||
|
||||
// Click handler is wired once on the dropdown root via event
|
||||
// delegation; per-row data attributes identify the hit type.
|
||||
dropdown.addEventListener("click", (ev) => {
|
||||
const row = (ev.target as HTMLElement).closest<HTMLElement>(".builder-search-row");
|
||||
if (!row) return;
|
||||
const kind = row.getAttribute("data-hit-kind");
|
||||
const payload = row.getAttribute("data-hit-payload");
|
||||
if (!kind || !payload) return;
|
||||
try {
|
||||
const hit = JSON.parse(payload);
|
||||
ev.stopPropagation();
|
||||
closeDropdown();
|
||||
if (kind === "event") void cb.onPickEvent(hit);
|
||||
else if (kind === "scenario") void cb.onPickScenario(hit);
|
||||
else if (kind === "project" && cb.onPickProject) void cb.onPickProject(hit);
|
||||
} catch (err) {
|
||||
console.error("builder-search: bad payload", err);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
focus: () => {
|
||||
input.focus();
|
||||
// Open the dropdown on focus even when input is empty — show the
|
||||
// "start typing" hint per PRD §2.2 (search box auto-focuses).
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
},
|
||||
close: closeDropdown,
|
||||
};
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
if (!active) return;
|
||||
if (active.abort) active.abort.abort();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
active.dropdown.remove();
|
||||
active.input.removeEventListener("input", onInput);
|
||||
active.input.removeEventListener("focus", onFocus);
|
||||
active.input.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
window.removeEventListener("scroll", reposition, true);
|
||||
active = null;
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
return;
|
||||
}
|
||||
if (q.length < 2) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.short"));
|
||||
return;
|
||||
}
|
||||
active.debounceTimer = window.setTimeout(() => {
|
||||
void runSearch(q);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
} else if (q.length >= 2) {
|
||||
void runSearch(q);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent): void {
|
||||
if (!active) return;
|
||||
if (ev.key === "Escape") {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
||||
const rows = Array.from(active.dropdown.querySelectorAll<HTMLElement>(".builder-search-row"));
|
||||
if (rows.length === 0) return;
|
||||
ev.preventDefault();
|
||||
const current = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
let idx = current ? rows.indexOf(current) : -1;
|
||||
idx = ev.key === "ArrowDown"
|
||||
? Math.min(rows.length - 1, idx + 1)
|
||||
: Math.max(0, idx - 1);
|
||||
rows.forEach((r) => r.classList.remove("is-focus"));
|
||||
rows[idx].classList.add("is-focus");
|
||||
rows[idx].scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
const focused = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
if (focused) {
|
||||
ev.preventDefault();
|
||||
focused.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!active) return;
|
||||
const target = ev.target as Node;
|
||||
if (active.input.contains(target)) return;
|
||||
if (active.dropdown.contains(target)) return;
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
async function runSearch(q: string): Promise<void> {
|
||||
if (!active) return;
|
||||
// Cancel any in-flight request so a slow earlier query can't clobber
|
||||
// a faster newer one.
|
||||
if (active.abort) active.abort.abort();
|
||||
const ctl = new AbortController();
|
||||
active.abort = ctl;
|
||||
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.loading"));
|
||||
|
||||
try {
|
||||
const url = "/api/builder/search?q=" + encodeURIComponent(q);
|
||||
const resp = await fetch(url, { signal: ctl.signal });
|
||||
if (!resp.ok) {
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as UniversalSearchResponse;
|
||||
if (active.abort !== ctl) return;
|
||||
renderResults(data);
|
||||
} catch (err) {
|
||||
if ((err as { name?: string })?.name === "AbortError") return;
|
||||
console.error("builder-search error:", err);
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderHint(message: string): void {
|
||||
if (!active) return;
|
||||
active.dropdown.innerHTML = `<div class="builder-search-hint">${escHtml(message)}</div>`;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderResults(data: UniversalSearchResponse): void {
|
||||
if (!active) return;
|
||||
const lang = active.lang;
|
||||
|
||||
const total = data.events.length + data.scenarios.length + data.projects.length;
|
||||
if (total === 0) {
|
||||
renderHint(t("builder.search.hint.empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Result-count summary per PRD §2.2: "N Ereignisse · M Szenarios · K Akten"
|
||||
const counts = `<div class="builder-search-summary">` +
|
||||
escHtml(tCount("builder.search.summary.events", data.events.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.scenarios", data.scenarios.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.projects", data.projects.length)) +
|
||||
`</div>`;
|
||||
|
||||
const sections: string[] = [counts];
|
||||
|
||||
if (data.events.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.events"),
|
||||
data.events.map((e) => renderEventRow(e, lang)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.scenarios.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.scenarios"),
|
||||
data.scenarios.map((s) => renderScenarioRow(s)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.projects.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.projects"),
|
||||
data.projects.map((p) => renderProjectRow(p, lang)).join(""),
|
||||
));
|
||||
}
|
||||
|
||||
active.dropdown.innerHTML = sections.join("");
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderGroup(label: string, rowsHtml: string): string {
|
||||
return `<section class="builder-search-group">` +
|
||||
`<header class="builder-search-group-label">${escHtml(label)}</header>` +
|
||||
rowsHtml +
|
||||
`</section>`;
|
||||
}
|
||||
|
||||
function renderEventRow(hit: EventSearchHit, lang: "de" | "en"): string {
|
||||
const name = lang === "en" ? (hit.name_en || hit.name_de) : (hit.name_de || hit.name_en);
|
||||
const ptName = lang === "en"
|
||||
? (hit.proceeding_type.name_en || hit.proceeding_type.name_de)
|
||||
: (hit.proceeding_type.name_de || hit.proceeding_type.name_en);
|
||||
const party = hit.primary_party ? `<span class="builder-search-party">${escHtml(hit.primary_party)}</span>` : "";
|
||||
const kind = hit.event_kind ? `<span class="builder-search-kind">${escHtml(hit.event_kind)}</span>` : "";
|
||||
// Payload for the click handler — we embed the full hit so builder.ts
|
||||
// doesn't need a second lookup. JSON-encoded into a data attribute,
|
||||
// attr-escaped on the way in.
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="event" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-pt-code">${escHtml(hit.proceeding_type.code)}</span>` +
|
||||
`<span class="builder-search-event-name">${escHtml(name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-pt-name">${escHtml(ptName)}</span>` +
|
||||
kind + party +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderScenarioRow(hit: ScenarioSearchHit): string {
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="scenario" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-scenario-name">${escHtml(hit.name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-status">${escHtml(hit.status)}</span>` +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderProjectRow(hit: ProjectSearchHit, _lang: "de" | "en"): string {
|
||||
const meta: string[] = [];
|
||||
if (hit.case_number) meta.push(hit.case_number);
|
||||
if (hit.matter_number) meta.push(hit.matter_number);
|
||||
if (hit.client_number) meta.push(hit.client_number);
|
||||
if (hit.reference) meta.push(hit.reference);
|
||||
const metaText = meta.length > 0 ? meta.join(" · ") : "";
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="project" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-project-type">${escHtml(hit.type)}</span>` +
|
||||
`<span class="builder-search-project-title">${escHtml(hit.title)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">${escHtml(metaText)}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = false;
|
||||
active.open = true;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function closeDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = true;
|
||||
active.open = false;
|
||||
if (active.abort) {
|
||||
active.abort.abort();
|
||||
active.abort = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reposition(): void {
|
||||
if (!active || !active.open) return;
|
||||
const rect = active.input.getBoundingClientRect();
|
||||
const top = rect.bottom + window.scrollY + 4;
|
||||
const left = rect.left + window.scrollX;
|
||||
const width = Math.max(rect.width, 380);
|
||||
active.dropdown.style.position = "absolute";
|
||||
active.dropdown.style.top = `${top}px`;
|
||||
active.dropdown.style.left = `${left}px`;
|
||||
active.dropdown.style.width = `${width}px`;
|
||||
active.dropdown.style.zIndex = "60";
|
||||
}
|
||||
|
||||
// tCount applies a simple plural pick: keys ".one" / ".other" carry
|
||||
// the singular/plural variants; the caller's key is the bare stem.
|
||||
function tCount(key: string, n: number): string {
|
||||
const variant = n === 1 ? `${key}.one` : `${key}.other`;
|
||||
return t(variant).replace("{n}", String(n));
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
271
frontend/src/client/builder-triplet.ts
Normal file
271
frontend/src/client/builder-triplet.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// ProceedingTriplet renderer for the Litigation Builder.
|
||||
//
|
||||
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
|
||||
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
|
||||
// body.
|
||||
//
|
||||
// B2 wires the live controls — perspective radio, scenario-flag strip,
|
||||
// remove button, collapse — and the per-event-card overlays (3-state
|
||||
// machine, action buttons, optional-horizon chip). The 3-column body
|
||||
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
|
||||
// per-card overlays are layered on top after innerHTML write via the
|
||||
// data-rule-id hooks added in the same slice.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
|
||||
import type { BuilderProceeding, BuilderEvent } from "./builder";
|
||||
import type { ProceedingTypeMeta } from "./builder-picker";
|
||||
|
||||
export interface ScenarioFlagCatalogEntry {
|
||||
flag_key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description?: string;
|
||||
hidden_unless_set: boolean;
|
||||
}
|
||||
|
||||
export interface TripletViewInput {
|
||||
proceeding: BuilderProceeding;
|
||||
meta: ProceedingTypeMeta;
|
||||
data: DeadlineResponse | null;
|
||||
side: Side;
|
||||
// Flag catalog filtered to the keys the active proceeding actually
|
||||
// references via its rules' condition_expr. B2 passes the global
|
||||
// catalog and lets the user toggle any — flags that don't gate any
|
||||
// rule are simply no-ops on this triplet.
|
||||
flagCatalog: ScenarioFlagCatalogEntry[];
|
||||
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
|
||||
// for the per-card state machine. Cards whose rule is absent default
|
||||
// to "planned".
|
||||
eventsByRule: Map<string, BuilderEvent>;
|
||||
// Per-card optional-horizon registry. Each rule with optional
|
||||
// children carries a `+N Optionen` chip; the chip's count comes from
|
||||
// here (defaults to scenario_events.horizon_optional, falls back to
|
||||
// proceeding-level when not stored per-card).
|
||||
columnsHtml: string;
|
||||
isChild: boolean;
|
||||
}
|
||||
|
||||
// Triplet header + controls + columns body. Pure-string render; the
|
||||
// caller (builder.ts) wires click handlers on top.
|
||||
export function renderTriplet(input: TripletViewInput): string {
|
||||
const lang = getLang();
|
||||
const procLabel = lang === "en"
|
||||
? (input.meta.nameEN || input.meta.name)
|
||||
: (input.meta.name || input.meta.nameEN);
|
||||
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
|
||||
|
||||
const body = input.data
|
||||
? input.columnsHtml
|
||||
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
|
||||
|
||||
const controls = renderControls(input);
|
||||
const flagStrip = renderFlagStrip(input);
|
||||
|
||||
return `
|
||||
<header class="builder-triplet-header">
|
||||
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
|
||||
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
|
||||
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
|
||||
${flagsBadge}
|
||||
</header>
|
||||
${controls}
|
||||
${flagStrip}
|
||||
<div class="builder-triplet-body">
|
||||
${body}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderControls(input: TripletViewInput): string {
|
||||
const perspective = input.side ?? "";
|
||||
const detailgrad = input.proceeding.detailgrad || "selected";
|
||||
|
||||
const radio = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-perspective-btn${active}"
|
||||
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
const detailBtn = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
|
||||
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
|
||||
return `<div class="builder-triplet-controls">
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
|
||||
<div class="builder-triplet-perspective">
|
||||
${radio("", "builder.triplet.perspective.none", perspective)}
|
||||
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
|
||||
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
|
||||
</div>
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
|
||||
<div class="builder-triplet-detailgrad">
|
||||
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
|
||||
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
|
||||
</div>
|
||||
<button type="button" class="builder-triplet-remove" data-action="remove">
|
||||
${escHtml(t("builder.triplet.remove"))}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFlagStrip(input: TripletViewInput): string {
|
||||
// B2 ships the full global catalog. Flags that don't gate any of the
|
||||
// active proceeding's rules are still toggle-able but have no effect
|
||||
// on the calc result (the engine simply doesn't read them).
|
||||
const lang = getLang();
|
||||
const flags = input.proceeding.scenario_flags || {};
|
||||
if (input.flagCatalog.length === 0) {
|
||||
return `<div class="builder-triplet-flagstrip">
|
||||
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
|
||||
</div>`;
|
||||
}
|
||||
const toggles = input.flagCatalog.map((entry) => {
|
||||
const label = lang === "en" ? entry.label_en : entry.label_de;
|
||||
const isOn = flags[entry.flag_key] === true;
|
||||
return `<label class="builder-triplet-flag-toggle">
|
||||
<input type="checkbox"
|
||||
data-action="flag"
|
||||
data-flag-key="${escAttr(entry.flag_key)}"
|
||||
${isOn ? "checked" : ""} />
|
||||
<span>${escHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
|
||||
}
|
||||
|
||||
function jurisdictionFor(meta: ProceedingTypeMeta): string {
|
||||
if (meta.jurisdiction) return meta.jurisdiction;
|
||||
if (meta.group) return meta.group;
|
||||
const dot = meta.code.indexOf(".");
|
||||
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
|
||||
return meta.code.toUpperCase();
|
||||
}
|
||||
|
||||
function activeFlagsBadge(flags: Record<string, unknown>): string {
|
||||
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
|
||||
if (active.length === 0) return "";
|
||||
const label = t("builder.triplet.flags.label");
|
||||
const chips = active.map((f) =>
|
||||
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
|
||||
).join("");
|
||||
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
|
||||
}
|
||||
|
||||
// overlayEventStates walks the rendered .fr-col-item nodes and:
|
||||
// - sets data-builder-state from eventsByRule lookup;
|
||||
// - appends a per-card action row (file / skip / reset);
|
||||
// - shows a +N Optionen chip when the rule has optional children
|
||||
// (the chip placeholder; B2 ships the per-card horizon control —
|
||||
// the actual horizon-count→render expansion lands when the calc
|
||||
// engine surfaces "available optionals" for a parent rule, which
|
||||
// pasteur's Options.IncludeOptional flag already exposes server-
|
||||
// side; full wiring is a follow-up). Cards without optional
|
||||
// children get no chip.
|
||||
export function overlayEventStates(
|
||||
root: HTMLElement,
|
||||
eventsByRule: Map<string, BuilderEvent>,
|
||||
on: {
|
||||
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
|
||||
onHorizon: (ruleId: string, delta: 1 | -1) => void;
|
||||
},
|
||||
): void {
|
||||
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
|
||||
items.forEach((item) => {
|
||||
const ruleId = item.getAttribute("data-rule-id");
|
||||
if (!ruleId) return;
|
||||
const ev = eventsByRule.get(ruleId.toLowerCase());
|
||||
const state = ev?.state || "planned";
|
||||
item.setAttribute("data-builder-state", state);
|
||||
|
||||
// Append actions (idempotent: clear any prior overlay first).
|
||||
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "builder-event-actions";
|
||||
actions.innerHTML = actionButtonsHtml(state);
|
||||
item.appendChild(actions);
|
||||
|
||||
actions.addEventListener("click", (ev) => {
|
||||
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
|
||||
if (!action) return;
|
||||
ev.stopPropagation();
|
||||
if (action === "file") {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
|
||||
if (v === null) return;
|
||||
on.onAction(ruleId, "file", { date: v.trim() || today });
|
||||
} else if (action === "skip") {
|
||||
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
|
||||
if (reason === null) return;
|
||||
on.onAction(ruleId, "skip", { reason: reason.trim() });
|
||||
} else {
|
||||
on.onAction(ruleId, "reset");
|
||||
}
|
||||
});
|
||||
|
||||
// Per-card optional horizon chip. The PRD §3.4 places the chip on
|
||||
// every card with optional children; until the calc surface exposes
|
||||
// an "optionals available count" on each parent rule, the chip is
|
||||
// shown only when the card has a stored non-zero horizon (so the
|
||||
// user can see and reduce a previously-set horizon). This is the
|
||||
// graceful B2 baseline; the full surface lands once the engine
|
||||
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
|
||||
const horizonCount = ev?.horizon_optional ?? 0;
|
||||
if (horizonCount > 0) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-toggle");
|
||||
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, -1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
} else {
|
||||
// Inline "+ Optionen" affordance — adds a horizon entry when
|
||||
// first clicked. Tagged as data-builder-feature so the cleanup
|
||||
// sweep can rip it out if the calc surface lands a counter.
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-add");
|
||||
chip.setAttribute("data-builder-feature", "horizon-add");
|
||||
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, 1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function actionButtonsHtml(state: BuilderEvent["state"]): string {
|
||||
// Re-render the action row per state. Cards in the planned state
|
||||
// show "File / Skip"; filed/skipped cards show "Reset to planned".
|
||||
if (state === "planned") {
|
||||
return `
|
||||
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
|
||||
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
|
||||
`;
|
||||
}
|
||||
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
1281
frontend/src/client/builder.ts
Normal file
1281
frontend/src/client/builder.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -214,6 +214,86 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
|
||||
"nav.procedures": "Verfahren & Fristen",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
|
||||
"builder.header.scenario": "Szenario:",
|
||||
"builder.header.akte": "Akte:",
|
||||
"builder.header.stichtag": "Stichtag:",
|
||||
"builder.header.search": "Suche:",
|
||||
"builder.akte.none": "\u2014 ohne \u2014",
|
||||
"builder.akte.banner.prefix": "Aus Akte:",
|
||||
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
|
||||
"builder.action.rename": "Benennen",
|
||||
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
|
||||
"builder.action.share": "Teilen",
|
||||
"builder.action.promote": "Als Projekt anlegen",
|
||||
"builder.mode.cold": "\u00dcbersicht",
|
||||
"builder.mode.event": "Ereignis",
|
||||
"builder.mode.akte": "Aus Akte",
|
||||
"builder.panel.title": "Meine Szenarien",
|
||||
"builder.panel.new": "+ Neues Szenario",
|
||||
"builder.panel.empty": "Noch keine Szenarien.",
|
||||
"builder.bucket.active": "Aktiv",
|
||||
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
|
||||
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
|
||||
"builder.empty.cta": "Neues Szenario starten",
|
||||
"builder.empty.recent": "Zuletzt bearbeitet",
|
||||
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
|
||||
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
|
||||
"builder.picker.close": "Schlie\u00dfen",
|
||||
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Verfahren:",
|
||||
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
|
||||
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
|
||||
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
|
||||
"builder.triplet.loading": "Berechne Fristen \u2026",
|
||||
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
|
||||
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
|
||||
"builder.triplet.side.defendant": "Beklagten-Sicht",
|
||||
"builder.triplet.flags.label": "Optionen:",
|
||||
"builder.triplet.perspective.label": "Perspektive:",
|
||||
"builder.triplet.perspective.none": "keine",
|
||||
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
|
||||
"builder.triplet.perspective.defendant": "Beklagter",
|
||||
"builder.triplet.detailgrad.label": "Detailgrad:",
|
||||
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
|
||||
"builder.triplet.detailgrad.all_options": "Alle Optionen",
|
||||
"builder.triplet.remove": "Entfernen",
|
||||
"builder.triplet.collapse": "Einklappen",
|
||||
"builder.triplet.expand": "Ausklappen",
|
||||
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
|
||||
"builder.event.state.planned": "geplant",
|
||||
"builder.event.state.filed": "eingereicht",
|
||||
"builder.event.state.skipped": "ausgelassen",
|
||||
"builder.event.action.file": "Einreichen",
|
||||
"builder.event.action.skip": "Auslassen",
|
||||
"builder.event.action.reset": "Zur\u00fcck zu geplant",
|
||||
"builder.event.actual_date.prompt": "Datum der Einreichung:",
|
||||
"builder.event.skip_reason.prompt": "Grund (optional):",
|
||||
"builder.event.horizon.label": "+{n} Optionen \u25be",
|
||||
"builder.event.horizon.hide": "Optionen ausblenden",
|
||||
"builder.save.idle": "\u00a0",
|
||||
"builder.save.saving": "Speichert \u2026",
|
||||
"builder.save.saved": "Gespeichert \u2713",
|
||||
"builder.save.error": "Speichern fehlgeschlagen",
|
||||
"builder.search.hint.start": "Tippe \u2026 z.\u202fB. \u201eKlageerwiderung\u201c, \u201eHinweis\u201c, \u201eHL-2024\u201c",
|
||||
"builder.search.hint.short": "Mindestens 2 Zeichen.",
|
||||
"builder.search.hint.loading": "Suche \u2026",
|
||||
"builder.search.hint.empty": "Keine Treffer.",
|
||||
"builder.search.hint.error": "Suche fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.search.hint.akte_b4": "Akten-Modus folgt in B4.",
|
||||
"builder.search.group.events": "Ereignisse",
|
||||
"builder.search.group.scenarios": "Szenarien",
|
||||
"builder.search.group.projects": "Akten",
|
||||
"builder.search.summary.events.one": "{n} Ereignis",
|
||||
"builder.search.summary.events.other": "{n} Ereignisse",
|
||||
"builder.search.summary.scenarios.one": "{n} Szenario",
|
||||
"builder.search.summary.scenarios.other": "{n} Szenarien",
|
||||
"builder.search.summary.projects.one": "{n} Akte",
|
||||
"builder.search.summary.projects.other": "{n} Akten",
|
||||
"builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
"deadlines.step2.perspective": "Perspektive und Datum",
|
||||
@@ -3418,6 +3498,86 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
|
||||
"nav.procedures": "Procedures & Deadlines",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
|
||||
"builder.header.scenario": "Scenario:",
|
||||
"builder.header.akte": "Matter:",
|
||||
"builder.header.stichtag": "Anchor:",
|
||||
"builder.header.search": "Search:",
|
||||
"builder.akte.none": "— none —",
|
||||
"builder.akte.banner.prefix": "From matter:",
|
||||
"builder.search.placeholder": "Event, scenario, matter …",
|
||||
"builder.action.rename": "Name it",
|
||||
"builder.action.rename.prompt": "Name for this scenario:",
|
||||
"builder.action.share": "Share",
|
||||
"builder.action.promote": "Create as project",
|
||||
"builder.mode.cold": "Overview",
|
||||
"builder.mode.event": "Event",
|
||||
"builder.mode.akte": "From matter",
|
||||
"builder.panel.title": "My scenarios",
|
||||
"builder.panel.new": "+ New scenario",
|
||||
"builder.panel.empty": "No scenarios yet.",
|
||||
"builder.bucket.active": "Active",
|
||||
"builder.empty.headline": "No scenario open.",
|
||||
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
|
||||
"builder.empty.cta": "Start a new scenario",
|
||||
"builder.empty.recent": "Recent",
|
||||
"builder.picker.placeholder": "— pick a scenario —",
|
||||
"builder.picker.title": "Add proceeding",
|
||||
"builder.picker.close": "Close",
|
||||
"builder.picker.aria": "Pick a proceeding",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Proceeding:",
|
||||
"builder.picker.empty": "No proceedings available.",
|
||||
"builder.picker.future_jurisdiction": "Other forums coming later.",
|
||||
"builder.canvas.add_proceeding": "+ Add proceeding",
|
||||
"builder.triplet.loading": "Calculating deadlines …",
|
||||
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
|
||||
"builder.triplet.side.claimant": "Claimant view",
|
||||
"builder.triplet.side.defendant": "Defendant view",
|
||||
"builder.triplet.flags.label": "Options:",
|
||||
"builder.triplet.perspective.label": "Perspective:",
|
||||
"builder.triplet.perspective.none": "none",
|
||||
"builder.triplet.perspective.claimant": "Claimant",
|
||||
"builder.triplet.perspective.defendant": "Defendant",
|
||||
"builder.triplet.detailgrad.label": "Detail:",
|
||||
"builder.triplet.detailgrad.selected": "Selected",
|
||||
"builder.triplet.detailgrad.all_options": "All options",
|
||||
"builder.triplet.remove": "Remove",
|
||||
"builder.triplet.collapse": "Collapse",
|
||||
"builder.triplet.expand": "Expand",
|
||||
"builder.triplet.no_flags": "(no flags for this proceeding type)",
|
||||
"builder.event.state.planned": "planned",
|
||||
"builder.event.state.filed": "filed",
|
||||
"builder.event.state.skipped": "skipped",
|
||||
"builder.event.action.file": "File",
|
||||
"builder.event.action.skip": "Skip",
|
||||
"builder.event.action.reset": "Reset to planned",
|
||||
"builder.event.actual_date.prompt": "Date of filing:",
|
||||
"builder.event.skip_reason.prompt": "Reason (optional):",
|
||||
"builder.event.horizon.label": "+{n} optional ▾",
|
||||
"builder.event.horizon.hide": "Hide optional",
|
||||
"builder.save.idle": " ",
|
||||
"builder.save.saving": "Saving …",
|
||||
"builder.save.saved": "Saved ✓",
|
||||
"builder.save.error": "Save failed",
|
||||
"builder.search.hint.start": "Type … e.g. \"defence\", \"hearing\", \"HL-2024\"",
|
||||
"builder.search.hint.short": "At least 2 characters.",
|
||||
"builder.search.hint.loading": "Searching …",
|
||||
"builder.search.hint.empty": "No matches.",
|
||||
"builder.search.hint.error": "Search failed. Try again.",
|
||||
"builder.search.hint.akte_b4": "Matter mode coming in B4.",
|
||||
"builder.search.group.events": "Events",
|
||||
"builder.search.group.scenarios": "Scenarios",
|
||||
"builder.search.group.projects": "Matters",
|
||||
"builder.search.summary.events.one": "{n} event",
|
||||
"builder.search.summary.events.other": "{n} events",
|
||||
"builder.search.summary.scenarios.one": "{n} scenario",
|
||||
"builder.search.summary.scenarios.other": "{n} scenarios",
|
||||
"builder.search.summary.projects.one": "{n} matter",
|
||||
"builder.search.summary.projects.other": "{n} matters",
|
||||
"builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
"deadlines.step2.perspective": "Perspective and Date",
|
||||
|
||||
@@ -1,150 +1,15 @@
|
||||
// /tools/procedures client (m/paliad#151,
|
||||
// docs/design-unified-procedural-events-tool-2026-05-27.md).
|
||||
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
|
||||
//
|
||||
// Boot logic + tab switching for the unified procedural-events tool.
|
||||
// Each entry tab mounts its own module; the search box and chip
|
||||
// filters in the top filter strip are wired in U1+ as each slice adds
|
||||
// its dimension-aware behaviour.
|
||||
//
|
||||
// U0 — Skeleton + tab toggling.
|
||||
// U1 — Direkt suchen mounts Mode A.
|
||||
// U2 — Geführt mounts Mode B wizard.
|
||||
// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle.
|
||||
//
|
||||
// Mode A renders its shell into #fristen-overhaul-root (replacing
|
||||
// children); Mode B renders into #fristen-overhaul-mode-host; the
|
||||
// result view (post-commit) writes into #fristen-overhaul-root. To
|
||||
// keep those IDs unique in the DOM, only the active tab's panel ever
|
||||
// hosts the overhaul scaffold — installOverhaulHost() tears down any
|
||||
// existing host and installs a fresh one inside the target panel
|
||||
// before handing off to the per-mode module.
|
||||
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
|
||||
// emitted by procedures.tsx; this file boots the i18n + sidebar
|
||||
// runtime and hands off to builder.ts.
|
||||
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountModeA } from "./fristenrechner-mode-a";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
import { mountWizard } from "./fristenrechner-wizard";
|
||||
import { initVerfahrensablauf } from "./verfahrensablauf";
|
||||
|
||||
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
|
||||
|
||||
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
|
||||
|
||||
function readTabFromUrl(): ProceduresTab {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("mode");
|
||||
if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab;
|
||||
return "proceeding";
|
||||
}
|
||||
|
||||
function writeTabToUrl(tab: ProceduresTab): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (tab === "proceeding") {
|
||||
url.searchParams.delete("mode");
|
||||
} else {
|
||||
url.searchParams.set("mode", tab);
|
||||
}
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
|
||||
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
|
||||
// any existing host first, so the IDs stay unique across the page even
|
||||
// when the user toggles between Direkt-suchen and Geführt — both Mode
|
||||
// A and the wizard read these IDs from document.getElementById which
|
||||
// returns the first match in DOM order, so two parallel hosts would
|
||||
// cross-wire.
|
||||
function installOverhaulHost(panelId: string): HTMLElement | null {
|
||||
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return null;
|
||||
panel.innerHTML = `
|
||||
<div class="procedures-overhaul-host">
|
||||
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
|
||||
<div id="fristen-overhaul-mode-host"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function setActiveTabUI(tab: ProceduresTab): void {
|
||||
for (const t of TABS) {
|
||||
const btn = document.getElementById(`procedures-tab-${t}`);
|
||||
const panel = document.getElementById(`procedures-panel-${t}`);
|
||||
const active = t === tab;
|
||||
if (btn) {
|
||||
btn.classList.toggle("is-active", active);
|
||||
btn.setAttribute("aria-selected", active ? "true" : "false");
|
||||
}
|
||||
if (panel) panel.hidden = !active;
|
||||
}
|
||||
}
|
||||
|
||||
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
|
||||
// selectedType + lastResponse + listeners that re-bind on every
|
||||
// proceeding click). Wire it exactly once per page load; on subsequent
|
||||
// activations the existing DOM + listeners are reused so picked
|
||||
// proceeding / dates / flags persist across tab switches.
|
||||
let verfahrensablaufWired = false;
|
||||
|
||||
async function activateTab(tab: ProceduresTab): Promise<void> {
|
||||
setActiveTabUI(tab);
|
||||
if (tab === "search") {
|
||||
installOverhaulHost("procedures-panel-search");
|
||||
await mountModeA();
|
||||
return;
|
||||
}
|
||||
if (tab === "wizard") {
|
||||
installOverhaulHost("procedures-panel-wizard");
|
||||
await mountWizard();
|
||||
return;
|
||||
}
|
||||
if (tab === "proceeding") {
|
||||
if (!verfahrensablaufWired) {
|
||||
initVerfahrensablauf();
|
||||
verfahrensablaufWired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireTabs(): void {
|
||||
for (const t of TABS) {
|
||||
const btn = document.getElementById(`procedures-tab-${t}`);
|
||||
if (!btn) continue;
|
||||
btn.addEventListener("click", () => {
|
||||
void activateTab(t);
|
||||
writeTabToUrl(t);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// boot dispatches on the URL: a deep link with `?event=` jumps straight
|
||||
// to the linear result view (the Direkt-suchen tab stays as the visible
|
||||
// context). Otherwise the requested tab — defaulting to "proceeding" —
|
||||
// activates per readTabFromUrl().
|
||||
async function boot(): Promise<void> {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const eventRef = params.get("event") || "";
|
||||
|
||||
if (eventRef) {
|
||||
setActiveTabUI("search");
|
||||
installOverhaulHost("procedures-panel-search");
|
||||
await mountResultView({
|
||||
eventRef,
|
||||
triggerDate: params.get("trigger_date") || undefined,
|
||||
party: params.get("party") || undefined,
|
||||
courtId: params.get("court_id") || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await activateTab(readTabFromUrl());
|
||||
}
|
||||
import { mountBuilder } from "./builder";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireTabs();
|
||||
void boot();
|
||||
void mountBuilder();
|
||||
});
|
||||
|
||||
@@ -1042,7 +1042,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return `<div class="${itemClasses}">
|
||||
// data-rule-id on the card root lets the Litigation Builder
|
||||
// overlay per-card state (planned/filed/skipped) + action
|
||||
// affordances onto cards rendered through this shared body
|
||||
// without re-implementing the columns renderer. Empty on
|
||||
// synthetic rows (appeal trigger marker etc.); the Builder
|
||||
// skips state lookup when missing.
|
||||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
|
||||
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
|
||||
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
|
||||
@@ -728,6 +728,84 @@ export type I18nKey =
|
||||
| "bottomnav.add.title"
|
||||
| "bottomnav.badge.deadlines"
|
||||
| "bottomnav.menu"
|
||||
| "builder.action.promote"
|
||||
| "builder.action.rename"
|
||||
| "builder.action.rename.prompt"
|
||||
| "builder.action.share"
|
||||
| "builder.akte.banner.prefix"
|
||||
| "builder.akte.none"
|
||||
| "builder.bucket.active"
|
||||
| "builder.canvas.add_proceeding"
|
||||
| "builder.empty.cta"
|
||||
| "builder.empty.headline"
|
||||
| "builder.empty.hint"
|
||||
| "builder.empty.recent"
|
||||
| "builder.event.action.file"
|
||||
| "builder.event.action.reset"
|
||||
| "builder.event.action.skip"
|
||||
| "builder.event.actual_date.prompt"
|
||||
| "builder.event.horizon.hide"
|
||||
| "builder.event.horizon.label"
|
||||
| "builder.event.skip_reason.prompt"
|
||||
| "builder.event.state.filed"
|
||||
| "builder.event.state.planned"
|
||||
| "builder.event.state.skipped"
|
||||
| "builder.header.akte"
|
||||
| "builder.header.scenario"
|
||||
| "builder.header.search"
|
||||
| "builder.header.stichtag"
|
||||
| "builder.mode.akte"
|
||||
| "builder.mode.cold"
|
||||
| "builder.mode.event"
|
||||
| "builder.panel.empty"
|
||||
| "builder.panel.new"
|
||||
| "builder.panel.title"
|
||||
| "builder.picker.aria"
|
||||
| "builder.picker.axis.forum"
|
||||
| "builder.picker.axis.proc"
|
||||
| "builder.picker.close"
|
||||
| "builder.picker.empty"
|
||||
| "builder.picker.future_jurisdiction"
|
||||
| "builder.picker.placeholder"
|
||||
| "builder.picker.title"
|
||||
| "builder.save.error"
|
||||
| "builder.save.idle"
|
||||
| "builder.save.saved"
|
||||
| "builder.save.saving"
|
||||
| "builder.search.anchor.divider"
|
||||
| "builder.search.group.events"
|
||||
| "builder.search.group.projects"
|
||||
| "builder.search.group.scenarios"
|
||||
| "builder.search.hint.akte_b4"
|
||||
| "builder.search.hint.empty"
|
||||
| "builder.search.hint.error"
|
||||
| "builder.search.hint.loading"
|
||||
| "builder.search.hint.short"
|
||||
| "builder.search.hint.start"
|
||||
| "builder.search.placeholder"
|
||||
| "builder.search.summary.events.one"
|
||||
| "builder.search.summary.events.other"
|
||||
| "builder.search.summary.projects.one"
|
||||
| "builder.search.summary.projects.other"
|
||||
| "builder.search.summary.scenarios.one"
|
||||
| "builder.search.summary.scenarios.other"
|
||||
| "builder.subtitle"
|
||||
| "builder.triplet.collapse"
|
||||
| "builder.triplet.detailgrad.all_options"
|
||||
| "builder.triplet.detailgrad.label"
|
||||
| "builder.triplet.detailgrad.selected"
|
||||
| "builder.triplet.expand"
|
||||
| "builder.triplet.flags.label"
|
||||
| "builder.triplet.loading"
|
||||
| "builder.triplet.no_flags"
|
||||
| "builder.triplet.perspective.claimant"
|
||||
| "builder.triplet.perspective.defendant"
|
||||
| "builder.triplet.perspective.label"
|
||||
| "builder.triplet.perspective.none"
|
||||
| "builder.triplet.remove"
|
||||
| "builder.triplet.side.claimant"
|
||||
| "builder.triplet.side.defendant"
|
||||
| "builder.triplet.unknown_proceeding"
|
||||
| "cal.day.back_to_month"
|
||||
| "cal.day.fri"
|
||||
| "cal.day.mon"
|
||||
|
||||
@@ -4,23 +4,19 @@ import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody";
|
||||
|
||||
// U0 — Skeleton for the unified procedural-events tool
|
||||
// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md).
|
||||
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
|
||||
//
|
||||
// Folds /tools/fristenrechner (Mode A + Mode B + result) and
|
||||
// /tools/verfahrensablauf into a single page at /tools/procedures. Each
|
||||
// later slice fills one of the four entry tabs:
|
||||
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
|
||||
// builder shell. Server-rendered chrome is minimal — the page-header
|
||||
// scenario picker, side panel, and canvas are all hydrated by
|
||||
// `builder.ts` at boot. The builder loads scenarios from
|
||||
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
|
||||
// per-proceeding triplets with the existing verfahrensablauf-core calc.
|
||||
//
|
||||
// U1 — Direkt suchen (Mode A search)
|
||||
// U2 — Geführt (Mode B wizard)
|
||||
// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter)
|
||||
// U4 — Hard-cut 301 (drop legacy pages, redirect URLs)
|
||||
//
|
||||
// This file ships only the page chrome — sidebar, header, filter strip
|
||||
// with search box, four entry-mode tabs, and the host containers the
|
||||
// later slices mount their UI into. No data wiring.
|
||||
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
|
||||
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
|
||||
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
|
||||
|
||||
export function renderProcedures(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
@@ -36,151 +32,137 @@ export function renderProcedures(): string {
|
||||
<title data-i18n="procedures.title">Verfahren & Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-procedures">
|
||||
<body className="has-sidebar page-procedures page-builder">
|
||||
<Sidebar currentPath="/tools/procedures" />
|
||||
<BottomNav currentPath="/tools/procedures" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<section className="tool-page builder-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="procedures.heading">Verfahren & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="procedures.subtitle">
|
||||
Verfahrensablauf, Fristenrechner und gerührte Suche in einem Tool.
|
||||
<p className="tool-subtitle" data-i18n="builder.subtitle">
|
||||
Litigation Builder — Szenarien bauen, Verfahren stapeln, Fristen behalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shared filter strip — search box + four chip groups
|
||||
(forum / proceeding / event_kind / party). Lives at the
|
||||
top of the page so every entry tab and output mode reads
|
||||
the same active filter set (design §4 + m's Q3
|
||||
divergence: search composes with chip filters). U0
|
||||
ships the markup only; chip hydration + search wiring
|
||||
arrive with U1-U3. */}
|
||||
<section className="procedures-filter-strip" aria-label="Filter">
|
||||
<div className="procedures-filter-search">
|
||||
<svg className="procedures-filter-search-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="procedures-search-input"
|
||||
className="procedures-filter-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="procedures.filter.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
/>
|
||||
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
|
||||
· Akte picker · Stichtag input. B1 wires the scenario picker
|
||||
+ name action + Stichtag + save indicator. Akte / share /
|
||||
promote land at B4 / B5; the affordances render disabled in
|
||||
B1 so the layout is stable across slices. */}
|
||||
<section className="builder-pageheader" aria-label="Builder-Steuerung">
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
|
||||
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario wählen"></select>
|
||||
</label>
|
||||
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
|
||||
<span data-i18n="builder.save.idle"> </span>
|
||||
</span>
|
||||
<span className="builder-pageheader-spacer"></span>
|
||||
<button type="button" id="builder-rename-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.rename">Benennen</button>
|
||||
<button type="button" id="builder-share-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
title="In B5 verfügbar"
|
||||
data-i18n="builder.action.share">Teilen</button>
|
||||
<button type="button" id="builder-promote-btn"
|
||||
className="builder-action-btn builder-action-btn--primary"
|
||||
disabled
|
||||
title="In B5 verfügbar"
|
||||
data-i18n="builder.action.promote">Als Projekt anlegen</button>
|
||||
</div>
|
||||
<div className="procedures-filter-chips" id="procedures-filter-chips">
|
||||
<div className="procedures-filter-chip-row" data-axis="forum">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="proc">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="kind">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="party">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
|
||||
</div>
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
|
||||
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte wählen">
|
||||
<option value="" data-i18n="builder.akte.none">— ohne —</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
|
||||
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
|
||||
defaultValue={today} aria-label="Stichtag" />
|
||||
</label>
|
||||
<label className="builder-pageheader-field builder-pageheader-field--grow">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
|
||||
<input type="search" id="builder-search-input" className="builder-search-input"
|
||||
data-i18n-placeholder="builder.search.placeholder"
|
||||
placeholder="Ereignis, Szenario, Akte …"
|
||||
autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Entry-mode tab strip — all four tabs visible from boot
|
||||
(m's Q3 divergence). The active tab is URL-driven
|
||||
(?mode=proceeding|search|wizard|akte); cold open lands
|
||||
on "proceeding" per design §11.5.Q3. */}
|
||||
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
|
||||
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
|
||||
event-triggered + akte ship at B3 / B4 and are disabled
|
||||
here so the layout stays stable across slices. */}
|
||||
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
|
||||
<button type="button"
|
||||
className="procedures-tab is-active"
|
||||
className="builder-mode is-active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
data-tab="proceeding"
|
||||
id="procedures-tab-proceeding">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">📚</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren wählen</span>
|
||||
data-mode="cold"
|
||||
id="builder-mode-cold">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.cold">Übersicht</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="search"
|
||||
id="procedures-tab-search">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">⚡</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
|
||||
data-mode="event"
|
||||
id="builder-mode-event">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="wizard"
|
||||
id="procedures-tab-wizard">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">🧭</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Geführt</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="akte"
|
||||
id="procedures-tab-akte">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">📁</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
|
||||
data-mode="akte"
|
||||
id="builder-mode-akte">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Per-tab content hosts. Only one is visible at a time —
|
||||
procedures.ts toggles `hidden` on the inactive ones.
|
||||
Each later slice fills the corresponding host. */}
|
||||
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-proceeding">
|
||||
{/* Verfahrensablauf wizard body — shared TSX component
|
||||
used by /tools/verfahrensablauf (legacy) and the
|
||||
unified /tools/procedures page. procedures.ts calls
|
||||
initVerfahrensablauf() on the first activation of
|
||||
this tab, which wires the .proceeding-btn clicks,
|
||||
timeline-container, detail-mode toggle, etc. against
|
||||
the markup. The legacy page's auto-boot is guarded
|
||||
against the procedures-only #procedures-panel-proceeding
|
||||
element so it doesn't fire twice. */}
|
||||
<VerfahrensablaufBody todayIso={today} />
|
||||
</section>
|
||||
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
|
||||
<div className="builder-body">
|
||||
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
|
||||
<header className="builder-sidepanel-header">
|
||||
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
|
||||
<button type="button" id="builder-new-scenario-btn"
|
||||
className="builder-sidepanel-newbtn"
|
||||
data-i18n="builder.panel.new">+ Neues Szenario</button>
|
||||
</header>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="active">
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
|
||||
</div>
|
||||
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
|
||||
</aside>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-search" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-search" hidden></section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-wizard" hidden></section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-akte" hidden>
|
||||
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
|
||||
Akten-Einstieg folgt in einem späteren Slice.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
|
||||
tree here; U0 leaves it empty + hidden so the
|
||||
tab placeholders are the only thing visible. */}
|
||||
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
|
||||
aria-label="Tree output" hidden></section>
|
||||
|
||||
{/* Linear-drawer host. Inline drawer expanding beneath a
|
||||
tree card (design §8 — desktop) AND the standalone
|
||||
linear follow-up view that Mode A / Mode B land on
|
||||
after locking a trigger event (design §3.2). U1
|
||||
switches it on. */}
|
||||
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
|
||||
aria-label="Linear output" hidden></section>
|
||||
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
|
||||
<div id="builder-canvas" className="builder-canvas">
|
||||
{/* Cold-open placeholder — replaced by triplet stack once a
|
||||
scenario is loaded. */}
|
||||
<div className="builder-empty" id="builder-empty">
|
||||
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
|
||||
Noch kein Szenario geöffnet.
|
||||
</p>
|
||||
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
|
||||
Starte ein neues Szenario, wähle aus deiner Liste oder übernimm eine Akte (B4).
|
||||
</p>
|
||||
<button type="button" id="builder-cta-new" className="builder-cta-new"
|
||||
data-i18n="builder.empty.cta">
|
||||
Neues Szenario starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -19811,3 +19811,840 @@ a.fristen-overhaul-rule-source {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Litigation Builder (m/paliad#153 B1+B2) --- */
|
||||
|
||||
.builder-page .tool-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-pageheader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.builder-pageheader-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.builder-pageheader-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.builder-pageheader-field--grow {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
.builder-pageheader-label {
|
||||
color: var(--color-text-subtle);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.builder-pageheader-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.builder-scenario-picker,
|
||||
.builder-akte-picker,
|
||||
.builder-stichtag-input,
|
||||
.builder-search-input {
|
||||
font: inherit;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.3rem;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.builder-search-input {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.builder-scenario-picker:disabled,
|
||||
.builder-akte-picker:disabled,
|
||||
.builder-search-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.builder-save-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
min-width: 8rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.builder-save-status[data-state="saving"] { color: var(--color-text-subtle); }
|
||||
.builder-save-status[data-state="saved"] { color: var(--color-accent-strong-fg); }
|
||||
.builder-save-status[data-state="error"] { color: var(--status-red-fg, #c5503a); }
|
||||
|
||||
/* B4 (m/paliad#153) — Akte-mode banner. Lime tint matches the Paliad
|
||||
accent palette; positioned on the page header so it's visible the
|
||||
whole time the user works in Akte mode. */
|
||||
.builder-akte-banner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 0.3rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.3rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.builder-action-btn {
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.builder-action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.builder-action-btn--primary {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.builder-action-btn--primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.builder-action-btn--secondary:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.builder-modebar {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
padding: 0.15rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-mode {
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-mode.is-active {
|
||||
background: var(--color-segment-active-bg);
|
||||
color: var(--color-segment-active-fg);
|
||||
}
|
||||
|
||||
.builder-mode:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.builder-body {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.builder-sidepanel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.builder-sidepanel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.builder-sidepanel-title {
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.builder-sidepanel-newbtn {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
background: var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.builder-sidepanel-newbtn:hover {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.builder-bucket-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-subtle);
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
}
|
||||
|
||||
.builder-scenario-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.builder-scenario-list-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.builder-scenario-list-item {
|
||||
cursor: pointer;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.builder-scenario-list-item.is-active {
|
||||
background: var(--color-accent-soft-bg);
|
||||
}
|
||||
|
||||
.builder-scenario-list-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-scenario-list-item:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.builder-canvas-wrap {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.builder-canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-empty {
|
||||
background: var(--color-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.builder-empty-headline {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.builder-empty-hint {
|
||||
color: var(--color-text-subtle);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.builder-cta-new {
|
||||
font: inherit;
|
||||
background: var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.55rem 1.2rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.builder-cta-new:hover {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.builder-recent {
|
||||
margin-top: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.builder-recent-title {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-subtle);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.builder-recent-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.builder-recent-item {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-recent-item:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.builder-triplet-host {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.builder-triplet-host[data-child="true"] {
|
||||
margin-left: 1.5rem;
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.builder-triplet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: var(--color-surface-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.builder-triplet-jurisdiction {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.builder-triplet-code {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-name {
|
||||
font-weight: 500;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.builder-triplet-side {
|
||||
background: var(--color-accent-soft-bg);
|
||||
color: var(--color-accent-soft-fg);
|
||||
border: 1px solid var(--color-accent-soft-border);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-triplet-flags {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-subtle);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.builder-triplet-flag-chip {
|
||||
background: var(--color-accent-soft-bg);
|
||||
border: 1px solid var(--color-accent-soft-border);
|
||||
color: var(--color-accent-soft-fg);
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.builder-triplet-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.builder-triplet-controls-label {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-perspective,
|
||||
.builder-triplet-detailgrad {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-2);
|
||||
padding: 0.1rem;
|
||||
}
|
||||
|
||||
.builder-triplet-perspective button,
|
||||
.builder-triplet-detailgrad button {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-perspective button.is-active,
|
||||
.builder-triplet-detailgrad button.is-active {
|
||||
background: var(--color-segment-active-bg);
|
||||
color: var(--color-segment-active-fg);
|
||||
}
|
||||
|
||||
.builder-triplet-remove {
|
||||
margin-left: auto;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-remove:hover {
|
||||
border-color: var(--status-red-border, #d08070);
|
||||
color: var(--status-red-fg, #c5503a);
|
||||
}
|
||||
|
||||
.builder-triplet-flagstrip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.builder-triplet-flag-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-triplet-flag-empty {
|
||||
font-style: italic;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-triplet-body {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.builder-triplet-loading,
|
||||
.builder-triplet-error {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.builder-add-proceeding-btn {
|
||||
font: inherit;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.7rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-add-proceeding-btn:hover {
|
||||
background: var(--color-accent-soft-bg);
|
||||
border-color: var(--color-accent-soft-border);
|
||||
color: var(--color-accent-soft-fg);
|
||||
}
|
||||
|
||||
/* Add-proceeding popover */
|
||||
|
||||
.builder-picker-popover {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.builder-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.builder-picker-title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.builder-picker-close {
|
||||
font: inherit;
|
||||
font-size: 1.2rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-picker-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.builder-picker-axis-label {
|
||||
flex: 0 0 6rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.builder-picker-chips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.builder-picker-chips--wrap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.builder-picker-chip {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.builder-picker-chip.is-active {
|
||||
background: var(--color-segment-active-bg);
|
||||
color: var(--color-segment-active-fg);
|
||||
border-color: var(--color-segment-active-border);
|
||||
}
|
||||
|
||||
.builder-picker-chip:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.builder-picker-chip:hover:not(:disabled) {
|
||||
background: var(--color-accent-soft-bg);
|
||||
}
|
||||
|
||||
.builder-picker-chip--proc {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.builder-picker-chip-code {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.builder-picker-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Event-card state overrides (B2). The 3-state machine sits on top of
|
||||
the existing .fr-col-item card. The Builder render passes editable=false
|
||||
to renderColumnsBody and overlays its own per-card state attributes
|
||||
on top of the card root via data-builder-state. */
|
||||
|
||||
.fr-col-item[data-builder-state="filed"] {
|
||||
background: var(--color-accent-soft-bg);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.fr-col-item[data-builder-state="filed"] .timeline-name::before {
|
||||
content: "✓ ";
|
||||
color: var(--color-accent-soft-fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fr-col-item[data-builder-state="skipped"] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.fr-col-item[data-builder-state="skipped"] .timeline-name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.builder-event-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.builder-event-action {
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.builder-event-action:hover {
|
||||
background: var(--color-accent-soft-bg);
|
||||
}
|
||||
|
||||
.builder-event-action[data-action="file"] {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.builder-event-action[data-action="file"]:hover {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.builder-event-horizon-chip {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-accent-soft-fg);
|
||||
background: var(--color-accent-soft-bg);
|
||||
border: 1px solid var(--color-accent-soft-border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
margin-top: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.builder-event-horizon-chip:hover {
|
||||
background: var(--color-accent-strong-bg);
|
||||
}
|
||||
|
||||
/* B3 — anchor highlight + DU BIST HIER divider. Picked event card
|
||||
carries a lime band (left border + soft background) and a
|
||||
horizontal divider is injected after its row in the columns grid.
|
||||
The divider spans all 3 columns via grid-column: 1 / -1. */
|
||||
|
||||
.builder-anchor-card {
|
||||
background: var(--color-accent-soft-bg);
|
||||
border-color: var(--color-accent);
|
||||
border-left: 4px solid var(--color-accent);
|
||||
box-shadow: 0 0 0 1px var(--color-accent-soft-border) inset;
|
||||
}
|
||||
|
||||
.builder-anchor-divider {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-accent-dark, #5b7b04);
|
||||
background: var(--color-accent-soft-bg);
|
||||
border: 1px dashed var(--color-accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.35rem 0.6rem;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
/* B3 — universal search dropdown. Floated under the page-header
|
||||
search input by JS (position: absolute, top/left set per
|
||||
reposition()). The dropdown renders typed result groups
|
||||
(events / scenarios / projects). */
|
||||
|
||||
.builder-search-dropdown {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.builder-search-hint {
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.builder-search-summary {
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.builder-search-group {
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.builder-search-group:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.builder-search-group-label {
|
||||
padding: 0.4rem 0.9rem 0.25rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.builder-search-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.builder-search-row:hover,
|
||||
.builder-search-row.is-focus {
|
||||
background: var(--color-accent-soft-bg);
|
||||
border-left-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.builder-search-row-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.builder-search-pt-code,
|
||||
.builder-search-project-type {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.22rem;
|
||||
padding: 0 0.32rem;
|
||||
}
|
||||
|
||||
.builder-search-event-name,
|
||||
.builder-search-scenario-name,
|
||||
.builder-search-project-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.builder-search-row-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.builder-search-kind,
|
||||
.builder-search-party,
|
||||
.builder-search-status {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive: collapse side panel into stacked block on narrow viewports. */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.builder-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.builder-sidepanel {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.builder-pageheader-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.builder-pageheader-field {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.builder-scenario-picker,
|
||||
.builder-akte-picker,
|
||||
.builder-search-input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
199
internal/handlers/builder_search.go
Normal file
199
internal/handlers/builder_search.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-346 / m/paliad#153 B3 — universal search for the Litigation
|
||||
// Builder. Returns events + scenarios + projects (Akten) keyed by type
|
||||
// so the search dropdown can render typed result groups.
|
||||
//
|
||||
// GET /api/builder/search?q=<term>&limit=<n>
|
||||
//
|
||||
// Response shape:
|
||||
//
|
||||
// {
|
||||
// "query": "<echoed q>",
|
||||
// "events": [ EventSearchHit, ... ], // anchor_rule_id + proceeding_type embedded
|
||||
// "scenarios": [ { id, name, status, updated_at }, ... ],
|
||||
// "projects": [ { id, title, type, reference, case_number, matter_number, client_number }, ... ],
|
||||
// "counts": { "events": N, "scenarios": M, "projects": K }
|
||||
// }
|
||||
//
|
||||
// Each group is independently capped (default 8 events / 5 scenarios /
|
||||
// 5 projects, max 30 per group). Missing services degrade gracefully —
|
||||
// an unavailable group is returned as an empty array, not an error,
|
||||
// so a knowledge-only deploy (DATABASE_URL unset) can still serve a
|
||||
// best-effort empty response shape rather than a 503 wall.
|
||||
|
||||
type builderSearchScenarioHit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type builderSearchProjectHit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
MatterNumber *string `json:"matter_number,omitempty"`
|
||||
ClientNumber *string `json:"client_number,omitempty"`
|
||||
}
|
||||
|
||||
type builderSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Events []services.EventSearchHit `json:"events"`
|
||||
Scenarios []builderSearchScenarioHit `json:"scenarios"`
|
||||
Projects []builderSearchProjectHit `json:"projects"`
|
||||
Counts builderSearchCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type builderSearchCounts struct {
|
||||
Events int `json:"events"`
|
||||
Scenarios int `json:"scenarios"`
|
||||
Projects int `json:"projects"`
|
||||
}
|
||||
|
||||
// handleBuilderSearch — GET /api/builder/search?q=<term>&limit=<n>
|
||||
//
|
||||
// Auth required. Returns 200 with empty groups when q is empty (matches
|
||||
// the fristenrechner search ergonomic — frontend can boot without a
|
||||
// pre-flight round trip).
|
||||
func handleBuilderSearch(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
perGroupLimit := parseBuilderSearchLimit(r.URL.Query().Get("limit"))
|
||||
|
||||
resp := builderSearchResponse{
|
||||
Query: q,
|
||||
Events: []services.EventSearchHit{},
|
||||
Scenarios: []builderSearchScenarioHit{},
|
||||
Projects: []builderSearchProjectHit{},
|
||||
}
|
||||
|
||||
if q == "" {
|
||||
// Match fristenrechner search: empty query → empty groups, not 400.
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Events: reuse the SearchEvents shape so anchor_rule_id +
|
||||
// proceeding_type travel with each hit. UPC v1 (PRD §0.4) — the
|
||||
// jurisdiction filter pins the corpus the builder serves today.
|
||||
if dbSvc != nil && dbSvc.deadlineSearch != nil {
|
||||
eventsResp, err := dbSvc.deadlineSearch.SearchEvents(ctx, q, services.EventSearchOptions{
|
||||
Jurisdiction: "UPC",
|
||||
Limit: perGroupLimit.events,
|
||||
})
|
||||
if err == nil && eventsResp != nil {
|
||||
resp.Events = eventsResp.Events
|
||||
}
|
||||
}
|
||||
|
||||
// Scenarios: caller's own scenarios filtered by ILIKE on name.
|
||||
// Borrows ListMyScenarios + filters in-memory; the list endpoint
|
||||
// already caps at the small per-user fan-out and there's no index
|
||||
// on (owner_id, name) yet — in-memory filter is cheap at 10s-of-
|
||||
// rows scale.
|
||||
if dbSvc != nil && dbSvc.scenarioBuilder != nil {
|
||||
scenarios, err := dbSvc.scenarioBuilder.ListMyScenarios(ctx, uid, "active")
|
||||
if err == nil {
|
||||
needle := strings.ToLower(q)
|
||||
hits := []builderSearchScenarioHit{}
|
||||
for _, sc := range scenarios {
|
||||
if !strings.Contains(strings.ToLower(sc.Name), needle) {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, builderSearchScenarioHit{
|
||||
ID: sc.ID,
|
||||
Name: sc.Name,
|
||||
Status: sc.Status,
|
||||
UpdatedAt: sc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
if len(hits) >= perGroupLimit.scenarios {
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Scenarios = hits
|
||||
}
|
||||
}
|
||||
|
||||
// Projects (Akten): visible projects filtered by trigram/ILIKE on
|
||||
// title, reference, client_number, matter_number. ProjectService.List
|
||||
// already applies team-based RLS via visibilityPredicate.
|
||||
if dbSvc != nil && dbSvc.projects != nil {
|
||||
projects, err := dbSvc.projects.List(ctx, uid, services.ProjectFilter{
|
||||
Search: q,
|
||||
})
|
||||
if err == nil {
|
||||
hits := make([]builderSearchProjectHit, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
hits = append(hits, builderSearchProjectHit{
|
||||
ID: p.ID,
|
||||
Type: p.Type,
|
||||
Title: p.Title,
|
||||
Reference: p.Reference,
|
||||
CaseNumber: p.CaseNumber,
|
||||
MatterNumber: p.MatterNumber,
|
||||
ClientNumber: p.ClientNumber,
|
||||
})
|
||||
if len(hits) >= perGroupLimit.projects {
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Projects = hits
|
||||
}
|
||||
}
|
||||
|
||||
resp.Counts = builderSearchCounts{
|
||||
Events: len(resp.Events),
|
||||
Scenarios: len(resp.Scenarios),
|
||||
Projects: len(resp.Projects),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type builderSearchPerGroup struct {
|
||||
events int
|
||||
scenarios int
|
||||
projects int
|
||||
}
|
||||
|
||||
// parseBuilderSearchLimit reads ?limit=<n> as a hint for the events
|
||||
// group (largest expected hit count). Scenarios + projects use smaller
|
||||
// caps because their drop-down rows are visually heavier. The shared
|
||||
// caller-supplied bound is interpreted as the events cap; scenarios
|
||||
// and projects are derived from it.
|
||||
func parseBuilderSearchLimit(raw string) builderSearchPerGroup {
|
||||
def := builderSearchPerGroup{events: 8, scenarios: 5, projects: 5}
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
if n > 30 {
|
||||
n = 30
|
||||
}
|
||||
return builderSearchPerGroup{
|
||||
events: n,
|
||||
scenarios: max(1, n/2),
|
||||
projects: max(1, n/2),
|
||||
}
|
||||
}
|
||||
@@ -527,6 +527,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// retires the legacy routes.
|
||||
protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList)
|
||||
protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate)
|
||||
// m/paliad#153 B4 — Akte mode entry point. Creates a project-backed
|
||||
// scenario from a paliad.projects row; subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/from-project", handleBuilderScenarioFromProject)
|
||||
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
|
||||
@@ -537,6 +541,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
|
||||
// m/paliad#153 B2 — read-only passthrough so the builder can render
|
||||
// per-triplet flag toggles without a per-project round-trip.
|
||||
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
|
||||
// m/paliad#153 B3 — universal search (events + scenarios + projects).
|
||||
protected.HandleFunc("GET /api/builder/search", handleBuilderSearch)
|
||||
// Dev-only test route — gated to PaliadinOwnerEmail (m).
|
||||
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
|
||||
|
||||
|
||||
@@ -52,6 +52,51 @@ func writeBuilderError(w http.ResponseWriter, err error) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Akte mode (B4, t-paliad-347)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenarioFromProject — POST /api/builder/scenarios/from-project
|
||||
//
|
||||
// Body: {"project_id": "<uuid>"}
|
||||
//
|
||||
// Creates a fresh project-backed scenario by snapshotting the project's
|
||||
// proceeding_type_id + our_side + scenario_flags into one top-level
|
||||
// triplet, and seeds scenario_events from every existing
|
||||
// paliad.deadlines row tied to a sequencing_rule. The new scenario's
|
||||
// origin_project_id pins the Akte link so subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags (PRD §2.3).
|
||||
//
|
||||
// Visibility: caller must be able to see the project. Bad input
|
||||
// (missing proceeding_type_id, invisible project) returns 400 / 404
|
||||
// via the standard service-error mapping.
|
||||
func handleBuilderScenarioFromProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
if body.ProjectID == uuid.Nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id ist erforderlich"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.CreateScenarioFromProject(r.Context(), uid, body.ProjectID)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -388,6 +433,44 @@ func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario flag catalog passthrough (m/paliad#153 B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
|
||||
//
|
||||
// Returns every row of paliad.scenario_flag_catalog so the Litigation
|
||||
// Builder can render per-triplet flag toggles without a per-project
|
||||
// round-trip. The catalog itself is global (no jurisdiction or
|
||||
// proceeding scope baked into the table); which flags actually apply
|
||||
// to a given proceeding type is decided by the calc engine via
|
||||
// condition_expr at calculation time. The client renders every catalog
|
||||
// flag and lets the user toggle them — flags with no effect on the
|
||||
// active proceeding's rules simply have no condition_expr referencing
|
||||
// them, so toggling is a no-op.
|
||||
//
|
||||
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
|
||||
// visibility checks aren't needed because the catalog is global.
|
||||
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.scenarioFlags == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "Flag-Katalog konnte nicht geladen werden",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-only test route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
|
||||
@@ -145,7 +145,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
AND pe.event_kind = $%d
|
||||
)`, opts.EventKind)
|
||||
}
|
||||
query := `SELECT code, name, name_en, jurisdiction
|
||||
query := `SELECT id, code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY sort_order`
|
||||
@@ -160,7 +160,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
for rows.Next() {
|
||||
var t lp.FristenrechnerType
|
||||
var juris sql.NullString
|
||||
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if juris.Valid {
|
||||
|
||||
@@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides(
|
||||
}
|
||||
var dRows []drow
|
||||
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
|
||||
q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
FROM paliad.deadlines d
|
||||
WHERE ` + scopeFilter
|
||||
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {
|
||||
|
||||
@@ -24,13 +24,29 @@ import (
|
||||
// fall-through) and at the row level via the migration-157 RLS policies.
|
||||
// The application-level check is the load-bearing one — the service
|
||||
// connects with the service-role credential, which bypasses RLS.
|
||||
//
|
||||
// B4 (t-paliad-347 / m/paliad#153) adds the Akte-mode dual-write:
|
||||
// project-backed scenarios (origin_project_id IS NOT NULL) write flag
|
||||
// toggles through to paliad.projects.scenario_flags and "filed" event
|
||||
// toggles through to paliad.deadlines, so the project's Verlauf / Frist
|
||||
// rail reflect builder activity without a separate sync step. The
|
||||
// scenario row itself records canvas view-state (ordinal, collapsed,
|
||||
// per-card horizon, notes); the SSoT for project-bound actuals stays
|
||||
// paliad.deadlines / paliad.projects.scenario_flags (PRD §2.3 + §10).
|
||||
type ScenarioBuilderService struct {
|
||||
db *sqlx.DB
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
flags *ScenarioFlagsService
|
||||
}
|
||||
|
||||
// NewScenarioBuilderService wires the service to the shared pool.
|
||||
func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService {
|
||||
return &ScenarioBuilderService{db: db}
|
||||
// NewScenarioBuilderService wires the service to the shared pool plus
|
||||
// the project + scenario-flags services it leans on for the Akte-mode
|
||||
// dual-write. projects + flags are optional in test setups (nil → the
|
||||
// dual-write hooks short-circuit), but a production wiring should
|
||||
// always pass them so Akte-backed scenarios stay in sync with project
|
||||
// surfaces.
|
||||
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService) *ScenarioBuilderService {
|
||||
return &ScenarioBuilderService{db: db, projects: projects, flags: flags}
|
||||
}
|
||||
|
||||
// ErrScenarioBuilderNotVisible is returned when the caller is neither
|
||||
@@ -204,7 +220,15 @@ func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, sc
|
||||
return nil, ErrScenarioBuilderNotVisible
|
||||
}
|
||||
|
||||
deep := &BuilderScenarioDeep{BuilderScenario: *sc}
|
||||
deep := &BuilderScenarioDeep{
|
||||
BuilderScenario: *sc,
|
||||
// Initialise to empty so the JSON response always carries arrays,
|
||||
// not null — the builder frontend's renderCanvas calls .filter on
|
||||
// proceedings/events unconditionally once state.active is set.
|
||||
Proceedings: []BuilderProceeding{},
|
||||
Events: []BuilderEvent{},
|
||||
Shares: []BuilderShare{},
|
||||
}
|
||||
|
||||
if err := s.db.SelectContext(ctx, &deep.Proceedings, `
|
||||
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
@@ -419,8 +443,19 @@ type PatchProceedingInput struct {
|
||||
}
|
||||
|
||||
// PatchProceeding updates fields on one proceeding row.
|
||||
//
|
||||
// Dual-write (B4): when the parent scenario is project-backed
|
||||
// (scenarios.origin_project_id IS NOT NULL) and the patched proceeding
|
||||
// is the top-level triplet (parent_scenario_proceeding_id IS NULL) and
|
||||
// the patch includes scenario_flags, the merged flag delta also lands on
|
||||
// paliad.projects.scenario_flags via ScenarioFlagsService.Patch. Top-
|
||||
// level only because child triplets (CCR child etc.) represent spawned
|
||||
// sub-proceedings whose flags don't belong on the parent project row;
|
||||
// the spawned proceeding will get its own project record when (and if)
|
||||
// the scenario is promoted via the B5 wizard.
|
||||
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -483,7 +518,7 @@ func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, sc
|
||||
created_at, updated_at`,
|
||||
strings.Join(sets, ", "), len(args)-1, len(args))
|
||||
var out BuilderProceeding
|
||||
err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
|
||||
err = s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out, q, args...)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -493,9 +528,55 @@ func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, sc
|
||||
}
|
||||
return nil, fmt.Errorf("patch proceeding: %w", err)
|
||||
}
|
||||
|
||||
// B4 dual-write: if the scenario is Akte-backed and we just
|
||||
// changed scenario_flags on the top-level triplet, mirror the
|
||||
// merged delta onto paliad.projects.scenario_flags. The PATCH
|
||||
// fires after the scenario_proceedings UPDATE commits — a failure
|
||||
// here logs but doesn't roll back the builder write (the builder
|
||||
// state is the user-visible canvas; the project mirror is a
|
||||
// convenience).
|
||||
if sc.OriginProjectID != nil && out.ParentScenarioProceedingID == nil &&
|
||||
len(input.ScenarioFlags) > 0 && s.flags != nil {
|
||||
if delta, derr := flagDeltaFromBuilder(input.ScenarioFlags); derr == nil && len(delta) > 0 {
|
||||
if _, perr := s.flags.Patch(ctx, userID, *sc.OriginProjectID, delta); perr != nil {
|
||||
// Don't fail the builder PATCH — log via the audit
|
||||
// reason that landed in the tx and surface the
|
||||
// error through fmt so callers can still inspect.
|
||||
return nil, fmt.Errorf("dual-write to project scenario_flags: %w", perr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// flagDeltaFromBuilder converts the builder's scenario_flags jsonb
|
||||
// (Record<string, unknown>) into the partial delta shape expected by
|
||||
// ScenarioFlagsService.Patch (map[string]*bool, where nil deletes the
|
||||
// key). Non-bool values are skipped; the builder only writes booleans
|
||||
// through its UI but defensive parsing keeps the dual-write honest if
|
||||
// a stray null sneaks in.
|
||||
func flagDeltaFromBuilder(raw json.RawMessage) (map[string]*bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var src map[string]any
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return nil, fmt.Errorf("decode flag delta: %w", err)
|
||||
}
|
||||
out := make(map[string]*bool, len(src))
|
||||
for k, v := range src {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
b := val
|
||||
out[k] = &b
|
||||
case nil:
|
||||
out[k] = nil
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProceeding removes a proceeding (and cascades to events + children).
|
||||
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
@@ -610,8 +691,20 @@ type PatchEventInput struct {
|
||||
|
||||
// PatchEvent updates fields on one event card. The card's parent
|
||||
// proceeding must belong to the addressed scenario.
|
||||
//
|
||||
// Dual-write (B4): when the parent scenario is project-backed
|
||||
// (scenarios.origin_project_id IS NOT NULL), the event's sequencing
|
||||
// rule is set, and the patch transitions the card to state='filed'
|
||||
// with an actual_date, the same fact lands on paliad.deadlines
|
||||
// (status='completed', completed_at=actual_date). If a deadline row
|
||||
// already exists for the (project_id, sequencing_rule_id) pair it's
|
||||
// updated in place; otherwise a fresh row is inserted carrying the
|
||||
// rule's display name + due_date=actual_date. The dual-write runs in
|
||||
// the same transaction as the scenario_events UPDATE so canvas and
|
||||
// project surfaces never diverge mid-flight.
|
||||
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
|
||||
@@ -659,8 +752,24 @@ func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenari
|
||||
horizon_optional, created_at, updated_at`,
|
||||
strings.Join(sets, ", "), len(args))
|
||||
var out BuilderEvent
|
||||
err := s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out, q, args...)
|
||||
err = s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
|
||||
if err := tx.GetContext(ctx, &out, q, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
// B4 dual-write: project-backed scenarios reflect "filed"
|
||||
// transitions on paliad.deadlines so the project's Verlauf /
|
||||
// Frist rail picks them up without a separate writer. We
|
||||
// only act when state explicitly flipped to 'filed' on this
|
||||
// patch — earlier rows that were already filed don't get
|
||||
// re-stamped.
|
||||
if sc.OriginProjectID != nil && input.State != nil && *input.State == "filed" &&
|
||||
out.SequencingRuleID != nil && out.ActualDate != nil {
|
||||
if err := s.dualWriteFiledDeadlineTx(ctx, tx, *sc.OriginProjectID,
|
||||
*out.SequencingRuleID, *out.ActualDate); err != nil {
|
||||
return fmt.Errorf("dual-write filed deadline: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("patch event: %w", err)
|
||||
@@ -668,6 +777,82 @@ func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenari
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// dualWriteFiledDeadlineTx upserts a paliad.deadlines row for the
|
||||
// (project_id, sequencing_rule_id) pair so a builder-filed event
|
||||
// surfaces on the project's deadline rail. If a row exists, it's
|
||||
// flipped to status='completed' + completed_at; otherwise a fresh row
|
||||
// is inserted with the rule's display name, due_date=actual_date, and
|
||||
// source='litigation_builder'. The whole thing runs inside the caller
|
||||
// transaction so the canvas event and the deadline never diverge.
|
||||
func (s *ScenarioBuilderService) dualWriteFiledDeadlineTx(ctx context.Context, tx *sqlx.Tx, projectID, ruleID uuid.UUID, actualDate time.Time) error {
|
||||
// Try update first — keeps any existing approval / event_type
|
||||
// hydration intact for deadlines created via the regular Akten
|
||||
// path. We touch only the columns the builder owns:
|
||||
// status / completed_at / updated_at.
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadlines
|
||||
SET status = 'completed',
|
||||
completed_at = $1,
|
||||
updated_at = now()
|
||||
WHERE project_id = $2
|
||||
AND sequencing_rule_id = $3
|
||||
AND status <> 'completed'`,
|
||||
actualDate, projectID, ruleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update existing deadline: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Already-completed rows: leave them alone, the builder isn't
|
||||
// reopening anything. Detect via a count probe so we don't
|
||||
// double-insert.
|
||||
var existing int
|
||||
if err := tx.GetContext(ctx, &existing,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2`,
|
||||
projectID, ruleID); err != nil {
|
||||
return fmt.Errorf("probe deadline row: %w", err)
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// No existing row — insert a fresh deadline. The title comes from
|
||||
// paliad.procedural_events.name joined via sequencing_rules.
|
||||
// procedural_event_id (sequencing_rules itself doesn't carry a
|
||||
// display label — the name lives on the procedural_event row).
|
||||
// rule_code falls back when the event has no name; the literal
|
||||
// "Litigation-Builder Event" is the last resort for rules that
|
||||
// have no procedural_event_id either. source='rule' (already
|
||||
// allowed by deadlines_source_check) since the row is rule-backed
|
||||
// — the Litigation Builder doesn't get its own source bucket; the
|
||||
// audit_reason on the surrounding tx tells the audit log who
|
||||
// inserted it.
|
||||
var title string
|
||||
if err := tx.GetContext(ctx, &title,
|
||||
`SELECT COALESCE(NULLIF(pe.name, ''), NULLIF(sr.rule_code, ''), 'Litigation-Builder Event')
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.id = $1`, ruleID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
title = "Litigation-Builder Event"
|
||||
} else {
|
||||
return fmt.Errorf("load rule name: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(project_id, title, due_date, sequencing_rule_id, status, completed_at, source, approval_status)
|
||||
VALUES ($1, $2, $3::date, $4, 'completed', $5::timestamptz, 'rule', 'legacy')`,
|
||||
projectID, title, actualDate, ruleID, actualDate); err != nil {
|
||||
return fmt.Errorf("insert builder deadline: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEvent removes one event card.
|
||||
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
@@ -908,6 +1093,189 @@ func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context,
|
||||
return nil, ErrScenarioBuilderNotVisible
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Akte mode — project-backed scenarios (B4, t-paliad-347)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// CreateScenarioFromProject builds a fresh project-backed scenario from
|
||||
// a paliad.projects row: the scenario's origin_project_id points at the
|
||||
// project, one top-level proceeding mirrors the project's
|
||||
// proceeding_type_id + our_side + scenario_flags, and every existing
|
||||
// paliad.deadlines row with a sequencing_rule_id surfaces as a
|
||||
// scenario_events row (state='filed' when the deadline is completed,
|
||||
// 'planned' otherwise).
|
||||
//
|
||||
// The scenario is the canvas view-state; paliad.projects.scenario_flags
|
||||
// + paliad.deadlines remain the SSoT for project-bound actuals (PRD
|
||||
// §2.3 + §10). Subsequent PatchProceeding / PatchEvent calls on this
|
||||
// scenario route their writes through to those SSoT tables via the
|
||||
// dual-write hooks below.
|
||||
//
|
||||
// Visibility: the caller must be able to see the project; the project's
|
||||
// type must be 'case' (it's the proceeding-bearing project rung) and
|
||||
// must have a proceeding_type_id set (otherwise there's nothing to seed
|
||||
// the builder with). Returns ErrInvalidInput when those preconditions
|
||||
// don't hold.
|
||||
func (s *ScenarioBuilderService) CreateScenarioFromProject(ctx context.Context, userID, projectID uuid.UUID) (*BuilderScenarioDeep, error) {
|
||||
if s.projects == nil {
|
||||
return nil, fmt.Errorf("%w: project service not wired", ErrInvalidInput)
|
||||
}
|
||||
proj, err := s.projects.GetByID(ctx, userID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proj == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if proj.ProceedingTypeID == nil || *proj.ProceedingTypeID <= 0 {
|
||||
return nil, fmt.Errorf("%w: project %s has no proceeding_type_id — Akte-mode requires one", ErrInvalidInput, projectID)
|
||||
}
|
||||
|
||||
// Read the project's persisted scenario_flags. The column is jsonb
|
||||
// NOT NULL DEFAULT '{}' (mig 154) so an empty map is always safe.
|
||||
var rawFlags []byte
|
||||
if err := s.db.GetContext(ctx, &rawFlags,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
|
||||
return nil, fmt.Errorf("read project scenario_flags: %w", err)
|
||||
}
|
||||
if len(rawFlags) == 0 {
|
||||
rawFlags = []byte(`{}`)
|
||||
}
|
||||
|
||||
// Pull every active+published sequencing_rule deadline row on the
|
||||
// project so the canvas can render filed/planned actuals as event
|
||||
// cards from first paint. CCR sub-projects are reached separately
|
||||
// when the user toggles with_ccr; the seed only covers the addressed
|
||||
// project's deadlines.
|
||||
type deadlineRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
SequencingRuleID *uuid.UUID `db:"sequencing_rule_id"`
|
||||
Status string `db:"status"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
CompletedAt *time.Time `db:"completed_at"`
|
||||
}
|
||||
var deadlines []deadlineRow
|
||||
if err := s.db.SelectContext(ctx, &deadlines,
|
||||
`SELECT id, sequencing_rule_id, status, due_date, completed_at
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND sequencing_rule_id IS NOT NULL`,
|
||||
projectID); err != nil {
|
||||
return nil, fmt.Errorf("read project deadlines: %w", err)
|
||||
}
|
||||
|
||||
// Derive the builder-side primary_party from the project's
|
||||
// our_side. The Project.OurSide column accepts the wider sub-role
|
||||
// set (claimant / applicant / appellant; defendant / respondent;
|
||||
// third_party / other) but the builder triplet has a binary
|
||||
// claimant|defendant axis per PRD §3.3 — fold the wider set down,
|
||||
// drop third_party / other to NULL (no perspective preselected).
|
||||
primaryParty := mapProjectOurSideToTripletParty(proj.OurSide)
|
||||
|
||||
name := strings.TrimSpace(proj.Title)
|
||||
if name == "" {
|
||||
name = "Akte"
|
||||
}
|
||||
|
||||
deep := &BuilderScenarioDeep{
|
||||
Proceedings: []BuilderProceeding{},
|
||||
Events: []BuilderEvent{},
|
||||
Shares: []BuilderShare{},
|
||||
}
|
||||
|
||||
err = s.withAuditTx(ctx, "scenario_builder: create from project", func(tx *sqlx.Tx) error {
|
||||
// 1. Insert the scenario header. origin_project_id pins the
|
||||
// Akte link; promotion later overwrites promoted_project_id
|
||||
// independently.
|
||||
if err := tx.GetContext(ctx, &deep.BuilderScenario,
|
||||
`INSERT INTO paliad.scenarios
|
||||
(owner_id, name, status, origin_project_id)
|
||||
VALUES ($1, $2, 'active', $3)
|
||||
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes,
|
||||
project_id, description, created_by,
|
||||
created_at, updated_at`,
|
||||
userID, name, projectID); err != nil {
|
||||
return fmt.Errorf("insert scenario row: %w", err)
|
||||
}
|
||||
|
||||
// 2. Insert one top-level proceeding mirroring the project's
|
||||
// procedural shape + flags. scenario_flags is copied
|
||||
// verbatim from the project — subsequent toggles on the
|
||||
// builder propagate back via PatchProceeding's dual-write.
|
||||
var proc BuilderProceeding
|
||||
if err := tx.GetContext(ctx, &proc,
|
||||
`INSERT INTO paliad.scenario_proceedings
|
||||
(scenario_id, proceeding_type_id, primary_party, scenario_flags, ordinal, detailgrad)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 0, 'selected')
|
||||
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
||||
stichtag, detailgrad, appeal_target, collapsed,
|
||||
created_at, updated_at`,
|
||||
deep.BuilderScenario.ID, *proj.ProceedingTypeID, primaryParty, rawFlags); err != nil {
|
||||
return fmt.Errorf("insert seed proceeding: %w", err)
|
||||
}
|
||||
deep.Proceedings = append(deep.Proceedings, proc)
|
||||
|
||||
// 3. One scenario_events row per project deadline. Filed
|
||||
// deadlines render with state='filed' + actual_date =
|
||||
// completed_at (falling back to due_date when the column
|
||||
// was never set). Pending / approved deadlines render
|
||||
// planned. Skipped is not derivable from the deadline row
|
||||
// shape; users mark skip on the canvas via PatchEvent.
|
||||
for _, d := range deadlines {
|
||||
state := "planned"
|
||||
var actualDate *time.Time
|
||||
if d.Status == "completed" {
|
||||
state = "filed"
|
||||
if d.CompletedAt != nil {
|
||||
actualDate = d.CompletedAt
|
||||
} else {
|
||||
due := d.DueDate
|
||||
actualDate = &due
|
||||
}
|
||||
}
|
||||
var ev BuilderEvent
|
||||
if err := tx.GetContext(ctx, &ev,
|
||||
`INSERT INTO paliad.scenario_events
|
||||
(scenario_proceeding_id, sequencing_rule_id, state, actual_date)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
||||
custom_label, state, actual_date, skip_reason, notes,
|
||||
horizon_optional, created_at, updated_at`,
|
||||
proc.ID, *d.SequencingRuleID, state, actualDate); err != nil {
|
||||
return fmt.Errorf("insert seed event: %w", err)
|
||||
}
|
||||
deep.Events = append(deep.Events, ev)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create scenario from project: %w", err)
|
||||
}
|
||||
return deep, nil
|
||||
}
|
||||
|
||||
// mapProjectOurSideToTripletParty folds paliad.projects.our_side (which
|
||||
// allows the wider claimant/applicant/appellant + defendant/respondent
|
||||
// + third_party/other set, mig 112) down to the builder triplet's
|
||||
// binary claimant|defendant axis (PRD §3.3). Returns nil when the
|
||||
// project hasn't picked a side or the role doesn't map (third_party /
|
||||
// other) — the canvas shows both columns equally in that case.
|
||||
func mapProjectOurSideToTripletParty(side *string) *string {
|
||||
if side == nil {
|
||||
return nil
|
||||
}
|
||||
switch *side {
|
||||
case "claimant", "applicant", "appellant":
|
||||
s := "claimant"
|
||||
return &s
|
||||
case "defendant", "respondent":
|
||||
s := "defendant"
|
||||
return &s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withAuditTx opens a transaction, stamps paliad.audit_reason via
|
||||
// set_config(..., true) so the reason persists for the duration of the
|
||||
// tx (matching the mig-079 audit-trigger pattern used by event_choice_
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -82,7 +83,7 @@ func TestScenarioBuilderService(t *testing.T) {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
|
||||
svc := NewScenarioBuilderService(pool)
|
||||
svc := NewScenarioBuilderService(pool, nil, nil)
|
||||
|
||||
// 1. Create a scenario for the owner. Empty name should default.
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
||||
@@ -216,5 +217,258 @@ func TestScenarioBuilderService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioBuilderAkteDualWrite pins B4's load-bearing contract
|
||||
// (m/paliad#153 / t-paliad-347 / PRD §2.3 + §10):
|
||||
//
|
||||
// - PatchProceeding on a project-backed scenario (origin_project_id
|
||||
// IS NOT NULL) MUST mirror scenario_flags onto
|
||||
// paliad.projects.scenario_flags;
|
||||
// - PatchEvent flipping state→'filed' on a project-backed scenario
|
||||
// MUST upsert a paliad.deadlines row (status='completed',
|
||||
// completed_at=actual_date);
|
||||
// - PatchProceeding/PatchEvent on a non-Akte (kontextfrei) scenario
|
||||
// MUST NOT touch paliad.projects.scenario_flags or
|
||||
// paliad.deadlines.
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL. Mirrors the live-DB pattern used
|
||||
// by TestScenarioBuilderService above.
|
||||
func TestScenarioBuilderAkteDualWrite(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
owner := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.projects WHERE created_by = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.users WHERE id = $1`, owner)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM auth.users WHERE id = $1`, owner)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// Seed owner.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
|
||||
owner, "builder-akte-test@hlc.com"); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang, global_role)
|
||||
VALUES ($1, $2, $3, 'munich', 'de', 'global_admin')`,
|
||||
owner, "builder-akte-test@hlc.com", "Builder Akte Owner"); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
// Look up a real proceeding_type_id + a sequencing_rule_id on that
|
||||
// proceeding so the deadline upsert has a real rule to point at.
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true LIMIT 1`,
|
||||
CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
var ruleID uuid.UUID
|
||||
if err := pool.GetContext(ctx, &ruleID,
|
||||
`SELECT id FROM paliad.sequencing_rules
|
||||
WHERE proceeding_type_id = $1
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
ORDER BY sequence_order NULLS LAST, id LIMIT 1`, ptID); err != nil {
|
||||
t.Fatalf("look up sequencing_rule: %v", err)
|
||||
}
|
||||
|
||||
// Seed a paliad.projects (type='case') row pinned to that
|
||||
// proceeding_type. our_side='defendant' so the builder triplet's
|
||||
// primary_party derives from it.
|
||||
projectID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, title, status, proceeding_type_id, our_side, created_by)
|
||||
VALUES ($1, 'case', 'Builder Akte Test Project', 'active', $2, 'defendant', $3)`,
|
||||
projectID, ptID, owner); err != nil {
|
||||
t.Fatalf("seed project: %v", err)
|
||||
}
|
||||
// Place the owner on the project team so visibility checks pass.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited)
|
||||
VALUES ($1, $2, 'lead', 'lead', false)`, projectID, owner); err != nil {
|
||||
t.Fatalf("seed project_teams: %v", err)
|
||||
}
|
||||
|
||||
// Wire up the service with the real project + flag deps so dual-
|
||||
// write hits live tables. NewProjectService + NewScenarioFlags
|
||||
// match the production wiring in cmd/server/main.go.
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Phase A — Akte-backed scenario writes through to project tables.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
akte, err := svc.CreateScenarioFromProject(ctx, owner, projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenarioFromProject: %v", err)
|
||||
}
|
||||
if akte.OriginProjectID == nil || *akte.OriginProjectID != projectID {
|
||||
t.Fatalf("origin_project_id = %v, want %v", akte.OriginProjectID, projectID)
|
||||
}
|
||||
if len(akte.Proceedings) != 1 {
|
||||
t.Fatalf("seed proceedings = %d, want 1", len(akte.Proceedings))
|
||||
}
|
||||
procID := akte.Proceedings[0].ID
|
||||
if akte.Proceedings[0].PrimaryParty == nil || *akte.Proceedings[0].PrimaryParty != "defendant" {
|
||||
t.Errorf("primary_party = %v, want defendant", akte.Proceedings[0].PrimaryParty)
|
||||
}
|
||||
|
||||
// Toggle with_ccr=true via PatchProceeding. Dual-write should land
|
||||
// the same key on projects.scenario_flags.
|
||||
if _, err := svc.PatchProceeding(ctx, owner, akte.ID, procID, PatchProceedingInput{
|
||||
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchProceeding (Akte): %v", err)
|
||||
}
|
||||
var rawProjFlags []byte
|
||||
if err := pool.GetContext(ctx, &rawProjFlags,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
|
||||
t.Fatalf("read project scenario_flags: %v", err)
|
||||
}
|
||||
var projFlags map[string]any
|
||||
if err := json.Unmarshal(rawProjFlags, &projFlags); err != nil {
|
||||
t.Fatalf("decode project scenario_flags: %v", err)
|
||||
}
|
||||
if v, ok := projFlags["with_ccr"].(bool); !ok || !v {
|
||||
t.Errorf("after Akte PatchProceeding, projects.scenario_flags.with_ccr = %v, want true", projFlags["with_ccr"])
|
||||
}
|
||||
|
||||
// Add an event card backed by a real sequencing rule, then PATCH
|
||||
// state='filed' with actual_date. Dual-write should insert a
|
||||
// paliad.deadlines row (status='completed', completed_at=actual_date).
|
||||
ev, err := svc.AddEvent(ctx, owner, akte.ID, procID, AddEventInput{
|
||||
SequencingRuleID: &ruleID,
|
||||
State: ptrString("planned"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddEvent (Akte): %v", err)
|
||||
}
|
||||
filedDate := mustDate(t, "2026-04-15")
|
||||
if _, err := svc.PatchEvent(ctx, owner, akte.ID, ev.ID, PatchEventInput{
|
||||
State: ptrString("filed"),
|
||||
ActualDate: &filedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchEvent filed (Akte): %v", err)
|
||||
}
|
||||
var deadlineCount int
|
||||
if err := pool.GetContext(ctx, &deadlineCount,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2
|
||||
AND status = 'completed'`,
|
||||
projectID, ruleID); err != nil {
|
||||
t.Fatalf("count deadlines: %v", err)
|
||||
}
|
||||
if deadlineCount != 1 {
|
||||
t.Errorf("after Akte PatchEvent filed, deadlines rows = %d, want 1", deadlineCount)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Phase B — kontextfrei scenario does NOT touch project surfaces.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Capture project scenario_flags + deadline count before the
|
||||
// kontextfrei mutations so we can assert no change.
|
||||
var beforeFlagsRaw []byte
|
||||
_ = pool.GetContext(ctx, &beforeFlagsRaw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
|
||||
var beforeDeadlines int
|
||||
_ = pool.GetContext(ctx, &beforeDeadlines,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID)
|
||||
|
||||
kf, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario (kontextfrei): %v", err)
|
||||
}
|
||||
if kf.OriginProjectID != nil {
|
||||
t.Fatalf("kontextfrei origin_project_id = %v, want nil", kf.OriginProjectID)
|
||||
}
|
||||
kfProc, err := svc.AddProceeding(ctx, owner, kf.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
PrimaryParty: ptrString("claimant"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProceeding (kontextfrei): %v", err)
|
||||
}
|
||||
// Flag toggle on a kontextfrei scenario MUST NOT mutate the
|
||||
// project's scenario_flags.
|
||||
if _, err := svc.PatchProceeding(ctx, owner, kf.ID, kfProc.ID, PatchProceedingInput{
|
||||
ScenarioFlags: json.RawMessage(`{"with_amend": true}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchProceeding (kontextfrei): %v", err)
|
||||
}
|
||||
var afterFlagsRaw []byte
|
||||
if err := pool.GetContext(ctx, &afterFlagsRaw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
|
||||
t.Fatalf("re-read project scenario_flags: %v", err)
|
||||
}
|
||||
if string(beforeFlagsRaw) != string(afterFlagsRaw) {
|
||||
t.Errorf("kontextfrei PatchProceeding leaked into projects.scenario_flags: before=%s after=%s",
|
||||
beforeFlagsRaw, afterFlagsRaw)
|
||||
}
|
||||
|
||||
// Filed-state event on a kontextfrei scenario MUST NOT touch
|
||||
// paliad.deadlines.
|
||||
kfEv, err := svc.AddEvent(ctx, owner, kf.ID, kfProc.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleID,
|
||||
State: ptrString("planned"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddEvent (kontextfrei): %v", err)
|
||||
}
|
||||
kfDate := mustDate(t, "2026-04-16")
|
||||
if _, err := svc.PatchEvent(ctx, owner, kf.ID, kfEv.ID, PatchEventInput{
|
||||
State: ptrString("filed"),
|
||||
ActualDate: &kfDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("PatchEvent filed (kontextfrei): %v", err)
|
||||
}
|
||||
var afterDeadlines int
|
||||
if err := pool.GetContext(ctx, &afterDeadlines,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID); err != nil {
|
||||
t.Fatalf("re-count deadlines: %v", err)
|
||||
}
|
||||
if afterDeadlines != beforeDeadlines {
|
||||
t.Errorf("kontextfrei PatchEvent filed leaked into deadlines: before=%d after=%d",
|
||||
beforeDeadlines, afterDeadlines)
|
||||
}
|
||||
}
|
||||
|
||||
// mustDate parses an ISO date or fails the test. Helper for the
|
||||
// dual-write test above.
|
||||
func mustDate(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
d, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse date %q: %v", s, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// (Note: ptrString lives in rule_editor_service_test.go in this package
|
||||
// and is reused here. No second declaration needed.)
|
||||
|
||||
@@ -405,6 +405,23 @@ func parseInlineSpans(text string) []inlineSpan {
|
||||
i := 0
|
||||
n := len(text)
|
||||
for i < n {
|
||||
// Preserve {{...}} placeholders verbatim. Underscores and
|
||||
// other Markdown-significant chars inside a placeholder key
|
||||
// (e.g. {{project.case_number}}) must not be interpreted as
|
||||
// bold/italic delimiters — otherwise the key gets stripped of
|
||||
// its underscores and the v1 placeholder pass looks up the
|
||||
// wrong key, surfacing [KEIN WERT: project.casenumber] in the
|
||||
// preview.
|
||||
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
|
||||
rel := strings.Index(text[i+2:], "}}")
|
||||
if rel >= 0 {
|
||||
end := i + 2 + rel + 2
|
||||
cur.WriteString(text[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
// Unmatched {{ — fall through to plain character handling.
|
||||
}
|
||||
// Bold delimiters first (longer match wins over italic).
|
||||
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
|
||||
flush()
|
||||
|
||||
@@ -86,6 +86,90 @@ func TestRenderMarkdownToOOXML_PlaceholdersPassThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_PlaceholderUnderscoresPreserved(t *testing.T) {
|
||||
// Regression: a placeholder key containing underscores (project.case_number,
|
||||
// user.display_name, project.patent_number_upc) used to get its underscores
|
||||
// consumed by the italic/bold inline scanner — the OOXML stored
|
||||
// {{project.casenumber}} and the preview surfaced
|
||||
// [KEIN WERT: project.casenumber] instead of the real value.
|
||||
cases := []string{
|
||||
"{{project.case_number}}",
|
||||
"{{user.display_name}}",
|
||||
"{{project.patent_number_upc}}",
|
||||
"prefix {{project.case_number}} suffix",
|
||||
"two: {{a.b_c}} and {{d.e_f}}",
|
||||
"mixed: _italic_ then {{project.case_number}} then __bold__",
|
||||
}
|
||||
for _, in := range cases {
|
||||
out := RenderMarkdownToOOXML(in, "Normal")
|
||||
// Every placeholder substring in the input must appear verbatim
|
||||
// in the output (XML escaping is irrelevant for {} and _).
|
||||
for _, ph := range extractPlaceholders(in) {
|
||||
if !strings.Contains(out, ph) {
|
||||
t.Errorf("input %q: placeholder %q lost; got %q", in, ph, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
|
||||
// Direct guard on the inline scanner. {{project.case_number}} must
|
||||
// emit as a single non-italic span containing the full placeholder.
|
||||
spans := parseInlineSpans("{{project.case_number}}")
|
||||
if len(spans) != 1 {
|
||||
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
|
||||
}
|
||||
if spans[0].Italic || spans[0].Bold {
|
||||
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
|
||||
}
|
||||
if spans[0].Text != "{{project.case_number}}" {
|
||||
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_ItalicAroundPlaceholder(t *testing.T) {
|
||||
// Italic delimiters outside a placeholder still work; the placeholder
|
||||
// itself stays literal even when it sits between italics.
|
||||
spans := parseInlineSpans("_before_ {{x.y_z}} _after_")
|
||||
var saw struct {
|
||||
italicBefore bool
|
||||
placeholder bool
|
||||
italicAfter bool
|
||||
}
|
||||
for _, s := range spans {
|
||||
if s.Italic && s.Text == "before" {
|
||||
saw.italicBefore = true
|
||||
}
|
||||
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
|
||||
saw.placeholder = true
|
||||
}
|
||||
if s.Italic && s.Text == "after" {
|
||||
saw.italicAfter = true
|
||||
}
|
||||
}
|
||||
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
|
||||
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// extractPlaceholders pulls every {{...}} occurrence out of a Markdown
|
||||
// source. Tiny helper, only used by the regression test above.
|
||||
func extractPlaceholders(s string) []string {
|
||||
var out []string
|
||||
for {
|
||||
start := strings.Index(s, "{{")
|
||||
if start < 0 {
|
||||
return out
|
||||
}
|
||||
end := strings.Index(s[start+2:], "}}")
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
out = append(out, s[start:start+2+end+2])
|
||||
s = s[start+2+end+2:]
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_XMLEscape(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("a & b < c > d", "")
|
||||
if strings.Contains(out, " & ") {
|
||||
|
||||
@@ -205,7 +205,11 @@ func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T)
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
// IncludeOptional=true because translation_request carries
|
||||
// priority='optional'; the test exercises the before-child-of-
|
||||
// court-set-parent flow, which is orthogonal to the optional-rule
|
||||
// suppression added in t-paliad-342.
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
@@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
// User pins the oral hearing to 2026-10-15.
|
||||
// User pins the oral hearing to 2026-10-15. IncludeOptional=true
|
||||
// because translation_request is priority='optional' (t-paliad-342).
|
||||
opts := CalcOptions{
|
||||
IncludeOptional: true,
|
||||
AnchorOverrides: map[string]string{
|
||||
oralCode: "2026-10-15",
|
||||
},
|
||||
|
||||
@@ -80,6 +80,21 @@ func Calculate(
|
||||
overrideDates[code] = od
|
||||
}
|
||||
|
||||
// Trigger-event anchors keyed by paliad.trigger_events.code
|
||||
// (t-paliad-342). Parsed up-front so malformed dates error before
|
||||
// the rule walk. When a rule has trigger_event_id set, the engine
|
||||
// looks up triggerAnchorByCode[trigger_event.code] for the
|
||||
// semantic anchor instead of falling back to the proceeding's
|
||||
// trigger date.
|
||||
triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors))
|
||||
for code, dateStr := range opts.TriggerEventAnchors {
|
||||
td, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err)
|
||||
}
|
||||
triggerAnchorByCode[code] = td
|
||||
}
|
||||
|
||||
// Look up proceeding type metadata.
|
||||
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
||||
if err != nil {
|
||||
@@ -213,6 +228,7 @@ func Calculate(
|
||||
perCardAppellant := opts.PerCardAppellant
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
hiddenCount := 0
|
||||
rulesAwaitingAnchor := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range walkRules {
|
||||
@@ -227,6 +243,17 @@ func Calculate(
|
||||
continue
|
||||
}
|
||||
|
||||
// Optional-rule suppression (t-paliad-342 / youpcorg#2570).
|
||||
// Rules tagged priority='optional' don't auto-fire in the
|
||||
// default timeline; the caller opts in via
|
||||
// CalcOptions.IncludeOptional. Cascade through skippedIDs so
|
||||
// children chaining off the suppressed rule also drop — they
|
||||
// can't compute a date against a missing parent.
|
||||
if r.Priority == "optional" && !opts.IncludeOptional {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
// SkipRules suppression (t-paliad-265).
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||
@@ -327,15 +354,43 @@ func Calculate(
|
||||
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
||||
// trigger_event_id, that catalog event is the actual semantic
|
||||
// anchor — not the parent_id node, which is only the calc-time
|
||||
// arithmetic anchor. Only the user-facing wire fields shift;
|
||||
// parentRule (and the parent_id chain feeding parentIsCourtSet
|
||||
// and the calc-time arithmetic below) stays anchored on the
|
||||
// rule tree.
|
||||
// arithmetic anchor. Only the user-facing wire fields shift
|
||||
// here; the calc-time anchor logic for trigger_event_id rules
|
||||
// lives just below.
|
||||
var triggerEventAnchor time.Time
|
||||
var hasTriggerEventAnchor bool
|
||||
if r.TriggerEventID != nil {
|
||||
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||
d.ParentRuleCode = te.Code
|
||||
d.ParentRuleName = te.NameDE
|
||||
d.ParentRuleNameEN = te.Name
|
||||
if td, ok := triggerAnchorByCode[te.Code]; ok {
|
||||
triggerEventAnchor = td
|
||||
hasTriggerEventAnchor = true
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event semantic-anchor suppression (t-paliad-342 /
|
||||
// youpcorg#2568). When a rule has an explicit trigger_event_id
|
||||
// but the caller hasn't supplied a date for that event via
|
||||
// CalcOptions.TriggerEventAnchors, the engine refuses to
|
||||
// fabricate a date off the proceeding's trigger date — the
|
||||
// rule's semantic anchor is the event itself, not the SoC.
|
||||
// Render IsConditional with empty dates and propagate via
|
||||
// courtSet so descendants chaining off this rule also surface
|
||||
// as conditional rather than projecting fictional dates.
|
||||
if !hasTriggerEventAnchor {
|
||||
d.IsConditional = true
|
||||
d.IsCourtSet = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
rulesAwaitingAnchor++
|
||||
if r.SubmissionCode != nil {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +434,20 @@ func Calculate(
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event anchor wins over the bucket logic below: a
|
||||
// zero-duration rule with trigger_event_id is "occurs on the
|
||||
// trigger event's date". Anchor missing was already caught
|
||||
// above (suppression branch).
|
||||
if hasTriggerEventAnchor {
|
||||
d.DueDate = triggerEventAnchor.Format("2006-01-02")
|
||||
d.OriginalDate = d.DueDate
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = triggerEventAnchor
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
if r.ParentID == nil && !r.IsCourtSet {
|
||||
// Bucket 1: timeline anchor.
|
||||
d.IsRootEvent = true
|
||||
@@ -457,11 +526,19 @@ func Calculate(
|
||||
continue
|
||||
}
|
||||
|
||||
// Anchor: prefer alt-anchor (e.g. priority_date for
|
||||
// epa.grant.exa publish) when supplied, then parent's computed
|
||||
// date (or user override), then trigger date.
|
||||
// Anchor priority:
|
||||
// 1. trigger_event_id semantic anchor (t-paliad-342) — when
|
||||
// the rule has trigger_event_id and the caller supplied a
|
||||
// date in TriggerEventAnchors, that date wins over the
|
||||
// parent chain AND the priority_date alt-anchor. The
|
||||
// missing-anchor case was already short-circuited above.
|
||||
// 2. priority_date alt-anchor (epa.grant.exa publish).
|
||||
// 3. parent's computed date (or user override).
|
||||
// 4. proceeding trigger date (default fallback).
|
||||
baseDate := triggerDate
|
||||
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
if hasTriggerEventAnchor {
|
||||
baseDate = triggerEventAnchor
|
||||
} else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
baseDate = *priorityDate
|
||||
} else if r.ParentID != nil {
|
||||
for _, prev := range rules {
|
||||
@@ -635,12 +712,13 @@ func Calculate(
|
||||
}
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
RulesAwaitingAnchor: rulesAwaitingAnchor,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding`.
|
||||
|
||||
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
379
pkg/litigationplanner/optional_and_trigger_anchor_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Tests for t-paliad-342 / youpcorg#2568 + #2570.
|
||||
//
|
||||
// Two paired engine semantics:
|
||||
//
|
||||
// - Optional rules (priority='optional') don't auto-fire in the
|
||||
// default timeline; the caller opts in via
|
||||
// CalcOptions.IncludeOptional.
|
||||
// - Rules with explicit trigger_event_id anchor on the trigger
|
||||
// event's date (CalcOptions.TriggerEventAnchors keyed by
|
||||
// trigger_events.code). Missing anchor = render conditional
|
||||
// instead of fabricating a date off the proceeding's trigger date.
|
||||
|
||||
// stubCatalogWithTriggers extends stubCatalog with a trigger-events
|
||||
// map so the engine can resolve TriggerEventID → code for the
|
||||
// trigger-anchor branch. stubCatalog from before_court_set_anchor_test.go
|
||||
// returns an empty map, which suffices for tests that don't exercise
|
||||
// trigger_event_id; here we need real entries.
|
||||
type stubCatalogWithTriggers struct {
|
||||
stubCatalog
|
||||
triggerEvents map[int64]TriggerEvent
|
||||
}
|
||||
|
||||
func (s *stubCatalogWithTriggers) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]TriggerEvent, error) {
|
||||
out := make(map[int64]TriggerEvent, len(ids))
|
||||
for _, id := range ids {
|
||||
if te, ok := s.triggerEvents[id]; ok {
|
||||
out[id] = te
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mandatory_socRule builds a minimal SoC root rule + the proceeding
|
||||
// type wrapper that nearly every test below needs.
|
||||
func mandatorySocFixture(t *testing.T) (ProceedingType, Rule, uuid.UUID) {
|
||||
t.Helper()
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "Verletzungsverfahren",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
socID, _ := uuid.NewRandom()
|
||||
socCode := "upc.inf.cfi.soc"
|
||||
procIDPtr := &procID
|
||||
str := func(s string) *string { return &s }
|
||||
soc := Rule{
|
||||
ID: socID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &socCode,
|
||||
Name: "Klageerhebung",
|
||||
NameEN: "SoC",
|
||||
PrimaryParty: str("claimant"),
|
||||
DurationValue: 0,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
}
|
||||
return pt, soc, socID
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventIDWithoutAnchor_Suppressed verifies the
|
||||
// RoP.109.5 bug from youpcorg#2568: a rule with trigger_event_id and
|
||||
// no parent_id must NOT fall back to the proceeding's trigger date.
|
||||
// The buggy behaviour rendered the rule with a fabricated date 2 weeks
|
||||
// before the user's SoC date.
|
||||
func TestCalculate_TriggerEventIDWithoutAnchor_Suppressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
ruleID, _ := uuid.NewRandom()
|
||||
ruleCode := "upc.inf.cfi.rop_109_5"
|
||||
rop109_5Trigger := int64(49)
|
||||
rop109_5 := Rule{
|
||||
ID: ruleID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &ruleCode,
|
||||
Name: "Vorbereitung mündliche Verhandlung",
|
||||
NameEN: "Oral hearing preparation",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 100,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
TriggerEventID: &rop109_5Trigger,
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
|
||||
triggerEvents: map[int64]TriggerEvent{
|
||||
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
|
||||
},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
rop, ok := byCode[ruleCode]
|
||||
if !ok {
|
||||
t.Fatalf("RoP.109.5 missing from timeline; want present with conditional-no-date")
|
||||
}
|
||||
if rop.DueDate != "" {
|
||||
t.Errorf("RoP.109.5: DueDate=%q, want empty (no oral_hearing anchor supplied)", rop.DueDate)
|
||||
}
|
||||
if !rop.IsConditional {
|
||||
t.Errorf("RoP.109.5: IsConditional=%v, want true", rop.IsConditional)
|
||||
}
|
||||
if timeline.RulesAwaitingAnchor != 1 {
|
||||
t.Errorf("RulesAwaitingAnchor=%d, want 1", timeline.RulesAwaitingAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventIDWithAnchor_Renders verifies the
|
||||
// caller-supplied trigger-event anchor produces correct arithmetic.
|
||||
// 2 weeks before 2026-10-15 = 2026-10-01.
|
||||
func TestCalculate_TriggerEventIDWithAnchor_Renders(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
ruleID, _ := uuid.NewRandom()
|
||||
ruleCode := "upc.inf.cfi.rop_109_5"
|
||||
rop109_5Trigger := int64(49)
|
||||
rop109_5 := Rule{
|
||||
ID: ruleID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &ruleCode,
|
||||
Name: "Vorbereitung mündliche Verhandlung",
|
||||
NameEN: "Oral hearing preparation",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 100,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
TriggerEventID: &rop109_5Trigger,
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
|
||||
triggerEvents: map[int64]TriggerEvent{
|
||||
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
|
||||
},
|
||||
}
|
||||
|
||||
opts := CalcOptions{
|
||||
TriggerEventAnchors: map[string]string{
|
||||
"oral_hearing": "2026-10-15",
|
||||
},
|
||||
}
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
rop := byCode[ruleCode]
|
||||
if rop.DueDate != "2026-10-01" {
|
||||
t.Errorf("RoP.109.5: DueDate=%q, want 2026-10-01 (2 weeks before oral_hearing 2026-10-15)", rop.DueDate)
|
||||
}
|
||||
if rop.IsConditional {
|
||||
t.Errorf("RoP.109.5: IsConditional=%v, want false (anchor supplied)", rop.IsConditional)
|
||||
}
|
||||
if timeline.RulesAwaitingAnchor != 0 {
|
||||
t.Errorf("RulesAwaitingAnchor=%d, want 0", timeline.RulesAwaitingAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_MandatoryRule_RendersByDefault is the control case for
|
||||
// the optional-suppression fix: mandatory rules render with their
|
||||
// computed dates by default. Prevents regression where the optional
|
||||
// filter accidentally drops mandatory rules too.
|
||||
func TestCalculate_MandatoryRule_RendersByDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
replyID, _ := uuid.NewRandom()
|
||||
replyCode := "upc.inf.cfi.reply"
|
||||
reply := Rule{
|
||||
ID: replyID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &replyCode,
|
||||
Name: "Klageerwiderung",
|
||||
NameEN: "Reply to SoC",
|
||||
PrimaryParty: str("defendant"),
|
||||
DurationValue: 3,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 10,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, reply}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
got, ok := byCode[replyCode]
|
||||
if !ok {
|
||||
t.Fatalf("mandatory reply rule missing from default timeline")
|
||||
}
|
||||
if got.DueDate != "2026-08-26" {
|
||||
t.Errorf("reply: DueDate=%q, want 2026-08-26 (3 months after SoC)", got.DueDate)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_OptionalRule_SuppressedByDefault pins the
|
||||
// youpcorg#2570 fix: priority='optional' rules don't render in the
|
||||
// default timeline. The caller opts in via IncludeOptional=true.
|
||||
func TestCalculate_OptionalRule_SuppressedByDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
confID, _ := uuid.NewRandom()
|
||||
confCode := "upc.inf.cfi.rop_262_2"
|
||||
conf := Rule{
|
||||
ID: confID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &confCode,
|
||||
Name: "Erwiderung Vertraulichkeitsantrag",
|
||||
NameEN: "Reply to confidentiality motion",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 20,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "optional",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.Code == confCode {
|
||||
t.Errorf("optional R.262(2) rule rendered in default timeline (DueDate=%q); want suppressed", d.DueDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_OptionalRule_RendersWithIncludeOptional verifies the
|
||||
// opt-in path: when the caller passes IncludeOptional=true, optional
|
||||
// rules show up in the timeline with their computed dates.
|
||||
func TestCalculate_OptionalRule_RendersWithIncludeOptional(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, socID := mandatorySocFixture(t)
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &pt.ID
|
||||
confID, _ := uuid.NewRandom()
|
||||
confCode := "upc.inf.cfi.rop_262_2"
|
||||
conf := Rule{
|
||||
ID: confID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &socID,
|
||||
SubmissionCode: &confCode,
|
||||
Name: "Erwiderung Vertraulichkeitsantrag",
|
||||
NameEN: "Reply to confidentiality motion",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 20,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "optional",
|
||||
}
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
|
||||
}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
got, ok := byCode[confCode]
|
||||
if !ok {
|
||||
t.Fatalf("optional R.262(2) missing from timeline despite IncludeOptional=true")
|
||||
}
|
||||
// R.262(2) is the "optional opposing-side" pattern (priority=optional,
|
||||
// primary_party=both, parent=SoC root) — the engine renders this as
|
||||
// IsConditional (no concrete date) per the t-paliad-289 logic
|
||||
// preserved in the walk. The point of this test is that the rule
|
||||
// is no longer suppressed wholesale by the t-paliad-342 default —
|
||||
// it surfaces, just with the conditional-render UX.
|
||||
if !got.IsConditional {
|
||||
t.Errorf("R.262(2): IsConditional=%v, want true (optional opposing-side, no real trigger recorded)", got.IsConditional)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_TriggerEventID_MalformedAnchor_Errors ensures
|
||||
// malformed dates in TriggerEventAnchors fail fast at the top of the
|
||||
// engine, before any rule walking — same protocol as AnchorOverrides.
|
||||
func TestCalculate_TriggerEventID_MalformedAnchor_Errors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pt, soc, _ := mandatorySocFixture(t)
|
||||
|
||||
cat := &stubCatalogWithTriggers{
|
||||
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc}},
|
||||
}
|
||||
|
||||
opts := CalcOptions{
|
||||
TriggerEventAnchors: map[string]string{
|
||||
"oral_hearing": "15-10-2026", // wrong format
|
||||
},
|
||||
}
|
||||
_, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err == nil {
|
||||
t.Fatalf("Calculate: want error on malformed TriggerEventAnchors date, got nil")
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,25 @@ type CalcOptions struct {
|
||||
// filter applied) so a stale frontend chip doesn't break the
|
||||
// timeline render — see IsValidAppealTarget.
|
||||
AppealTarget string
|
||||
|
||||
// IncludeOptional surfaces rules with priority='optional' in the
|
||||
// default timeline (t-paliad-342 / youpcorg#2570). Default false:
|
||||
// optional rules don't auto-fire alongside mandatory ones. The
|
||||
// caller (paliad /tools/procedures, youpc.org/deadlines) wires this
|
||||
// to a user-facing "show optional applications" toggle.
|
||||
IncludeOptional bool
|
||||
|
||||
// TriggerEventAnchors supplies concrete dates for procedural events
|
||||
// referenced by rules' trigger_event_id (t-paliad-342 / youpcorg#2568).
|
||||
// Key = paliad.trigger_events.code (e.g. "oral_hearing"), value =
|
||||
// YYYY-MM-DD. When a rule carries an explicit trigger_event_id, that
|
||||
// catalog event is the authoritative semantic anchor: arithmetic
|
||||
// resolves against TriggerEventAnchors[code] if set, otherwise the
|
||||
// rule is suppressed as IsConditional (no fabricated date off the
|
||||
// user's trigger date). Empty map = engine never anchors on a
|
||||
// trigger event, so every rule with trigger_event_id surfaces as
|
||||
// conditional.
|
||||
TriggerEventAnchors map[string]string
|
||||
}
|
||||
|
||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||
@@ -375,6 +394,13 @@ type Timeline struct {
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
// RulesAwaitingAnchor counts rules suppressed because their
|
||||
// trigger_event_id anchor date wasn't supplied via
|
||||
// CalcOptions.TriggerEventAnchors (t-paliad-342). Such rules still
|
||||
// render in the timeline as IsConditional (no date) — the field
|
||||
// gives the caller a single integer for "N rules waiting on an
|
||||
// anchor" UI affordances + telemetry.
|
||||
RulesAwaitingAnchor int `json:"rulesAwaitingAnchor,omitempty"`
|
||||
}
|
||||
|
||||
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
||||
@@ -505,7 +531,17 @@ type RuleCalculationProceeding struct {
|
||||
|
||||
// FristenrechnerType mirrors the /api/tools/proceeding-types response
|
||||
// metadata.
|
||||
//
|
||||
// ID is the paliad.proceeding_types primary key. Surfaces so frontend
|
||||
// pickers (Litigation Builder add-proceeding, fristenrechner-wizard
|
||||
// project prefill) can POST the FK directly without a code→id round
|
||||
// trip. Historically code-keyed; the Litigation Builder POSTing
|
||||
// proceeding_type_id (int) to /api/builder/scenarios/{id}/proceedings
|
||||
// forced surfacing the id (t-paliad-345 — the missing id meant the
|
||||
// POST silently sent body={} and the "+ Verfahren hinzufügen" button
|
||||
// did nothing).
|
||||
type FristenrechnerType struct {
|
||||
ID int `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
|
||||
50
pkg/litigationplanner/types_wire_test.go
Normal file
50
pkg/litigationplanner/types_wire_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFristenrechnerType_WireShapeIncludesID is the regression test for
|
||||
// t-paliad-345: the /api/tools/proceeding-types JSON response must
|
||||
// include `id` so frontend pickers (Litigation Builder add-proceeding,
|
||||
// fristenrechner-wizard project prefill) can POST proceeding_type_id
|
||||
// directly without a code→id round trip. When the id was missing the
|
||||
// Litigation Builder "+ Verfahren hinzufügen" button silently dropped
|
||||
// the proceeding_type_id from the POST body (JSON.stringify omits
|
||||
// undefined keys), the server rejected with 400, and the client
|
||||
// swallowed the error — user-visible symptom was "nothing happens".
|
||||
func TestFristenrechnerType_WireShapeIncludesID(t *testing.T) {
|
||||
in := FristenrechnerType{
|
||||
ID: 42,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "UPC Verletzungsverfahren",
|
||||
NameEN: "UPC Infringement Action",
|
||||
Group: "UPC",
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
if !strings.Contains(got, `"id":42`) {
|
||||
t.Errorf("missing id in wire shape: %s", got)
|
||||
}
|
||||
for _, want := range []string{`"code":"upc.inf.cfi"`, `"nameEN":"UPC Infringement Action"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in wire shape: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Round-trip — a client that posts the id back to /api/builder/
|
||||
// scenarios/{id}/proceedings should see it preserved as an integer
|
||||
// (paliad.scenario_proceedings.proceeding_type_id is INT, not UUID).
|
||||
var out FristenrechnerType
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.ID != 42 {
|
||||
t.Errorf("id lost on round-trip: got %d want 42", out.ID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user