Add approval template revision administration
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user