Compare commits

...

19 Commits

Author SHA1 Message Date
mAi
cc13a5b857 chore(admin): remove /admin/rules/export page + export-migrations API (t-paliad-297)
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
Workflow shifted to hand-written numbered migrations; the audit-row SQL
export tool no longer has any consumers. Pure deletion — /admin/rules
and /admin/rules/{id}/edit stay; only the export-to-SQL flow goes.

Deleted:
- frontend/src/admin-rules-export.tsx
- frontend/src/client/admin-rules-export.ts

Removed:
- routes GET /admin/rules/export and GET /admin/api/rules/export-migrations
- handleAdminExportRuleMigrations + handleAdminRulesExportPage
- RuleEditorService.ExportMigrationsSince + ExportResult + sqlEscape helper
- build.ts entries (import, client bundle, dist HTML write)
- Sidebar "Regel-Migrations" nav item + "Migrations exportieren" button on /admin/rules
- all admin.rules.export.* + nav.admin.rules_export + admin.rules.list.export i18n keys (DE+EN)
- .admin-rules-export-* CSS rules (dead after page deletion)

Doc references in design-fristen-phase2-2026-05-15.md and
design-paliad-data-export-2026-05-19.md updated to mark the endpoint as
removed (acceptance #2 requires grep to return zero hits).
2026-05-26 11:50:14 +02:00
mAi
abef74fe63 Merge: t-paliad-296 — sort post-trigger optional events by duration ascending (m/paliad#128)
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-26 11:22:33 +02:00
mAi
49ddaa4eb8 feat(fristenrechner): sort post-trigger events by duration ASC within parent group (t-paliad-296)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Optional events anchored on the same trigger (e.g. the four
post-Entscheidung rules in upc.inf.cfi) used to render in catalog
sequence_order, so a 2-month rule (R.118.4 Folgeentscheidungen)
would precede a 1-month rule (R.151 Kostenentscheidung) chained
off the same decision. Now the calculator does a post-evaluation
permutation pass that sorts consecutive same-parent rows by
duration ascending — days < weeks < months < years, ties broken
by duration_value then submission_code.

Different trigger groups keep their proceeding-sequence position
— the walk only ever permutes rows that already share a parent.
Root rules (no parent) are never sorted against each other.
Court-set / conditional rows whose date isn't in the duration
ladder sort LAST within their group.

Verified order against m's report: R.151 cost_app + R.353
rectification (1-month tier) now render before R.220.1
appeal_spawn + R.118.4 cons_orders (2-month tier).

Issue: m/paliad#128
2026-05-26 11:21:29 +02:00
mAi
1bd2ebb4ae Merge: t-paliad-294 — conditional label uses trigger_event name (R.262(2) → Vertraulichkeitsantrag) (m/paliad#126)
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-26 11:19:40 +02:00
mAi
f6c8eb5bcf fix(projection): conditional label uses trigger_event_id, not parent_id
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
t-paliad-294 / m/paliad#126. knuth's #121 conditional-rendering
defaulted the "abhängig von <parent>" chip to the rule's parent_id
display name. For R.262(2) Erwiderung auf Vertraulichkeitsantrag the
parent_id resolves to the SoC (Klageerhebung), but the rule's real
semantic anchor is the opposing party's confidentiality application
(paliad.trigger_events id=25). The chip read "abhängig von
Klageerhebung", which is wrong.

Fix: when a rule has a non-NULL trigger_event_id, the engine stamps
ParentRuleCode / ParentRuleName / ParentRuleNameEN from the
trigger_events catalog row instead of from the parent_id chain. The
parent_id stays as the calc-time arithmetic anchor — only the user-
facing dependency identity shifts.

Generalises across every rule with a real trigger_event_id (2 rows
in the live corpus today: confidentiality_response and
translations_lodge — both relabel correctly).

Touches both surfaces in one shot: verfahrensablauf-core's chip
("abhängig von …") and shape-timeline's "Folgt aus …" footer both
read from ParentRule*, so no frontend change needed.

Tests: extend TestUIDeadline_IsConditional_UncertainAnchors with a
DE+EN string-pinning case for R.262(2) plus a generalisation guard
for translations_lodge. Negative guard asserts the chip no longer
leaks "Klageerhebung" / "Statement of Claim".
2026-05-26 11:19:01 +02:00
mAi
5ba4df9d55 Merge: t-paliad-293 — event-card overhaul (caret menu + iconified state + no-scroll unhide) (m/paliad#125)
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-26 10:48:22 +02:00
mAi
7ca6b2d643 feat(verfahrensablauf): event-card overhaul — iconified state + caret-popover unhide (t-paliad-293)
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
m/paliad#125 — concern A (horizontal scroll) and concern B (compact
event-card UX).

Concern A: the inline "Wieder einblenden" chip from t-paliad-290 pushed
hidden cards past their column width on 375/414/768, causing horizontal
page scroll. Fix: drop the chip entirely; surface the un-hide as a
prominent "Wieder einblenden" entry inside the caret popover (matches
the m's "actions live in the caret menu" framing). The card title row
now also wraps + shrinks (flex-wrap + min-width:0 + overflow-wrap)
so no inline child can ever blow the row width.

Concern B (the bigger UX): cards now speak m's "cut the tree of
possibilities" vocabulary via iconified state markers in the title row:
  - Optional event → ⊙ (timeline-state-icon--optional)
  - Hidden by user → 👁⃠ (timeline-state-icon--hidden)
  - Conditional anchor → already covered by the "abhängig von <parent>"
    chip on the date column (t-paliad-289); no duplicate marker.
  - CCR-included / appellant picks → already on the per-card chip.

The legacy `.optional-badge` text chip and `.event-card-choices-unhide`
inline chip are gone — both replaced by the icon language + popover
entry.

Renderer wires the unhide path with two contracts:
  - data-is-hidden="1" on the caret button when isHidden=true, so the
    popover knows to render the prominent unhide block on top.
  - Defensive fallback: if a rule's choices_offered was edited away
    after the user had already saved skip=true (so isHidden=true but
    choicesOffered is empty), the renderer synthesizes {skip:[true,
    false]} so the popover still has an un-hide path.

CSS:
  - .timeline-item min-height 4rem → 2.75rem (less vertical air).
  - .timeline-content padding-bottom 1rem → 0.6rem (tighter gutter).
  - .timeline-item-header gains flex-wrap + min-width:0.
  - .timeline-name gains min-width:0 + overflow-wrap:anywhere
    (long German compounds wrap mid-word instead of overflowing).
  - New: .timeline-state-icon[--optional|--hidden] icon-style markers.
  - New: .event-card-choices-unhide-btn — prominent full-width lime
    pill inside the popover, midnight-text in both themes (matches
    the active-option pin from m/paliad#123).

i18n:
  - state.optional.tooltip — "Optionales Ereignis" / "Optional event"
  - state.hidden.tooltip — "Ausgeblendet — über Optionen-Menü wieder
    einblenden" / "Hidden — restore via the options menu"
  - choices.unhide.chip kept (now used as the popover button label).

Tests: 27 → 29 tests in verfahrensablauf-core.test.ts. Old isHidden
inline-chip cases replaced by state-icon + caret-data-is-hidden
contract cases. Added defensive-fallback case for the synthesized
skip offer. Added regression guard that the legacy
.event-card-choices-unhide class is no longer emitted. Added
optional-priority → ⊙ icon contract pair.

Hard rules respected:
  - Title + date + Rule citation unchanged (m likes these).
  - Click-to-edit on date span (.frist-date-edit) untouched.
  - Conditional rendering (t-paliad-289 chip + dotted border) untouched.
  - Per-card actions (skip, appellant pick, include-CCR, unhide) all
    reachable via the caret popover.

go build ./... && go test ./internal/... && cd frontend && bun run
build && bun test — all green (181 tests).
2026-05-26 10:11:02 +02:00
mAi
ed8af0dca9 Merge: t-paliad-289 — conditional rule projection (post-rebase) (m/paliad#121)
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-26 09:58:48 +02:00
mAi
293e612582 feat(projection): IsConditional for uncertain-anchor rules (t-paliad-289)
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
Rules anchored on uncertain triggers (R.109 backward-anchor without
oral-hearing date; R.118(4) without validity decision; R.262(2)
without recorded Vertraulichkeitsantrag) previously rendered concrete
dates fabricated off the trigger date. Add IsConditional projection
flag so the SmartTimeline + Verfahrensablauf surfaces "abhängig von
<parent>" instead of a misleading date.

Backend (fristenrechner.go):
- Add IsConditional + ParentRuleCode/Name/NameEN to UIDeadline.
- Pre-pass populates courtSet from rule.is_court_set=true BEFORE the
  main loop, so order-of-evaluation in sequence_order no longer matters
  for the parent-court-set check. Fixes R.109(1) "Antrag auf
  Simultanübersetzung" (sequence_order=45 < Mündliche Verhandlung's
  sequence_order=50): the timing='before' backward arithmetic was
  computing 1 month before the trigger date because the court-set
  parent hadn't been classified yet.
- Set IsConditional=true on every IsCourtSetIndirect branch (catches
  R.109 backward + R.118(4) cons_orders chain off the decision).
- Set IsConditional=true for priority='optional' + primary_party='both'
  rules whose data-model parent is the trigger anchor (covers R.262(2)
  confidentiality_response: the data anchors on SoC, but the real
  trigger is the opposing party's confidentiality motion which may
  never happen). Suppressed by IsOverridden so user anchors win.

Backend (projection_service.go):
- Add IsConditional to TimelineEvent + propagate from UIDeadline.
- New Status="conditional" for projected rows; clears Date, populates
  DependsOnRuleCode/Name from UIDeadline.ParentRule* so the row
  carries the "abhängig von <parent>" payload even when the parent
  has no computed date for annotateDependsOn to discover.

Frontend (verfahrensablauf-core.ts + CSS + i18n):
- CalculatedDeadline gains isConditional + parentRule* fields.
- deadlineCardHtml renders "abhängig von <parent>" chip with
  click-to-edit affordance in place of the date column when
  isConditional=true. IsConditional wins over IsCourtSet for the
  date column (they overlap; "abhängig von <parent>" names the
  specific blocker).
- .timeline-item--conditional / .fr-col-item--conditional CSS:
  dotted border + faded text so the conditional state reads at glance.
- Replaced escHtml's DOM-backed implementation with a pure-JS regex
  escape so the module is testable in bun test without jsdom (the
  old form forced fixtures to leave several fields empty just to
  avoid the DOM dependency).

Tests:
- TestApplyLookaheadCap_ConditionalRowsPassThrough: pure-function lock
  that conditional rows pass through applyLookaheadCap untouched
  (don't count against ProjectedTotal/Shown, don't get capped).
- TestUIDeadline_IsConditional_UncertainAnchors (TEST_DATABASE_URL):
  asserts R.109(1)/(4), R.118(4) chain, and R.262(2) all render
  IsConditional=true with empty DueDate + populated ParentRule*; SoD
  stays non-conditional; override on the oral hearing flips R.109(1)
  back to concrete date.
- 4 new bun tests for the conditional rendering branches in
  deadlineCardHtml.

UX path verified by tests + manual review of the live rule corpus:
opening a UPC inf project without oral-hearing date now surfaces
R.109(1) + R.109(4) as conditional; recording the Vertraulichkeitsantrag
(anchoring R.262(2) via the existing "Datum setzen" flow) flips it
back to a concrete date.

go build / go test / bun test / bun run build all clean.
2026-05-26 09:56:15 +02:00
mAi
9d3325bd88 Merge: t-paliad-291 — dark-mode lime-chip contrast fix across 6 selectors (m/paliad#123)
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-26 09:47:02 +02:00
mAi
18d2e743ba fix(styles): dark-mode contrast on lime-active chips (t-paliad-291)
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
Six surfaces paired a lime background with var(--color-text), which
flips to cream in dark mode and collapses contrast on the high-luminance
brand lime. Switch them to var(--color-accent-dark) — the design token
already defined to stay midnight in both themes as the WCAG-AA fg on
lime.

Affected:
  - .event-card-choices-option--active  (Berufung durch … popover —
    m's primary report on m/paliad#123)
  - .fristen-row.is-active .fristen-row-num
  - .form-hint-badge
  - .paliadin-widget-send-btn
  - .smart-timeline-anchor-submit
  - .admin-rules-chip.active

Lime hue and non-active states untouched.

Refs: m/paliad#123
2026-05-26 09:45:59 +02:00
mAi
07d2eb472c Merge: t-paliad-287 — submission form revision (Frist drop + grouped sections + Add Party + DB picker) (m/paliad#119)
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-26 09:42:58 +02:00
mAi
7cdccd55ae feat(submission-draft): grouped sections + per-side Add Party with DB picker (t-paliad-287)
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
Restructures the submission-draft sidebar per m's m/paliad#119 review.

Three changes on the variable form (Part B):
- VARIABLE_GROUPS collapses into four lawyer-facing sections: Mandant
  & Verfahren (firm.* + project.* + procedural_event.*), Parteien
  (manual {{parties.<role>.*}} overrides), Frist (the now-internal
  deadline.* block, COLLAPSED by default since the skeletons no
  longer render it), Sonstiges (today.* / user.* trim).
- Group sections are click-to-collapse via a sticky state map; the
  Frist + Parteien-override sections open closed so the visible form
  stays tight on first load.
- The legacy {{rule.*}} aliases drop off the sidebar — still resolved
  by SubmissionVarsService for old templates, no longer surfaced as
  override rows (they cluttered the form and the canonical
  procedural_event.* names cover the same ground).

Multi-party + Add Party (Part C):
- The party picker now renders all three role buckets (claimants /
  defendants / others) even when empty, so the lawyer can populate via
  Add Party. The block is hidden only when no project is attached.
- Each side gets a "+ Partei hinzufügen (Klägerseite / Beklagtenseite
  / Weitere Parteien)" button that opens an inline panel with two
  tabs:
  - Manual entry — name, role (pre-filled from side), representative.
    Submits to POST /api/projects/{id}/parties, creating a real
    paliad.parties row that immediately surfaces in available_parties.
  - Aus DB übernehmen — debounced (200ms) search against the new
    GET /api/parties/search endpoint. Returns hits across every
    visible project with project_title + reference for context.
    Already-on-this-project rows are filtered out client-side. Picking
    a hit clones name/role/representative into a fresh row on the
    current project — the simplest semantics that survives the
    paliad.parties.project_id NOT NULL contract while honouring m's
    "no manual re-typing" requirement.
- Newly-added parties land in selected_parties immediately so the new
  party is rendered in the next preview round-trip without an extra
  click. Implicit-"all" default is preserved (empty selected_parties
  still means "every party on the project, including this new one").
- Search-result repaints reach only into the <ul>, not the whole
  picker — keeps focus + selection on the search input across
  keystrokes.

CSS:
- Collapsible-section caret rotation, busy/disabled form states, tab
  highlights, DB-picker result rows with project chip + hover, all
  inherit the existing lime-tint accent so the new affordances look
  native to the editor.

TSX:
- Comment update on the parties block; no structural change. The
  bilingual hint copy in i18n.ts now nudges towards Add Party.
2026-05-26 09:41:36 +02:00
mAi
d4ed989b8f feat(parties): cross-project party search endpoint for submission picker (t-paliad-287)
Adds PartyService.Search returning paliad.parties rows from every
project the caller can see, matched by case-insensitive substring on
name or representative. Wired via GET /api/parties/search?q=... — used
by the submission-draft Add-Party panel's "Aus DB übernehmen" tab.

Visibility flows through the same visibilityPredicatePositional helper
every project-scoped read uses; invisible projects' parties never
surface. Capped at 25 hits per call (no pagination — typical lookup is
"the party I'm thinking of by name", not a browse).

Result shape carries project_title + project_reference so the picker
can disambiguate identically-named parties across cases.
2026-05-26 09:41:07 +02:00
mAi
54fb676db5 chore(templates): drop 'Frist' block from skeleton + HL-firm-skeleton (t-paliad-287)
Per m's m/paliad#119 report: the {{deadline.*}} block was leaking
internal/admin context (Frist-Bezeichnung, Fälligkeit, "berechnet aus",
Quelle) into court-bound submissions. The dedicated Frist heading and
its 4 body lines are removed from both gen-skeleton-submission-template
(_skeleton.docx) and gen-hl-skeleton-template (_firm-skeleton.docx).
The {{deadline.due_date_long_en}} reference in the locale-aware
verification footer is also dropped. {{deadline.*}} placeholders stay
resolvable in SubmissionVarsService — a custom template can still pick
them up — but the default skeletons no longer render them in the body.

Regenerated .docx files uploaded to HL/mWorkRepo:
- 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx → d0ecc0e
- 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx → 25954c9
2026-05-26 09:41:01 +02:00
mAi
c3eaa9b1d4 Merge: t-paliad-290 — show-hidden toggle + un-hide chip on Verfahrensablauf (m/paliad#122)
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-26 09:39:52 +02:00
mAi
80883eaac5 feat(verfahrensablauf): re-surface hidden optional events — show-hidden toggle + un-hide chip (t-paliad-290)
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
m/paliad#122. atlas's #96 Slice A added per-card 'Überspringen' but no
un-skip path — hidden cards just disappeared from the timeline. This
adds the missing return path:

- CalcOptions.IncludeHidden (default false) tells the calculator to
  re-surface skipRules entries as faded rows instead of dropping them.
  When true, the rule renders with UIDeadline.IsHidden=true and the
  descendant-suppression cascade is bypassed so children compute their
  dates off the un-suppressed parent.
- UIResponse.HiddenCount always reflects the projection's hide count
  (gate-passed rules whose submission_code is in skipRules) so the
  "Ausgeblendete (N)" badge stays accurate regardless of toggle state.
- /tools/verfahrensablauf gets a "Ausgeblendete anzeigen" checkbox next
  to the perspective + appellant selectors. URL-driven (?show_hidden=1)
  so the state is shareable and survives reload. The row hides itself
  on projections with zero hidden cards.
- Hidden cards render via .timeline-item--hidden / .fr-col-item--hidden
  (opacity 0.55 + dotted border, mirroring the existing
  --skipped fade) and carry an inline "Wieder einblenden" chip. Clicking
  the chip removes the skip choice via the page's existing
  attachEventCardChoices remove callback (URL state + recalc included)
  and runs through a new delegated handler in event-card-choices.ts.
- 3 new i18n keys (DE+EN): choices.show_hidden.label,
  choices.show_hidden.count, choices.unhide.chip.

The skip-choice storage shape (paliad.project_event_choices, atlas's
table) is unchanged — un-hide is just a delete of the skip row.

Tests: 3 new bun-test cases pin the chip contract (emits on isHidden=
true with submission_code, suppressed otherwise); go test ./internal/...
+ bun run build clean.
2026-05-26 09:38:31 +02:00
mAi
5e17de6e07 Merge: t-paliad-288 — Verfahrensablauf 'Beide' → 'Nicht festgelegt' (m/paliad#120)
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-26 09:35:19 +02:00
mAi
0e1f62e375 feat(verfahrensablauf): replace 'Beide' chip with 'Nicht festgelegt' (t-paliad-288)
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
The Verfahrensablauf side selector offered Klägerseite / Beklagtenseite /
Beide. 'Beide' is legally impossible (no party is on both sides) — the
state being modelled is "perspective not yet picked", not "both sides".
Rename the chip to 'Nicht festgelegt' (DE) / 'Undefined' (EN) without
changing the underlying state value or projection behaviour.

- frontend/src/verfahrensablauf.tsx: chip label flips to
  deadlines.side.undefined; add inline hint chip
  "Wählen Sie eine Seite, um die Spalten zu fokussieren." next to the
  radio cluster, shown only while no side is picked.
- frontend/src/client/verfahrensablauf.ts: sideLabelI18n() returns the
  new key for null; syncSideHintVisibility() toggles hint display from
  initPerspectiveControls, the side-radio change handler, and
  showSideRadioCluster (chip→radio override path).
- frontend/src/client/i18n.ts: rename deadlines.side.both →
  deadlines.side.undefined (DE: Nicht festgelegt, EN: Undefined); add
  deadlines.side.hint in both languages.
- frontend/src/i18n-keys.ts: rename in the union, keep alphabetical
  order.
- frontend/src/styles/global.css: .side-radio-cluster becomes inline-flex
  so the hint sits next to the toggle; .side-hint styled muted+italic.

URL backward-compat: ?side=both is already silently treated as null by
readSideFromURL (only accepts claimant|defendant) — same column
behaviour as before, no migration needed. projects.field.our_side.both
is a different concept (a project being a multi-party participant) and
stays untouched.

Tests: 17/17 in verfahrensablauf-core.test.ts still pass; the
"default (no opts) mirrors 'both' rules into ours AND opponent" case
already covers the unchanged null-side projection. Go build + tests
clean. Frontend build clean (i18n scan: 2901 keys, data-i18n
attributes clean).

m/paliad#120
2026-05-26 09:33:00 +02:00
31 changed files with 2316 additions and 509 deletions

View File

@@ -421,7 +421,7 @@ The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depend
| `POST /api/admin/rules` | POST | global_admin | Create a new rule from scratch (starts as `lifecycle_state='draft'`). |
| `GET /admin/rules/{id}/audit` | GET | global_admin | Audit log for this rule. |
| `POST /admin/rules/{id}/preview` | POST | global_admin | Preview-on-trigger-date — runs calculator with this draft replacing its published peer; returns the resulting timeline (no persistence). |
| `POST /admin/rules/export-migration` | POST | global_admin | Export pending (draft + audit-since-last-export) rules as a `*.up.sql` blob the human can paste into `internal/db/migrations/`. Sets `migration_exported=true` on the audit rows. |
| _(removed t-paliad-297)_ migration-export endpoint | — | — | Was a SQL-export tool generating `*.up.sql` from audit rows. Workflow shifted to hand-written numbered migrations; tool removed in m/paliad#129. |
### 4.2 Draft → published lifecycle

View File

@@ -43,7 +43,7 @@ A full org export today is **< 600 rows of user content** plus reference data
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
**Existing export precedent.** _(Originally pointed at the admin rule-migration export. That tool was deleted in m/paliad#129 / t-paliad-297. The gating pattern — `adminGate(users, …)` on a download endpoint that streams a generated artifact — still lives on other admin handlers, e.g. `handleAdminDownloadBackup` for `/api/admin/backups/{id}/file`.)_ Re-use the gating helper.
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
@@ -591,7 +591,7 @@ No other slice deltas. v1 still ships slices 1+2+3.
- `docs/design-data-model-v2.md` projects + mandanten + ltree path + can_see_project predicate.
- `docs/design-approval-policy-ui-2026-05-07.md` 5-source audit union (this design adds the 6th source).
- `docs/design-profession-vs-project-role-2026-05-07.md` profession ladder for the §4 project gate.
- `internal/handlers/admin_rules.go:303` `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
- `internal/handlers/backups.go` `handleAdminDownloadBackup` (precedent for admin-gated artifact download; the older rule-migration export precedent was removed in t-paliad-297).
- `internal/services/project_service.go:15` visibility predicate.
- `internal/services/derivation_service.go` `EffectiveProjectRole` for the project gate.
- `github.com/xuri/excelize/v2` chosen xlsx library.

View File

@@ -46,7 +46,6 @@ import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
import { renderAdminRulesList } from "./src/admin-rules-list";
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
import { renderAdminRulesExport } from "./src/admin-rules-export";
import { renderPaliadin } from "./src/paliadin";
import { renderAdminPaliadin } from "./src/admin-paliadin";
import { renderAdminBackups } from "./src/admin-backups";
@@ -284,7 +283,6 @@ async function build() {
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
join(import.meta.dir, "src/client/admin-rules-list.ts"),
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
join(import.meta.dir, "src/client/admin-rules-export.ts"),
join(import.meta.dir, "src/client/paliadin.ts"),
// t-paliad-161 — inline Paliadin widget. Loaded via the
// PaliadinWidget component on every authenticated page, so the
@@ -416,7 +414,6 @@ async function build() {
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());

View File

@@ -1,80 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
// editor can copy or download. Optional ?since=<audit-id> query lets
// the editor scope the export to a particular audit window — empty =
// every un-exported audit row.
export function renderAdminRulesExport(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<p className="admin-rules-breadcrumb">
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">&larr; Regeln verwalten</a>
</p>
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Ver&auml;nderungen.
Manuell in <code>internal/db/migrations/</code> einchecken.
</p>
</div>
</div>
<div className="admin-rules-export-controls">
<div className="form-field">
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
</div>
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
Export generieren
</button>
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
Als Datei herunterladen
</button>
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
In Zwischenablage kopieren
</button>
</div>
<div id="export-feedback" className="form-msg" style="display:none" />
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
<span id="export-summary-count" />
<span id="export-summary-latest" />
</div>
<pre id="export-output" className="admin-rules-export-pre" />
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-rules-export.js"></script>
</body>
</html>
);
}

View File

@@ -39,9 +39,6 @@ export function renderAdminRulesList(): string {
</p>
</div>
<div className="admin-rules-header-actions">
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
Migrations exportieren
</a>
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
+ Neue Regel
</button>

View File

@@ -1,100 +0,0 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-export.ts — /admin/rules/export. Calls
// GET /admin/api/rules/export-migrations[?since=<uuid>] and renders the
// SQL blob server-side. Download builds a Blob URL and triggers a
// fake <a> click; copy uses navigator.clipboard.
interface ExportResult {
migration_sql: string;
count: number;
latest_audit_id: string;
}
let latest: ExportResult | null = null;
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("export-feedback") as HTMLElement | null;
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
el.style.display = "block";
if (!isError) setTimeout(() => { el.style.display = "none"; }, 4000);
}
async function runExport() {
const since = (document.getElementById("export-since") as HTMLInputElement).value.trim();
const qs = new URLSearchParams();
if (since) qs.set("since", since);
const url = "/admin/api/rules/export-migrations" + (qs.toString() ? "?" + qs.toString() : "");
const out = document.getElementById("export-output") as HTMLElement;
const summary = document.getElementById("export-summary") as HTMLElement;
const dl = document.getElementById("export-download") as HTMLElement;
const cp = document.getElementById("export-copy") as HTMLElement;
out.textContent = t("admin.rules.export.running") || "Lade...";
summary.style.display = "none";
dl.style.display = "none";
cp.style.display = "none";
const resp = await fetch(url);
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || (t("admin.rules.export.error") || "Export fehlgeschlagen."), true);
out.textContent = "";
return;
}
latest = await resp.json() as ExportResult;
out.textContent = latest.migration_sql;
summary.style.display = "";
const countEl = document.getElementById("export-summary-count") as HTMLElement;
const latestEl = document.getElementById("export-summary-latest") as HTMLElement;
countEl.textContent = (t("admin.rules.export.count") || "Audit-Zeilen: {n}").replace("{n}", String(latest.count));
if (latest.latest_audit_id) {
latestEl.textContent = (t("admin.rules.export.latest") || "Letzte Audit-ID: {id}").replace("{id}", latest.latest_audit_id);
} else {
latestEl.textContent = "";
}
if (latest.count > 0) {
dl.style.display = "";
cp.style.display = "";
showFeedback((t("admin.rules.export.ok") || "{n} Audit-Zeilen exportiert.").replace("{n}", String(latest.count)), false);
} else {
showFeedback(t("admin.rules.export.no_pending") || "Keine offenen Audit-Zeilen zum Export.", false);
}
}
function downloadFile() {
if (!latest) return;
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const name = `rules-export-${ts}.up.sql`;
const blob = new Blob([latest.migration_sql], { type: "application/sql" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function copyToClipboard() {
if (!latest) return;
try {
await navigator.clipboard.writeText(latest.migration_sql);
showFeedback(t("admin.rules.export.copied") || "In Zwischenablage kopiert.", false);
} catch (e) {
showFeedback(t("admin.rules.export.copy_failed") || "Kopieren fehlgeschlagen.", true);
}
}
function init() {
initI18n();
initSidebar();
(document.getElementById("export-run") as HTMLElement).addEventListener("click", runExport);
(document.getElementById("export-download") as HTMLElement).addEventListener("click", downloadFile);
(document.getElementById("export-copy") as HTMLElement).addEventListener("click", copyToClipboard);
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -254,6 +254,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
"deadlines.conditional.depends_on": "abhängig von {parent}",
"deadlines.conditional.unset": "abhängig von vorgelagertem Ereignis",
"deadlines.optional.badge": "auf Antrag",
"deadlines.priority.mandatory": "Pflicht",
"deadlines.priority.recommended": "empfohlen",
@@ -325,6 +327,14 @@ const translations: Record<Lang, Record<string, string>> = {
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
"choices.reset": "Auswahl zurücksetzen",
"choices.commit.error": "Konnte Auswahl nicht speichern",
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
"choices.show_hidden.label": "Ausgeblendete anzeigen",
"choices.show_hidden.count": "Ausgeblendete ({n})",
"choices.unhide.chip": "Wieder einblenden",
// t-paliad-293 \u2014 iconified state markers on the Verfahrensablauf
// event cards. Tooltip-only text; the glyph is the primary signal.
"state.optional.tooltip": "Optionales Ereignis",
"state.hidden.tooltip": "Ausgeblendet \u2014 \u00fcber Optionen-Men\u00fc wieder einblenden",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
"deadlines.mode.event": "Was kommt nach\u2026",
@@ -439,9 +449,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.label": "Seite:",
"deadlines.side.claimant": "Klägerseite",
"deadlines.side.defendant": "Beklagtenseite",
"deadlines.side.both": "Beide",
"deadlines.side.undefined": "Nicht festgelegt",
"deadlines.side.from_project": "Aus Akte:",
"deadlines.side.override": "Andere Seite wählen",
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
@@ -1497,7 +1508,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Aus Projekt importieren",
"submissions.draft.parties.title": "Parteien",
"submissions.draft.parties.hint": "Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.",
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
@@ -2881,7 +2892,6 @@ const translations: Record<Lang, Record<string, string>> = {
// `admin.procedural_events.*` aliases live after the EN block — they
// pin the contract for when .tsx files rebind in Slice B (B.5).
"nav.admin.rules": "Verfahrensschritte verwalten",
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
"admin.card.rules.title": "Verfahrensschritte verwalten",
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
@@ -2889,7 +2899,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.list.heading": "Verfahrensschritte verwalten",
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
"admin.rules.list.export": "Migrations exportieren",
"admin.rules.tab.rules": "Regeln",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Lade…",
@@ -3051,23 +3060,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.modal.restore.title": "Wiederherstellen",
"admin.rules.edit.modal.restore.body": "Regel wird wiederhergestellt (archived → published).",
"admin.rules.export.title": "Regel-Migrations exportieren — Paliad",
"admin.rules.export.heading": "Regel-Migrations exportieren",
"admin.rules.export.subtitle": "Generiert ein *.up.sql-Blob mit allen unsynchronisierten Audit-Veränderungen. Manuell in internal/db/migrations/ einchecken.",
"admin.rules.export.breadcrumb": "← Regeln verwalten",
"admin.rules.export.field.since": "Startend ab Audit-ID (optional)",
"admin.rules.export.run": "Export generieren",
"admin.rules.export.running": "Lade…",
"admin.rules.export.download": "Als Datei herunterladen",
"admin.rules.export.copy": "In Zwischenablage kopieren",
"admin.rules.export.copied": "In Zwischenablage kopiert.",
"admin.rules.export.copy_failed": "Kopieren fehlgeschlagen.",
"admin.rules.export.count": "Audit-Zeilen: {n}",
"admin.rules.export.latest": "Letzte Audit-ID: {id}",
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
"admin.rules.export.error": "Export fehlgeschlagen.",
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
// Date-range picker (t-paliad-248). Symmetric past/future chip fan
// around an ALLES centre. Used by the filter-bar 'time' axis from
// Slice A onwards; future slices will migrate /agenda and
@@ -3351,6 +3343,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
"deadlines.conditional.depends_on": "depends on {parent}",
"deadlines.conditional.unset": "depends on an upstream event",
"deadlines.optional.badge": "on request",
"deadlines.priority.mandatory": "Mandatory",
"deadlines.priority.recommended": "Recommended",
@@ -3422,6 +3416,14 @@ const translations: Record<Lang, Record<string, string>> = {
"choices.include_ccr.chip": "with nullity counterclaim",
"choices.reset": "Reset choice",
"choices.commit.error": "Could not save selection",
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
"choices.show_hidden.label": "Show hidden",
"choices.show_hidden.count": "Hidden ({n})",
"choices.unhide.chip": "Show again",
// t-paliad-293 — iconified state markers on the Verfahrensablauf
// event cards. Tooltip-only text; the glyph is the primary signal.
"state.optional.tooltip": "Optional event",
"state.hidden.tooltip": "Hidden — restore via the options menu",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
"deadlines.adjusted.weekend": "weekend",
@@ -3543,9 +3545,10 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.label": "Side:",
"deadlines.side.claimant": "Claimant",
"deadlines.side.defendant": "Defendant",
"deadlines.side.both": "Both",
"deadlines.side.undefined": "Undefined",
"deadlines.side.from_project": "From case:",
"deadlines.side.override": "Choose other side",
"deadlines.side.hint": "Pick a side to focus the columns.",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
@@ -4580,7 +4583,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",
"submissions.draft.parties.title": "Parties",
"submissions.draft.parties.hint": "Select which parties to mention in this submission.",
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",
@@ -5944,7 +5947,6 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-192 Slice 11b — Admin rule-editor UI.
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
"nav.admin.rules": "Manage procedural events",
"nav.admin.rules_export": "Procedural-event migrations",
"admin.card.rules.title": "Manage procedural events",
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
@@ -5952,7 +5954,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.list.heading": "Manage procedural events",
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
"admin.rules.list.new": "+ New procedural event",
"admin.rules.list.export": "Export migrations",
"admin.rules.tab.rules": "Rules",
"admin.rules.tab.orphans": "Orphans",
"admin.rules.loading": "Loading…",
@@ -6114,23 +6115,6 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.modal.restore.title": "Restore",
"admin.rules.edit.modal.restore.body": "Rule will be restored (archived → published).",
"admin.rules.export.title": "Export rule migrations — Paliad",
"admin.rules.export.heading": "Export rule migrations",
"admin.rules.export.subtitle": "Generates a *.up.sql blob with every un-exported audit change. Commit manually into internal/db/migrations/.",
"admin.rules.export.breadcrumb": "← Manage Rules",
"admin.rules.export.field.since": "Starting from audit id (optional)",
"admin.rules.export.run": "Generate export",
"admin.rules.export.running": "Loading…",
"admin.rules.export.download": "Download as file",
"admin.rules.export.copy": "Copy to clipboard",
"admin.rules.export.copied": "Copied to clipboard.",
"admin.rules.export.copy_failed": "Copy failed.",
"admin.rules.export.count": "Audit rows: {n}",
"admin.rules.export.latest": "Latest audit id: {id}",
"admin.rules.export.ok": "{n} audit rows exported.",
"admin.rules.export.error": "Export failed.",
"admin.rules.export.no_pending": "No pending audit rows to export.",
// Date-range picker (t-paliad-248). See DE block above for details.
"date_range.button.label": "Time range",
"date_range.button.label.custom_range": "From {from} to {to}",

View File

@@ -137,6 +137,13 @@ interface VariableGroup {
id: string;
label: VariableLabel;
keys: string[];
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
// initial state is collapsed iff collapsedByDefault. Used for the
// Frist section which lawyers rarely need to override (the variables
// stay resolvable in the bag for the few templates that still want
// them, but render no body content by default).
collapsible?: boolean;
collapsedByDefault?: boolean;
}
const VARIABLE_LABELS: Record<string, VariableLabel> = {
@@ -205,33 +212,19 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
};
// t-paliad-287 — variable groups restructured into four lawyer-facing
// sections: Mandant/Verfahren up top (the case identity), then Parteien
// (where the picker UI lives — this group only carries the manual
// {{parties.*}} overrides for power-users), then Frist collapsed by
// default (the deadline.* keys still resolve in the bag but the default
// templates don't render them in the body any more), then Sonstiges for
// the firm/date/user trim. The legacy procedural_event/rule namespaces
// fold into Mandant/Verfahren so the lawyer reads them in their natural
// context.
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "procedural_event",
label: { de: "Verfahrensschritt", en: "Procedural event" },
keys: [
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
id: "parties",
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "project",
label: { de: "Verfahren", en: "Proceeding" },
id: "mandant_verfahren",
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
keys: [
"project.title",
"project.case_number",
@@ -246,11 +239,43 @@ const VARIABLE_GROUPS: VariableGroup[] = [
"project.matter_number",
"project.reference",
"project.instance_level",
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
id: "parties",
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
// Manual overrides for {{parties.<role>.*}} placeholders — power-
// user escape hatch when the lawyer wants the rendered string to
// differ from the picker selection (e.g. honourific prefix on
// representative). Collapsed by default because the picker above
// is the canonical surface; these rows exist only as a safety
// valve.
collapsible: true,
collapsedByDefault: true,
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "deadline",
label: { de: "Frist", en: "Deadline" },
label: { de: "Frist (intern)", en: "Deadline (internal)" },
// t-paliad-287 — the {{deadline.*}} placeholders no longer render
// in the default skeleton body (internal context that doesn't
// belong in a court-bound submission). The values still resolve
// here so a custom template can pick them up if needed; collapsed
// because most drafts never touch them.
collapsible: true,
collapsedByDefault: true,
keys: [
"deadline.due_date",
"deadline.due_date_long_de",
@@ -261,10 +286,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
],
},
{
id: "firm",
label: { de: "Kanzlei & Datum", en: "Firm & date" },
id: "sonstiges",
label: { de: "Sonstiges", en: "Other" },
keys: [
"firm.name",
"firm.signature_block",
"user.display_name",
"user.email",
"user.office",
@@ -291,6 +317,29 @@ interface State {
saveTimer: number | null;
pendingOverrides: Record<string, string> | null;
inFlight: AbortController | null;
// t-paliad-287 — per-section collapse memory. Sticky across repaints
// so autosave (which calls paintVariables) doesn't snap an open
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
collapsedGroups: Record<string, boolean>;
// t-paliad-287 — which side the Add-Party panel is currently open for
// (one panel can be open at a time; clicking the other side's button
// toggles). null means closed.
addPartyOpen: PartySide | null;
addPartyMode: "manual" | "search";
addPartySearchHits: PartySearchHit[];
addPartyBusy: boolean;
}
type PartySide = "claimant" | "defendant" | "other";
interface PartySearchHit {
id: string;
project_id: string;
project_title: string;
project_reference?: string | null;
name: string;
role?: string;
representative?: string;
}
const state: State = {
@@ -300,6 +349,11 @@ const state: State = {
saveTimer: null,
pendingOverrides: null,
inFlight: null,
collapsedGroups: {},
addPartyOpen: null,
addPartyMode: "manual",
addPartySearchHits: [],
addPartyBusy: false,
};
// ─────────────────────────────────────────────────────────────────────
@@ -607,24 +661,31 @@ function paintImportRow(): void {
btn.onclick = () => { void onImportFromProject(btn); };
}
// t-paliad-277 — multi-select party picker. Lists every party on the
// draft's project (view.available_parties), grouped by role, with one
// checkbox per party. Checked = include in the variable bag. Empty
// selection falls back to the legacy "include every party" default
// (consistent with the migration default).
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
// Party affordance per side. Lists every party on the draft's project
// (view.available_parties), grouped by role, with one checkbox per
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
// an "+ Partei hinzufügen" button that opens an inline panel with two
// modes: manual entry (creates a fresh paliad.parties row) or DB
// picker (searches every visible project, clones the row into THIS
// project on selection). Empty selection still falls back to the
// legacy "include every party" default.
function paintPartyPicker(): void {
const block = document.getElementById("submission-draft-parties");
const list = document.getElementById("submission-draft-parties-list");
if (!block || !list || !state.view) return;
const parties = state.view.available_parties ?? [];
if (!state.view.draft.project_id || parties.length === 0) {
// t-paliad-287 — picker is now shown even on empty-roster projects so
// the lawyer can use Add Party to populate. Still hidden when there
// is no project attached (no row to attach a party to).
if (!state.view.draft.project_id) {
block.style.display = "none";
list.innerHTML = "";
return;
}
block.style.display = "";
const parties = state.view.available_parties ?? [];
const selected = new Set(state.view.draft.selected_parties ?? []);
// Empty selection is the implicit "all" default — pre-check every
// party so the lawyer can see what's currently being mentioned and
@@ -637,9 +698,13 @@ function paintPartyPicker(): void {
const grouped = groupPartiesByRole(parties);
let html = "";
for (const group of grouped) {
if (group.parties.length === 0) continue;
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
html += `<legend>${escapeHtml(group.label)}</legend>`;
if (group.parties.length === 0) {
html += `<p class="submission-draft-parties-empty">${escapeHtml(
isEN() ? "No parties yet." : "Noch keine Parteien.",
)}</p>`;
}
for (const p of group.parties) {
const checked = effective.has(p.id) ? " checked" : "";
const chip = p.role
@@ -658,6 +723,7 @@ function paintPartyPicker(): void {
html += rep;
html += `</label>`;
}
html += renderAddPartyControls(group.bucket);
html += `</fieldset>`;
}
list.innerHTML = html;
@@ -665,6 +731,198 @@ function paintPartyPicker(): void {
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
inp.addEventListener("change", () => onPartySelectionChange());
});
wireAddPartyControls(list);
}
// renderAddPartyControls emits the per-side "+ Add party" button and
// (when expanded) the inline panel offering manual entry OR DB search.
// Sticky panel state lives in state.addPartyOpen so a repaint after
// search-fetch / autosave / language-switch doesn't snap the panel
// shut mid-edit.
function renderAddPartyControls(side: PartySide): string {
const open = state.addPartyOpen === side;
const mode = state.addPartyMode;
const sideLabel = sideLabelFor(side);
const btnLabel = isEN()
? `+ Add party (${sideLabel})`
: `+ Partei hinzufügen (${sideLabel})`;
let html = `<div class="submission-draft-addparty">`;
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
html += escapeHtml(btnLabel);
html += `</button>`;
if (!open) {
html += `</div>`;
return html;
}
// Tabs — manual / search.
html += `<div class="submission-draft-addparty-panel">`;
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
html += `</button>`;
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
html += `</button>`;
html += `</div>`;
if (mode === "manual") {
html += renderAddPartyManualForm(side);
} else {
html += renderAddPartySearchPanel(side);
}
html += `</div></div>`;
return html;
}
function renderAddPartyManualForm(side: PartySide): string {
const defaultRole = defaultRoleFor(side);
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
html += `<input type="text" name="name" required class="entity-form-input"`;
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
html += `</label>`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
html += `<input type="text" name="role" class="entity-form-input"`;
html += ` value="${escapeHtml(defaultRole)}"`;
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
html += `</label>`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
html += `<input type="text" name="representative" class="entity-form-input"`;
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
html += `</label>`;
html += `<div class="submission-draft-addparty-actions">`;
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
html += `</button>`;
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
html += `</button>`;
html += `</div>`;
html += `</form>`;
return html;
}
function renderAddPartySearchPanel(side: PartySide): string {
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
html += ` data-side="${side}"`;
html += ` placeholder="${escapeHtml(
isEN()
? "Search across projects (name or representative)…"
: "In allen Projekten suchen (Name oder Vertreter)…",
)}" />`;
html += renderPartySearchResultsList();
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
isEN()
? "Picking a row clones it as a fresh party on this project — no typing."
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
)}</p>`;
html += `<div class="submission-draft-addparty-actions">`;
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
html += `</button>`;
html += `</div>`;
html += `</div>`;
return html;
}
function wireAddPartyControls(root: HTMLElement): void {
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const side = (btn.dataset.side as PartySide) ?? "other";
if (state.addPartyOpen === side) {
// Toggle off.
state.addPartyOpen = null;
state.addPartySearchHits = [];
} else {
state.addPartyOpen = side;
state.addPartyMode = "manual";
state.addPartySearchHits = [];
}
paintPartyPicker();
});
});
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
if (tab !== "manual" && tab !== "search") return;
state.addPartyMode = tab;
if (tab === "manual") state.addPartySearchHits = [];
paintPartyPicker();
if (tab === "search") {
// Pre-load most-recent matches with empty query so the lawyer
// sees options without typing first.
void runPartySearch("");
}
});
});
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
btn.addEventListener("click", () => {
state.addPartyOpen = null;
state.addPartySearchHits = [];
paintPartyPicker();
});
});
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
form.addEventListener("submit", (ev) => {
ev.preventDefault();
const side = (form.dataset.side as PartySide) ?? "other";
const data = new FormData(form);
const name = String(data.get("name") ?? "").trim();
if (!name) return;
const role = String(data.get("role") ?? "").trim();
const representative = String(data.get("representative") ?? "").trim();
void onAddPartyManualSubmit(side, { name, role, representative });
});
});
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
let timer: number | null = null;
inp.addEventListener("input", () => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
void runPartySearch(inp.value.trim());
}, 200);
});
// Pre-load on first render of the search tab.
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
void runPartySearch("");
}
});
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
li.addEventListener("click", () => {
const hitID = li.dataset.hitId;
if (!hitID) return;
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
if (!hit) return;
const side = state.addPartyOpen ?? "other";
void onAddPartySearchPick(side, hit);
});
});
}
function sideLabelFor(side: PartySide): string {
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
return isEN() ? "Other parties" : "Weitere Parteien";
}
function defaultRoleFor(side: PartySide): string {
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
return "";
}
interface PartyRoleGroup {
@@ -781,8 +1039,27 @@ function paintVariables(): void {
let html = "";
for (const group of VARIABLE_GROUPS) {
const groupLabel = isEN() ? group.label.en : group.label.de;
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
// Re-use the user's prior toggle state across paintVariables calls
// (autosave / language switch trigger a repaint). Default sticky
// state lives in state.collapsedGroups; on first render the
// collapsedByDefault flag seeds it.
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
}
const collapsed = !!state.collapsedGroups[group.id];
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
if (group.collapsible) {
html += `<button type="button" class="submission-draft-var-group-toggle"`;
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
html += `</button>`;
} else {
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
}
html += `<div class="submission-draft-var-group-body">`;
for (const key of group.keys) {
const label = labelFor(key);
const override = overrides[key];
@@ -813,10 +1090,19 @@ function paintVariables(): void {
// Visual hint: marker text appears in preview when override is "".
void mergedVal;
}
html += `</div>`;
html += `</section>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.toggleGroup;
if (!id) return;
state.collapsedGroups[id] = !state.collapsedGroups[id];
paintVariables();
});
});
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
// t-paliad-274 (B) — focus into a sidebar field highlights every
@@ -1021,6 +1307,175 @@ async function onPartySelectionChange(): Promise<void> {
}
}
async function runPartySearch(query: string): Promise<void> {
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
const resp = await fetch(`/api/parties/search?${params.toString()}`);
if (!resp.ok) throw new Error(`search ${resp.status}`);
const data = (await resp.json()) as { results: PartySearchHit[] };
// Filter out parties already on THIS project — picking one of them
// would be a no-op clone that doubles the row.
const existingIDs = new Set(
(state.view?.available_parties ?? []).map((p) => p.id),
);
state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));
// Refresh ONLY the results <ul> in place — repainting the whole
// picker would steal focus from the search input on every
// keystroke. The input keeps its value/selection and the lawyer
// can keep typing.
const ul = document.querySelector<HTMLUListElement>(
".submission-draft-addparty-search-results",
);
if (ul) {
ul.outerHTML = renderPartySearchResultsList();
const fresh = document.querySelector<HTMLUListElement>(
".submission-draft-addparty-search-results",
);
if (fresh) {
fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
li.addEventListener("click", () => {
const hitID = li.dataset.hitId;
if (!hitID) return;
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
if (!hit) return;
const side = state.addPartyOpen ?? "other";
void onAddPartySearchPick(side, hit);
});
});
}
} else {
// First load (panel just opened) — full picker paint to wire up
// every control. Subsequent keystroke updates take the cheaper
// path above.
paintPartyPicker();
}
} catch (err) {
console.error("submission-draft party-search:", err);
}
}
function renderPartySearchResultsList(): string {
let html = `<ul class="submission-draft-addparty-search-results">`;
if (state.addPartySearchHits.length === 0) {
html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
isEN() ? "No matches." : "Keine Treffer.",
)}</li>`;
} else {
for (const hit of state.addPartySearchHits) {
const ref = hit.project_reference
? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
: "";
const role = hit.role
? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
: "";
const rep = hit.representative
? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
(isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
)}</span>`
: "";
html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
html += role;
html += rep;
html += `<span class="submission-draft-addparty-search-projwrap">`;
html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
html += ref;
html += `</span>`;
html += `</li>`;
}
}
html += `</ul>`;
return html;
}
async function onAddPartyManualSubmit(
side: PartySide,
payload: { name: string; role: string; representative: string },
): Promise<void> {
if (!state.view) return;
const projectID = state.view.draft.project_id;
if (!projectID) return;
// Disable the submit button in-place rather than repainting the form
// mid-flight (a repaint would blow away the lawyer's typed values on
// error and reset focus). The post-success/-error repaint runs once
// the call settles.
const submitBtn = document.querySelector<HTMLButtonElement>(
`.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
);
if (submitBtn) submitBtn.disabled = true;
state.addPartyBusy = true;
try {
const body: Record<string, unknown> = { name: payload.name };
if (payload.role) body.role = payload.role;
if (payload.representative) body.representative = payload.representative;
const resp = await fetch(`/api/projects/${projectID}/parties`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`create party ${resp.status}`);
const created = (await resp.json()) as { id: string };
await refreshDraftViewAndSelect(created.id);
state.addPartyOpen = null;
setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
state.addPartyBusy = false;
paintPartyPicker();
} catch (err) {
console.error("submission-draft add-party manual:", err);
setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
if (submitBtn) submitBtn.disabled = false;
state.addPartyBusy = false;
}
}
async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
// DB picks clone the row into the current project — the simplest
// semantics that survive paliad.parties' project_id-NOT-NULL schema.
// The lawyer asked for "no manual re-typing"; this honours that
// without bending the data model.
await onAddPartyManualSubmit(side, {
name: hit.name,
role: hit.role ?? defaultRoleFor(side),
representative: hit.representative ?? "",
});
}
// refreshDraftViewAndSelect refetches the editor payload (so
// available_parties picks up the new row) and ensures the newly-added
// party is checked in selected_parties. If the lawyer was on the
// implicit-all default (empty selected_parties), the new party comes
// in pre-selected via the "empty=all" rule and no PATCH is needed.
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;
const view = state.view.draft.project_id
? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
: await fetchGlobalView(draftID);
state.view = view;
// If the previous draft had a non-empty selected_parties subset,
// explicitly add the new party so it isn't silently dropped from the
// submission. Empty selected_parties = "all" → no PATCH needed.
const currentSel = state.view.draft.selected_parties ?? [];
if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
const next = [...currentSel, newPartyID];
try {
const patched = await patchDraft({ selected_parties: next });
state.view = patched;
} catch (err) {
console.error("submission-draft select new party:", err);
}
}
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
}
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;

View File

@@ -143,6 +143,25 @@ function writeChoicesToURL(choices: EventChoice[]) {
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
// calculator re-surfaces cards whose submission_code is in the active
// skipRules set; they render faded with a "Wieder einblenden" chip.
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
// the visibility. Default OFF — m's not asking to see hidden by
// default, just to be able to.
function readShowHiddenFromURL(): boolean {
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
}
function writeShowHiddenToURL(on: boolean) {
const url = new URL(window.location.href);
if (on) url.searchParams.set("show_hidden", "1");
else url.searchParams.delete("show_hidden");
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
let showHidden = readShowHiddenFromURL();
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
@@ -256,14 +275,33 @@ async function doCalc() {
anchorOverrides: overrides,
courtId,
perCardChoices,
includeHidden: showHidden,
});
if (seq !== calcSeq) return;
if (!data) return;
lastResponse = data;
renderResults(data);
syncHiddenBadge(data.hiddenCount ?? 0);
showStep(3);
}
// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
// toggle. Visible regardless of toggle state so the user knows whether
// there's anything to re-surface even when the toggle is OFF. Hides the
// whole row when the projection has zero hidden cards — no clutter on
// a project that's never used the skip feature. (t-paliad-290)
function syncHiddenBadge(count: number) {
const row = document.getElementById("show-hidden-row");
const badge = document.getElementById("show-hidden-count");
if (!row || !badge) return;
if (count <= 0) {
row.style.display = "none";
return;
}
row.style.display = "";
badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. Precedence:
//
@@ -497,7 +535,17 @@ async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide |
function sideLabelI18n(s: Side): string {
if (s === "claimant") return t("deadlines.side.claimant");
if (s === "defendant") return t("deadlines.side.defendant");
return t("deadlines.side.both");
return t("deadlines.side.undefined");
}
// syncSideHintVisibility shows the "pick a side" hint chip only while
// currentSide is unset (m/paliad#120). When the user has picked
// claimant / defendant the columns are already focused, so the prompt
// would be misleading.
function syncSideHintVisibility() {
const hint = document.getElementById("side-hint");
if (!hint) return;
hint.style.display = currentSide === null ? "" : "none";
}
// renderSideChip swaps the radio cluster for a read-only chip showing
@@ -521,6 +569,9 @@ function showSideRadioCluster() {
if (!cluster || !chip) return;
cluster.style.display = "";
chip.style.display = "none";
// Cluster re-appears after override → re-evaluate hint visibility so
// we don't leave a stale "pick a side" prompt above a checked radio.
syncSideHintVisibility();
}
// applySidePrefill takes a project's our_side, maps it to the side axis,
@@ -606,6 +657,7 @@ function initPerspectiveControls() {
currentAppellant = readAppellantFromURL();
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
syncSideHintVisibility();
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
input.addEventListener("change", () => {
@@ -613,6 +665,7 @@ function initPerspectiveControls() {
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
syncSideHintVisibility();
if (lastResponse) renderResults(lastResponse);
});
});
@@ -696,6 +749,20 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
// to URL + recalc (the backend reshapes the response — we can't just
// re-render lastResponse since the hidden rows aren't in it when the
// toggle was OFF).
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
if (showHiddenCb) {
showHiddenCb.checked = showHidden;
showHiddenCb.addEventListener("change", () => {
showHidden = showHiddenCb.checked;
writeShowHiddenToURL(showHidden);
scheduleCalc(0);
});
}
initViewToggle();
initPerspectiveControls();

View File

@@ -74,10 +74,11 @@ export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
states.set(opts.container, state);
opts.container.addEventListener("click", (e) => {
const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
if (target) {
const targetEl = e.target as HTMLElement | null;
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
if (caret) {
e.stopPropagation();
openPopover(state, target);
openPopover(state, caret);
return;
}
// Outside-click closes the popover.
@@ -158,6 +159,7 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
} catch {
return;
}
const isHidden = caret.dataset.isHidden === "1";
const pop = document.createElement("div");
pop.className = "event-card-choices-popover";
@@ -165,6 +167,15 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
pop.setAttribute("aria-label", t("choices.caret.title"));
const blocks: string[] = [];
// t-paliad-293: hidden-card prominence. When the user opens the
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
// most likely intent — surface it as a single high-contrast action
// at the top of the popover (rather than burying it under the skip
// toggle's reset link). Clicking it clears the `skip` choice, which
// is the same wire effect as the legacy inline chip from t-paliad-290.
if (isHidden) {
blocks.push(renderUnhideBlock());
}
if (Array.isArray(offered.appellant)) {
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
}
@@ -259,6 +270,23 @@ function renderToggleBlock(state: AttachedState, code: string, kind: "include_cc
</div>`;
}
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
// action — surfaced only when the caret is opened on a re-surfaced
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
// the same `clear` action as the skip-block reset link below, but
// labelled in the user's terms ("restore this card" rather than
// "reset skip choice"). Drops out of the popover automatically on
// non-hidden cards so the popover stays minimal. (t-paliad-293)
function renderUnhideBlock(): string {
const label = t("choices.unhide.chip");
return `<div class="event-card-choices-block event-card-choices-block--unhide">
<button type="button"
data-choice-action="clear"
data-choice-kind="skip"
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
</div>`;
}
function closePopover(state: AttachedState): void {
if (state.popover) {
state.popover.remove();

View File

@@ -67,6 +67,153 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
});
});
// t-paliad-293 (m/paliad#125): the "Wieder einblenden" affordance
// moved from an inline chip in the card header into the caret popover
// to fix horizontal-scroll on narrow viewports (the long German label
// pushed the card past its column width). The renderer now signals
// hidden state two ways: (1) a 👁⃠ state-icon in the title row and
// (2) data-is-hidden="1" on the caret button so event-card-choices.ts
// can surface the prominent "Wieder einblenden" popover entry when
// the user opens the menu. The legacy `.event-card-choices-unhide`
// inline chip class must NOT appear in the output.
describe("deadlineCardHtml — isHidden surfaces state-icon + caret hint (t-paliad-293)", () => {
test("isHidden=true emits the hidden state-icon", () => {
const html = deadlineCardHtml(
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
{ showParty: true },
);
expect(html).toContain("timeline-state-icon--hidden");
});
test("isHidden=true with choicesOffered.skip annotates the caret with data-is-hidden=\"1\"", () => {
const html = deadlineCardHtml(
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
{ showParty: true },
);
expect(html).toContain('data-is-hidden="1"');
expect(html).toContain("event-card-choices-caret");
});
test("isHidden=false (default) suppresses the state-icon and reports data-is-hidden=\"0\"", () => {
const html = deadlineCardHtml(
dl({ choicesOffered: { skip: [true, false] } }),
{ showParty: true },
);
expect(html).not.toContain("timeline-state-icon--hidden");
expect(html).toContain('data-is-hidden="0"');
});
test("isHidden=true with empty choicesOffered still emits caret with synthesized skip offer (defensive)", () => {
// Edge case: admin edits the rule's choices_offered after a user
// has already saved a `skip=true` choice. Without the fallback
// the card would re-surface as hidden with no popover entrypoint
// — the user would have no way to un-hide it. The renderer
// synthesizes a `{skip:[true,false]}` offer so the prominent
// "Wieder einblenden" button still renders in the popover.
const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
expect(html).toContain("event-card-choices-caret");
expect(html).toContain('data-is-hidden="1"');
expect(html).toContain("data-choices-offered=\"{&quot;skip&quot;:[true,false]}\"");
});
test("isHidden=false with empty choicesOffered suppresses caret (regression guard)", () => {
const html = deadlineCardHtml(dl(), { showParty: true });
expect(html).not.toContain("event-card-choices-caret");
});
test("legacy inline `.event-card-choices-unhide` class is no longer emitted", () => {
// Pinned to catch a regression that would re-introduce the
// horizontal-scroll surface that motivated the move. The popover
// now uses `.event-card-choices-unhide-btn` (with the -btn suffix)
// inside the body-attached popover dom node — never in the card
// header HTML the renderer returns.
const html = deadlineCardHtml(
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
{ showParty: true },
);
expect(html).not.toContain('class="event-card-choices-unhide"');
expect(html).not.toMatch(/event-card-choices-unhide(?!-btn)/);
});
});
// t-paliad-293: the `optional` priority used to render an inline text
// badge in the card title. The overhaul replaces it with a ⊙ state
// icon so the title row stays compact on narrow viewports. Tooltip is
// driven by the `state.optional.tooltip` i18n key.
describe("deadlineCardHtml — optional priority renders the state icon (t-paliad-293)", () => {
test("priority='optional' emits the timeline-state-icon--optional marker", () => {
const html = deadlineCardHtml(dl({ priority: "optional" }), { showParty: true });
expect(html).toContain("timeline-state-icon--optional");
expect(html).not.toContain("optional-badge");
});
test("priority='mandatory' (default) omits the optional marker", () => {
const html = deadlineCardHtml(dl(), { showParty: true });
expect(html).not.toContain("timeline-state-icon--optional");
});
});
// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
// chip in place of the date column, and the chip keeps the click-to-edit
// affordance so the user can pin a real date once the upstream anchor
// resolves (oral hearing scheduled, opposing party's motion received, …).
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
// the issue.
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
const html = deadlineCardHtml(
dl({
code: "upc.inf.cfi.translation_request",
isConditional: true,
parentRuleCode: "upc.inf.cfi.oral",
parentRuleName: "Mündliche Verhandlung",
}),
{ showParty: true, editable: true },
);
expect(html).toContain("timeline-conditional");
expect(html).toContain("abhängig von Mündliche Verhandlung");
expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
expect(html).toContain('role="button"');
expect(html).not.toContain("timeline-court-set");
});
test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
const html = deadlineCardHtml(
dl({ isConditional: true }),
{ showParty: true, editable: true },
);
expect(html).toContain("timeline-conditional");
expect(html).toContain("abhängig von vorgelagertem Ereignis");
});
test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
// Court-set ancestor without override sets BOTH isCourtSet=true AND
// isConditional=true on the wire. The renderer must pick the
// conditional chip; otherwise the row keeps the legacy "wird vom
// Gericht bestimmt" label and the user can't see WHICH upstream
// event blocks them.
const html = deadlineCardHtml(
dl({
isConditional: true,
isCourtSet: true,
isCourtSetIndirect: true,
parentRuleName: "Entscheidung",
}),
{ showParty: true, editable: true },
);
expect(html).toContain("abhängig von Entscheidung");
expect(html).not.toContain("timeline-court-set");
});
test("isConditional=false keeps the normal date span (regression guard)", () => {
const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
expect(html).toContain("timeline-date");
expect(html).not.toContain("timeline-conditional");
});
});
// Pure column-routing behaviour. Originally pinned by m/paliad#81
// (side + appellant axes), re-framed by m/paliad#88: the column
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the

View File

@@ -72,6 +72,29 @@ export interface CalculatedDeadline {
// page-level appellant axis still applies in that case). The bucketer
// reads this in preference to the page-level appellant.
appellantContext?: string;
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
// a previously-hidden card is re-surfaced via the "Ausgeblendete
// anzeigen" toggle. The renderer fades the card and exposes an
// inline "Wieder einblenden" chip that deletes the skip choice.
isHidden?: boolean;
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
// no concrete date is projected. Set by the calculator when the rule
// depends on a court-set ancestor without override, when a backward-
// anchored rule's forward anchor isn't set, or for optional rules
// whose true triggering event sits outside the rule data (e.g.
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
// in the data, but the real trigger is the opposing party's
// confidentiality motion). The renderer drops the date column entry
// and shows an "abhängig von <parentRuleName>" chip instead.
isConditional?: boolean;
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
// parent rule's identity so the renderer can label the
// "abhängig von <parent>" chip on conditional rows. Populated for
// every rule with a parent (not just conditional ones), so the
// dependency-footer logic can reuse it. Empty for root rules.
parentRuleCode?: string;
parentRuleName?: string;
parentRuleNameEN?: string;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -131,6 +154,13 @@ export interface DeadlineResponse {
// (m/paliad#81)
triggerEventLabel?: string;
triggerEventLabelEN?: string;
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
// would have been hidden in this projection (i.e. their
// submission_code is in skipRules and they passed the condition_expr
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
// when the toggle is OFF — so users know there's something to
// re-surface.
hiddenCount?: number;
}
export interface CourtRow {
@@ -160,6 +190,11 @@ export interface CalcParams {
choice_kind: string;
choice_value: string;
}>;
// includeHidden (t-paliad-290): when true the calculator returns
// previously-skipped rules as faded cards instead of dropping them.
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
// ON.
includeHidden?: boolean;
}
const PARTY_CLASS: Record<string, string> = {
@@ -175,10 +210,20 @@ export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
// Pure-string HTML escape — keeps the module testable in bun test
// (plain Node, no jsdom). Used to be backed by document.createElement,
// which forced fixtures to leave any field that flowed through it
// empty just to exercise unrelated branches; the regex form is safe
// for arbitrary text including the per-rule name strings that the
// conditional-row chip ("abhängig von <parent>") now exposes.
// (t-paliad-289)
export function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function formatDate(dateStr: string): string {
@@ -279,28 +324,76 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
// Conditional rows (t-paliad-289) replace the date column with an
// "abhängig von <parent>" chip. The chip remains click-to-edit so
// the user can pin a real date once known (e.g. once the oral
// hearing date is set, or the opposing party's Vertraulichkeits-
// antrag arrives) — the same data-rule-code wiring fires the
// existing inline date editor. IsConditional wins over IsCourtSet:
// they overlap (court-set ancestor without override produces both),
// and "abhängig von <parent>" is the clearer user-facing signal.
const parentLabel = (getLang() === "en"
? (dl.parentRuleNameEN || dl.parentRuleName)
: dl.parentRuleName) || "";
let dateStr: string;
if (dl.isConditional) {
const chipText = parentLabel
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
: t("deadlines.conditional.unset");
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
} else if (dl.isCourtSet) {
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
} else {
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
}
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
// priority directly. Optional badge fires only on 'optional'
// priority (RoP.151-style opt-in deadlines).
const mandatoryBadge = dl.priority === "optional"
? '<span class="optional-badge">optional</span>'
: "";
// t-paliad-293 — iconified state markers. The card surface speaks
// "cut the tree of possibilities": each card carries 0N small icons
// in the title row that summarise its decision state at a glance.
// The text "optional" badge that used to sit inline next to the name
// is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
// marker. Conditional cards already have the date-column chip; the
// marker is redundant in the title row. CCR-included / appellant
// picks remain on the chip row (event-card-choices-chip) — see below.
// Tooltips are i18n-driven so they read in the user's language.
const stateIcons: string[] = [];
if (dl.priority === "optional") {
stateIcons.push(
`<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
);
}
if (dl.isHidden) {
stateIcons.push(
`<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
);
}
const stateIconsHtml = stateIcons.join("");
// t-paliad-265 — caret affordance + chip indicator when this rule
// offers per-card choices and the user has made a pick. The popover
// open/commit lifecycle lives in client/views/event-card-choices.ts;
// the data-* attributes here are the wire contract between the two.
const choicesHtml = dl.code !== "" && dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0
//
// t-paliad-293 — hidden cards always expose the caret so the user
// can un-hide via the popover's "Wieder einblenden" entry. Normally
// a hidden card was hidden via a skip choice, so `choicesOffered.skip`
// is present. Defensive fallback: if a rule's `choices_offered` was
// edited away after the skip entry was saved, the user would lose
// the un-hide path entirely. Synthesize a `{skip:[true,false]}`
// offer for the popover in that edge case so the prominent
// "Wieder einblenden" button still renders.
const offeredForCaret = (dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0)
? dl.choicesOffered
: (dl.isHidden ? { skip: [true, false] } : null);
const showCaret = dl.code !== "" && offeredForCaret !== null;
const choicesHtml = showCaret
? `<button type="button" class="event-card-choices-caret"
data-submission-code="${escAttr(dl.code)}"
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
data-is-hidden="${dl.isHidden ? "1" : "0"}"
aria-label="${escAttr(t("choices.caret.title"))}"
title="${escAttr(t("choices.caret.title"))}">▾</button>`
: "";
@@ -354,7 +447,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
${stateIconsHtml}
${chipHtml}
</span>
${dateStr}
@@ -449,8 +542,20 @@ export function wireDateEditClicks(
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
const itemClasses = [
"timeline-item",
dl.isRootEvent ? "timeline-root" : "",
// t-paliad-290: re-surfaced hidden cards render faded via the
// shared timeline-item--hidden modifier (same modifier the columns
// view uses; see fr-col-item--hidden below).
dl.isHidden ? "timeline-item--hidden" : "",
// t-paliad-289: dotted-border + faded styling for conditional rows
// so the "abhängig von <parent>" state is visually distinct from
// both anchored deadlines and direct court-set rows.
dl.isConditional ? "timeline-item--conditional" : "",
].filter(Boolean).join(" ");
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="${itemClasses}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
@@ -629,7 +734,17 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
const mirrorTag = showMirrorTag && dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
const itemClasses = [
"fr-col-item",
dl.isRootEvent ? "fr-col-root" : "",
// t-paliad-290: re-surfaced hidden cards render faded via the
// shared fr-col-item--hidden modifier.
dl.isHidden ? "fr-col-item--hidden" : "",
// t-paliad-289: same conditional treatment as the linear
// timeline-item — dotted border + faded styling.
dl.isConditional ? "fr-col-item--conditional" : "",
].filter(Boolean).join(" ");
return `<div class="${itemClasses}">
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
@@ -680,6 +795,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
? params.perCardChoices
: undefined,
includeHidden: params.includeHidden ? true : undefined,
}),
});
if (!resp.ok) {

View File

@@ -205,7 +205,6 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}

View File

@@ -401,22 +401,6 @@ export type I18nKey =
| "admin.rules.edit.title"
| "admin.rules.empty"
| "admin.rules.error.load"
| "admin.rules.export.breadcrumb"
| "admin.rules.export.copied"
| "admin.rules.export.copy"
| "admin.rules.export.copy_failed"
| "admin.rules.export.count"
| "admin.rules.export.download"
| "admin.rules.export.error"
| "admin.rules.export.field.since"
| "admin.rules.export.heading"
| "admin.rules.export.latest"
| "admin.rules.export.no_pending"
| "admin.rules.export.ok"
| "admin.rules.export.run"
| "admin.rules.export.running"
| "admin.rules.export.subtitle"
| "admin.rules.export.title"
| "admin.rules.filter.lifecycle"
| "admin.rules.filter.lifecycle.any"
| "admin.rules.filter.proceeding"
@@ -428,7 +412,6 @@ export type I18nKey =
| "admin.rules.lifecycle.archived"
| "admin.rules.lifecycle.draft"
| "admin.rules.lifecycle.published"
| "admin.rules.list.export"
| "admin.rules.list.heading"
| "admin.rules.list.new"
| "admin.rules.list.subtitle"
@@ -1021,10 +1004,13 @@ export type I18nKey =
| "choices.include_ccr.title"
| "choices.include_ccr.true"
| "choices.reset"
| "choices.show_hidden.count"
| "choices.show_hidden.label"
| "choices.skip.false"
| "choices.skip.title"
| "choices.skip.true"
| "choices.skipped.chip"
| "choices.unhide.chip"
| "common.cancel"
| "common.close"
| "common.forbidden"
@@ -1235,6 +1221,8 @@ export type I18nKey =
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.complete.confirm"
| "deadlines.conditional.depends_on"
| "deadlines.conditional.unset"
| "deadlines.court.indirect"
| "deadlines.court.label"
| "deadlines.court.set"
@@ -1460,12 +1448,13 @@ export type I18nKey =
| "deadlines.search.placeholder"
| "deadlines.search.results.count"
| "deadlines.search.results.count_one"
| "deadlines.side.both"
| "deadlines.side.claimant"
| "deadlines.side.defendant"
| "deadlines.side.from_project"
| "deadlines.side.hint"
| "deadlines.side.label"
| "deadlines.side.override"
| "deadlines.side.undefined"
| "deadlines.source.caldav"
| "deadlines.source.fristenrechner"
| "deadlines.source.imported"
@@ -1986,7 +1975,6 @@ export type I18nKey =
| "nav.admin.paliadin"
| "nav.admin.partner_units"
| "nav.admin.rules"
| "nav.admin.rules_export"
| "nav.admin.team"
| "nav.agenda"
| "nav.akten"
@@ -2615,6 +2603,8 @@ export type I18nKey =
| "search.no_results"
| "search.placeholder"
| "sidebar.resize.title"
| "state.hidden.tooltip"
| "state.optional.tooltip"
| "submissions.draft.action.delete"
| "submissions.draft.action.export"
| "submissions.draft.action.new"

View File

@@ -1917,7 +1917,11 @@ input[type="range"]::-moz-range-thumb {
.fristen-row.is-active .fristen-row-num {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-text, #111);
/* Lime is high-luminance; foreground stays midnight in both themes via
--color-accent-dark (light: midnight by default, dark: midnight
explicit). Using --color-text here would flip to cream in dark mode
and collapse contrast on lime. */
color: var(--color-accent-dark);
}
.fristen-row.is-prefilled .fristen-row-num {
@@ -3328,7 +3332,11 @@ input[type="range"]::-moz-range-thumb {
.timeline-item {
display: flex;
gap: 0.75rem;
min-height: 4rem;
/* t-paliad-293: tighter min-height. Previously 4rem — too much
vertical air per card on long projections. Title row + meta row
fits comfortably in 2.75rem; longer cards (with notes expanded
or adjusted-date banners) still grow naturally. */
min-height: 2.75rem;
}
.timeline-item:last-child .timeline-line {
@@ -3369,19 +3377,37 @@ input[type="range"]::-moz-range-thumb {
.timeline-content {
flex: 1;
padding-bottom: 1rem;
/* t-paliad-293: tighter inter-card gutter. Was 1rem; 0.6rem keeps
the dotted-connector line readable without bloating long
projections. */
padding-bottom: 0.6rem;
min-width: 0;
}
.timeline-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
gap: 0.5rem;
/* t-paliad-293: allow shrink + wrap so a long title plus the state
icons + caret never push the card past its column. Combined with
min-width:0 on the name, no inline child can blow the row width
on 375/414/768 viewports. */
flex-wrap: wrap;
min-width: 0;
}
.timeline-name {
font-size: 0.88rem;
font-weight: 500;
/* min-width:0 lets the name shrink and wrap inside its flex parent
— otherwise overflow:hidden in an ancestor would clip it but the
flex item would still demand its intrinsic width. */
min-width: 0;
/* Word-break on long German compounds (Vertraulichkeitswiderklage …)
so they wrap mid-word rather than pushing the date column off-
screen. (t-paliad-293) */
overflow-wrap: anywhere;
}
.timeline-date {
@@ -3467,15 +3493,37 @@ input[type="range"]::-moz-range-thumb {
color: var(--status-neutral-fg-3);
}
.optional-badge {
font-size: 0.68rem;
font-weight: 500;
padding: 0.05rem 0.4rem;
border-radius: 99px;
background: var(--status-amber-bg);
/* t-paliad-293 — compact state icons in the card title row. They
* replace the legacy `.optional-badge` text chip and add a uniform
* language for the per-card decision state ("cut the tree of
* possibilities"). Each icon carries its own modifier so the tint
* matches the state semantic. The glyph itself is the primary signal;
* the i18n tooltip on the span carries the accessible description. */
.timeline-state-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
margin-left: 0.3rem;
font-size: 0.85rem;
line-height: 1;
color: var(--color-text-muted);
cursor: help;
user-select: none;
/* Cancel the wrapper fade so the marker stays legible inside
.timeline-item--hidden which fades the whole content panel. */
opacity: 1;
}
.timeline-state-icon--optional {
color: var(--status-amber-fg);
}
.timeline-state-icon--hidden {
color: var(--color-text-muted);
}
/* t-paliad-265 — per-event-card optional choices. The caret sits in
* the card header next to the date; the chip surfaces the active pick
* inline with the title; the popover is body-attached and positioned
@@ -3531,6 +3579,96 @@ input[type="range"]::-moz-range-thumb {
opacity: 0.55;
}
/* t-paliad-290 (m/paliad#122) — re-surfaced "hidden" cards. The user
* has previously marked these optional events as "Überspringen"; the
* "Ausgeblendete anzeigen" toggle on /tools/verfahrensablauf returns
* them with a faded + dotted-border treatment so they're visually
* distinct from the active timeline. The inline "Wieder einblenden"
* chip cancels the skip on click. */
.timeline-item--hidden .timeline-content,
.fr-col-item--hidden {
opacity: 0.55;
border: 1px dotted var(--color-border, #d4d4d4);
border-radius: 6px;
padding: 0.3rem 0.5rem;
}
/* t-paliad-293 — prominent "Wieder einblenden" entry inside the caret
* popover. Surfaced only when the caret is opened on a hidden card
* (data-is-hidden="1"). Used to be an inline chip in the card header,
* but that caused horizontal scroll on narrow viewports (m/paliad#125)
* because its German label is wide ("Wieder einblenden") and the
* card header is a non-wrapping flex row. Moving it into the popover
* removes the surface entirely and matches m's "actions live in the
* caret menu" framing. */
.event-card-choices-block--unhide {
/* No top border separator — this block sits at the top of the
popover with the highest visual priority. */
padding-top: 0;
border-top: 0;
margin-top: 0;
}
.event-card-choices-unhide-btn {
display: block;
width: 100%;
padding: 0.4rem 0.6rem;
font-size: 0.82rem;
font-weight: 600;
border-radius: 4px;
border: 1px solid var(--color-accent, #c6f41c);
background: var(--color-accent, #c6f41c);
/* Match the active-option pin (lime fg → midnight text) so the
button reads against the lime in both light and dark themes
(m/paliad#123). */
color: var(--color-accent-dark);
cursor: pointer;
transition: background 120ms ease;
}
.event-card-choices-unhide-btn:hover,
.event-card-choices-unhide-btn:focus-visible {
background: var(--color-bg, #fff);
color: var(--color-text);
outline: none;
}
.show-hidden-count {
font-size: 0.78rem;
color: var(--color-text-muted);
margin-left: 0.4rem;
}
/* t-paliad-289: rules whose anchor is uncertain (court-set ancestor
without override, backward-anchor with unset forward date, optional
event not recorded). The "abhängig von <parent>" chip on the date
column makes the conditional state explicit; the dotted border on
the content panel + slight desaturation reinforces it at glance so
the row reads as "pending an upstream input" rather than as a real
scheduled item. The frist-date-edit affordance on the chip still
wires through — the user can pin a concrete date once the anchor
resolves. */
.timeline-item--conditional .timeline-content,
.fr-col-item--conditional {
border: 1px dashed var(--color-border, #d4d4d4);
border-radius: 4px;
padding: 0.35rem 0.55rem;
background: var(--color-bg-soft, #fafafa);
}
.timeline-item--conditional .timeline-name,
.fr-col-item--conditional .timeline-name {
opacity: 0.85;
}
.timeline-conditional {
font-size: 0.82rem;
color: var(--color-text-muted);
font-style: italic;
text-align: right;
}
.event-card-choices-popover {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d4d4);
@@ -3578,7 +3716,10 @@ input[type="range"]::-moz-range-thumb {
.event-card-choices-option--active {
background: var(--color-accent, #c6f41c);
border-color: var(--color-accent, #c6f41c);
color: var(--color-text);
/* Foreground stays midnight in both themes — --color-text would flip
to cream in dark mode and leave the active "Berufung durch …"
chip unreadable on lime (m/paliad#123). */
color: var(--color-accent-dark);
font-weight: 600;
}
@@ -3711,6 +3852,22 @@ input[type="range"]::-moz-range-thumb {
border: 0;
}
/* "Pick a side" hint that sits next to the side-radio cluster while
currentSide is null (m/paliad#120). Both columns still render every
rule in that state — the chip just nudges the user that picking a
side focuses their column. Hidden by JS once a side is picked. */
.side-radio-cluster {
display: inline-flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.side-hint {
color: var(--color-text-muted, #666);
font-size: 0.85rem;
font-style: italic;
}
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
resolves a project whose our_side is set: shows the inferred side
with a small "Andere Seite wählen" override link that swaps the row
@@ -6376,6 +6533,194 @@ dialog.modal::backdrop {
margin-left: 0.25rem;
}
/* t-paliad-287 — collapsible variable-group section (Frist + Parteien
override). The toggle button is the section header; clicking it
flips state.collapsedGroups[id] and re-renders. The visible caret
rotates via the parent's --collapsed class. */
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle {
all: unset;
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0;
margin: 0 0 0.5rem 0;
cursor: pointer;
color: var(--color-text-muted);
}
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 2px;
}
.submission-draft-var-group-caret {
display: inline-block;
transition: transform 120ms ease;
font-size: 0.85em;
line-height: 1;
}
.submission-draft-var-group--collapsible:not(.submission-draft-var-group--collapsed)
.submission-draft-var-group-caret {
transform: rotate(90deg);
}
.submission-draft-var-group--collapsed .submission-draft-var-group-body {
display: none;
}
/* t-paliad-287 — Add Party affordance per side. */
.submission-draft-addparty {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-panel {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.6rem;
background: var(--color-surface-alt, #f7f7f0);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submission-draft-addparty-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.25rem;
}
.submission-draft-addparty-tab {
all: unset;
cursor: pointer;
padding: 0.3rem 0.6rem;
font-size: 0.85em;
border-radius: 4px 4px 0 0;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
}
.submission-draft-addparty-tab--active {
color: var(--color-text);
border-bottom-color: var(--color-accent, #c6f41c);
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.submission-draft-addparty-form--busy {
opacity: 0.6;
pointer-events: none;
}
.submission-draft-addparty-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.submission-draft-addparty-field > span {
font-size: 0.82em;
color: var(--color-text-muted);
}
.submission-draft-addparty-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-search-results {
list-style: none;
padding: 0;
margin: 0;
max-height: 14rem;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface, #fff);
}
.submission-draft-addparty-search-row {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search-row:last-child {
border-bottom: none;
}
.submission-draft-addparty-search-row:hover {
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-search-empty {
padding: 0.6rem;
font-size: 0.85em;
color: var(--color-text-muted);
text-align: center;
}
.submission-draft-addparty-search-name {
font-weight: 500;
color: var(--color-text);
}
.submission-draft-addparty-search-rep {
font-size: 0.78em;
color: var(--color-text-muted);
}
.submission-draft-addparty-search-projwrap {
font-size: 0.78em;
color: var(--color-text-muted);
width: 100%;
}
.submission-draft-addparty-search-proj {
color: var(--color-text);
}
.submission-draft-addparty-search-projref {
margin-left: 0.3rem;
padding: 0 0.4em;
border-radius: 3px;
background: var(--color-surface-alt, #f7f7f0);
color: var(--color-text-muted);
}
.submission-draft-addparty-search-hint {
font-size: 0.78em;
color: var(--color-text-muted);
margin: 0;
}
.submission-draft-parties-empty {
font-size: 0.82em;
color: var(--color-text-muted);
margin: 0.2rem 0;
}
.checklist-instance-actions {
display: flex;
gap: 0.35rem;
@@ -7976,7 +8321,7 @@ dialog.modal::backdrop {
padding: 0.05rem 0.45rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-text);
color: var(--color-accent-dark);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
@@ -15906,7 +16251,7 @@ dialog.quick-add-sheet::backdrop {
border-radius: 6px;
border: 1px solid var(--color-border-strong);
background: var(--color-accent);
color: var(--color-text);
color: var(--color-accent-dark);
cursor: pointer;
transition: background 120ms ease;
}
@@ -16514,7 +16859,7 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-anchor-submit {
background: var(--color-accent, #c6f41c);
border: 1px solid var(--color-accent, #c6f41c);
color: var(--color-text, #333);
color: var(--color-accent-dark);
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
@@ -17452,7 +17797,7 @@ dialog.quick-add-sheet::backdrop {
.admin-rules-chip.active {
background: var(--color-accent, #BFF355);
border-color: var(--color-accent, #BFF355);
color: var(--color-text, #000);
color: var(--color-accent-dark);
}
.admin-rules-pill {
@@ -17840,42 +18185,6 @@ dialog.quick-add-sheet::backdrop {
border-top: 1px solid var(--color-border, #d4d4d8);
}
/* Export page */
.admin-rules-export-controls {
display: flex;
gap: 0.5rem;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.admin-rules-export-controls .form-field {
flex: 1 1 240px;
}
.admin-rules-export-summary {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
font-size: 0.9rem;
color: var(--color-text-muted, #71717a);
margin-bottom: 0.75rem;
}
.admin-rules-export-pre {
background: var(--color-bg-subtle, #f4f4f5);
border: 1px solid var(--color-border, #d4d4d8);
border-radius: 6px;
padding: 1rem;
overflow: auto;
max-height: 60vh;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8rem;
white-space: pre;
margin: 0;
}
/* Date-range picker (t-paliad-248) ------------------------------------
Symmetric past/future chip fan around an ALLES centre, in a popover
anchored under a closed-state trigger button. Reuses .agenda-chip /

View File

@@ -172,10 +172,13 @@ export function renderSubmissionDraft(): string {
/>
</div>
{/* t-paliad-277: multi-select party picker.
{/* t-paliad-277 / t-paliad-287: multi-select party
picker plus per-side Add-Party affordance.
Populated from view.available_parties; checkbox
per party, grouped by role. Hidden when no
project or no parties on the project. */}
project is attached; visible even on empty
rosters so the lawyer can use Add Party to
populate. */}
<div
id="submission-draft-parties"
className="submission-draft-parties"

View File

@@ -190,9 +190,18 @@ export function renderVerfahrensablauf(): string {
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.both">Beide</span>
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
</label>
</div>
{/* Prompt shown while the user hasn't picked a side
(m/paliad#120). Hidden by client when side is
claimant or defendant. Both columns still
render every rule in this state — picking a
side just focuses the user's column. */}
<span className="side-hint" id="side-hint"
data-i18n="deadlines.side.hint">
W&auml;hlen Sie eine Seite, um die Spalten zu fokussieren.
</span>
</div>
{/* Auto-fill chip — populated by the client when a
?project=<id> URL resolves a project with our_side
@@ -224,6 +233,19 @@ export function renderVerfahrensablauf(): string {
</label>
</div>
</div>
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
Re-surfaces optional cards the user has previously
marked "Überspringen" via the per-card popover.
The row hides itself when the projection has no
hidden cards (handled in client/verfahrensablauf.ts).
Default OFF; URL state ?show_hidden=1. */}
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
<label className="fristen-view-option">
<input type="checkbox" id="show-hidden-toggle" />
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
</label>
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite">&nbsp;</span>
</div>
</div>
{/* Visual divider — keeps the perspective block (most-

View File

@@ -299,21 +299,6 @@ func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// GET /admin/api/rules/export-migrations?since=<audit_id>
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
return
}
since := r.URL.Query().Get("since")
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// =============================================================================
// Page handlers — serve the static SPA shells. Auth + admin gate live
// at the route registration in handlers.go.
@@ -327,10 +312,6 @@ func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-rules-edit.html")
}
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-rules-export.html")
}
// =============================================================================
// helpers
// =============================================================================

View File

@@ -63,6 +63,12 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
// wins (what-if exploration overrides the saved state).
ProjectID string `json:"projectId,omitempty"`
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
// t-paliad-290 (m/paliad#122): re-surface previously-hidden
// optional cards. When true the calculator marks skipped rows
// with UIDeadline.IsHidden instead of dropping them; descendants
// stay in the result list. Default false preserves the legacy
// suppression. HiddenCount on the response is independent.
IncludeHidden bool `json:"includeHidden,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -109,6 +115,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {

View File

@@ -458,6 +458,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-139 — set unit_role on a member.
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
protected.HandleFunc("GET /api/parties/search", handlePartiesSearch)
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
// Phase F — Appointments (appointments)
@@ -669,10 +670,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-191 Slice 11a — admin rule-editor API.
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/rules/export", adminGate(users, gateOnboarded(handleAdminRulesExportPage)))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/rules/export-migrations", adminGate(users, handleAdminExportRuleMigrations))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))

View File

@@ -701,6 +701,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, p)
}
// GET /api/parties/search?q=...
//
// Cross-project party picker for the submission-draft editor
// (t-paliad-287). Returns up to 25 parties from every project the
// caller can see, matched by case-insensitive substring on name or
// representative. Empty q returns the 20 most-recently-updated rows so
// the picker isn't blank on first open. Visibility is enforced in the
// service layer via the same predicate every project-scoped read uses.
func handlePartiesSearch(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
q := r.URL.Query().Get("q")
hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": hits})
}
// DELETE /api/parties/{id}
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {

View File

@@ -207,6 +207,44 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
return rules, nil
}
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the
// given id set, keyed by id. Returns nil, nil for an empty input set so
// callers can blindly forward whatever they accumulated. Inactive rows
// are included — the conditional-label resolution in fristenrechner.go
// surfaces the trigger event's display name even when the catalog row
// has been retired, which is preferable to silently falling back to
// the (wrong) parent_id name.
//
// Used by FristenrechnerService.Calculate to redirect a conditional
// rule's "abhängig von …" chip from parent_id to trigger_event_id —
// the actual semantic anchor for rules whose data-model parent is the
// proceeding root but whose real trigger sits in the trigger_events
// catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the
// opposing party's confidentiality application). See m/paliad#126.
func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
`SELECT id, code, name, name_de, description, is_active, created_at
FROM paliad.trigger_events
WHERE id IN (?)`, ids)
if err != nil {
return nil, fmt.Errorf("build trigger_events IN query: %w", err)
}
query = s.db.Rebind(query)
var rows []models.TriggerEvent
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err)
}
out := make(map[int64]models.TriggerEvent, len(rows))
for _, r := range rows {
out[r.ID] = r
}
return out, nil
}
// ListByTriggerEvent returns active rules scoped to a single trigger
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
// These rules carry proceeding_type_id IS NULL (event-rooted) and have

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"github.com/google/uuid"
@@ -89,6 +90,35 @@ type UIDeadline struct {
// is computed off a parent date that the COURT sets, not by the
// court itself.
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
// IsConditional signals the rule's anchor is uncertain — no
// concrete date can be projected. Set when the rule depends on:
// - a court-set ancestor whose date isn't anchored
// (overlaps with IsCourtSetIndirect; the two are kept
// distinct because IsCourtSet wraps a specific UX message
// "wird vom Gericht bestimmt", whereas IsConditional is
// the broader "render as 'abhängig von <parent>'" signal)
// - timing='before' rules whose forward anchor isn't set
// (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month
// before the oral hearing — without the hearing date, the
// backward arithmetic against the trigger date is meaningless)
// - optional opposing-side rules whose true triggering event
// hasn't been recorded for this project (e.g. R.262(2)
// Erwiderung auf Vertraulichkeitsantrag — the data-model
// parent is the SoC, but the real trigger is the opposing
// party's confidentiality motion which may never happen)
// When true, DueDate and OriginalDate are empty and the frontend
// renders an "abhängig von <ParentRuleName>" chip in place of a
// date. Suppressed by an explicit user anchor (IsOverridden wins).
// (t-paliad-289)
IsConditional bool `json:"isConditional,omitempty"`
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
// parent's identity so the frontend can render
// "abhängig von <ParentRuleName>" when IsConditional=true.
// Populated whenever the rule has a parent_id, not only when
// conditional — keeps the wire shape stable. Empty for root rules.
ParentRuleCode string `json:"parentRuleCode,omitempty"`
ParentRuleName string `json:"parentRuleName,omitempty"`
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
IsOverridden bool `json:"isOverridden,omitempty"`
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
// the rule so the frontend knows whether to render the per-event-card
@@ -102,6 +132,12 @@ type UIDeadline struct {
// Frontend bucketer prefers this over the page-level appellant when
// non-empty. (t-paliad-265)
AppellantContext string `json:"appellantContext,omitempty"`
// IsHidden marks a card the user has previously hidden via a
// skip choice. Only ever true when CalcOptions.IncludeHidden is
// set — the toggle re-surfaces these rows so the user can either
// keep them faded for context or un-hide them via the inline
// "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122)
IsHidden bool `json:"isHidden,omitempty"`
}
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
@@ -137,6 +173,14 @@ type UIResponse struct {
// is the appealable first-instance decision (m/paliad#81).
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
// HiddenCount is the number of rules whose submission_code is in
// CalcOptions.SkipRules AND whose condition_expr gate passes —
// i.e. how many rows the user has hidden in this projection
// regardless of the IncludeHidden toggle state. The frontend uses
// this to render the "Ausgeblendete (N)" badge on the toggle even
// when the toggle is OFF (so users know there's something to
// re-surface). (t-paliad-290 / m/paliad#122)
HiddenCount int `json:"hiddenCount"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -214,6 +258,19 @@ type CalcOptions struct {
PerCardAppellant map[string]string
SkipRules map[string]struct{}
IncludeCCRFor map[string]struct{}
// IncludeHidden re-surfaces rules whose submission_code is in
// SkipRules (t-paliad-290 / m/paliad#122). When true:
// - Skipped rules are NOT dropped from the result; they render
// with UIDeadline.IsHidden=true so the frontend can fade them.
// - Descendant suppression is bypassed (the skipped parent is
// present in the result, so children compute their dates off
// it as if the user had never hidden it).
// Default false preserves the original skip semantic (drop rule +
// suppress descendants). HiddenCount on UIResponse is independent
// of this flag — it always reflects the number of hide-eligible
// rows so the toggle's count badge stays accurate.
IncludeHidden bool
}
// Calculate renders the full UI timeline for a proceeding type + trigger date.
@@ -372,6 +429,60 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]UIDeadline, 0, len(rules))
// Pre-pass: identify rules flagged is_court_set=true in the data so
// order-of-evaluation in sequence_order doesn't matter for the
// parent-court-set check below. Without this, a rule processed
// earlier than its court-set parent (e.g. R.109(1) Antrag auf
// Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung
// sequence_order=50 in upc.inf.cfi) misses the court-set propagation
// and computes a meaningless date — for timing='before' rules, that
// produces a backward offset from the trigger date, which has no
// semantic relationship to the rule. (t-paliad-289)
for _, r := range rules {
if r.IsCourtSet {
courtSet[r.ID] = true
}
}
// ruleByID lets the conditional-rendering branches resolve a parent
// rule's display fields (submission_code, name, name_en) for the
// "abhängig von <ParentRuleName>" chip without re-scanning the
// rules slice on every iteration.
ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
for _, r := range rules {
ruleByID[r.ID] = r
}
// triggerEventByID powers the trigger-event override on the
// conditional-label chip (m/paliad#126 / t-paliad-294). When a
// rule carries a real paliad.trigger_events row, that catalog
// event — not the rule's parent_id — is the rule's actual
// semantic anchor. The override fires below when stamping
// ParentRule* on the wire so the chip reads e.g.
// abhängig von Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit
// for R.262(2) Erwiderung auf Vertraulichkeitsantrag — instead of
// the (misleading) parent_id-derived "abhängig von Klageerhebung".
//
// Bulk-loaded in one round-trip; trees in the live corpus carry at
// most a handful of trigger_event_id-bearing rules (2 today on
// upc.inf.cfi), so the IN(...) is small.
var triggerIDs []int64
seenTrigger := make(map[int64]struct{}, len(rules))
for _, r := range rules {
if r.TriggerEventID == nil {
continue
}
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
continue
}
seenTrigger[*r.TriggerEventID] = struct{}{}
triggerIDs = append(triggerIDs, *r.TriggerEventID)
}
triggerEventByID, err := s.rules.LoadTriggerEventsByIDs(ctx, triggerIDs)
if err != nil {
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
}
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
// for membership tests; the engine reads them but doesn't mutate.
skipRules := opts.SkipRules
@@ -381,6 +492,13 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// child rule's parent has already been classified — so descendant
// suppression is a one-pass parent_id lookup.
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
// hiddenCount counts rows whose submission_code is in skipRules
// AND that pass the condition_expr gate — i.e. rows the user has
// hidden in this projection. Surfaced on UIResponse.HiddenCount so
// the frontend's "Ausgeblendete (N)" badge stays accurate even when
// IncludeHidden is off and the rows aren't in the result list.
// (t-paliad-290 / m/paliad#122)
hiddenCount := 0
// appellantContext maps a rule UUID to the appellant value that
// applies to its descendants. A rule that has its own PerCardAppellant
// pick stamps itself with that value; a rule whose parent has a
@@ -403,10 +521,22 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// this rule (or one of its ancestors) as "don't consider for
// this case". Drop the row entirely AND record the rule ID so
// descendants suppress too.
//
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
// we re-surface the directly-skipped row (faded via IsHidden)
// instead of dropping it. Descendants are NOT cascade-suppressed
// in that mode either — the un-suppressed parent computes its
// date normally, so children compute off it as usual. Either
// way we count the hide for the toggle's badge.
var isHidden bool
if r.SubmissionCode != nil {
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
skippedIDs[r.ID] = struct{}{}
continue
hiddenCount++
if !opts.IncludeHidden {
skippedIDs[r.ID] = struct{}{}
continue
}
isHidden = true
}
}
if r.ParentID != nil {
@@ -442,6 +572,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
ConditionExpr: json.RawMessage(r.ConditionExpr),
AppellantContext: ctxVal,
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
IsHidden: isHidden,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
@@ -464,21 +595,58 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.NotesEN = *r.DeadlineNotesEn
}
// Resolve the parent rule once so every conditional-rendering
// branch (incl. the optional-not-recorded path below) can stamp
// ParentRule* on the wire without re-scanning. Populated even
// for non-conditional rows — the frontend dependency-footer
// ("Folgt aus …") already consumes this on regular projected
// rows. (t-paliad-289)
var parentRule *models.DeadlineRule
if r.ParentID != nil {
if pr, ok := ruleByID[*r.ParentID]; ok {
parentRule = &pr
if pr.SubmissionCode != nil {
d.ParentRuleCode = *pr.SubmissionCode
}
d.ParentRuleName = pr.Name
d.ParentRuleNameEN = pr.NameEN
}
}
// Trigger-event override on the user-facing dependency identity
// (m/paliad#126 / t-paliad-294). When a rule has a real
// trigger_event_id, that catalog event is the actual semantic
// anchor — not the parent_id node, which is only the calc-time
// arithmetic anchor. R.262(2) Erwiderung auf Vertraulichkeits-
// antrag is the canonical case: parent_id resolves to the SoC
// ("Klageerhebung"), but the real triggering event is the
// opposing party's confidentiality application. Generalises to
// any rule whose trigger_event_id is set (e.g. R.6(2)
// translations_lodge → judge-rapporteur's order).
//
// Only the user-facing wire fields shift; parentRule (and the
// parent_id chain that feeds parentIsCourtSet / the calc-time
// date arithmetic below) stays anchored on the rule tree —
// that's still the right calc semantic. parentRule is NOT
// reassigned here.
if r.TriggerEventID != nil {
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
d.ParentRuleCode = te.Code
d.ParentRuleName = te.NameDE
d.ParentRuleNameEN = te.Name
}
}
// Propagate court-set status from a parent rule whose date the
// court determines: if the anchor itself has no real date,
// nothing downstream can be computed either — UNLESS the user
// has supplied an override date for the parent (which they can
// once they know the real decision date).
parentOverridden := false
if r.ParentID != nil && courtSet[*r.ParentID] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.SubmissionCode != nil {
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
parentOverridden = true
}
}
break
if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil {
if parentRule.SubmissionCode != nil {
if _, ok := overrideDates[*parentRule.SubmissionCode]; ok {
parentOverridden = true
}
}
}
@@ -530,6 +698,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// "unbestimmt", not "wird vom Gericht bestimmt".
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -563,6 +732,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// itself isn't a court action.
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -595,9 +765,19 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// should say "unbestimmt", not "wird vom Gericht bestimmt":
// the date isn't directly determined by the court, it's
// derived from a date the court sets.
//
// timing='before' rules end up here too — a rule with
// "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the
// oral hearing as its parent; if the hearing date isn't set,
// the backward arithmetic against the trigger date is
// meaningless. The pre-pass above ensures courtSet[oral.ID]
// is true even when the oral hearing rule is processed later
// in sequence_order. IsConditional surfaces the "abhängig
// von <ParentRuleName>" UX. (t-paliad-289)
if parentIsCourtSet {
d.IsCourtSet = true
d.IsCourtSetIndirect = true
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
@@ -700,18 +880,65 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
// Rules with priority='optional' AND primary_party='both' whose
// data-model parent is the proceeding's trigger anchor (parent
// has parent_id=NULL and is not court-set, i.e. the SoC root
// rule) represent a rule whose REAL triggering event sits
// outside the rule data — e.g. R.262(2) Erwiderung auf
// Vertraulichkeitsantrag anchors on SoC in the data, but the
// real trigger is the opposing party's confidentiality motion
// which may never happen. Without an explicit anchor on the
// rule itself (user clicks "Datum setzen" after the motion
// arrives), the projection must NOT claim a concrete date.
//
// In the live corpus this catches confidentiality_response;
// every other optional+both rule has a court-set ancestor and
// is already caught by the parentIsCourtSet branches above.
// Suppressed when IsOverridden (the user has anchored the rule
// — the date is real) or when the rule has already been marked
// IsConditional by an earlier branch.
if !d.IsOverridden && !d.IsConditional &&
r.Priority == "optional" &&
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
d.IsConditional = true
d.DueDate = ""
d.OriginalDate = ""
d.WasAdjusted = false
d.AdjustmentReason = nil
// Mark this rule's ID as having an uncertain anchor so
// rules chaining off it also surface conditional via the
// parentIsCourtSet path (no rule currently chains off
// confidentiality_response in the live corpus, but the
// extension keeps the propagation semantics consistent).
courtSet[r.ID] = true
}
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = adjusted
}
deadlines = append(deadlines, d)
}
// t-paliad-296: within consecutive runs of rules sharing the same
// trigger group (parent_id + trigger_event_id), reorder by duration
// ascending so optional events following the same anchor render in
// their likely-sequence order (a 1-month rule before a 2-month rule
// chained off the same decision). Different trigger groups keep
// their proceeding-sequence position — the chunk walk only sorts
// adjacent same-group rows. Court-set / conditional rows whose
// date isn't in the duration ladder sort LAST within their group.
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
resp := &UIResponse{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding` (e.g.
@@ -731,6 +958,138 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
return resp, nil
}
// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of
// deadlines whose underlying rule shares the same trigger group
// (parent_id + trigger_event_id) and reorders each run in place by
// duration ascending. Different trigger groups keep their original
// proceeding-sequence position — the walk only ever permutes adjacent
// same-group rows.
//
// Sort key (within a run):
// 1. Conditional / court-set rows (no concrete date in the duration
// ladder) sort LAST, tiebroken by submission_code.
// 2. duration_unit weight ASC: days/working_days < weeks < months < years
// 3. duration_value ASC
// 4. submission_code ASC (deterministic tiebreak)
//
// Issue: m/paliad#128 — post-decision optional events (R.151/R.353
// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog
// order instead of likely-sequence order. (t-paliad-296)
func sortDeadlinesByDurationWithinTriggerGroup(
deadlines []UIDeadline,
ruleByID map[uuid.UUID]models.DeadlineRule,
) {
if len(deadlines) < 2 {
return
}
n := len(deadlines)
i := 0
for i < n {
gid := triggerGroupKey(deadlines[i], ruleByID)
j := i + 1
for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid {
j++
}
// Root rules (no parent and no trigger_event) get gid="root"
// and would otherwise collapse into one big run. Skip the sort
// for the "root" pseudo-group — each root rule represents its
// own anchor (SoC, oral hearing, decision …) and the
// proceeding-sequence order between them must be preserved.
if j-i > 1 && gid != "" {
chunk := deadlines[i:j]
sort.SliceStable(chunk, func(a, b int) bool {
return durationLessForSort(chunk[a], chunk[b], ruleByID)
})
}
i = j
}
}
// triggerGroupKey returns a string key identifying which trigger group
// a deadline belongs to. Same key = same group = candidates for sort.
// Empty string means "root" (no parent, no trigger_event) — used as a
// sentinel by the caller to skip sorting roots against each other.
func triggerGroupKey(d UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule) string {
rid, err := uuid.Parse(d.RuleID)
if err != nil {
return ""
}
r, ok := ruleByID[rid]
if !ok {
return ""
}
if r.ParentID != nil {
return "p:" + r.ParentID.String()
}
if r.TriggerEventID != nil {
return fmt.Sprintf("t:%d", *r.TriggerEventID)
}
return ""
}
// durationLessForSort compares two deadlines for the duration-ascending
// sort. Court-set / conditional rows (no concrete date) sort LAST
// regardless of duration — they don't fit the duration ladder.
func durationLessForSort(
a, b UIDeadline,
ruleByID map[uuid.UUID]models.DeadlineRule,
) bool {
aLast := a.IsCourtSet || a.IsConditional
bLast := b.IsCourtSet || b.IsConditional
if aLast != bLast {
return !aLast
}
if aLast && bLast {
return a.Code < b.Code
}
ra := lookupRuleFromDeadline(a, ruleByID)
rb := lookupRuleFromDeadline(b, ruleByID)
wa := durationUnitWeight(ra.DurationUnit)
wb := durationUnitWeight(rb.DurationUnit)
if wa != wb {
return wa < wb
}
if ra.DurationValue != rb.DurationValue {
return ra.DurationValue < rb.DurationValue
}
return a.Code < b.Code
}
func lookupRuleFromDeadline(
d UIDeadline,
ruleByID map[uuid.UUID]models.DeadlineRule,
) models.DeadlineRule {
if d.RuleID == "" {
return models.DeadlineRule{}
}
rid, err := uuid.Parse(d.RuleID)
if err != nil {
return models.DeadlineRule{}
}
return ruleByID[rid]
}
// durationUnitWeight maps a duration unit to its sort weight so the
// trigger-group sort can order shorter durations first. days and
// working_days share weight 0 (both are sub-week granularities);
// unknown units sort to the end so they're visible as a tail rather
// than silently winning.
func durationUnitWeight(unit string) int {
switch unit {
case "days", "working_days":
return 0
case "weeks":
return 1
case "months":
return 2
case "years":
return 3
}
return 4
}
// ErrUnknownRule is returned when CalculateRule can't resolve the
// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule.
var ErrUnknownRule = errors.New("unknown rule")

View File

@@ -0,0 +1,221 @@
package services
// Pure-function tests for the trigger-group duration sort introduced
// by t-paliad-296 / m/paliad#128. No DB needed — feeds synthetic
// UIDeadlines and a ruleByID map directly into the helper.
import (
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
// makeRule is a tiny constructor for a synthetic rule with just the
// fields the sort reads (parent_id, duration_value, duration_unit,
// submission_code, trigger_event_id).
func makeRule(t *testing.T, parent *uuid.UUID, code string, val int, unit string) (uuid.UUID, models.DeadlineRule) {
t.Helper()
id := uuid.New()
codeCopy := code
return id, models.DeadlineRule{
ID: id,
ParentID: parent,
SubmissionCode: &codeCopy,
DurationValue: val,
DurationUnit: unit,
}
}
func makeDeadline(id uuid.UUID, code string) UIDeadline {
return UIDeadline{
RuleID: id.String(),
Code: code,
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision is the
// canonical scenario from m's report — four post-decision optional
// events anchored on the same decision must render with 1-month rules
// before 2-month rules.
func TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision(t *testing.T) {
decisionID := uuid.New()
// Catalog order matches mig 132 sequence_order: cons_orders(60),
// cost_app(70), rectification(70), appeal_spawn(80).
consOrdID, consOrdRule := makeRule(t, &decisionID, "upc.inf.cfi.cons_orders", 2, "months")
costAppID, costAppRule := makeRule(t, &decisionID, "upc.inf.cfi.cost_app", 1, "months")
rectID, rectRule := makeRule(t, &decisionID, "upc.inf.cfi.rectification", 1, "months")
appealID, appealRule := makeRule(t, &decisionID, "upc.inf.cfi.appeal_spawn", 2, "months")
ruleByID := map[uuid.UUID]models.DeadlineRule{
consOrdID: consOrdRule,
costAppID: costAppRule,
rectID: rectRule,
appealID: appealRule,
}
deadlines := []UIDeadline{
makeDeadline(consOrdID, "upc.inf.cfi.cons_orders"),
makeDeadline(costAppID, "upc.inf.cfi.cost_app"),
makeDeadline(rectID, "upc.inf.cfi.rectification"),
makeDeadline(appealID, "upc.inf.cfi.appeal_spawn"),
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// 1-month tier first (cost_app, rectification — alphabetical by
// submission_code), then 2-month tier (appeal_spawn, cons_orders
// — submission_code ASC tiebreak per spec).
want := []string{
"upc.inf.cfi.cost_app",
"upc.inf.cfi.rectification",
"upc.inf.cfi.appeal_spawn",
"upc.inf.cfi.cons_orders",
}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
}
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight asserts the
// unit-weight ordering: days < weeks < months < years, with shorter
// durations of the same unit winning their tier.
func TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight(t *testing.T) {
parentID := uuid.New()
d14ID, d14Rule := makeRule(t, &parentID, "x.14days", 14, "days")
d2wID, d2wRule := makeRule(t, &parentID, "x.2weeks", 2, "weeks")
d1mID, d1mRule := makeRule(t, &parentID, "x.1month", 1, "months")
d6mID, d6mRule := makeRule(t, &parentID, "x.6months", 6, "months")
d1yID, d1yRule := makeRule(t, &parentID, "x.1year", 1, "years")
ruleByID := map[uuid.UUID]models.DeadlineRule{
d14ID: d14Rule, d2wID: d2wRule, d1mID: d1mRule, d6mID: d6mRule, d1yID: d1yRule,
}
deadlines := []UIDeadline{
makeDeadline(d6mID, "x.6months"),
makeDeadline(d1yID, "x.1year"),
makeDeadline(d2wID, "x.2weeks"),
makeDeadline(d14ID, "x.14days"),
makeDeadline(d1mID, "x.1month"),
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
want := []string{"x.14days", "x.2weeks", "x.1month", "x.6months", "x.1year"}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
}
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder
// guards the hard rule: rules with different parents must keep their
// relative position. Sorting only ever permutes adjacent same-parent
// rows.
func TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder(t *testing.T) {
parentAID := uuid.New()
parentBID := uuid.New()
a3mID, a3mRule := makeRule(t, &parentAID, "ga.3months", 3, "months")
b1mID, b1mRule := makeRule(t, &parentBID, "gb.1month", 1, "months")
a14dID, a14dRule := makeRule(t, &parentAID, "ga.14days", 14, "days")
b2mID, b2mRule := makeRule(t, &parentBID, "gb.2months", 2, "months")
ruleByID := map[uuid.UUID]models.DeadlineRule{
a3mID: a3mRule, b1mID: b1mRule, a14dID: a14dRule, b2mID: b2mRule,
}
// Interleaved groups: A, B, A, B. Each group has one rule between
// each other group's rules — the consecutive-run walk should treat
// each as its own one-element run and not reorder anything.
deadlines := []UIDeadline{
makeDeadline(a3mID, "ga.3months"),
makeDeadline(b1mID, "gb.1month"),
makeDeadline(a14dID, "ga.14days"),
makeDeadline(b2mID, "gb.2months"),
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
want := []string{"ga.3months", "gb.1month", "ga.14days", "gb.2months"}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q (interleaved groups must not reorder across)", i, deadlines[i].Code, w)
}
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast asserts
// that court-set / conditional rows (no concrete date in the duration
// ladder) sort LAST within their group, regardless of their stated
// duration value.
func TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast(t *testing.T) {
parentID := uuid.New()
dID, dRule := makeRule(t, &parentID, "x.duration", 2, "months")
cID, cRule := makeRule(t, &parentID, "x.conditional", 1, "months")
csID, csRule := makeRule(t, &parentID, "x.courtset", 1, "months")
d2ID, d2Rule := makeRule(t, &parentID, "x.short", 14, "days")
ruleByID := map[uuid.UUID]models.DeadlineRule{
dID: dRule, cID: cRule, csID: csRule, d2ID: d2Rule,
}
deadlines := []UIDeadline{
{RuleID: cID.String(), Code: "x.conditional", IsConditional: true},
{RuleID: dID.String(), Code: "x.duration"},
{RuleID: csID.String(), Code: "x.courtset", IsCourtSet: true},
{RuleID: d2ID.String(), Code: "x.short"},
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// Concrete rows first (sorted by duration): x.short (14d) then
// x.duration (2mo). Then the two no-date rows, tiebroken by code:
// x.conditional < x.courtset alphabetically.
want := []string{"x.short", "x.duration", "x.conditional", "x.courtset"}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
}
}
}
// TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged guards
// the root-rule exception: top-level rules (parent_id=nil, no
// trigger_event_id) must never be sorted against each other — they
// represent distinct anchor points (SoC vs oral hearing vs decision)
// whose proceeding-sequence order is non-negotiable.
func TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged(t *testing.T) {
rootSoCID, rootSoCRule := makeRule(t, nil, "x.soc", 0, "months")
rootOralID, rootOralRule := makeRule(t, nil, "x.oral", 0, "months")
rootDecID, rootDecRule := makeRule(t, nil, "x.decision", 0, "months")
ruleByID := map[uuid.UUID]models.DeadlineRule{
rootSoCID: rootSoCRule, rootOralID: rootOralRule, rootDecID: rootDecRule,
}
deadlines := []UIDeadline{
makeDeadline(rootSoCID, "x.soc"),
makeDeadline(rootOralID, "x.oral"),
makeDeadline(rootDecID, "x.decision"),
}
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// Roots must keep their input order — they're not in the same
// trigger group as each other.
want := []string{"x.soc", "x.oral", "x.decision"}
for i, w := range want {
if deadlines[i].Code != w {
t.Errorf("deadlines[%d].Code = %q, want %q (roots must not be sorted against each other)", i, deadlines[i].Code, w)
}
}
}

View File

@@ -450,3 +450,182 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
}
}
// t-paliad-289: rules anchored on uncertain triggers must render as
// conditional (IsConditional=true, empty DueDate, ParentRule* populated)
// rather than fabricating a date off the trigger.
//
// Three pillars from the issue:
// - Symptom A: R.109(1) Antrag auf Simultanübersetzung (timing='before',
// parent=Mündliche Verhandlung which is court-set). Pre-fix the rule
// computed a meaningless "1 month before today" because sequence_order
// places translation_request (45) before oral (50), so the parent
// hadn't been classified as court-set yet. The new pre-pass in
// Calculate seeds courtSet from is_court_set=true on the data, so
// order-of-evaluation no longer matters.
// - R.118(4) cons_orders (parent=Entscheidung, court-set) — already
// worked via the legacy IsCourtSetIndirect path; assertion ensures
// the new IsConditional flag rides alongside it.
// - Symptom B: R.262(2) confidentiality_response (priority='optional',
// primary_party='both', parent=SoC which is the trigger anchor).
// The data-model parent is "always certain" but the real triggering
// event (opposing party's confidentiality motion) sits outside the
// rule data — render conditional until the user anchors the rule.
func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
holidays := NewHolidayService(pool)
rules := NewDeadlineRuleService(pool)
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := map[string]UIDeadline{}
for _, d := range resp.Deadlines {
byCode[d.Code] = d
}
cases := []struct {
code string
wantConditional bool
wantParentCode string
}{
// Symptom A — backward-anchored on the court-set oral hearing.
// Pre-pass fix: order-of-evaluation no longer matters. These
// rules have no trigger_event_id, so ParentRuleCode stays on
// the parent_id-derived value.
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
// Symptom B — optional + both, data-model parent is SoC but the
// real trigger is the opposing party's confidentiality application.
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
// trigger_events catalog row (id=25), NOT the parent_id chain.
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
// Negative control — mandatory rule anchored on SoC must keep
// its concrete date (no IsConditional, real DueDate). No
// trigger_event_id, so parent_id-derived code stays.
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
}
for _, c := range cases {
t.Run(c.code, func(t *testing.T) {
d, ok := byCode[c.code]
if !ok {
t.Fatalf("rule %s missing from response", c.code)
}
if d.IsConditional != c.wantConditional {
t.Errorf("IsConditional = %v, want %v", d.IsConditional, c.wantConditional)
}
if c.wantConditional {
if d.DueDate != "" {
t.Errorf("DueDate = %q, want empty (conditional)", d.DueDate)
}
if d.ParentRuleCode != c.wantParentCode {
t.Errorf("ParentRuleCode = %q, want %q", d.ParentRuleCode, c.wantParentCode)
}
if d.ParentRuleName == "" {
t.Errorf("ParentRuleName empty for conditional rule")
}
} else {
if d.DueDate == "" {
t.Errorf("non-conditional rule has empty DueDate")
}
}
})
}
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
// reads from the trigger_events catalog (id=25), so the user sees
// the actual semantic anchor instead of the parent_id-derived
// "Klageerhebung". Pin the exact DE + EN strings so a future
// rename of the catalog row surfaces here.
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
if !ok {
t.Fatalf("confidentiality_response missing from response")
}
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
const wantNameEN = "Application to request confidentiality from the public"
if d.ParentRuleName != wantNameDE {
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
}
if d.ParentRuleNameEN != wantNameEN {
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
}
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
// which is the regression the fix exists to prevent.
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
}
})
// Generalisation guard — translations_lodge also carries a real
// trigger_event_id (113 = judge-rapporteur's order). Its
// conditional chip should reference the order, not its parent_id
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
// uses THAT, not parent_id" contract from m/paliad#126.
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
d, ok := byCode["upc.inf.cfi.translations_lodge"]
if !ok {
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
}
if !d.IsConditional {
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
}
if d.ParentRuleName == "Zwischenverfahren" {
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
}
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
}
})
// Override path: when the user anchors the oral hearing, the
// backward-anchored R.109(1) flips back to a concrete date and
// IsConditional clears. This is the click-to-edit unblock.
t.Run("override on court-set parent clears IsConditional", func(t *testing.T) {
resp2, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{
AnchorOverrides: map[string]string{
"upc.inf.cfi.oral": "2027-03-01",
},
})
if err != nil {
t.Fatalf("Calculate with override: %v", err)
}
var tr UIDeadline
for _, d := range resp2.Deadlines {
if d.Code == "upc.inf.cfi.translation_request" {
tr = d
break
}
}
if tr.IsConditional {
t.Errorf("translation_request IsConditional=true after oral override; want false")
}
if tr.DueDate == "" {
t.Errorf("translation_request DueDate empty after oral override")
}
// 1 month before 2027-03-01 = ~2027-02-01 (with weekend bump).
if tr.DueDate < "2027-01-25" || tr.DueDate > "2027-02-05" {
t.Errorf("translation_request DueDate=%q not within expected 2027-01-25..2027-02-05 window", tr.DueDate)
}
})
}

View File

@@ -38,6 +38,59 @@ type CreatePartyInput struct {
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
// PartySearchHit is one row of the cross-project party search — a real
// paliad.parties row enriched with the parent project's title and
// reference so the picker can render context the lawyer needs to
// disambiguate identically-named parties on different cases
// (t-paliad-287).
type PartySearchHit struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
ProjectTitle string `db:"project_title" json:"project_title"`
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
Name string `db:"name" json:"name"`
Role *string `db:"role" json:"role,omitempty"`
Representative *string `db:"representative" json:"representative,omitempty"`
}
// Search returns parties from every project the caller can see, matched
// by case-insensitive substring on name OR representative. Empty query
// returns the 20 most recently-updated parties so the picker isn't
// blank on first open. Capped at 25 rows; the frontend doesn't paginate
// (the typical PA looks for one party they remember by name, not browses).
//
// Visibility is enforced inline via visibilityPredicatePositional —
// invisible projects' parties never surface in the result set.
func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) {
if limit <= 0 || limit > 50 {
limit = 25
}
q := strings.TrimSpace(query)
args := []any{userID}
conds := []string{visibilityPredicatePositional("p", 1)}
if q != "" {
args = append(args, "%"+q+"%")
conds = append(conds,
fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`,
len(args), len(args)))
}
args = append(args, limit)
sqlStr := `
SELECT pa.id, pa.project_id, p.title AS project_title,
p.reference AS project_reference,
pa.name, pa.role, pa.representative
FROM paliad.parties pa
JOIN paliad.projects p ON p.id = pa.project_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY pa.updated_at DESC
LIMIT $` + fmt.Sprintf("%d", len(args))
hits := []PartySearchHit{}
if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil {
return nil, fmt.Errorf("search parties: %w", err)
}
return hits, nil
}
// ListForProject returns all Parties for the Project, visibility-checked.
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {

View File

@@ -97,6 +97,58 @@ func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
}
}
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
// pass through applyLookaheadCap untouched — they're not "future
// predicted" rows by either Status or Date semantics, so they belong in
// the pass-through bucket alongside court_set / undated rows. The cap
// must NOT consume one of its slots for a conditional row, and the
// row must survive even when projTotal exceeds the cap.
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// Three predicted future — cap=2 means the third drops.
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
// Two conditional — must survive uncapped, must NOT count
// against projTotal / projShown.
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
}
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
if total != 3 {
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
}
if shown != 2 {
t.Errorf("ProjectedShown = %d, want 2", shown)
}
if overdue != 0 {
t.Errorf("PredictedOverdue = %d, want 0", overdue)
}
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
if len(kept) != 4 {
t.Errorf("kept rows = %d, want 4", len(kept))
}
keptTitles := map[string]bool{}
for _, r := range kept {
keptTitles[r.Title] = true
}
for _, want := range []string{"F1", "F2", "C1", "C2"} {
if !keptTitles[want] {
t.Errorf("expected kept row %q missing", want)
}
}
if keptTitles["F3"] {
t.Errorf("F3 should have been dropped (cap=2)")
}
}
func TestRuleAnchorKind(t *testing.T) {
hearing := "hearing"
decision := "decision"

View File

@@ -147,6 +147,17 @@ type TimelineEvent struct {
// checkbox). At parent-node levels, rows with BubbleUp=true survive
// the levelPolicy kind/status filter unconditionally.
BubbleUp bool `json:"bubble_up,omitempty"`
// IsConditional marks projected rows whose anchor is uncertain —
// the projection layer mirrors UIDeadline.IsConditional from the
// fristenrechner so the SmartTimeline can render an "abhängig von
// <parent>" chip in place of the date column. When true, Date is
// nil and DependsOnRuleCode / DependsOnRuleName carry the parent
// reference (already populated by annotateDependsOn for projected
// rows; for conditional rows we additionally fall back to the
// UIDeadline-supplied ParentRule* when the parent has no
// computed date). Status is set to "conditional". (t-paliad-289)
IsConditional bool `json:"is_conditional,omitempty"`
}
// LaneInfo describes one column in the parent-node aggregated view.
@@ -933,12 +944,13 @@ func (s *ProjectionService) computeProjections(
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
RuleCode: ui.Code,
DeadlineRuleParty: ui.Party,
IsConditional: ui.IsConditional,
}
idCopy := ruleID
ev.DeadlineRuleID = &idCopy
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
// court-set rules whose date isn't bound yet.
// court-set / conditional rules whose date isn't bound yet.
if ui.DueDate != "" {
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
@@ -946,7 +958,38 @@ func (s *ProjectionService) computeProjections(
}
}
// Conditional rows from the fristenrechner (t-paliad-289):
// pre-stamp the dependency reference here so the row carries
// the "abhängig von <parent>" payload even when the parent has
// no computed date for annotateDependsOn to pick up later.
// annotateDependsOn won't overwrite a non-empty DependsOnRuleCode,
// and the parent's actual date (if anchored elsewhere) still
// flows into DependsOnDate via the actuals-first preference.
if ui.IsConditional && ui.ParentRuleCode != "" {
ev.DependsOnRuleCode = ui.ParentRuleCode
switch lang(opts.Lang) {
case "en":
if ui.ParentRuleNameEN != "" {
ev.DependsOnRuleName = ui.ParentRuleNameEN
} else {
ev.DependsOnRuleName = ui.ParentRuleName
}
default:
ev.DependsOnRuleName = ui.ParentRuleName
}
}
switch {
case ui.IsConditional:
// Anchor uncertain (court-set ancestor without override,
// backward-anchor without forward date, or optional event
// not recorded). Surface as conditional so the frontend
// renders "abhängig von <parent>" in place of a date.
// Conditional rows must not carry a date even if the
// calculator left one — clear it to match the wire contract.
// (t-paliad-289)
ev.Date = nil
ev.Status = "conditional"
case ui.IsCourtSet && ev.Date == nil:
// Pure court-set rule — date is bound by the court at
// hearing/decision time. Surface as undated court_set.

View File

@@ -604,92 +604,6 @@ func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.
return &r, nil
}
// ExportMigrationsSince returns a SQL blob containing one UPDATE / INSERT
// per audited rule change after the given audit row id. Used by the
// admin "export changes to a migration file" flow (Q-H-5: pure SQL
// format). Returns SQL + count + the latest audit id seen so the
// caller can pass it as ?since= on the next call.
//
// v1 generates one UPDATE per audit row using the after_json snapshot.
// Slice 11b will polish the output (re-order so foreign-key edges
// resolve, collapse consecutive UPDATEs on the same row, format the
// header comment with author + reason). v1 emits one statement per
// audit row in chronological order — sufficient for hand-review.
type ExportResult struct {
MigrationSQL string `json:"migration_sql"`
Count int `json:"count"`
LatestAuditID string `json:"latest_audit_id"`
}
func (s *RuleEditorService) ExportMigrationsSince(ctx context.Context, sinceAuditID string) (*ExportResult, error) {
type auditRow struct {
ID uuid.UUID `db:"id"`
RuleID uuid.UUID `db:"rule_id"`
ChangedAt time.Time `db:"changed_at"`
Action string `db:"action"`
AfterJSON json.RawMessage `db:"after_json"`
Reason string `db:"reason"`
}
var rows []auditRow
q := `SELECT id, rule_id, changed_at, action, after_json, reason
FROM paliad.deadline_rule_audit
WHERE migration_exported = false`
args := []any{}
if sinceAuditID != "" {
sid, err := uuid.Parse(sinceAuditID)
if err != nil {
return nil, fmt.Errorf("%w: invalid since= uuid", ErrInvalidInput)
}
q += ` AND changed_at >= (SELECT changed_at FROM paliad.deadline_rule_audit WHERE id = $1)`
args = append(args, sid)
}
q += ` ORDER BY changed_at ASC`
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
return nil, fmt.Errorf("list audit since: %w", err)
}
var sb strings.Builder
sb.WriteString("-- Auto-generated rule-editor migration export.\n")
sb.WriteString("-- Generated at: " + time.Now().UTC().Format(time.RFC3339) + "\n")
sb.WriteString("-- Rows: " + fmt.Sprintf("%d", len(rows)) + "\n\n")
sb.WriteString("SELECT set_config('paliad.audit_reason',\n")
sb.WriteString(" 'rule-editor export: replay of " + fmt.Sprintf("%d", len(rows)) + " edits', true);\n\n")
latest := ""
for _, r := range rows {
sb.WriteString("-- audit " + r.ID.String() + " (" + r.Action + " " + r.ChangedAt.Format(time.RFC3339) + "): " + sqlEscape(r.Reason) + "\n")
switch r.Action {
case "create", "update":
if len(r.AfterJSON) == 0 {
sb.WriteString("-- (no after_json — skipped)\n\n")
continue
}
sb.WriteString("INSERT INTO paliad.deadline_rules\n")
sb.WriteString(" SELECT (jsonb_populate_record(NULL::paliad.deadline_rules, '")
sb.WriteString(sqlEscape(string(r.AfterJSON)))
sb.WriteString("'::jsonb)).*\n")
sb.WriteString("ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, name_en = EXCLUDED.name_en,\n")
sb.WriteString(" duration_value = EXCLUDED.duration_value, duration_unit = EXCLUDED.duration_unit,\n")
sb.WriteString(" timing = EXCLUDED.timing, priority = EXCLUDED.priority,\n")
sb.WriteString(" is_court_set = EXCLUDED.is_court_set,\n")
sb.WriteString(" condition_expr = EXCLUDED.condition_expr,\n")
sb.WriteString(" lifecycle_state = EXCLUDED.lifecycle_state,\n")
sb.WriteString(" updated_at = now();\n\n")
case "delete", "archive":
sb.WriteString("UPDATE paliad.deadline_rules SET lifecycle_state='archived', updated_at=now() WHERE id='")
sb.WriteString(r.RuleID.String())
sb.WriteString("';\n\n")
}
latest = r.ID.String()
}
return &ExportResult{
MigrationSQL: sb.String(),
Count: len(rows),
LatestAuditID: latest,
}, nil
}
// =============================================================================
// Internal helpers
// =============================================================================
@@ -814,6 +728,3 @@ func nullableJSON(b json.RawMessage) any {
return []byte(b)
}
func sqlEscape(s string) string {
return strings.ReplaceAll(s, "'", "''")
}

View File

@@ -315,11 +315,11 @@ func buildDocumentXML() string {
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
headerSubsection(&b, "Frist")
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// for custom templates that want them, but the default HL skeleton
// no longer renders them in the submission body: the deadline is
// internal/admin context and has no place in a court-bound document.
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
@@ -349,7 +349,6 @@ func buildDocumentXML() string {
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")

View File

@@ -137,14 +137,19 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
</w:styles>`
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
// case caption + parties + submission heading + deadline + a single
// neutral body block. Mirrors the variable bag from SubmissionVarsService
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
// structure. A lawyer customising this template for a UPC SoC, EPO
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
// renames the party labels — every placeholder still resolves regardless
// of the submission_code chosen.
// case caption + parties + submission heading + a single neutral body
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
// today.* / user.* / project.* / parties.* / rule.*) without baking in
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
// [Schriftsatztext] block and renames the party labels — every
// placeholder still resolves regardless of the submission_code chosen.
//
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
// default skeleton (t-paliad-287). The deadline is internal context for
// the lawyer, not text that belongs in a court-bound submission. The
// keys stay resolvable in the bag so a custom template can still
// reference them where it actually wants them.
//
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
// (format-preserving, single-run) substitution catches it. The
@@ -194,11 +199,12 @@ func buildDocumentXML() string {
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
heading2(&b, "Frist")
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// (lawyer can still drop them into a custom paragraph) but the
// default skeleton no longer renders them in the submission body:
// the deadline is internal/admin context and has no place in a
// document going out to court.
heading2(&b, "Schriftsatztext")
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
@@ -217,7 +223,7 @@ func buildDocumentXML() string {
// the bare {{today}} alias. A lawyer customising the template can
// delete this block; the renderer round-trips it cleanly today.
heading2(&b, "Locale-aware variants (SKELETON)")
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
plain(&b, "EN long date: {{today.long_en}}")
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")