Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).
Admin rules list (/admin/rules):
- Column header renamed "Code" → "Submission Code / Einreichung-Kennung"
- New "Rechtsgrundlage" column shows rule_code alongside the submission
code; the old single-column fallback (rule_code || code) is gone.
- Filter-search placeholder updated to "Name, Submission Code,
Rechtsgrundlage…"
- Rule interface: code → submission_code field.
Admin rules edit (/admin/rules/{id}/edit):
- f-code → f-submission-code; input is now read-only with a
upc.inf.cfi.soc-style placeholder (consistent with the backend
RulePatch which doesn't allow editing the submission code).
- Labels reframe rule_code as "Rechtsgrundlage (Kurzform)" and
legal_source as "Rechtsgrundlage (Langform)" so the legal-citation
pair is named consistently with the list column.
- Rule interface: code → submission_code field.
i18n: new keys admin.rules.col.submission_code,
admin.rules.col.legal_citation, admin.rules.edit.field.submission_code
in both DE + EN; old admin.rules.col.code + admin.rules.edit.field.code
removed.
bun run build clean.
668 lines
26 KiB
TypeScript
668 lines
26 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
|
|
"#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 = `<li class="admin-rules-audit-empty">${esc(t("admin.rules.edit.audit.empty") || "Keine Audit-Einträge.")}</li>`;
|
|
} 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
|
|
? `<span class="admin-rules-audit-badge">${esc(t("admin.rules.edit.audit.exported") || "exported")}</span>`
|
|
: "";
|
|
return `
|
|
<li class="admin-rules-audit-entry admin-rules-audit-action-${esc(e.action)}">
|
|
<div class="admin-rules-audit-head">
|
|
<span class="admin-rules-audit-action">${esc(actionLabel)}</span>
|
|
<span class="admin-rules-audit-time">${esc(fmtDateTime(e.changed_at))}</span>
|
|
${exported}
|
|
</div>
|
|
<div class="admin-rules-audit-actor">${esc(actor)}</div>
|
|
${e.reason ? `<div class="admin-rules-audit-reason">${esc(e.reason)}</div>` : ""}
|
|
</li>
|
|
`;
|
|
}).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<string, unknown> {
|
|
const validation = validateConditionExpr();
|
|
if (!validation.ok) throw new Error(validation.msg);
|
|
const payload: Record<string, unknown> = {
|
|
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<string, unknown>;
|
|
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 = `<p class="admin-rules-hint admin-rules-hint-error">${esc(t("admin.rules.edit.preview.only_drafts") || "Preview ist nur für Drafts verfügbar.")}</p>`;
|
|
out.style.display = "";
|
|
return;
|
|
}
|
|
const triggerDate = getInput("preview-trigger-date");
|
|
if (!triggerDate) {
|
|
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(t("admin.rules.edit.preview.trigger_required") || "Bitte Trigger-Datum angeben.")}</p>`;
|
|
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 = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
|
|
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 = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;
|
|
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<unknown> };
|
|
const r = resp as Result;
|
|
const list = (r && (r.deadlines || r.deadline)) as Array<Record<string, unknown>> | undefined;
|
|
if (!list || list.length === 0) {
|
|
out.innerHTML = `<p class="admin-rules-hint">${esc(t("admin.rules.edit.preview.empty") || "Keine Deadlines.")}</p>`;
|
|
return;
|
|
}
|
|
out.innerHTML = `<ul class="admin-rules-preview-list">${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 `<li>
|
|
${code ? `<code>${esc(code)}</code>` : ""}
|
|
<span class="admin-rules-preview-name">${esc(name)}</span>
|
|
<span class="admin-rules-preview-date">${esc(date)}</span>
|
|
</li>`;
|
|
}).join("")}</ul>`;
|
|
}
|
|
|
|
// --------------------------------------------------------------------
|
|
// 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);
|