diff --git a/README.md b/README.md index 5982fd0..8505453 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,19 @@ This module does not own: Detailed boundary notes are in [docs/APPROVALS_DOMAIN_BOUNDARY.md](docs/APPROVALS_DOMAIN_BOUNDARY.md). +Tenant approval administrators manage reusable chains under +`Admin > Tenant > Approval templates`. Template edits and publication create +immutable revisions with content hashes and actor provenance. The history API +and UI compare any two revisions as structured JSON-pointer changes. The +request workspace also exposes due-step escalation to approval administrators; +the server rechecks the due timestamp and request revision before recording the +transition. + +Template API additions: + +- `GET /api/v1/approvals/templates/{template_id}/history` +- `GET /api/v1/approvals/templates/{template_id}/compare` + ## Integrations Optional integrations: diff --git a/src/govoplan_approvals/backend/manifest.py b/src/govoplan_approvals/backend/manifest.py index 330cc26..4fc56f6 100644 --- a/src/govoplan_approvals/backend/manifest.py +++ b/src/govoplan_approvals/backend/manifest.py @@ -179,6 +179,13 @@ manifest = ModuleManifest( label="Approval request workspace", order=20, ), + ViewSurface( + id="approvals.admin.templates", + module_id=MODULE_ID, + kind="section", + label="Approval templates", + order=30, + ), ), ), capability_factories={CAPABILITY_APPROVAL_REQUESTS: _requests}, @@ -240,6 +247,7 @@ manifest = ModuleManifest( "help_contexts": [ "approvals.navigation", "approvals.workspace", + "approvals.admin.templates", "approvals.state.permission-blocked", "approvals.state.empty", ], @@ -292,6 +300,35 @@ manifest = ModuleManifest( }, }, ), + DocumentationTopic( + id="approvals.workflow.administer-templates", + title="Administer approval templates", + summary="Create reusable approval chains, publish immutable revisions, compare history, and escalate steps only after their configured due time.", + body=( + "Approval administrators manage templates under Admin > Tenant > Approval templates. A stable key identifies the template while every edit creates a new draft revision with its own content hash, actor, predecessor, and timestamp. Publishing creates another immutable revision that new requests can bind to exactly; existing requests never follow later template changes. " + "The history dialog compares any two tenant-visible revisions as deterministic JSON-pointer changes without hiding unchanged evidence. Request operators with approval administration permission see Escalate only for pending requests, and the action becomes available after the current step's due time. The backend rechecks the due time and optimistic-concurrency revision before recording the lifecycle transition." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "product_owner", "auditor"), + links=( + DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"), + DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"), + DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"), + DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"), + ), + metadata={ + "help_contexts": [ + "approvals.admin.templates", + "approvals.action.escalate-request", + ], + "consequence_classes": { + "revise_template": "Supersedes the current template and creates a new draft revision.", + "publish_template": "Creates an immutable published revision available to new requests.", + "escalate_request": "Records that the current due step entered escalation without deciding it.", + }, + }, + ), ), architecture=declared_module_architecture( layer="human_work_procedure", diff --git a/src/govoplan_approvals/backend/router.py b/src/govoplan_approvals/backend/router.py index 20b561c..d338faf 100644 --- a/src/govoplan_approvals/backend/router.py +++ b/src/govoplan_approvals/backend/router.py @@ -173,6 +173,80 @@ def api_publish_template( raise _error(exc) from exc +@router.get("/templates/{template_id}", response_model=dict[str, Any]) +def api_get_template( + template_id: str, + revision: int | None = Query(default=None, ge=1), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_approvals.backend.manifest import READ_SCOPE + + _require(principal, READ_SCOPE) + item = SqlApprovalRequests().get_template( + session, + principal, + template_id=template_id, + revision=revision, + ) + if item is None: + raise HTTPException(status_code=404, detail="Approval template not found") + return dict(item) + + +@router.get( + "/templates/{template_id}/history", + response_model=list[dict[str, Any]], +) +def api_get_template_history( + template_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> list[dict[str, Any]]: + from govoplan_approvals.backend.manifest import READ_SCOPE + + _require(principal, READ_SCOPE) + try: + return [ + dict(item) + for item in SqlApprovalRequests().template_history( + session, + principal, + template_id=template_id, + ) + ] + except (ApprovalStoreError, LookupError) as exc: + raise _error(exc) from exc + + +@router.get( + "/templates/{template_id}/compare", + response_model=dict[str, Any], +) +def api_compare_template_revisions( + template_id: str, + from_revision: int = Query(ge=1), + to_revision: int = Query(ge=1), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_approvals.backend.manifest import READ_SCOPE + + _require(principal, READ_SCOPE) + try: + return dict( + SqlApprovalRequests().compare_template_revisions( + session, + principal, + template_id=template_id, + from_revision=from_revision, + to_revision=to_revision, + ) + ) + except (ApprovalStoreError, LookupError) as exc: + raise _error(exc) from exc + + @router.get("/{request_id}", response_model=dict[str, Any]) def api_get_request( request_id: str, diff --git a/src/govoplan_approvals/backend/service.py b/src/govoplan_approvals/backend/service.py index 5c9aa98..b06da88 100644 --- a/src/govoplan_approvals/backend/service.py +++ b/src/govoplan_approvals/backend/service.py @@ -238,6 +238,75 @@ class SqlApprovalRequests: .all() ) + def template_history( + self, + session: object, + principal: object, + *, + template_id: str, + ) -> tuple[Mapping[str, object], ...]: + rows = ( + _session(session) + .query(ApprovalTemplateRevision) + .filter( + ApprovalTemplateRevision.tenant_id == _tenant(principal), + ApprovalTemplateRevision.template_id == template_id, + ) + .order_by(ApprovalTemplateRevision.revision.desc()) + .all() + ) + if not rows: + raise LookupError("Approval template not found.") + return tuple(_template_mapping(row) for row in rows) + + def compare_template_revisions( + self, + session: object, + principal: object, + *, + template_id: str, + from_revision: int, + to_revision: int, + ) -> Mapping[str, object]: + if from_revision < 1 or to_revision < 1: + raise ApprovalStoreError( + "Approval template revisions must be positive integers." + ) + rows = ( + _session(session) + .query(ApprovalTemplateRevision) + .filter( + ApprovalTemplateRevision.tenant_id == _tenant(principal), + ApprovalTemplateRevision.template_id == template_id, + ApprovalTemplateRevision.revision.in_( + (from_revision, to_revision) + ), + ) + .all() + ) + by_revision = {row.revision: row for row in rows} + missing = [ + revision + for revision in (from_revision, to_revision) + if revision not in by_revision + ] + if missing: + raise LookupError( + "Approval template revision not found: " + + ", ".join(str(revision) for revision in missing) + ) + before = by_revision[from_revision] + after = by_revision[to_revision] + return { + "template_id": template_id, + "from_revision": _template_mapping(before), + "to_revision": _template_mapping(after), + "changes": _structured_changes( + before.payload, + after.payload, + ), + } + def create_request( self, session: object, @@ -1073,10 +1142,80 @@ def _template_mapping(row: ApprovalTemplateRevision) -> dict[str, object]: "state": row.state, "content_sha256": row.content_sha256, "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + "previous_revision_id": row.previous_revision_id, + "actor_id": row.actor_id, **dict(row.payload), } +_MISSING = object() + + +def _structured_changes( + before: object, + after: object, + *, + path: str = "", +) -> list[dict[str, object]]: + if isinstance(before, Mapping) and isinstance(after, Mapping): + changes: list[dict[str, object]] = [] + keys = sorted(set(before).union(after), key=str) + for key in keys: + child_path = f"{path}/{_json_pointer_segment(str(key))}" + changes.extend( + _structured_changes( + before.get(key, _MISSING), + after.get(key, _MISSING), + path=child_path, + ) + ) + return changes + if isinstance(before, list) and isinstance(after, list): + changes = [] + for index in range(max(len(before), len(after))): + changes.extend( + _structured_changes( + before[index] if index < len(before) else _MISSING, + after[index] if index < len(after) else _MISSING, + path=f"{path}/{index}", + ) + ) + return changes + if before is _MISSING: + return [ + { + "path": path or "/", + "change": "added", + "before": None, + "after": after, + } + ] + if after is _MISSING: + return [ + { + "path": path or "/", + "change": "removed", + "before": before, + "after": None, + } + ] + if before != after: + return [ + { + "path": path or "/", + "change": "changed", + "before": before, + "after": after, + } + ] + return [] + + +def _json_pointer_segment(value: str) -> str: + return value.replace("~", "~0").replace("/", "~1") + + def _template_ref(row: ApprovalTemplateRevision) -> ApprovalTemplateRef: return ApprovalTemplateRef( id=row.template_id, diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 0817fb7..dc4b5f7 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -318,6 +318,81 @@ class ApprovalRuntimeTests(unittest.TestCase): subject_digest="b" * 64, ) + def test_template_history_and_structural_compare_are_tenant_bound(self) -> None: + original = ApprovalTemplateCreateCommand( + key="monthly-release", + title="Monthly release", + description="Initial process", + steps=( + ApprovalStepDefinition( + "review", + "Review", + (ApprovalActorSelector("role", "reviewers"),), + ), + ), + ) + revised_command = ApprovalTemplateCreateCommand( + key="monthly-release", + title="Monthly release approval", + description="Initial process", + steps=( + ApprovalStepDefinition( + "review", + "Independent review", + (ApprovalActorSelector("role", "reviewers"),), + ), + ), + ) + with self.Session() as session: + created = self.service.create_template( + session, + self.requester, + command=original, + idempotency_key="history-template-1", + ) + revised = self.service.revise_template( + session, + self.requester, + template_id=created.id, + command=revised_command, + expected_revision=1, + idempotency_key="history-template-2", + ) + self.assertEqual(2, revised.revision) + + history = self.service.template_history( + session, + self.requester, + template_id=created.id, + ) + self.assertEqual([2, 1], [item["revision"] for item in history]) + self.assertIsNotNone(history[1]["superseded_at"]) + self.assertEqual("requester", history[0]["actor_id"]) + + comparison = self.service.compare_template_revisions( + session, + self.requester, + template_id=created.id, + from_revision=1, + to_revision=2, + ) + changes = {item["path"]: item for item in comparison["changes"]} + self.assertEqual( + "Monthly release", + changes["/title"]["before"], + ) + self.assertEqual( + "Independent review", + changes["/steps/0/label"]["after"], + ) + + with self.assertRaises(LookupError): + self.service.template_history( + session, + Principal("tenant-2", "other"), + template_id=created.id, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index 0fada9e..8b964f7 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -11,7 +11,11 @@ class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase): self.assertIsNotNone(frontend) self.assertEqual({"/approvals"}, {item.path for item in frontend.routes}) # type: ignore[union-attr] self.assertEqual( - {"approvals.navigation", "approvals.workspace"}, + { + "approvals.navigation", + "approvals.workspace", + "approvals.admin.templates", + }, {item.id for item in frontend.view_surfaces}, # type: ignore[union-attr] ) @@ -19,11 +23,13 @@ class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase): topics = {topic.id: topic for topic in manifest.documentation} guide = topics["approvals.module-boundary"] reference = topics["approvals.reference.fields-and-consequences"] + templates = topics["approvals.workflow.administer-templates"] self.assertIn("approvals.workspace", guide.metadata["help_contexts"]) self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) self.assertIn("approvals.field.subject-digest", reference.metadata["help_contexts"]) self.assertIn("create_request", reference.metadata["consequence_classes"]) self.assertIn("reject_request", reference.metadata["consequence_classes"]) + self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"]) if __name__ == "__main__": diff --git a/webui/package.json b/webui/package.json index f38f5f2..75baf2e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -16,5 +16,8 @@ "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20" }, - "peerDependenciesMeta": { "@govoplan/core-webui": { "optional": true } } + "peerDependenciesMeta": { "@govoplan/core-webui": { "optional": true } }, + "scripts": { + "test:approval-templates": "node tests/approval-templates-ui-structure.test.mjs" + } } diff --git a/webui/src/api/approvals.ts b/webui/src/api/approvals.ts index 7a3dd11..2b5fce9 100644 --- a/webui/src/api/approvals.ts +++ b/webui/src/api/approvals.ts @@ -29,6 +29,31 @@ export type ApprovalRequest = { }; export type ApprovalDraft = Omit; export type ApprovalEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record }; +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; +}; +export type ApprovalTemplateDraft = Pick; +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 { + 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 { + return apiFetch(settings, apiPath("/api/v1/approvals/templates", { limit: 200 }), { signal }); +} + +export function createApprovalTemplate(settings: ApiSettings, template: ApprovalTemplateDraft): Promise { + 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 { + 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 { + 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 { + 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 { + return apiFetch(settings, apiPath(`/api/v1/approvals/templates/${encodeURIComponent(templateId)}/compare`, { + from_revision: fromRevision, + to_revision: toRevision + }), { signal }); +} diff --git a/webui/src/features/approvals/ApprovalRequestDialog.tsx b/webui/src/features/approvals/ApprovalRequestDialog.tsx index b72016b..cad0e24 100644 --- a/webui/src/features/approvals/ApprovalRequestDialog.tsx +++ b/webui/src/features/approvals/ApprovalRequestDialog.tsx @@ -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) { - setDraft((current) => ({ ...current, steps: current.steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) })); - } - return }>
@@ -60,28 +56,13 @@ export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { setDraft({ ...draft, separation_of_duties: value })} /> setDraft({ ...draft, unique_actors_across_steps: value })} />
-

Steps

-
- {draft.steps.map((step, index) =>
- patchStep(index, { key: event.target.value })} /> - patchStep(index, { label: event.target.value })} /> - - patchStep(index, { selectors: [{ ...step.selectors[0], value: event.target.value }] })} /> - patchStep(index, { required_approvals: Number(event.target.value) })} /> - patchStep(index, { signature_required: value })} /> - } 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) })} /> -
)} -
+ setDraft({ ...draft, steps })} />
; } -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 { diff --git a/webui/src/features/approvals/ApprovalStepsEditor.tsx b/webui/src/features/approvals/ApprovalStepsEditor.tsx new file mode 100644 index 0000000..2c39ac7 --- /dev/null +++ b/webui/src/features/approvals/ApprovalStepsEditor.tsx @@ -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) { + 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) { + const step = steps[stepIndex]; + patchStep(stepIndex, { + selectors: step.selectors.map((selector, index) => index === selectorIndex ? { ...selector, ...patch } : selector) + }); + } + + return <> +
+

Steps

+ +
+
+ {steps.map((step, stepIndex) =>
+
+ {step.label || `Step ${stepIndex + 1}`} +
+ } disabled={disabled || stepIndex === 0} onClick={() => moveStep(stepIndex, -1)} /> + } disabled={disabled || stepIndex === steps.length - 1} onClick={() => moveStep(stepIndex, 1)} /> + } variant="danger" disabled={disabled || steps.length === 1} disabledReason={steps.length === 1 ? APPROVALS_I18N.oneStep : undefined} onClick={() => onChange(steps.filter((_, index) => index !== stepIndex))} /> +
+
+
+ patchStep(stepIndex, { key: event.target.value })} /> + patchStep(stepIndex, { label: event.target.value })} /> + patchStep(stepIndex, { required_approvals: Number(event.target.value) })} /> + + patchStep(stepIndex, { due_at: value || null })} /> + patchStep(stepIndex, { forbidden_evidence_roles: commaList(event.target.value) })} /> + patchStep(stepIndex, { signature_required: value })} /> +
+
Eligible actors
+
+ {step.selectors.map((selector, selectorIndex) =>
+ + patchSelector(stepIndex, selectorIndex, { value: event.target.value })} /> + patchSelector(stepIndex, selectorIndex, { label: event.target.value || null })} /> + } variant="danger" disabled={disabled || step.selectors.length === 1} onClick={() => patchStep(stepIndex, { selectors: step.selectors.filter((_, index) => index !== selectorIndex) })} /> +
)} +
+
)} +
+ ; +} + +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))]; +} diff --git a/webui/src/features/approvals/ApprovalTemplatesPanel.tsx b/webui/src/features/approvals/ApprovalTemplatesPanel.tsx new file mode 100644 index 0000000..c3e9e5d --- /dev/null +++ b/webui/src/features/approvals/ApprovalTemplatesPanel.tsx @@ -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([]); + const [editor, setEditor] = useState(null); + const [draft, setDraft] = useState(emptyTemplate()); + const [publishing, setPublishing] = useState(null); + const [historyTemplate, setHistoryTemplate] = useState(null); + const [history, setHistory] = useState([]); + const [fromRevision, setFromRevision] = useState(1); + const [toRevision, setToRevision] = useState(1); + const [comparison, setComparison] = useState(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[]>(() => [ + { 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) =>
{row.title}
{row.key}
}, + { id: "state", header: "State", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.state, render: (row) => }, + { 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) => , disabled: !canAdmin, disabledReason: !canAdmin ? "Approval administration permission is required." : undefined, onClick: () => openEdit(row) }, + { id: "history", label: `History for ${row.title}`, icon: , onClick: () => void openHistory(row) }, + { id: "publish", label: `Publish ${row.title}`, icon: , applicable: row.state === "draft", disabled: !canAdmin, disabledReason: !canAdmin ? "Approval administration permission is required." : undefined, onClick: () => setPublishing(row) } + ]} /> } + ], [canAdmin]); + + const historyColumns = useMemo[]>(() => [ + { 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) => }, + { 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) => {row.content_sha256.slice(0, 16)}... } + ], []); + + const changeColumns = useMemo[]>(() => [ + { id: "path", header: "Path", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sticky: "start", value: (row) => row.path, render: (row) => {row.path} }, + { id: "change", header: "Change", width: 110, resizable: false, value: (row) => row.change, render: (row) => }, + { id: "before", header: "Before", width: "minmax(180px, 1fr)", minWidth: 160, fill: true, resizable: true, value: (row) => printable(row.before), render: (row) => {printable(row.before)} }, + { id: "after", header: "After", width: "minmax(180px, 1fr)", minWidth: 160, fill: true, resizable: true, value: (row) => printable(row.after), render: (row) => {printable(row.after)} } + ], []); + + 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 <> + } variant="primary" onClick={openCreate} disabled={!canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : undefined} />}> +
row.id} emptyText="No approval templates found." />
+
+ + !busy && setEditor(null)} closeDisabled={busy} className="approval-template-dialog" footer={<>}> +
+
+ setDraft({ ...draft, key: event.target.value })} /> + setDraft({ ...draft, title: event.target.value })} /> +