Surfaces the Slice 11a admin API at /admin/rules so editors can drive
the rule lifecycle without curling. Three new pages, each gated by
adminGate on the route + sidebar reveal via /api/me:
/admin/rules — list page with filters (proceeding,
trigger event, lifecycle chips, fuzzy
search) and a second "Orphans" tab that
loads paliad.deadline_rule_backfill_orphans
via the new GET /admin/api/orphans
endpoint. Pick-chip on each candidate
fires the reason modal → POST resolve.
"+ Neue Regel" opens the same reason modal
with minimal required fields (name DE/EN
+ duration) and routes to the edit page
on success.
/admin/rules/{id}/edit — full form (37 columns grouped: identity /
proceeding / timing / party / display /
lifecycle / condition). Side panel hosts
the preview widget (trigger date + flags
→ GET .../preview, drafts only) and the
audit-log timeline (paginated, 20 per
page). Bottom action bar adapts to
lifecycle_state — save-draft + publish on
drafts, clone on published/archived,
archive on draft/published, restore on
archived. Every action opens the reason
modal with ≥10-char client-side guard per
Slice 11a edge case #4.
/admin/rules/export — minimal SQL preview + "Download as file"
/ "Copy to clipboard". Optional `since`
audit-id scopes the export window.
condition_expr ships with a raw JSON textarea + inline parse
validation; the tree-builder is out of scope for Slice 11b (raw JSON
is sufficient given the existing 172-row corpus and validates the
same grammar live). The dependency on document.querySelectorAll for
form binding follows the admin-event-types / admin-audit-log
playbook — no new component substrate needed.
Wiring:
- frontend/build.ts: 3 new entrypoints + 3 new HTML writes.
- frontend/src/admin.tsx: new "Regeln verwalten" card with ICON_TABLE.
- frontend/src/components/Sidebar.tsx: two new admin nav entries
(Regeln + Regel-Migrations).
- frontend/src/client/i18n.ts: 162 new keys (DE+EN), under
admin.rules.* and admin.rules.edit.* and admin.rules.export.*.
- frontend/src/styles/global.css: new admin-rules-* CSS block
appended (chips, pills, audit timeline, edit-grid, preview list,
orphan cards, export pre). Uses paliad's existing CSS tokens so
light/dark/auto themes inherit automatically.
Route registration:
- GET /admin/rules — list page shell
- GET /admin/rules/{id}/edit — edit page shell
- GET /admin/rules/export — export page shell
All routes adminGate + gateOnboarded, so non-admin users 404 before
the shell even loads. Backend audit and lifecycle invariants from
Slice 11a stay authoritative; the frontend never bypasses them.
101 lines
3.9 KiB
TypeScript
101 lines
3.9 KiB
TypeScript
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);
|