Files
paliad/frontend/src/admin-rules-edit.tsx
mAi 193b988798 feat(t-paliad-192): admin rule-editor frontend (Slice 11b)
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.
2026-05-15 02:09:35 +02:00

353 lines
21 KiB
TypeScript

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/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// 37-column rule row plus a side panel with the preview widget and the
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
// rule's current state (draft/published/archived). Every write goes
// through a reason modal that enforces the ≥10-char rule from Slice 11a
// edge case #4.
//
// The id of the rule is parsed from the URL path on hydration —
// frontend never reads it from a server-injected blob, so the static
// HTML shell is reusable for every rule. condition_expr ships with a
// raw JSON textarea + a simple AND/OR/NOT tree-builder (toggle).
export function renderAdminRulesEdit(): 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.edit.title">Regel bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header admin-rules-edit-header">
<div>
<p className="admin-rules-breadcrumb">
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">&larr; Regeln verwalten</a>
</p>
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
<div className="admin-rules-edit-meta">
<span id="rules-edit-lifecycle" className="admin-rules-pill admin-rules-pill-draft" />
<span id="rules-edit-id" className="admin-rules-edit-uuid" />
</div>
</div>
</div>
<div id="rules-edit-feedback" className="form-msg" style="display:none" />
<div className="admin-rules-edit-grid">
<form id="rules-edit-form" className="entity-form admin-rules-edit-form" autocomplete="off">
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.identity">Identit&auml;t</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-name" data-i18n="admin.rules.edit.field.name">Name (DE)</label>
<input type="text" id="f-name" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-name-en" data-i18n="admin.rules.edit.field.name_en">Name (EN)</label>
<input type="text" id="f-name-en" className="admin-rules-input" />
</div>
</div>
<div className="form-field">
<label htmlFor="f-description" data-i18n="admin.rules.edit.field.description">Beschreibung</label>
<textarea id="f-description" className="admin-rules-input" rows={2} />
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-code" data-i18n="admin.rules.edit.field.code">Code</label>
<input type="text" id="f-code" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rule-Code (zit.)</label>
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
</div>
<div className="form-field">
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage</label>
<input type="text" id="f-legal-source" className="admin-rules-input" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.proceeding">Verfahren &amp; Trigger</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-proceeding" data-i18n="admin.rules.edit.field.proceeding">Verfahrenstyp</label>
<select id="f-proceeding" className="admin-rules-select">
<option value="" data-i18n="admin.rules.edit.field.proceeding.none"></option>
</select>
</div>
<div className="form-field">
<label htmlFor="f-trigger" data-i18n="admin.rules.edit.field.trigger">Trigger-Ereignis</label>
<select id="f-trigger" className="admin-rules-select">
<option value="" data-i18n="admin.rules.edit.field.trigger.none"></option>
</select>
</div>
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
<label htmlFor="f-concept" data-i18n="admin.rules.edit.field.concept">Konzept (UUID)</label>
<input type="text" id="f-concept" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
<label htmlFor="f-sequence" data-i18n="admin.rules.edit.field.sequence_order">Reihenfolge</label>
<input type="number" id="f-sequence" className="admin-rules-input" min="0" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.timing">Berechnung</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-duration" data-i18n="admin.rules.edit.field.duration_value">Dauer</label>
<input type="number" id="f-duration" className="admin-rules-input" min="0" />
</div>
<div className="form-field">
<label htmlFor="f-duration-unit" data-i18n="admin.rules.edit.field.duration_unit">Einheit</label>
<select id="f-duration-unit" className="admin-rules-select">
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
<option value="working_days">working_days</option>
</select>
</div>
<div className="form-field">
<label htmlFor="f-timing" data-i18n="admin.rules.edit.field.timing">Timing</label>
<select id="f-timing" className="admin-rules-select">
<option value=""></option>
<option value="after">after</option>
<option value="before">before</option>
</select>
</div>
<div className="form-field">
<label htmlFor="f-combine-op" data-i18n="admin.rules.edit.field.combine_op">Combine-Op</label>
<select id="f-combine-op" className="admin-rules-select">
<option value=""></option>
<option value="max">max</option>
<option value="min">min</option>
</select>
</div>
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-alt-duration" data-i18n="admin.rules.edit.field.alt_duration_value">Alt-Dauer</label>
<input type="number" id="f-alt-duration" className="admin-rules-input" min="0" />
</div>
<div className="form-field">
<label htmlFor="f-alt-duration-unit" data-i18n="admin.rules.edit.field.alt_duration_unit">Alt-Einheit</label>
<select id="f-alt-duration-unit" className="admin-rules-select">
<option value=""></option>
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
<option value="working_days">working_days</option>
</select>
</div>
<div className="form-field">
<label htmlFor="f-alt-rule-code" data-i18n="admin.rules.edit.field.alt_rule_code">Alt-Rule-Code</label>
<input type="text" id="f-alt-rule-code" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-anchor-alt" data-i18n="admin.rules.edit.field.anchor_alt">Alt-Anchor</label>
<input type="text" id="f-anchor-alt" className="admin-rules-input" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.party">Partei &amp; Ereignis</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-primary-party" data-i18n="admin.rules.edit.field.primary_party">Prim&auml;re Partei</label>
<input type="text" id="f-primary-party" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
<input type="text" id="f-event-type" className="admin-rules-input" />
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.display">Anzeige &amp; Notizen</legend>
<div className="form-field">
<label htmlFor="f-notes" data-i18n="admin.rules.edit.field.deadline_notes">Hinweise (DE)</label>
<textarea id="f-notes" className="admin-rules-input" rows={2} />
</div>
<div className="form-field">
<label htmlFor="f-notes-en" data-i18n="admin.rules.edit.field.deadline_notes_en">Hinweise (EN)</label>
<textarea id="f-notes-en" className="admin-rules-input" rows={2} />
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.lifecycle">Priorit&auml;t &amp; Flags</legend>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-priority" data-i18n="admin.rules.edit.field.priority">Priorit&auml;t</label>
<select id="f-priority" className="admin-rules-select">
<option value="mandatory">mandatory</option>
<option value="recommended">recommended</option>
<option value="optional">optional</option>
<option value="informational">informational</option>
</select>
</div>
<div className="form-field admin-rules-checkbox-field">
<label>
<input type="checkbox" id="f-is-court-set" />
<span data-i18n="admin.rules.edit.field.is_court_set">Gerichtlich gesetzt</span>
</label>
</div>
<div className="form-field admin-rules-checkbox-field">
<label>
<input type="checkbox" id="f-is-spawn" />
<span data-i18n="admin.rules.edit.field.is_spawn">Spawn</span>
</label>
</div>
</div>
<div className="admin-rules-edit-row" id="f-spawn-row" style="display:none">
<div className="form-field">
<label htmlFor="f-spawn-label" data-i18n="admin.rules.edit.field.spawn_label">Spawn-Label</label>
<input type="text" id="f-spawn-label" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-spawn-proceeding" data-i18n="admin.rules.edit.field.spawn_proceeding">Spawn-Verfahren</label>
<select id="f-spawn-proceeding" className="admin-rules-select">
<option value="" data-i18n="admin.rules.edit.field.spawn_proceeding.none"></option>
</select>
</div>
</div>
</fieldset>
<fieldset className="admin-rules-fieldset">
<legend data-i18n="admin.rules.edit.section.condition">Bedingung (condition_expr)</legend>
<p className="admin-rules-hint" data-i18n="admin.rules.edit.field.condition_hint">
JSON-Grammatik: <code>&#123;"flag":"name"&#125;</code> · <code>&#123;"op":"and|or","args":[...]&#125;</code> · <code>&#123;"op":"not","args":[...]&#125;</code>
</p>
<div className="form-field">
<textarea id="f-condition-expr" className="admin-rules-input admin-rules-code-input" rows={5} placeholder='z. B. {"flag":"with_ccr"}' />
<p className="admin-rules-hint" id="f-condition-msg" />
</div>
</fieldset>
</form>
<aside className="admin-rules-edit-side">
{/* Preview widget */}
<div className="admin-rules-edit-card">
<h3 data-i18n="admin.rules.edit.preview.heading">Preview</h3>
<p className="admin-rules-hint" data-i18n="admin.rules.edit.preview.hint">
Nur f&uuml;r Drafts. Berechnet die Fristenkette mit dieser Draft-Regel anstelle der publizierten Variante.
</p>
<div className="form-field">
<label htmlFor="preview-trigger-date" data-i18n="admin.rules.edit.preview.trigger_date">Trigger-Datum</label>
<input type="date" lang="de" id="preview-trigger-date" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="preview-flags" data-i18n="admin.rules.edit.preview.flags">Flags (komma-separiert)</label>
<input type="text" id="preview-flags" className="admin-rules-input" placeholder="z. B. with_ccr,is_appeal" />
</div>
<button type="button" id="preview-run" className="btn-secondary" data-i18n="admin.rules.edit.preview.run">
Preview berechnen
</button>
<div id="preview-result" className="admin-rules-preview-result" style="display:none" />
</div>
{/* Audit-log timeline */}
<div className="admin-rules-edit-card">
<h3 data-i18n="admin.rules.edit.audit.heading">Audit-Log</h3>
<ol id="rules-edit-audit" className="admin-rules-audit-list">
<li className="admin-rules-loading" data-i18n="admin.rules.edit.audit.loading">Lade...</li>
</ol>
<button type="button" id="audit-loadmore" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.audit.loadmore">
Weitere laden
</button>
</div>
</aside>
</div>
{/* Action bar */}
<div className="admin-rules-actionbar">
<button type="button" id="action-save-draft" className="btn-primary" style="display:none" data-i18n="admin.rules.edit.action.save_draft">
Draft speichern
</button>
<button type="button" id="action-publish" className="btn-primary" style="display:none" data-i18n="admin.rules.edit.action.publish">
Publish
</button>
<button type="button" id="action-clone" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.clone">
Als Draft klonen
</button>
<button type="button" id="action-archive" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.archive">
Archivieren
</button>
<button type="button" id="action-restore" className="btn-secondary" style="display:none" data-i18n="admin.rules.edit.action.restore">
Wiederherstellen
</button>
</div>
</div>
</section>
</main>
{/* Reason modal — shared for every lifecycle action. Action-specific
body text is set by the client at open time. */}
<div className="modal-overlay" id="rules-action-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="rules-action-modal-title">Aktion best&auml;tigen</h2>
<button className="modal-close" id="rules-action-modal-close" type="button" aria-label="Close">&times;</button>
</div>
<p id="rules-action-modal-body" className="invite-modal-body" />
<form id="rules-action-modal-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="rules-action-modal-reason" data-i18n="admin.rules.modal.reason">Grund</label>
<textarea
id="rules-action-modal-reason"
className="admin-rules-input"
rows={3}
required
minlength={10}
/>
<p className="admin-rules-hint" data-i18n="admin.rules.modal.reason.hint">
Mindestens 10 Zeichen.
</p>
</div>
<p className="form-msg" id="rules-action-modal-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="rules-action-modal-cancel" data-i18n="common.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="rules-action-modal-submit" data-i18n="admin.rules.modal.confirm">
Best&auml;tigen
</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-rules-edit.js"></script>
</body>
</html>
);
}