Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
109 lines
12 KiB
TypeScript
109 lines
12 KiB
TypeScript
import { AlarmClock, Check, Plus, X } from "lucide-react";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, LoadingIndicator, MetricCard, MetricGrid, PageScrollViewport, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, WorkspaceActionBar, WorkspaceLayout, 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";
|
|
|
|
export default function ApprovalsPage({ settings, auth }: PlatformRouteContext) {
|
|
const { language } = usePlatformLanguage();
|
|
const [items, setItems] = useState<ApprovalRequest[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [selected, setSelected] = useState<ApprovalRequest | null>(null);
|
|
const [history, setHistory] = useState<ApprovalEvent[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
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);
|
|
try {
|
|
const response = await listApprovals(settings, signal);
|
|
setItems(response.requests);
|
|
setSelectedId((current) => preferred ?? current ?? response.requests[0]?.id ?? null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [settings]);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
void load(controller.signal).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval requests could not be loaded.")); });
|
|
return () => controller.abort();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedId) { setSelected(null); setHistory([]); return; }
|
|
const controller = new AbortController();
|
|
Promise.all([getApproval(settings, selectedId, controller.signal), approvalHistory(settings, selectedId, controller.signal)]).then(([item, events]) => { setSelected(item); setHistory(events); }).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval details could not be loaded.")); });
|
|
return () => controller.abort();
|
|
}, [selectedId, settings]);
|
|
|
|
async function reload(id: string) {
|
|
const [item, events] = await Promise.all([getApproval(settings, id), approvalHistory(settings, id)]);
|
|
setSelected(item);
|
|
setHistory(events);
|
|
await load(undefined, id);
|
|
}
|
|
|
|
return <main className="approvals-page"><WorkspaceLayout
|
|
variant="split"
|
|
primarySize="compact"
|
|
primaryScrollable={false}
|
|
contentScrollable={false}
|
|
surface="contained"
|
|
className="approvals-shell"
|
|
primaryClassName="approvals-list-panel"
|
|
contentClassName="approvals-workspace"
|
|
primaryLabel="Approval requests"
|
|
contentLabel="Approval request details"
|
|
interfaceId="approvals.workspace"
|
|
helpContextId="approvals.workspace"
|
|
helpModuleId="approvals"
|
|
primary={<>
|
|
<WorkspaceActionBar
|
|
title="Approval requests"
|
|
titleHelp={<DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} />} scope="collection-pane" variant="collection" refreshable reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "Refresh approvals" }} className="approvals-toolbar" createAction={<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button>} />
|
|
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<SelectionList label="Approval requests" variant="navigation">{items.map((item) => <SelectionListItem key={item.id} selected={item.id === selectedId} onClick={() => setSelectedId(item.id)}><SelectionListItemContent title={item.title} description={`${item.subject_module} / ${item.subject_type}`} /><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></SelectionListItem>)}</SelectionList>{!loading && items.length === 0 && <StatePanel size="compact" description="No approval requests" />}</PageScrollViewport>
|
|
</>}
|
|
>
|
|
{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)} />{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" helpContextId="approvals.action.decide-request" helpModuleId="approvals" 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" helpContextId="approvals.action.decide-request" helpModuleId="approvals" 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>
|
|
<MetricGrid columns={4} spacing="block"><MetricCard density="compact" label="Revision" value={selected.revision} /><MetricCard density="compact" label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><MetricCard density="compact" label="Requested by" value={selected.requested_by || "-"} /><MetricCard density="compact" label="Steps" value={selected.steps.length} /></MetricGrid>
|
|
{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>
|
|
<section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString(language)}</time></div>)}</div></section>
|
|
</div></PageScrollViewport>}
|
|
{!selected && !loading && <StatePanel size="fill" title="Approval requests" description="Select or create an approval request." />}
|
|
</WorkspaceLayout>
|
|
{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>;
|
|
}
|
|
|
|
function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }: { outcome: "approved" | "rejected"; busy: boolean; signatureRequired: boolean; onClose: () => void; onConfirm: (reason: string, signatureId: string) => Promise<boolean> }) {
|
|
const [reason, setReason] = useState("");
|
|
const [signatureId, setSignatureId] = useState("");
|
|
const { requestDiscard } = useUnsavedChanges();
|
|
const dirty = Boolean(reason || signatureId);
|
|
const valid = Boolean(reason.trim() && (!signatureRequired || signatureId.trim()));
|
|
useUnsavedDraftGuard({ dirty, onSave: async () => valid && onConfirm(reason.trim(), signatureId.trim()), onDiscard: () => { setReason(""); setSignatureId(""); }, title: "i18n:govoplan-approvals.unsaved_title", message: "i18n:govoplan-approvals.unsaved_message" });
|
|
const requestClose = () => { if (busy) return; if (dirty) requestDiscard(onClose); else onClose(); };
|
|
return <Dialog open title={`${humanize(outcome)} request`} titleHelp={<DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} />} onClose={requestClose} closeDisabled={busy} portal footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant={outcome === "approved" ? "primary" : "danger"} disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void onConfirm(reason.trim(), signatureId.trim())}>Confirm</Button></>}><div className="approval-decision-form"><FormField label="Reason" documentation={APPROVALS_FIELD_DOCUMENTATION}><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>{signatureRequired && <FormField label="Signature reference" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={signatureId} disabled={busy} onChange={(event) => setSignatureId(event.target.value)} /></FormField>}</div></Dialog>;
|
|
}
|
|
|
|
function tone(state: ApprovalRequest["state"]): "active" | "inactive" | "warning" { if (state === "approved") return "active"; if (["rejected", "cancelled", "expired"].includes(state)) return "inactive"; return "warning"; }
|
|
function humanize(value: string): string { return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
|
|
function text(value: unknown, fallback: string): string { return value instanceof Error ? value.message : fallback; }
|