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([]); const [selectedId, setSelectedId] = useState(null); const [selected, setSelected] = useState(null); const [history, setHistory] = useState([]); 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
} scope="collection-pane" variant="collection" refreshable reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "Refresh approvals" }} className="approvals-toolbar" createAction={} /> {loading && }{items.map((item) => setSelectedId(item.id)}>)}{!loading && items.length === 0 && } } > {error && {error}} {!canWrite && } {selected &&

{selected.title}

{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}
{canAdmin && selected.state === "pending" && }
{selected.description &&

{selected.description}

}

Approval chain

{selected.steps.map((step, index) =>
{index + 1}{step.label}{step.required_approvals} required / {step.selectors.map((item) => `${humanize(item.kind)}: ${item.label || item.value}`).join(", ")}{step.signature_required && Signature}
)}

History

{history.map((event) =>
{event.sequence}{humanize(event.event_type)}
)}
} {!selected && !loading && }
{creating && setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />} {selected && decision && 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 && 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)); }} />}
; } function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }: { outcome: "approved" | "rejected"; busy: boolean; signatureRequired: boolean; onClose: () => void; onConfirm: (reason: string, signatureId: string) => Promise }) { 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 } onClose={requestClose} closeDisabled={busy} portal footer={<>}>