Compare commits

...

10 Commits

Author SHA1 Message Date
mAi
3e93e94d10 feat(builder): B3 — event-triggered mode + universal search (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
PRD §2.2 + §3.1: the page-header search box drives a typed dropdown
returning grouped event / scenario / project hits, and the "Ereignis"
entry mode is enabled. Picking an event creates a scratch scenario
with one triplet anchored on that event's proceeding type, with the
event card auto-anchored (lime band + "━━━━ DU BIST HIER ━━━━" divider
above the next-coming events).

Backend: new GET /api/builder/search reuses
DeadlineSearchService.SearchEvents for the events corpus (UPC v1),
filters owned scenarios by ILIKE on name, and reuses ProjectService.List
for the Akten group (team-RLS via visibilityPredicate). Each group is
capped independently (default 8 events / 5 scenarios / 5 projects, max
30). Missing services degrade gracefully — empty group, not 503.

Frontend: builder-search.ts owns the dropdown (debounced 180ms,
arrow-key navigation, Enter to pick, abort on next query). builder.ts
gains mode state ("cold" | "event" | "akte"), wires the mode bar +
search input, and runs applyAnchorHighlight after triplet hydration —
the helper finds the .fr-col-item with the picked rule_id, adds the
.builder-anchor-card lime band, and inserts a full-width
.builder-anchor-divider after the anchor's row in the columns grid
via JS row-index math (the grid is row-major with 3 header cells
+ 3-cells-per-row body).

Filter pill reset: setMode() clears the search input and closes the
dropdown when switching entry modes. Forum/proc/party/kind chips are
not yet rendered separately (they live in the search dropdown today);
the reset hook attaches there too when those land in a follow-up.

Verification:
  - bun build (frontend bundles + i18n scan clean)
  - go vet ./... + go test ./... (all packages pass)
  - Playwright: mode switch focuses search, debounced fetch fires,
    typed result groups render with N · M · K pluralization, event
    pick creates scratch scenario + adds proceeding, anchor card
    + DU BIST HIER divider render in the columns grid (screenshots
    confirmed visually)
2026-05-28 10:10:33 +02:00
mAi
28ea103260 Merge: t-paliad-345 — surface proceeding_type id so Builder add-proceeding works (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 09:50:57 +02:00
mAi
1c77cb6e67 fix(builder): surface proceeding_type id so add-proceeding POST works (t-paliad-345)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (pull_request) Has been cancelled
Paliad CI gate / test-go (pull_request) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / deploy (pull_request) Has been cancelled
The Litigation Builder's "+ Verfahren hinzufügen" silently failed in
prod after t-paliad-343 B2 shipped — clicking a Verfahren chip in the
picker did nothing, no visible error.

Root cause: the wire shape FristenrechnerType (the response of
/api/tools/proceeding-types) carried code+name+nameEN+group but not
id. Builder.ts mountAddProceedingPicker's callback POSTed
`{proceeding_type_id: meta.id}` to
/api/builder/scenarios/{id}/proceedings — meta.id was undefined,
JSON.stringify dropped the key, the server returned 400 ("invalid
input: proceeding_type_id is required"), and fetchJSON swallowed the
error to console. The user saw "nothing happens".

Fix:
- Add `ID int json:"id"` to lp.FristenrechnerType.
- SELECT id in FristenrechnerService.ListProceedings + Scan into the
  new field.
- Defensive guard in builder.ts openAddProceedingPicker — refuse to
  POST without a positive integer id and log a clear error, so a
  future wire-shape regression cannot recreate the silent-fail.
- Regression test in pkg/litigationplanner/types_wire_test.go pins the
  contract (id present in JSON, round-trips as integer).

Side-benefit: fristenrechner-wizard.ts:599-628 documented this exact
gap as a known limitation ("S5/follow-up can extend the wire shape to
include id"). That workaround can now be retired in a follow-up.

Refs m/paliad#153 (Litigation Builder)
2026-05-28 09:48:32 +02:00
mAi
1f6e586c63 Merge: t-paliad-344 — fix stale deadlines.rule_id refs + builder null-guards (m/paliad#154)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:48:17 +02:00
mAi
a4b865d6bd fix(builder): initialise scenario sub-arrays + client null-guard (t-paliad-344)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
GetScenarioDeep returned nil slices for proceedings/events/shares when
a scenario had zero rows, which Go's encoding/json serialises as `null`
rather than `[]`. The builder's renderCanvas then unconditionally calls
`state.active.proceedings.filter(...)` on a null and dies with
`procedures.js:101 TypeError: Cannot read properties of null
(reading 'filter')` — every cold-open scenario crashed the page before
the empty canvas could render.

Backend (root cause): initialise Proceedings / Events / Shares to empty
slices in BuilderScenarioDeep before SelectContext, so the wire shape
is always arrays. Existing rows still load via SelectContext, which
truncates the placeholder and refills from the DB.

Frontend (defence in depth): on loadScenario(), normalise each of the
three arrays to `[]` if the server response is not an array. Catches a
future regression (or an older deployed build) without re-introducing
the same crash class.

bun build clean, go vet + go test ./... green.
2026-05-28 00:47:19 +02:00
mAi
a905911cf4 fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:

  - internal/services/deadline_service.go:268 — DeadlineService.
    ListVisibleForUser. Powers /api/events?type=deadline (dashboard
    deadline rail, /deadlines page, every status bucket). Threw
    `pq: column f.rule_id does not exist` on every request → 500
    for any authenticated user hitting the dashboard.

  - internal/services/projection_service.go:1250 — collectActualsForOverrides.
    Same column on `paliad.deadlines d`. Logged once per projection
    pass (`ERROR service: projection: deadlines: ...`); aliased the
    rename to `rule_id` so the receiving struct tag still scans.

Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).

Root cause: mig 140 commit (1129bab) renamed the JOIN to
`f.sequencing_rule_id` but left the SELECT clause on the older name.
The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so
the wire shape is unchanged — only the column reference flips.

bun build clean, go vet ./... clean, go test ./... green.
2026-05-28 00:47:08 +02:00
mAi
88c03e922f Merge: t-paliad-343 B2 — multi-triplet + spawn + per-event state (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-28 00:29:50 +02:00
mAi
6bcac2dd20 Merge: t-paliad-343 B1 — Litigation Builder shell + cold-open (m/paliad#153) 2026-05-28 00:29:50 +02:00
mAi
46dc4ec94b feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Builds on B1 (commit 6c1d8cc). After this slice a user can compose a
multi-proceeding scenario kontextfrei: stack proceedings, flip
perspective per-triplet, toggle scenario flags, auto-spawn child
proceedings on flag transitions, and mark individual event cards as
planned / filed / skipped — all auto-saved to paliad.scenario_*.

PRD §7.1 B2 acceptance shipped:
  - Multi-triplet stack: top-level proceedings sorted by ordinal,
    child proceedings nested inline with a left lime border.
  - Per-triplet controls bar: perspective radio (none / claimant /
    defendant), Detailgrad pill (selected / all options), Entfernen
    action. Each control PATCHes the proceeding row and re-renders the
    affected triplet.
  - Per-triplet flag strip: every paliad.scenario_flag_catalog row
    rendered as a checkbox, bound to scenario_proceedings.scenario_flags.
    Active flags also surface as chips in the triplet header for quick
    legibility.
  - Spawn nesting: when `with_ccr` flips ON on upc.inf.cfi the builder
    auto-POSTs an upc.ccr.cfi child proceeding linked via
    parent_scenario_proceeding_id; flip OFF deletes the child (events
    cascade via the schema). The SPAWN_MAP table is data-driven so
    future spawn flags slot in.
  - 3-state event cards (planned / filed / skipped):
    overlayEventStates walks the rendered .fr-col-item nodes (the
    data-rule-id hook added to verfahrensablauf-core in this slice)
    and stamps each card with data-builder-state + per-state action
    buttons (File / Skip / Reset to planned). Filed cards prompt for
    a date; skipped cards prompt for an optional reason. POSTs or
    PATCHes paliad.scenario_events keyed by sequencing_rule_id.
  - Per-card optional horizon chip: stores horizon_optional on the
    scenario_event row, increment / decrement chip on every card.
    The full surface awaits a calc-engine "optionals available"
    counter (PRD §3.4 follow-up); the persistence layer + UX hook are
    in place so the wiring lands without another schema touch.
  - Page-header Stichtag drives default dates for every triplet (the
    triplet's per-stichtag override path is wired but the per-triplet
    Stichtag input is a B3+ affordance).

verfahrensablauf-core.renderColumnsBody now stamps data-rule-id (and
data-submission-code as a future hook) on every .fr-col-item root —
non-breaking enhancement; the legacy /tools/* pages don't read either
attribute. Verified by re-running the existing 57-test suite.

Backend: one new read-only endpoint
GET /api/builder/scenario-flag-catalog passes through
ScenarioFlagsService.ListCatalog so the builder doesn't need a
per-project round-trip to render flag toggles.

bun run build clean (3050 i18n keys), go vet ./... clean, go test ./...
clean, frontend bun test (verfahrensablauf-core suite) 57 / 57 pass.
2026-05-28 00:28:48 +02:00
mAi
6c1d8cc0cf feat(builder): B1 — Litigation Builder shell + cold-open mode (m/paliad#153)
Replaces cronus's U0-U4 catalog at /tools/procedures with a
persistence-backed builder shell on top of B0's API surface
(/api/builder/scenarios/*, t-paliad-340).

PRD §7.1 B1 acceptance shipped:
  - Page header: scenario picker, name action, Akte picker stub,
    Stichtag input, search input, save status indicator.
  - Entry-mode radio (cold-open active; event-triggered + akte
    rendered disabled for B3/B4 layout stability).
  - Empty canvas with "Neues Szenario starten" CTA and a 5-most-recent
    list rendered when the user has saved scenarios.
  - Side panel "Meine Szenarien" with the Aktiv bucket; clicking an
    item loads the scenario into the canvas.
  - Add-proceeding inline picker (Forum chip row → Verfahren chip row
    → Hinzufügen). UPC v1; other forums chipped but disabled.
  - First proceeding triplet renders end-to-end via
    verfahrensablauf-core.calculateDeadlines + renderColumnsBody (the
    existing 3-column proaktiv|court|reaktiv body, read-only in B1).
  - Auto-save with 500ms debounce on name + stichtag patches; save
    status flips idle → saving → saved/error in the page header.

New client modules under frontend/src/client/:
  - builder.ts       — orchestrator (URL state, fetch, auto-save loop,
                       canvas render, scenario-list re-paint).
  - builder-picker.ts — inline Forum/Verfahren popover for the
                       add-proceeding affordance.
  - builder-triplet.ts — single-triplet header + body wrapper.

procedures.tsx rewritten as the shell scaffolding (sidebar, page
header, mode radio, two-column body); procedures.ts now boots the
builder instead of toggling the 4-tab catalog.

Legacy U0-U4 modules (verfahrensablauf.ts, verfahrensablauf-state.ts,
VerfahrensablaufBody.tsx, procedures' tab toggle in client/procedures.ts,
fristenrechner-* mounts) are no longer reachable from /tools/procedures
but kept in the tree for the B6 cleanup sweep per PRD §7.4.

i18n.ts grew 60 keys × 2 langs under builder.*. global.css grew a
self-contained .builder-* block at the file tail.

bun run build, go vet ./..., and go test ./... all green.
2026-05-28 00:20:46 +02:00
19 changed files with 3492 additions and 277 deletions

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

File diff suppressed because it is too large Load Diff

View File

@@ -214,6 +214,85 @@ 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.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 +3497,85 @@ 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.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",

View File

@@ -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();
});

View File

@@ -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>`;

View File

@@ -728,6 +728,83 @@ 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.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"

View File

@@ -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,139 @@ export function renderProcedures(): string {
<title data-i18n="procedures.title">Verfahren &amp; Fristen &mdash; 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 &amp; Fristen</h1>
<p className="tool-subtitle" data-i18n="procedures.subtitle">
Verfahrensablauf, Fristenrechner und ger&uuml;hrte Suche in einem Tool.
<p className="tool-subtitle" data-i18n="builder.subtitle">
Litigation Builder &mdash; 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&hellip;"
/>
{/* 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&auml;hlen"></select>
</label>
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
<span data-i18n="builder.save.idle">&nbsp;</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&uuml;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&uuml;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&auml;hlen">
<option value="" data-i18n="builder.akte.none">&mdash; ohne &mdash;</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 &hellip;"
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">&#128218;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren w&auml;hlen</span>
data-mode="cold"
id="builder-mode-cold">
<span className="builder-mode-label" data-i18n="builder.mode.cold">&Uuml;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">&#9889;</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">&#129517;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Gef&uuml;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">&#128193;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
data-mode="akte"
id="builder-mode-akte"
disabled
title="In B4 verf&uuml;gbar">
<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&auml;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&ouml;ffnet.
</p>
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
Starte ein neues Szenario, w&auml;hle aus deiner Liste oder &uuml;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>

View File

@@ -19811,3 +19811,823 @@ 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); }
.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%;
}
}

View 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),
}
}

View File

@@ -537,6 +537,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)

View File

@@ -388,6 +388,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
// ---------------------------------------------------------------------------

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -204,7 +204,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,

View File

@@ -531,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"`

View 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)
}
}