import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n"; import { initSidebar } from "./sidebar"; // admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule // row, drives every form field, the preview widget, the audit-log // timeline and the lifecycle action bar. Every write is gated behind // a reason modal — the ≥10-char rule is enforced client-side per // Slice 11a edge case #4. interface Rule { id: string; proceeding_type_id?: number | null; parent_id?: string | null; // submission_code is the proceeding-prefixed identifier of this rule // within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from // rule_code (legal citation, e.g. `RoP.013.1`). submission_code?: string | null; rule_code?: string | null; name: string; name_en: string; description?: string | null; primary_party?: string | null; event_type?: string | null; duration_value: number; duration_unit: string; timing?: string | null; alt_duration_value?: number | null; alt_duration_unit?: string | null; alt_rule_code?: string | null; anchor_alt?: string | null; combine_op?: string | null; legal_source?: string | null; deadline_notes?: string | null; deadline_notes_en?: string | null; priority: string; is_court_set: boolean; is_spawn: boolean; spawn_label?: string | null; spawn_proceeding_type_id?: number | null; trigger_event_id?: number | null; condition_expr?: unknown; sequence_order: number; concept_id?: string | null; lifecycle_state: string; draft_of?: string | null; published_at?: string | null; updated_at: string; created_at: string; } interface ProceedingType { id: number; code: string; name_de: string; name_en: string; } interface TriggerEvent { id: number; code: string; name: string; name_de: string; } interface AuditEntry { id: string; rule_id: string; changed_by?: string | null; changed_by_display_name?: string | null; changed_at: string; action: string; before_json?: unknown; after_json?: unknown; reason: string; migration_exported: boolean; } let ruleId = ""; let rule: Rule | null = null; let proceedings: ProceedingType[] = []; let triggers: TriggerEvent[] = []; let auditEntries: AuditEntry[] = []; let auditOffset = 0; const AUDIT_PAGE = 20; let auditHasMore = false; let previewDebounce: number | undefined; function esc(s: string | null | undefined): string { const d = document.createElement("div"); d.textContent = s ?? ""; return d.innerHTML; } function fmtDateTime(iso: string): string { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const locale = getLang() === "de" ? "de-DE" : "en-GB"; return d.toLocaleString(locale, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } function parseRuleIDFromPath(): string { // /admin/rules/{uuid}/edit const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname); return m ? decodeURIComponent(m[1]) : ""; } function showFeedback(msg: string, isError: boolean) { const el = document.getElementById("rules-edit-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); } } function lifecycleLabel(state: string): string { return tDyn(`admin.rules.lifecycle.${state}`) || state; } function lifecycleClass(state: string): string { switch (state) { case "draft": return "admin-rules-pill admin-rules-pill-draft"; case "published": return "admin-rules-pill admin-rules-pill-published"; case "archived": return "admin-rules-pill admin-rules-pill-archived"; default: return "admin-rules-pill"; } } // -------------------------------------------------------------------- // Loaders. // -------------------------------------------------------------------- async function loadProceedings(): Promise { const resp = await fetch("/api/proceeding-types-db?category=fristenrechner"); if (!resp.ok) return; proceedings = (await resp.json()) as ProceedingType[]; fillProceedingSelect("f-proceeding", proceedings); fillProceedingSelect("f-spawn-proceeding", proceedings); } async function loadTriggers(): Promise { const resp = await fetch("/api/tools/trigger-events"); if (!resp.ok) return; triggers = (await resp.json()) as TriggerEvent[]; const sel = document.getElementById("f-trigger") as HTMLSelectElement | null; if (!sel) return; const placeholder = sel.querySelector('option[value=""]'); sel.innerHTML = ""; if (placeholder) sel.appendChild(placeholder); for (const te of triggers) { const opt = document.createElement("option"); opt.value = String(te.id); opt.textContent = `${te.code} · ${getLang() === "en" ? te.name : te.name_de}`; sel.appendChild(opt); } } function fillProceedingSelect(selectId: string, list: ProceedingType[]) { const sel = document.getElementById(selectId) as HTMLSelectElement | null; if (!sel) return; const placeholder = sel.querySelector('option[value=""]'); sel.innerHTML = ""; if (placeholder) sel.appendChild(placeholder); for (const pt of list) { const opt = document.createElement("option"); opt.value = String(pt.id); opt.textContent = `${pt.code} · ${getLang() === "en" ? pt.name_en : pt.name_de}`; sel.appendChild(opt); } } async function loadRule(): Promise { const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`); if (!resp.ok) { if (resp.status === 404) { showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true); } else { showFeedback(t("admin.rules.edit.error.load") || "Konnte Regel nicht laden.", true); } return; } rule = await resp.json() as Rule; populateForm(); updateLifecycleUI(); } async function loadAudit(reset: boolean = true): Promise { if (reset) { auditEntries = []; auditOffset = 0; } const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`); if (!resp.ok) return; const body = await resp.json(); const rows = Array.isArray(body) ? body as AuditEntry[] : []; auditEntries.push(...rows); auditOffset += rows.length; auditHasMore = rows.length === AUDIT_PAGE; renderAudit(); } // -------------------------------------------------------------------- // Form binding. // -------------------------------------------------------------------- function setInput(id: string, val: unknown) { const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null; if (!el) return; if (val == null) { el.value = ""; return; } el.value = String(val); } function setCheckbox(id: string, val: boolean) { const el = document.getElementById(id) as HTMLInputElement | null; if (!el) return; el.checked = !!val; } function getInput(id: string): string { const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null; return el ? el.value.trim() : ""; } function getCheckbox(id: string): boolean { const el = document.getElementById(id) as HTMLInputElement | null; return el ? el.checked : false; } function getOptionalInt(id: string): number | null { const v = getInput(id); if (!v) return null; const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; } function getOptionalString(id: string): string | null { const v = getInput(id); return v ? v : null; } function populateForm() { if (!rule) return; const heading = document.getElementById("rules-edit-heading") as HTMLElement; const idEl = document.getElementById("rules-edit-id") as HTMLElement; const lifecycleEl = document.getElementById("rules-edit-lifecycle") as HTMLElement; heading.textContent = (getLang() === "en" ? rule.name_en : rule.name) || rule.name; idEl.textContent = rule.id; lifecycleEl.className = lifecycleClass(rule.lifecycle_state); lifecycleEl.textContent = lifecycleLabel(rule.lifecycle_state); setInput("f-name", rule.name); setInput("f-name-en", rule.name_en); setInput("f-description", rule.description ?? ""); setInput("f-submission-code", rule.submission_code ?? ""); setInput("f-rule-code", rule.rule_code ?? ""); setInput("f-legal-source", rule.legal_source ?? ""); setInput("f-proceeding", rule.proceeding_type_id ?? ""); setInput("f-trigger", rule.trigger_event_id ?? ""); setInput("f-parent", rule.parent_id ?? ""); setInput("f-concept", rule.concept_id ?? ""); setInput("f-sequence", rule.sequence_order); setInput("f-duration", rule.duration_value); setInput("f-duration-unit", rule.duration_unit); setInput("f-timing", rule.timing ?? ""); setInput("f-combine-op", rule.combine_op ?? ""); setInput("f-alt-duration", rule.alt_duration_value ?? ""); setInput("f-alt-duration-unit", rule.alt_duration_unit ?? ""); setInput("f-alt-rule-code", rule.alt_rule_code ?? ""); setInput("f-anchor-alt", rule.anchor_alt ?? ""); setInput("f-primary-party", rule.primary_party ?? ""); setInput("f-event-type", rule.event_type ?? ""); setInput("f-notes", rule.deadline_notes ?? ""); setInput("f-notes-en", rule.deadline_notes_en ?? ""); setInput("f-priority", rule.priority); setCheckbox("f-is-court-set", rule.is_court_set); setCheckbox("f-is-spawn", rule.is_spawn); setInput("f-spawn-label", rule.spawn_label ?? ""); setInput("f-spawn-proceeding", rule.spawn_proceeding_type_id ?? ""); toggleSpawnRow(); setInput("f-condition-expr", rule.condition_expr ? JSON.stringify(rule.condition_expr, null, 2) : ""); } function toggleSpawnRow() { const row = document.getElementById("f-spawn-row") as HTMLElement | null; if (!row) return; row.style.display = getCheckbox("f-is-spawn") ? "" : "none"; } function updateLifecycleUI() { const draftOnly = (id: string, show: boolean) => { const el = document.getElementById(id) as HTMLElement | null; if (el) el.style.display = show ? "" : "none"; }; if (!rule) return; const isDraft = rule.lifecycle_state === "draft"; const isPublished = rule.lifecycle_state === "published"; const isArchived = rule.lifecycle_state === "archived"; draftOnly("action-save-draft", isDraft); draftOnly("action-publish", isDraft); draftOnly("action-clone", isPublished || isArchived); draftOnly("action-archive", isDraft || isPublished); draftOnly("action-restore", isArchived); // Lock form fields when not editable (i.e. not draft). Published / // archived rules show the form read-only so editors can confirm // they're about to clone the right row. const readOnly = !isDraft; document.querySelectorAll( "#rules-edit-form input, #rules-edit-form select, #rules-edit-form textarea", ).forEach((el) => { el.disabled = readOnly; }); } function renderAudit() { const list = document.getElementById("rules-edit-audit") as HTMLElement | null; const more = document.getElementById("audit-loadmore") as HTMLElement | null; if (!list) return; if (auditEntries.length === 0) { list.innerHTML = `
  • ${esc(t("admin.rules.edit.audit.empty") || "Keine Audit-Einträge.")}
  • `; } else { list.innerHTML = auditEntries.map((e) => { const actor = e.changed_by_display_name || (e.changed_by ? e.changed_by.slice(0, 8) : (t("admin.rules.edit.audit.actor.system") || "System")); const actionLabel = tDyn(`admin.rules.edit.audit.action.${e.action}`) || e.action; const exported = e.migration_exported ? `${esc(t("admin.rules.edit.audit.exported") || "exported")}` : ""; return `
  • ${esc(actionLabel)} ${esc(fmtDateTime(e.changed_at))} ${exported}
    ${esc(actor)}
    ${e.reason ? `
    ${esc(e.reason)}
    ` : ""}
  • `; }).join(""); } if (more) more.style.display = auditHasMore ? "" : "none"; } // -------------------------------------------------------------------- // Validation helpers. // -------------------------------------------------------------------- function validateConditionExpr(): { ok: boolean; value: unknown | undefined; msg: string } { const raw = getInput("f-condition-expr"); const msgEl = document.getElementById("f-condition-msg") as HTMLElement | null; if (!raw) { if (msgEl) { msgEl.textContent = ""; msgEl.className = "admin-rules-hint"; } return { ok: true, value: undefined, msg: "" }; } try { const parsed = JSON.parse(raw); if (msgEl) { msgEl.textContent = "✓ " + (t("admin.rules.edit.field.condition.valid") || "JSON gültig."); msgEl.className = "admin-rules-hint admin-rules-hint-ok"; } return { ok: true, value: parsed, msg: "" }; } catch (err) { const m = err instanceof Error ? err.message : String(err); if (msgEl) { msgEl.textContent = "⚠ " + m; msgEl.className = "admin-rules-hint admin-rules-hint-error"; } return { ok: false, value: undefined, msg: m }; } } // -------------------------------------------------------------------- // Action modal (reason + lifecycle handler). // -------------------------------------------------------------------- type Action = "save-draft" | "publish" | "clone" | "archive" | "restore"; let pendingAction: Action | null = null; function openActionModal(action: Action) { pendingAction = action; const modal = document.getElementById("rules-action-modal") as HTMLElement; const title = document.getElementById("rules-action-modal-title") as HTMLElement; const body = document.getElementById("rules-action-modal-body") as HTMLElement; const msg = document.getElementById("rules-action-modal-msg") as HTMLElement; const reasonInput = document.getElementById("rules-action-modal-reason") as HTMLTextAreaElement; msg.style.display = "none"; reasonInput.value = ""; switch (action) { case "save-draft": title.textContent = t("admin.rules.edit.modal.save_draft.title") || "Draft speichern"; body.textContent = t("admin.rules.edit.modal.save_draft.body") || "Bitte einen Grund für die Änderung angeben (mind. 10 Zeichen). Wird ins Audit-Log geschrieben."; break; case "publish": title.textContent = t("admin.rules.edit.modal.publish.title") || "Publish"; body.textContent = t("admin.rules.edit.modal.publish.body") || "Diese Draft-Regel wird live geschaltet. Bestehende publizierte Variante wird archiviert."; break; case "clone": title.textContent = t("admin.rules.edit.modal.clone.title") || "Als Draft klonen"; body.textContent = t("admin.rules.edit.modal.clone.body") || "Eine neue Draft-Kopie dieser Regel wird angelegt. Sie werden auf die neue Draft-Seite weitergeleitet."; break; case "archive": title.textContent = t("admin.rules.edit.modal.archive.title") || "Archivieren"; body.textContent = t("admin.rules.edit.modal.archive.body") || "Regel wird archiviert. Calculator nutzt sie nicht mehr."; break; case "restore": title.textContent = t("admin.rules.edit.modal.restore.title") || "Wiederherstellen"; body.textContent = t("admin.rules.edit.modal.restore.body") || "Regel wird wiederhergestellt (archived → published)."; break; } modal.style.display = "flex"; reasonInput.focus(); } function closeActionModal() { (document.getElementById("rules-action-modal") as HTMLElement).style.display = "none"; pendingAction = null; } async function submitActionModal(ev: Event) { ev.preventDefault(); if (!pendingAction || !rule) return; const reasonInput = document.getElementById("rules-action-modal-reason") as HTMLTextAreaElement; const msg = document.getElementById("rules-action-modal-msg") as HTMLElement; const submit = document.getElementById("rules-action-modal-submit") as HTMLButtonElement; const reason = reasonInput.value.trim(); if (reason.length < 10) { msg.textContent = t("admin.rules.modal.reason.too_short") || "Grund muss mindestens 10 Zeichen enthalten."; msg.className = "form-msg form-msg-error"; msg.style.display = "block"; return; } submit.disabled = true; try { if (pendingAction === "save-draft") { await doSaveDraft(reason); } else if (pendingAction === "publish") { await doLifecycle("publish", reason); } else if (pendingAction === "clone") { await doClone(reason); } else if (pendingAction === "archive") { await doLifecycle("archive", reason); } else if (pendingAction === "restore") { await doLifecycle("restore", reason); } } finally { submit.disabled = false; } } function buildPatchPayload(): Record { const validation = validateConditionExpr(); if (!validation.ok) throw new Error(validation.msg); const payload: Record = { name: getInput("f-name"), name_en: getInput("f-name-en"), description: getInput("f-description"), primary_party: getInput("f-primary-party"), event_type: getInput("f-event-type"), duration_value: getOptionalInt("f-duration") ?? 0, duration_unit: getInput("f-duration-unit"), timing: getOptionalString("f-timing"), alt_duration_value: getOptionalInt("f-alt-duration"), alt_duration_unit: getOptionalString("f-alt-duration-unit"), alt_rule_code: getOptionalString("f-alt-rule-code"), anchor_alt: getOptionalString("f-anchor-alt"), combine_op: getOptionalString("f-combine-op"), rule_code: getOptionalString("f-rule-code"), legal_source: getOptionalString("f-legal-source"), deadline_notes: getInput("f-notes"), deadline_notes_en: getInput("f-notes-en"), priority: getInput("f-priority"), is_court_set: getCheckbox("f-is-court-set"), is_spawn: getCheckbox("f-is-spawn"), spawn_label: getOptionalString("f-spawn-label"), spawn_proceeding_type_id: getOptionalInt("f-spawn-proceeding"), trigger_event_id: getOptionalInt("f-trigger"), sequence_order: getOptionalInt("f-sequence") ?? 0, }; if (validation.value !== undefined) { payload.condition_expr = validation.value; } return payload; } async function doSaveDraft(reason: string) { const msg = document.getElementById("rules-action-modal-msg") as HTMLElement; let payload: Record; try { payload = buildPatchPayload(); } catch (e) { msg.textContent = e instanceof Error ? e.message : String(e); msg.className = "form-msg form-msg-error"; msg.style.display = "block"; return; } payload.reason = reason; const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!resp.ok) { const body = await resp.json().catch(() => ({ error: resp.statusText })); msg.textContent = body.error || (t("admin.rules.edit.action.save_draft.error") || "Speichern fehlgeschlagen."); msg.className = "form-msg form-msg-error"; msg.style.display = "block"; return; } rule = await resp.json() as Rule; closeActionModal(); populateForm(); updateLifecycleUI(); await loadAudit(true); showFeedback(t("admin.rules.edit.action.save_draft.ok") || "Draft gespeichert.", false); } async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) { const msg = document.getElementById("rules-action-modal-msg") as HTMLElement; const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason }), }); if (!resp.ok) { const body = await resp.json().catch(() => ({ error: resp.statusText })); msg.textContent = body.error || (tDyn(`admin.rules.edit.action.${op}.error`) || "Aktion fehlgeschlagen."); msg.className = "form-msg form-msg-error"; msg.style.display = "block"; return; } rule = await resp.json() as Rule; closeActionModal(); populateForm(); updateLifecycleUI(); await loadAudit(true); showFeedback(tDyn(`admin.rules.edit.action.${op}.ok`) || (t("admin.rules.edit.action.ok") || "Erledigt."), false); } async function doClone(reason: string) { const msg = document.getElementById("rules-action-modal-msg") as HTMLElement; const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason }), }); if (!resp.ok) { const body = await resp.json().catch(() => ({ error: resp.statusText })); msg.textContent = body.error || (t("admin.rules.edit.action.clone.error") || "Klonen fehlgeschlagen."); msg.className = "form-msg form-msg-error"; msg.style.display = "block"; return; } const newRule = await resp.json() as Rule; window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`; } // -------------------------------------------------------------------- // Preview. // -------------------------------------------------------------------- async function runPreview() { const out = document.getElementById("preview-result") as HTMLElement; if (!rule) return; if (rule.lifecycle_state !== "draft") { out.innerHTML = `

    ${esc(t("admin.rules.edit.preview.only_drafts") || "Preview ist nur für Drafts verfügbar.")}

    `; out.style.display = ""; return; } const triggerDate = getInput("preview-trigger-date"); if (!triggerDate) { out.innerHTML = `

    ${esc(t("admin.rules.edit.preview.trigger_required") || "Bitte Trigger-Datum angeben.")}

    `; out.style.display = ""; return; } const flagsRaw = getInput("preview-flags"); const qs = new URLSearchParams(); qs.set("trigger_date", triggerDate); if (flagsRaw) qs.set("flags", flagsRaw); out.innerHTML = `

    ${esc(t("admin.rules.edit.preview.running") || "Berechne...")}

    `; out.style.display = ""; const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`); if (!resp.ok) { const body = await resp.json().catch(() => ({ error: resp.statusText })); out.innerHTML = `

    ${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}

    `; return; } const body = await resp.json(); renderPreview(body); } function renderPreview(resp: unknown) { const out = document.getElementById("preview-result") as HTMLElement; type Result = { deadlines?: Array<{ name?: string; titleDE?: string; due_date?: string; dueDate?: string; ruleCode?: string; rule_code?: string }>; deadline?: Array }; const r = resp as Result; const list = (r && (r.deadlines || r.deadline)) as Array> | undefined; if (!list || list.length === 0) { out.innerHTML = `

    ${esc(t("admin.rules.edit.preview.empty") || "Keine Deadlines.")}

    `; return; } out.innerHTML = `
      ${list.map((d) => { const name = String(d.name || d.titleDE || d.title || ""); const date = String(d.due_date || d.dueDate || ""); const code = String(d.rule_code || d.ruleCode || ""); return `
    • ${code ? `${esc(code)}` : ""} ${esc(name)} ${esc(date)}
    • `; }).join("")}
    `; } // -------------------------------------------------------------------- // Init. // -------------------------------------------------------------------- async function init() { initI18n(); initSidebar(); ruleId = parseRuleIDFromPath(); if (!ruleId) { showFeedback(t("admin.rules.edit.error.bad_id") || "Ungültige Regel-ID in der URL.", true); return; } (document.getElementById("rules-action-modal-close") as HTMLElement).addEventListener("click", closeActionModal); (document.getElementById("rules-action-modal-cancel") as HTMLElement).addEventListener("click", closeActionModal); (document.getElementById("rules-action-modal-form") as HTMLFormElement).addEventListener("submit", submitActionModal); (document.getElementById("action-save-draft") as HTMLElement).addEventListener("click", () => openActionModal("save-draft")); (document.getElementById("action-publish") as HTMLElement).addEventListener("click", () => openActionModal("publish")); (document.getElementById("action-clone") as HTMLElement).addEventListener("click", () => openActionModal("clone")); (document.getElementById("action-archive") as HTMLElement).addEventListener("click", () => openActionModal("archive")); (document.getElementById("action-restore") as HTMLElement).addEventListener("click", () => openActionModal("restore")); (document.getElementById("f-is-spawn") as HTMLInputElement).addEventListener("change", toggleSpawnRow); (document.getElementById("f-condition-expr") as HTMLTextAreaElement).addEventListener("input", () => { validateConditionExpr(); }); (document.getElementById("preview-run") as HTMLElement).addEventListener("click", () => { window.clearTimeout(previewDebounce); previewDebounce = window.setTimeout(runPreview, 100); }); (document.getElementById("audit-loadmore") as HTMLElement).addEventListener("click", () => loadAudit(false)); await Promise.all([loadProceedings(), loadTriggers()]); await loadRule(); await loadAudit(true); onLangChange(() => { if (rule) { populateForm(); updateLifecycleUI(); } renderAudit(); }); } document.addEventListener("DOMContentLoaded", init);