Add approval template revision administration

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent 3abbe117e5
commit 7f64247625
15 changed files with 833 additions and 36 deletions
+68
View File
@@ -29,6 +29,31 @@ export type ApprovalRequest = {
};
export type ApprovalDraft = Omit<ApprovalRequest, "id" | "revision" | "state" | "current_step_key" | "current_step_index" | "requested_by" | "completed_at">;
export type ApprovalEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record<string, unknown> };
export type ApprovalTemplate = {
id: string;
key: string;
title: string;
description?: string | null;
revision: number;
state: "draft" | "published";
content_sha256: string;
recorded_at: string;
superseded_at?: string | null;
previous_revision_id?: string | null;
actor_id?: string | null;
steps: ApprovalStep[];
separation_of_duties: boolean;
unique_actors_across_steps: boolean;
metadata: Record<string, unknown>;
};
export type ApprovalTemplateDraft = Pick<ApprovalTemplate, "key" | "title" | "description" | "steps" | "separation_of_duties" | "unique_actors_across_steps" | "metadata">;
export type ApprovalTemplateChange = { path: string; change: "added" | "removed" | "changed"; before: unknown; after: unknown };
export type ApprovalTemplateComparison = {
template_id: string;
from_revision: ApprovalTemplate;
to_revision: ApprovalTemplate;
changes: ApprovalTemplateChange[];
};
export function listApprovals(settings: ApiSettings, signal?: AbortSignal): Promise<{ requests: ApprovalRequest[] }> {
return apiFetch(settings, apiPath("/api/v1/approvals", { limit: 200 }), { signal });
@@ -52,3 +77,46 @@ export function decideApproval(settings: ApiSettings, request: ApprovalRequest,
body: JSON.stringify({ outcome, reason, expected_revision: request.revision, idempotency_key: crypto.randomUUID(), signature_ref: signatureRef ?? null })
});
}
export function escalateApproval(settings: ApiSettings, request: ApprovalRequest): Promise<ApprovalRequest> {
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(request.id)}/escalate`, {
method: "POST",
body: JSON.stringify({ expected_revision: request.revision, idempotency_key: crypto.randomUUID() })
});
}
export function listApprovalTemplates(settings: ApiSettings, signal?: AbortSignal): Promise<ApprovalTemplate[]> {
return apiFetch(settings, apiPath("/api/v1/approvals/templates", { limit: 200 }), { signal });
}
export function createApprovalTemplate(settings: ApiSettings, template: ApprovalTemplateDraft): Promise<ApprovalTemplate> {
return apiFetch(settings, "/api/v1/approvals/templates", {
method: "POST",
body: JSON.stringify({ template, idempotency_key: crypto.randomUUID() })
});
}
export function reviseApprovalTemplate(settings: ApiSettings, current: ApprovalTemplate, template: ApprovalTemplateDraft): Promise<ApprovalTemplate> {
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(current.id)}`, {
method: "PUT",
body: JSON.stringify({ template, expected_revision: current.revision, idempotency_key: crypto.randomUUID() })
});
}
export function publishApprovalTemplate(settings: ApiSettings, current: ApprovalTemplate): Promise<ApprovalTemplate> {
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(current.id)}/publish`, {
method: "POST",
body: JSON.stringify({ expected_revision: current.revision, idempotency_key: crypto.randomUUID() })
});
}
export function approvalTemplateHistory(settings: ApiSettings, templateId: string, signal?: AbortSignal): Promise<ApprovalTemplate[]> {
return apiFetch(settings, `/api/v1/approvals/templates/${encodeURIComponent(templateId)}/history`, { signal });
}
export function compareApprovalTemplateRevisions(settings: ApiSettings, templateId: string, fromRevision: number, toRevision: number, signal?: AbortSignal): Promise<ApprovalTemplateComparison> {
return apiFetch(settings, apiPath(`/api/v1/approvals/templates/${encodeURIComponent(templateId)}/compare`, {
from_revision: fromRevision,
to_revision: toRevision
}), { signal });
}
@@ -1,7 +1,7 @@
import { Plus, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, ToggleSwitch, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui";
import { createApproval, type ApprovalDraft, type ApprovalRequest, type ApprovalStep } from "../../api/approvals";
import { Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, ToggleSwitch, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui";
import { createApproval, type ApprovalDraft, type ApprovalRequest } from "../../api/approvals";
import ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor";
import { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { settings: ApiSettings; onClose: () => void; onSaved: (value: ApprovalRequest) => void }) {
@@ -41,10 +41,6 @@ export default function ApprovalRequestDialog({ settings, onClose, onSaved }: {
else onClose();
}
function patchStep(index: number, patch: Partial<ApprovalStep>) {
setDraft((current) => ({ ...current, steps: current.steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) }));
}
return <Dialog open title="New Approval request" onClose={requestClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
<div className="approval-editor">
<div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div>
@@ -60,28 +56,13 @@ export default function ApprovalRequestDialog({ settings, onClose, onSaved }: {
<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 })} />
</div>
<div className="approval-editor-heading"><h3>Steps</h3><Button disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined} onClick={() => setDraft({ ...draft, steps: [...draft.steps, emptyStep(draft.steps.length + 1)] })}><Plus size={16} aria-hidden="true" />Add step</Button></div>
<div className="approval-step-list">
{draft.steps.map((step, index) => <div className="approval-step-editor" key={`${index}:${step.key}`}>
<FormField label="Key"><input value={step.key} disabled={busy} onChange={(event) => patchStep(index, { key: event.target.value })} /></FormField>
<FormField label="Label"><input value={step.label} disabled={busy} onChange={(event) => patchStep(index, { label: event.target.value })} /></FormField>
<FormField label="Actor type"><select value={step.selectors[0].kind} disabled={busy} onChange={(event) => patchStep(index, { selectors: [{ ...step.selectors[0], kind: event.target.value as ApprovalStep["selectors"][number]["kind"] }] })}><option value="account">Account</option><option value="group">Group</option><option value="role">Role</option><option value="function_assignment">Function assignment</option><option value="any_account">Any account</option></select></FormField>
<FormField label="Actor value"><input value={step.selectors[0].value} disabled={busy} onChange={(event) => patchStep(index, { selectors: [{ ...step.selectors[0], value: event.target.value }] })} /></FormField>
<FormField label="Required"><input type="number" min={1} value={step.required_approvals} disabled={busy} onChange={(event) => patchStep(index, { required_approvals: Number(event.target.value) })} /></FormField>
<ToggleSwitch label="Signature" checked={step.signature_required} disabled={busy} onChange={(value) => patchStep(index, { signature_required: value })} />
<IconButton label={`Remove ${step.label || "step"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.steps.length === 1} disabledReason={busy ? APPROVALS_I18N.busy : draft.steps.length === 1 ? APPROVALS_I18N.oneStep : undefined} onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, itemIndex) => itemIndex !== index) })} />
</div>)}
</div>
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
</div>
</Dialog>;
}
function emptyStep(index: number): ApprovalStep {
return { key: `step-${index}`, label: "", selectors: [{ kind: "account", value: "" }], required_approvals: 1, rejection_policy: "fail_fast", signature_required: false, forbidden_evidence_roles: [], metadata: {} };
}
function initialDraft(): ApprovalDraft {
return { title: "", description: "", subject_module: "", subject_type: "", subject_id: "", subject_version: "", subject_digest: "", steps: [emptyStep(1)], separation_of_duties: true, unique_actors_across_steps: false, expires_at: null, policy_refs: [], evidence_actors: {}, template_id: null, template_revision: null, metadata: {} };
return { title: "", description: "", subject_module: "", subject_type: "", subject_id: "", subject_version: "", subject_digest: "", steps: [emptyApprovalStep(1)], separation_of_duties: true, unique_actors_across_steps: false, expires_at: null, policy_refs: [], evidence_actors: {}, template_id: null, template_revision: null, metadata: {} };
}
function draftKey(draft: ApprovalDraft): string {
@@ -0,0 +1,98 @@
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import {
Button,
DateTimeField,
FormField,
IconButton,
ToggleSwitch
} from "@govoplan/core-webui";
import type { ApprovalSelector, ApprovalStep } from "../../api/approvals";
import { APPROVALS_I18N } from "./interfacePatterns";
export default function ApprovalStepsEditor({
steps,
disabled,
onChange
}: {
steps: ApprovalStep[];
disabled?: boolean;
onChange: (steps: ApprovalStep[]) => void;
}) {
function patchStep(index: number, patch: Partial<ApprovalStep>) {
onChange(steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
}
function moveStep(index: number, offset: -1 | 1) {
const target = index + offset;
if (target < 0 || target >= steps.length) return;
const next = [...steps];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
}
function patchSelector(stepIndex: number, selectorIndex: number, patch: Partial<ApprovalSelector>) {
const step = steps[stepIndex];
patchStep(stepIndex, {
selectors: step.selectors.map((selector, index) => index === selectorIndex ? { ...selector, ...patch } : selector)
});
}
return <>
<div className="approval-editor-heading">
<h3>Steps</h3>
<Button disabled={disabled} disabledReason={disabled ? APPROVALS_I18N.busy : undefined} onClick={() => onChange([...steps, emptyApprovalStep(steps.length + 1)])}><Plus size={16} aria-hidden="true" />Add step</Button>
</div>
<div className="approval-step-list">
{steps.map((step, stepIndex) => <section className="approval-step-editor" key={`${stepIndex}:${step.key}`}>
<div className="approval-step-heading">
<strong>{step.label || `Step ${stepIndex + 1}`}</strong>
<div>
<IconButton label={`Move ${step.label || "step"} up`} icon={<ArrowUp />} disabled={disabled || stepIndex === 0} onClick={() => moveStep(stepIndex, -1)} />
<IconButton label={`Move ${step.label || "step"} down`} icon={<ArrowDown />} disabled={disabled || stepIndex === steps.length - 1} onClick={() => moveStep(stepIndex, 1)} />
<IconButton label={`Remove ${step.label || "step"}`} icon={<Trash2 />} variant="danger" disabled={disabled || steps.length === 1} disabledReason={steps.length === 1 ? APPROVALS_I18N.oneStep : undefined} onClick={() => onChange(steps.filter((_, index) => index !== stepIndex))} />
</div>
</div>
<div className="approval-step-fields">
<FormField label="Key"><input value={step.key} disabled={disabled} onChange={(event) => patchStep(stepIndex, { key: event.target.value })} /></FormField>
<FormField label="Label"><input value={step.label} disabled={disabled} onChange={(event) => patchStep(stepIndex, { label: event.target.value })} /></FormField>
<FormField label="Required approvals"><input type="number" min={1} max={500} value={step.required_approvals} disabled={disabled} onChange={(event) => patchStep(stepIndex, { required_approvals: Number(event.target.value) })} /></FormField>
<FormField label="Rejection policy"><select value={step.rejection_policy} disabled={disabled} onChange={(event) => patchStep(stepIndex, { rejection_policy: event.target.value as ApprovalStep["rejection_policy"] })}><option value="fail_fast">Fail immediately</option><option value="collect">Collect all decisions</option></select></FormField>
<FormField label="Escalation due"><DateTimeField value={step.due_at ?? ""} disabled={disabled} onChange={(value) => patchStep(stepIndex, { due_at: value || null })} /></FormField>
<FormField label="Forbidden evidence roles"><input value={step.forbidden_evidence_roles.join(", ")} disabled={disabled} onChange={(event) => patchStep(stepIndex, { forbidden_evidence_roles: commaList(event.target.value) })} /></FormField>
<ToggleSwitch label="Signature required" checked={step.signature_required} disabled={disabled} onChange={(value) => patchStep(stepIndex, { signature_required: value })} />
</div>
<div className="approval-selector-heading"><span>Eligible actors</span><Button disabled={disabled} onClick={() => patchStep(stepIndex, { selectors: [...step.selectors, emptySelector()] })}><Plus size={16} aria-hidden="true" />Add actor selector</Button></div>
<div className="approval-selector-list">
{step.selectors.map((selector, selectorIndex) => <div key={selectorIndex}>
<FormField label="Actor type"><select value={selector.kind} disabled={disabled} onChange={(event) => { const kind = event.target.value as ApprovalSelector["kind"]; patchSelector(stepIndex, selectorIndex, { kind, value: kind === "any_account" ? "*" : selector.value === "*" ? "" : selector.value }); }}><option value="account">Account</option><option value="group">Group</option><option value="role">Role</option><option value="function_assignment">Function assignment</option><option value="any_account">Any account</option></select></FormField>
<FormField label="Actor value"><input value={selector.value} disabled={disabled || selector.kind === "any_account"} onChange={(event) => patchSelector(stepIndex, selectorIndex, { value: event.target.value })} /></FormField>
<FormField label="Display label"><input value={selector.label ?? ""} disabled={disabled} onChange={(event) => patchSelector(stepIndex, selectorIndex, { label: event.target.value || null })} /></FormField>
<IconButton label="Remove actor selector" icon={<Trash2 />} variant="danger" disabled={disabled || step.selectors.length === 1} onClick={() => patchStep(stepIndex, { selectors: step.selectors.filter((_, index) => index !== selectorIndex) })} />
</div>)}
</div>
</section>)}
</div>
</>;
}
export function emptyApprovalStep(index: number): ApprovalStep {
return {
key: `step-${index}`,
label: "",
selectors: [emptySelector()],
required_approvals: 1,
rejection_policy: "fail_fast",
due_at: null,
signature_required: false,
forbidden_evidence_roles: [],
metadata: {}
};
}
function emptySelector(): ApprovalSelector {
return { kind: "account", value: "", label: null };
}
function commaList(value: string): string[] {
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
}
@@ -0,0 +1,242 @@
import { GitCompareArrows, History, Pencil, Plus, Send } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
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" description="Define reusable, immutable approval chains and compare every published or draft revision." loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /><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">
<div className="approval-editor-grid">
<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 })} />
</div>
<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>
<div 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>
</div>
{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);
}
@@ -1,7 +1,7 @@
import { Check, Plus, RefreshCw, X } from "lucide-react";
import { AlarmClock, Check, Plus, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ActionBlockerHint, Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui";
import { approvalHistory, decideApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals";
import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui";
import { approvalHistory, decideApproval, escalateApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals";
import ApprovalRequestDialog from "./ApprovalRequestDialog";
import { APPROVALS_DOCUMENTATION, APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
@@ -16,8 +16,12 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext)
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [decision, setDecision] = useState<"approved" | "rejected" | null>(null);
const [escalating, setEscalating] = useState(false);
const canWrite = hasScope(auth, "approvals:workspace:write");
const canDecide = hasScope(auth, "approvals:workspace:decide");
const canAdmin = hasScope(auth, "approvals:workspace:admin");
const currentStep = selected?.steps[selected.current_step_index];
const escalationDue = Boolean(currentStep?.due_at && new Date(currentStep.due_at).getTime() <= Date.now());
const load = useCallback(async (signal?: AbortSignal, preferred?: string) => {
setLoading(true);
@@ -59,7 +63,7 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext)
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Approval creation permission", details: APPROVALS_I18N.writeReason, requiredAction: APPROVALS_I18N.permissionAction, actor: APPROVALS_I18N.permissionActor, target: APPROVALS_I18N.permissionDestination }} labels={{ requiredAction: APPROVALS_I18N.requiredAction, actor: APPROVALS_I18N.actor, target: APPROVALS_I18N.destination }} documentation={APPROVALS_DOCUMENTATION} />}
{selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail">
<header><div><h2>{selected.title}</h2><span>{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}</span></div><div><StatusBadge status={tone(selected.state)} label={humanize(selected.state)} /><Button variant="primary" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("rejected")}><X size={16} aria-hidden="true" />Reject</Button></div></header>
<header><div><h2>{selected.title}</h2><span>{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}</span></div><div><StatusBadge status={tone(selected.state)} label={humanize(selected.state)} />{canAdmin && selected.state === "pending" && <Button disabled={busy || !escalationDue} disabledReason={busy ? APPROVALS_I18N.busy : !escalationDue ? "The current step is not due for escalation." : undefined} onClick={() => setEscalating(true)}><AlarmClock size={16} aria-hidden="true" />Escalate</Button>}<Button variant="primary" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("rejected")}><X size={16} aria-hidden="true" />Reject</Button></div></header>
<div className="approval-metrics"><Metric label="Revision" value={selected.revision} /><Metric label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><Metric label="Requested by" value={selected.requested_by || "-"} /><Metric label="Steps" value={selected.steps.length} /></div>
{selected.description && <p>{selected.description}</p>}
<section><h3>Approval chain</h3><div className="approval-chain">{selected.steps.map((step, index) => <div key={step.key} className={step.key === selected.current_step_key ? "is-current" : ""}><span>{index + 1}</span><strong>{step.label}</strong><small>{step.required_approvals} required / {step.selectors.map((item) => `${humanize(item.kind)}: ${item.label || item.value}`).join(", ")}</small>{step.signature_required && <em>Signature</em>}</div>)}</div></section>
@@ -70,6 +74,7 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext)
</div>
{creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />}
{selected && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => setDecision(null)} onConfirm={async (reason, signatureId) => { setBusy(true); setError(""); try { await decideApproval(settings, selected, decision, reason, signatureId ? { owner_module: "signatures", object_id: signatureId } : undefined); setDecision(null); await reload(selected.id); return true; } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); return false; } finally { setBusy(false); } }} />}
{selected && <ConfirmDialog open={escalating} title="Escalate approval step" message={`Escalate ${currentStep?.label ?? "the current step"}? This records an explicit lifecycle transition and lets the configured escalation workflow react.`} confirmLabel="Escalate step" busy={busy} onCancel={() => setEscalating(false)} onConfirm={() => { setBusy(true); setError(""); void escalateApproval(settings, selected).then(() => { setEscalating(false); return reload(selected.id); }).catch((failure) => setError(text(failure, "The Approval step could not be escalated."))).finally(() => setBusy(false)); }} />}
</main>;
}
+27 -3
View File
@@ -1,9 +1,29 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { hasScope, type AdminSectionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/approvals.css";
const ApprovalsPage = lazy(() => import("./features/approvals/ApprovalsPage"));
const ApprovalTemplatesPanel = lazy(() => import("./features/approvals/ApprovalTemplatesPanel"));
const approvalsAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "tenant-approval-templates",
moduleId: "approvals",
kind: "management",
surfaceId: "approvals.admin.templates",
label: "Approval templates",
group: "TENANT",
order: 55,
anyOf: ["approvals:workspace:read", "approvals:workspace:admin"],
render: ({ settings, auth }) => createElement(ApprovalTemplatesPanel, {
settings,
canAdmin: hasScope(auth, "approvals:workspace:admin")
})
}
]
};
export const approvalsModule: PlatformWebModule = {
id: "approvals",
@@ -16,8 +36,12 @@ export const approvalsModule: PlatformWebModule = {
navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
viewSurfaces: [
{ id: "approvals.navigation", moduleId: "approvals", kind: "navigation", label: "Approvals navigation", order: 10 },
{ id: "approvals.workspace", moduleId: "approvals", kind: "route", label: "Approval request workspace", order: 20 }
]
{ id: "approvals.workspace", moduleId: "approvals", kind: "route", label: "Approval request workspace", order: 20 },
{ id: "approvals.admin.templates", moduleId: "approvals", kind: "section", label: "Approval templates", order: 30 }
],
uiCapabilities: {
"admin.sections": approvalsAdminSections
}
};
export default approvalsModule;
+14 -3
View File
@@ -25,13 +25,24 @@
.approval-chain em { font-size: .8rem; font-style: normal; }
.approval-history > div { grid-template-columns: 32px minmax(0, 1fr) auto; }
.approval-history time { color: var(--text-muted, #65717e); font-size: .82rem; }
.approval-request-dialog { width: min(1080px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); }
.approval-request-dialog, .approval-template-dialog { width: min(1160px, calc(100vw - 32px)); height: min(860px, calc(100vh - 32px)); }
.approval-editor { display: flex; min-height: 0; flex-direction: column; gap: 12px; overflow: auto; }
.approval-editor-help { display: flex; justify-content: flex-end; }
.approval-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.approval-editor-wide { grid-column: 1 / -1; }
.approval-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.approval-step-list { display: flex; flex-direction: column; gap: 8px; }
.approval-step-editor { display: grid; grid-template-columns: minmax(100px,.7fr) minmax(130px,1fr) 150px minmax(140px,1fr) 80px 120px 34px; align-items: end; gap: 8px; }
.approval-step-editor { display: flex; flex-direction: column; gap: 10px; padding: 10px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; }
.approval-step-heading, .approval-selector-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.approval-step-heading > div { display: flex; align-items: center; gap: 4px; }
.approval-step-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; gap: 8px; }
.approval-selector-heading { padding-top: 8px; border-top: 1px solid var(--border-color, #d8dde3); }
.approval-selector-list { display: flex; flex-direction: column; gap: 6px; }
.approval-selector-list > div { display: grid; grid-template-columns: 180px minmax(160px, 1fr) minmax(160px, 1fr) 34px; align-items: end; gap: 8px; }
.approval-template-history-dialog { width: min(1180px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); }
.approval-template-history-layout { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 12px; overflow: hidden; }
.approval-template-history-layout > .admin-table-surface { min-height: 180px; flex: 1; overflow: auto; }
.approval-compare-toolbar { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 8px; }
.approval-diff-value { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.approval-decision-form { display: flex; min-width: min(520px, 75vw); flex-direction: column; gap: 10px; }
@media (max-width: 850px) { .approvals-shell { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-editor { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } }
@media (max-width: 850px) { .approvals-shell { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-fields, .approval-selector-list > div, .approval-compare-toolbar { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } }