Files
zemion 11eca4ed28 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:29 +02:00

243 lines
13 KiB
TypeScript

import { GitCompareArrows, History, Pencil, Plus, Send } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { FormGrid, ActionToolbar,
AdminIconButton,
AdminPageLayout,
Button,
ConfirmDialog,
DataGrid,
Dialog,
DocumentationHelpLink,
FormField,
StatusBadge,
TableActionGroup,
ToggleSwitch,
adminErrorMessage,
formatAdminDateTime as formatDateTime,
type ApiSettings,
type DataGridColumn
} from "@govoplan/core-webui";
import {
approvalTemplateHistory,
compareApprovalTemplateRevisions,
createApprovalTemplate,
listApprovalTemplates,
publishApprovalTemplate,
reviseApprovalTemplate,
type ApprovalTemplate,
type ApprovalTemplateChange,
type ApprovalTemplateComparison,
type ApprovalTemplateDraft
} from "../../api/approvals";
import ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor";
import { APPROVALS_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
type EditorState = { mode: "create" | "edit"; current?: ApprovalTemplate };
export default function ApprovalTemplatesPanel({
settings,
canAdmin
}: {
settings: ApiSettings;
canAdmin: boolean;
}) {
const [templates, setTemplates] = useState<ApprovalTemplate[]>([]);
const [editor, setEditor] = useState<EditorState | null>(null);
const [draft, setDraft] = useState<ApprovalTemplateDraft>(emptyTemplate());
const [publishing, setPublishing] = useState<ApprovalTemplate | null>(null);
const [historyTemplate, setHistoryTemplate] = useState<ApprovalTemplate | null>(null);
const [history, setHistory] = useState<ApprovalTemplate[]>([]);
const [fromRevision, setFromRevision] = useState(1);
const [toRevision, setToRevision] = useState(1);
const [comparison, setComparison] = useState<ApprovalTemplateComparison | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
try {
setTemplates(await listApprovalTemplates(settings));
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, [settings.accessToken, settings.apiBaseUrl]);
const columns = useMemo<DataGridColumn<ApprovalTemplate>[]>(() => [
{ id: "title", header: "Template", width: "minmax(220px, 1fr)", minWidth: 190, fill: true, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => row.title, render: (row) => <div><strong>{row.title}</strong><div className="muted small-note">{row.key}</div></div> },
{ id: "state", header: "State", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.state, render: (row) => <StatusBadge status={row.state} /> },
{ id: "revision", header: "Revision", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.revision },
{ id: "steps", header: "Steps", width: 90, resizable: false, sortable: true, value: (row) => row.steps.length },
{ id: "recorded", header: "Updated", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.recorded_at, render: (row) => formatDateTime(row.recorded_at) },
{ id: "actions", header: "Actions", width: 132, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
{ id: "edit", label: `Revise ${row.title}`, icon: <Pencil />, disabled: !canAdmin, disabledReason: !canAdmin ? "Approval administration permission is required." : undefined, onClick: () => openEdit(row) },
{ id: "history", label: `History for ${row.title}`, icon: <History />, onClick: () => void openHistory(row) },
{ id: "publish", label: `Publish ${row.title}`, icon: <Send />, applicable: row.state === "draft", disabled: !canAdmin, disabledReason: !canAdmin ? "Approval administration permission is required." : undefined, onClick: () => setPublishing(row) }
]} /> }
], [canAdmin]);
const historyColumns = useMemo<DataGridColumn<ApprovalTemplate>[]>(() => [
{ id: "revision", header: "Revision", width: 90, resizable: false, value: (row) => row.revision },
{ id: "state", header: "State", width: 110, resizable: false, value: (row) => row.state, render: (row) => <StatusBadge status={row.state} /> },
{ id: "recorded", header: "Recorded", width: 180, resizable: true, fill: true, value: (row) => row.recorded_at, render: (row) => formatDateTime(row.recorded_at) },
{ id: "actor", header: "Actor", width: "minmax(160px, 1fr)", minWidth: 140, resizable: true, value: (row) => row.actor_id || "", render: (row) => row.actor_id || "System" },
{ id: "hash", header: "Content hash", width: 180, resizable: true, value: (row) => row.content_sha256, render: (row) => <code title={row.content_sha256}>{row.content_sha256.slice(0, 16)}...</code> }
], []);
const changeColumns = useMemo<DataGridColumn<ApprovalTemplateChange>[]>(() => [
{ id: "path", header: "Path", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sticky: "start", value: (row) => row.path, render: (row) => <code>{row.path}</code> },
{ id: "change", header: "Change", width: 110, resizable: false, value: (row) => row.change, render: (row) => <StatusBadge status={row.change} /> },
{ id: "before", header: "Before", width: "minmax(180px, 1fr)", minWidth: 160, fill: true, resizable: true, value: (row) => printable(row.before), render: (row) => <code className="approval-diff-value">{printable(row.before)}</code> },
{ id: "after", header: "After", width: "minmax(180px, 1fr)", minWidth: 160, fill: true, resizable: true, value: (row) => printable(row.after), render: (row) => <code className="approval-diff-value">{printable(row.after)}</code> }
], []);
function openCreate() {
setDraft(emptyTemplate());
setEditor({ mode: "create" });
}
function openEdit(current: ApprovalTemplate) {
setDraft({
key: current.key,
title: current.title,
description: current.description ?? "",
steps: structuredClone(current.steps),
separation_of_duties: current.separation_of_duties,
unique_actors_across_steps: current.unique_actors_across_steps,
metadata: { ...current.metadata }
});
setEditor({ mode: "edit", current });
}
async function save() {
if (!editor) return;
setBusy(true);
setError("");
try {
const saved = editor.mode === "create"
? await createApprovalTemplate(settings, draft)
: await reviseApprovalTemplate(settings, editor.current!, draft);
setEditor(null);
setSuccess(`${saved.title} saved as draft revision ${saved.revision}.`);
await load();
} catch (reason) {
setError(adminErrorMessage(reason));
await load();
} finally {
setBusy(false);
}
}
async function publish() {
if (!publishing) return;
setBusy(true);
setError("");
try {
const published = await publishApprovalTemplate(settings, publishing);
setPublishing(null);
setSuccess(`${published.title} revision ${published.revision} published.`);
await load();
} catch (reason) {
setError(adminErrorMessage(reason));
await load();
} finally {
setBusy(false);
}
}
async function openHistory(template: ApprovalTemplate) {
setHistoryTemplate(template);
setComparison(null);
setError("");
try {
const revisions = await approvalTemplateHistory(settings, template.id);
setHistory(revisions);
const newest = revisions[0]?.revision ?? template.revision;
const oldest = revisions.at(-1)?.revision ?? newest;
setFromRevision(oldest);
setToRevision(newest);
} catch (reason) {
setError(adminErrorMessage(reason));
}
}
async function compare() {
if (!historyTemplate) return;
setBusy(true);
setError("");
try {
setComparison(await compareApprovalTemplateRevisions(settings, historyTemplate.id, fromRevision, toRevision));
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setBusy(false);
}
}
const valid = Boolean(
draft.key.trim()
&& draft.title.trim()
&& draft.steps.length
&& draft.steps.every((step) => step.key.trim() && step.label.trim() && step.required_approvals > 0 && step.selectors.length && step.selectors.every((selector) => selector.value.trim()))
);
return <>
<AdminPageLayout title="Approval templates" titleHelp={<DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} />} description="Define reusable, immutable approval chains and compare every published or draft revision." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Create approval template" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : undefined} /></>}>
<div className="admin-table-surface"><DataGrid id="approval-templates-v1" rows={templates} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No approval templates found." /></div>
</AdminPageLayout>
<Dialog open={Boolean(editor)} title={editor?.mode === "create" ? "Create approval template" : "Revise approval template"} onClose={() => !busy && setEditor(null)} closeDisabled={busy} className="approval-template-dialog" footer={<><Button onClick={() => setEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !valid || !canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : !valid ? APPROVALS_I18N.incomplete : busy ? APPROVALS_I18N.busy : undefined}>{busy ? "Saving..." : "Save draft revision"}</Button></>}>
<div className="approval-editor">
<FormGrid columns={2} gap="compact" collapseAt="workspace">
<FormField label="Stable key"><input value={draft.key} disabled={busy || editor?.mode === "edit"} onChange={(event) => setDraft({ ...draft, key: event.target.value })} /></FormField>
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
</FormGrid>
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
</div>
</Dialog>
<Dialog open={Boolean(historyTemplate)} title={`${historyTemplate?.title ?? "Template"} history`} onClose={() => !busy && setHistoryTemplate(null)} className="approval-template-history-dialog" footer={<Button onClick={() => setHistoryTemplate(null)} disabled={busy}>Close</Button>}>
<div className="approval-template-history-layout">
<div className="admin-table-surface"><DataGrid id="approval-template-history-v1" rows={history} columns={historyColumns} initialFit="container" getRowKey={(row) => `${row.id}:${row.revision}`} emptyText="No template revisions found." /></div>
<ActionToolbar className="approval-compare-toolbar">
<FormField label="From revision"><select value={fromRevision} onChange={(event) => setFromRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField>
<FormField label="To revision"><select value={toRevision} onChange={(event) => setToRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField>
<Button onClick={() => void compare()} disabled={busy || !history.length}><GitCompareArrows aria-hidden="true" />Compare</Button>
</ActionToolbar>
{comparison && <div className="admin-table-surface"><DataGrid id="approval-template-compare-v1" rows={comparison.changes} columns={changeColumns} initialFit="container" getRowKey={(row) => `${row.path}:${row.change}`} emptyText="These revisions have identical template content." /></div>}
</div>
</Dialog>
<ConfirmDialog open={Boolean(publishing)} title="Publish approval template" message={`Publish ${publishing?.title ?? "this template"}? Requests can then bind permanently to the new immutable revision.`} confirmLabel="Publish revision" busy={busy} onCancel={() => setPublishing(null)} onConfirm={() => void publish()} />
</>;
}
function emptyTemplate(): ApprovalTemplateDraft {
return {
key: "",
title: "",
description: "",
steps: [emptyApprovalStep(1)],
separation_of_duties: true,
unique_actors_across_steps: false,
metadata: {}
};
}
function printable(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "string") return value;
return JSON.stringify(value);
}