Migrate Approvals interface patterns
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { Check, Plus, RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Dialog, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, type PlatformRouteContext } from "@govoplan/core-webui";
|
||||
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 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);
|
||||
@@ -50,29 +52,36 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext)
|
||||
|
||||
return <main className="approvals-page"><div className="approvals-shell">
|
||||
<aside className="approvals-list-panel">
|
||||
<div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} onClick={() => void load()} />{canWrite && <Button variant="primary" onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button>}</div>
|
||||
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div></PageScrollViewport>
|
||||
<div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? APPROVALS_I18N.loading : busy ? APPROVALS_I18N.busy : undefined} onClick={() => void load()} /><Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /></div>
|
||||
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div>{!loading && items.length === 0 && <div className="approvals-empty">No approval requests</div>}</PageScrollViewport>
|
||||
</aside>
|
||||
<section className="approvals-workspace">
|
||||
{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)} />{canDecide && ["pending", "escalated"].includes(selected.state) && <><Button variant="primary" disabled={busy} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy} 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)} /><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>
|
||||
<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()}</time></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 && <div className="approvals-empty">Select or create an approval request.</div>}
|
||||
</section>
|
||||
</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); } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); } finally { setBusy(false); } }} />}
|
||||
{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); } }} />}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }: { outcome: "approved" | "rejected"; busy: boolean; signatureRequired: boolean; onClose: () => void; onConfirm: (reason: string, signatureId: string) => Promise<void> }) {
|
||||
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("");
|
||||
return <Dialog open title={`${humanize(outcome)} request`} onClose={onClose} closeDisabled={busy} portal footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant={outcome === "approved" ? "primary" : "danger"} disabled={busy || !reason.trim() || (signatureRequired && !signatureId.trim())} onClick={() => void onConfirm(reason.trim(), signatureId.trim())}>Confirm</Button></>}><div className="approval-decision-form"><FormField label="Reason"><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>{signatureRequired && <FormField label="Signature reference"><input value={signatureId} disabled={busy} onChange={(event) => setSignatureId(event.target.value)} /></FormField>}</div></Dialog>;
|
||||
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`} 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"><div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div><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 Metric({ label, value }: { label: string; value: string | number }) { return <div><span>{label}</span><strong>{value}</strong></div>; }
|
||||
|
||||
Reference in New Issue
Block a user