Compare commits
16 Commits
mai/ritchi
...
mai/artemi
| Author | SHA1 | Date | |
|---|---|---|---|
| cc13a5b857 | |||
| abef74fe63 | |||
| 49ddaa4eb8 | |||
| 1bd2ebb4ae | |||
| f6c8eb5bcf | |||
| 5ba4df9d55 | |||
| 7ca6b2d643 | |||
| ed8af0dca9 | |||
| 293e612582 | |||
| 9d3325bd88 | |||
| 18d2e743ba | |||
| 07d2eb472c | |||
| c3eaa9b1d4 | |||
| 80883eaac5 | |||
| 5e17de6e07 | |||
| 0e1f62e375 |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 — 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">← 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ä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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
@@ -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}",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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=\"{"skip":[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
|
||||
|
||||
@@ -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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 0–N 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) {
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -8164,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;
|
||||
@@ -16094,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;
|
||||
}
|
||||
@@ -16702,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;
|
||||
@@ -17640,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 {
|
||||
@@ -18028,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 /
|
||||
|
||||
@@ -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ä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"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual divider — keeps the perspective block (most-
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -670,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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
221
internal/services/fristenrechner_sort_test.go
Normal file
221
internal/services/fristenrechner_sort_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, "'", "''")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user