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.
243 lines
13 KiB
TypeScript
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);
|
|
}
|