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
+13
View File
@@ -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:
@@ -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",
+74
View File
@@ -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,
+139
View File
@@ -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,
+75
View File
@@ -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()
@@ -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__":
+4 -1
View File
@@ -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"
}
}
+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; } }
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const panel = readFileSync("src/features/approvals/ApprovalTemplatesPanel.tsx", "utf8");
const approvalsPage = readFileSync("src/features/approvals/ApprovalsPage.tsx", "utf8");
const api = readFileSync("src/api/approvals.ts", "utf8");
const moduleSource = readFileSync("src/module.ts", "utf8");
assert.match(panel, /Approval templates/);
assert.match(panel, /approvalTemplateHistory/);
assert.match(panel, /compareApprovalTemplateRevisions/);
assert.match(panel, /publishApprovalTemplate/);
assert.match(panel, /<ConfirmDialog[\s\S]*Publish approval template/);
assert.match(approvalsPage, /escalateApproval/);
assert.match(approvalsPage, /!escalationDue/);
assert.match(api, /templates\/\$\{encodeURIComponent\(templateId\)\}\/history/);
assert.match(api, /templates\/\$\{encodeURIComponent\(templateId\)\}\/compare/);
assert.match(moduleSource, /approvals\.admin\.templates/);
assert.doesNotMatch(`${panel}\n${approvalsPage}`, /window\.(?:alert|confirm|prompt)\(/);
console.log("Approval template administration UI structural contract passed.");