Migrate Approvals interface patterns
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Approvals Interface Pattern Migration
|
||||
|
||||
This migration applies the GovOPlaN interface pattern language to the
|
||||
Approvals-owned route without changing the append-only approval model or
|
||||
importing optional sibling modules.
|
||||
|
||||
## Surface Inventory
|
||||
|
||||
| Surface | Archetype | Consequence class | Contract |
|
||||
| --- | --- | --- | --- |
|
||||
| `/approvals` list | Searchable work queue | Select request or create immutable chain | Shared loading, empty, error, permission, selection, and help states |
|
||||
| Approval detail | Governed record detail | Inspect exact subject, chain, state, and evidence | Locale-aware history, status, provenance, explained lifecycle actions |
|
||||
| Request dialog | Consequential definition editor | Freeze exact subject and approval chain | Guarded draft, field help, validation reasons, at least one step |
|
||||
| Decision dialog | Governed decision | Append approval or rejection evidence | Required reason/signature, guarded draft, explicit confirmation action |
|
||||
|
||||
## Consequence And Availability Rules
|
||||
|
||||
- Creation freezes the subject identity and digest plus every ordered step,
|
||||
selector, quorum, separation, and signature requirement.
|
||||
- Approving or rejecting appends attributable evidence and cannot be edited.
|
||||
- Only pending or escalated requests can be decided. Terminal requests remain
|
||||
available for reconstruction.
|
||||
- Missing create or decide permission is visible and points to the Access role
|
||||
assignment destination and responsible administrator.
|
||||
- Signature references are evidence pointers and never a cryptographic claim.
|
||||
|
||||
The module uses Core dialogs, controls, status, blockers, help, loading, empty,
|
||||
error, and draft-guard contracts. Native selection buttons preserve keyboard
|
||||
order; bounded list/detail viewports remain responsive. English and German
|
||||
catalogues cover module-owned copy and dates follow the active platform locale.
|
||||
@@ -235,6 +235,62 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"approvals.navigation",
|
||||
"approvals.workspace",
|
||||
"approvals.state.permission-blocked",
|
||||
"approvals.state.empty",
|
||||
],
|
||||
"privacy_notes": [
|
||||
"Approval lists and histories remain tenant-bound and permission-filtered.",
|
||||
"Signature references identify evidence but do not expose private key material.",
|
||||
"Decision history retains actor and reason as governed evidence.",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="approvals.reference.fields-and-consequences",
|
||||
title="Approval fields and consequences",
|
||||
summary="Exact-subject identity, selector, separation-of-duty, signature, and decision consequences.",
|
||||
body=(
|
||||
"Subject module, type, identifier, version, and SHA-256 digest freeze the exact object revision being approved. "
|
||||
"Ordered steps, actor selectors, required counts, requester separation, unique actors, and signature requirements "
|
||||
"are copied into the immutable request and do not follow later template changes. Actor values are provider-neutral "
|
||||
"identifiers interpreted through Access and IDM contracts. Approval or rejection appends a decision with actor, "
|
||||
"reason, optional signature reference, and optimistic-concurrency revision. Completed, rejected, cancelled, and "
|
||||
"expired requests remain evidence and cannot be decided again."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Approvals boundary and recovery",
|
||||
href="govoplan-approvals/docs/APPROVALS_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"approvals.field.subject-reference",
|
||||
"approvals.field.subject-digest",
|
||||
"approvals.field.actor-selector",
|
||||
"approvals.field.separation-of-duties",
|
||||
"approvals.field.signature-reference",
|
||||
"approvals.action.create-request",
|
||||
"approvals.action.decide-request",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"create_request": "Freezes an exact subject and immutable approval chain.",
|
||||
"approve_step": "Appends an attributable decision and may advance or complete the chain.",
|
||||
"reject_request": "Appends a rejection and completes the request according to its frozen policy.",
|
||||
"retain_evidence": "Keeps request revisions, decisions, reasons, and signature references for reconstruction.",
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_approvals.backend.manifest import manifest
|
||||
|
||||
|
||||
class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
self.assertEqual({"/approvals"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
|
||||
self.assertEqual(
|
||||
{"approvals.navigation", "approvals.workspace"},
|
||||
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||
)
|
||||
|
||||
def test_help_privacy_and_consequence_metadata_remain_published(self) -> None:
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
guide = topics["approvals.module-boundary"]
|
||||
reference = topics["approvals.reference.fields-and-consequences"]
|
||||
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"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,45 +1,66 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Dialog, DismissibleAlert, FormField, IconButton, ToggleSwitch, type ApiSettings } from "@govoplan/core-webui";
|
||||
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 { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
|
||||
|
||||
export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { settings: ApiSettings; onClose: () => void; onSaved: (value: ApprovalRequest) => void }) {
|
||||
const [draft, setDraft] = useState<ApprovalDraft>(() => initialDraft());
|
||||
const [baseline] = useState<ApprovalDraft>(() => initialDraft());
|
||||
const [draft, setDraft] = useState<ApprovalDraft>(baseline);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
const valid = useMemo(() => Boolean(draft.title.trim() && draft.subject_module.trim() && draft.subject_type.trim() && draft.subject_id.trim() && /^[0-9a-f]{64}$/.test(draft.subject_digest) && draft.steps.every((step) => step.key.trim() && step.label.trim() && step.selectors.every((selector) => selector.value.trim()))), [draft]);
|
||||
const dirty = draftKey(draft) !== draftKey(baseline);
|
||||
|
||||
async function save() {
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
onSaved(await createApproval(settings, draft));
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Approval request could not be created.");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => setDraft(baseline),
|
||||
title: "i18n:govoplan-approvals.unsaved_title",
|
||||
message: "i18n:govoplan-approvals.unsaved_message"
|
||||
});
|
||||
|
||||
function requestClose() {
|
||||
if (busy) return;
|
||||
if (dirty) requestDiscard(onClose);
|
||||
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={onClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant="primary" disabled={busy || !valid} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
|
||||
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>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<div className="approval-editor-grid">
|
||||
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||
<FormField label="Subject module"><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
|
||||
<FormField label="Subject type"><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
|
||||
<FormField label="Subject ID"><input value={draft.subject_id} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_id: event.target.value })} /></FormField>
|
||||
<FormField label="Subject version"><input value={draft.subject_version ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_version: event.target.value })} /></FormField>
|
||||
<FormField label="Subject SHA-256"><input value={draft.subject_digest} disabled={busy} maxLength={64} spellCheck={false} onChange={(event) => setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /></FormField>
|
||||
<FormField label="Title" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||
<FormField label="Subject module" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
|
||||
<FormField label="Subject type" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
|
||||
<FormField label="Subject ID" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_id} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_id: event.target.value })} /></FormField>
|
||||
<FormField label="Subject version" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_version ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_version: event.target.value })} /></FormField>
|
||||
<FormField label="Subject SHA-256" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_digest} disabled={busy} maxLength={64} spellCheck={false} onChange={(event) => setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /></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>
|
||||
<div className="approval-editor-heading"><h3>Steps</h3><Button disabled={busy} onClick={() => setDraft({ ...draft, steps: [...draft.steps, emptyStep(draft.steps.length + 1)] })}><Plus size={16} aria-hidden="true" />Add step</Button></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>
|
||||
@@ -48,7 +69,7 @@ export default function ApprovalRequestDialog({ settings, onClose, onSaved }: {
|
||||
<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} onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, itemIndex) => itemIndex !== index) })} />
|
||||
<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>
|
||||
</div>
|
||||
@@ -62,3 +83,7 @@ function emptyStep(index: number): ApprovalStep {
|
||||
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: {} };
|
||||
}
|
||||
|
||||
function draftKey(draft: ApprovalDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
@@ -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>; }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const APPROVALS_DOCUMENTATION = {
|
||||
topicId: "approvals.module-boundary",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const APPROVALS_FIELD_DOCUMENTATION = {
|
||||
topicId: "approvals.reference.fields-and-consequences",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const APPROVALS_I18N = {
|
||||
loading: "i18n:govoplan-approvals.loading_reason",
|
||||
busy: "i18n:govoplan-approvals.busy_reason",
|
||||
writeReason: "i18n:govoplan-approvals.write_permission_reason",
|
||||
decideReason: "i18n:govoplan-approvals.decide_permission_reason",
|
||||
lifecycleReason: "i18n:govoplan-approvals.lifecycle_reason",
|
||||
incomplete: "i18n:govoplan-approvals.incomplete_reason",
|
||||
oneStep: "i18n:govoplan-approvals.one_step_reason",
|
||||
requiredAction: "i18n:govoplan-approvals.required_action",
|
||||
actor: "i18n:govoplan-approvals.responsible_actor",
|
||||
destination: "i18n:govoplan-approvals.destination",
|
||||
permissionAction: "i18n:govoplan-approvals.permission_action",
|
||||
permissionActor: "i18n:govoplan-approvals.permission_actor",
|
||||
permissionDestination: "i18n:govoplan-approvals.permission_destination"
|
||||
} as const;
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
const en = {
|
||||
"i18n:govoplan-approvals.approvals": "Approvals",
|
||||
"i18n:govoplan-approvals.loading_reason": "Approval data is still loading.",
|
||||
"i18n:govoplan-approvals.busy_reason": "Another Approval action is still running.",
|
||||
"i18n:govoplan-approvals.write_permission_reason": "Your account may not create Approval requests.",
|
||||
"i18n:govoplan-approvals.decide_permission_reason": "Your account may not decide this Approval request.",
|
||||
"i18n:govoplan-approvals.lifecycle_reason": "Only pending or escalated requests can be decided.",
|
||||
"i18n:govoplan-approvals.incomplete_reason": "Complete the required subject, digest, steps, and selector fields first.",
|
||||
"i18n:govoplan-approvals.one_step_reason": "An Approval chain must retain at least one step.",
|
||||
"i18n:govoplan-approvals.required_action": "Required action",
|
||||
"i18n:govoplan-approvals.responsible_actor": "Responsible actor",
|
||||
"i18n:govoplan-approvals.destination": "Destination",
|
||||
"i18n:govoplan-approvals.permission_action": "Ask for the corresponding Approval permission.",
|
||||
"i18n:govoplan-approvals.permission_actor": "An Access or tenant administrator",
|
||||
"i18n:govoplan-approvals.permission_destination": "Access role assignments",
|
||||
"i18n:govoplan-approvals.unsaved_title": "Unsaved Approval request",
|
||||
"i18n:govoplan-approvals.unsaved_message": "Save or discard the Approval draft before leaving this surface.",
|
||||
"Approvals": "Approvals",
|
||||
"Refresh approvals": "Refresh approvals",
|
||||
"New request": "New request",
|
||||
"Loading approvals": "Loading approvals",
|
||||
"No approval requests": "No approval requests",
|
||||
"Select or create an approval request.": "Select or create an approval request.",
|
||||
"No Approval creation permission": "No Approval creation permission",
|
||||
"Approve": "Approve",
|
||||
"Reject": "Reject",
|
||||
"Revision": "Revision",
|
||||
"Current step": "Current step",
|
||||
"Requested by": "Requested by",
|
||||
"Steps": "Steps",
|
||||
"Complete": "Complete",
|
||||
"Approval chain": "Approval chain",
|
||||
"History": "History",
|
||||
"Signature": "Signature",
|
||||
"Cancel": "Cancel",
|
||||
"Confirm": "Confirm",
|
||||
"Reason": "Reason",
|
||||
"Signature reference": "Signature reference",
|
||||
"New Approval request": "New Approval request",
|
||||
"Creating": "Creating",
|
||||
"Create request": "Create request",
|
||||
"Title": "Title",
|
||||
"Subject module": "Subject module",
|
||||
"Subject type": "Subject type",
|
||||
"Subject ID": "Subject ID",
|
||||
"Subject version": "Subject version",
|
||||
"Subject SHA-256": "Subject SHA-256",
|
||||
"Description": "Description",
|
||||
"Separate requester and approver": "Separate requester and approver",
|
||||
"Different actor for every step": "Different actor for every step",
|
||||
"Add step": "Add step",
|
||||
"Key": "Key",
|
||||
"Label": "Label",
|
||||
"Actor type": "Actor type",
|
||||
"Actor value": "Actor value",
|
||||
"Required": "Required",
|
||||
"Account": "Account",
|
||||
"Group": "Group",
|
||||
"Role": "Role",
|
||||
"Function assignment": "Function assignment",
|
||||
"Any account": "Any account"
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
"i18n:govoplan-approvals.approvals": "Genehmigungen",
|
||||
"i18n:govoplan-approvals.loading_reason": "Genehmigungsdaten werden noch geladen.",
|
||||
"i18n:govoplan-approvals.busy_reason": "Eine andere Genehmigungsaktion läuft noch.",
|
||||
"i18n:govoplan-approvals.write_permission_reason": "Ihr Konto darf keine Genehmigungsanträge erstellen.",
|
||||
"i18n:govoplan-approvals.decide_permission_reason": "Ihr Konto darf diesen Genehmigungsantrag nicht entscheiden.",
|
||||
"i18n:govoplan-approvals.lifecycle_reason": "Nur ausstehende oder eskalierte Anträge können entschieden werden.",
|
||||
"i18n:govoplan-approvals.incomplete_reason": "Füllen Sie zuerst Gegenstand, Prüfsumme, Schritte und Auswahlfelder aus.",
|
||||
"i18n:govoplan-approvals.one_step_reason": "Eine Genehmigungskette muss mindestens einen Schritt behalten.",
|
||||
"i18n:govoplan-approvals.required_action": "Erforderliche Aktion",
|
||||
"i18n:govoplan-approvals.responsible_actor": "Verantwortliche Stelle",
|
||||
"i18n:govoplan-approvals.destination": "Ziel",
|
||||
"i18n:govoplan-approvals.permission_action": "Fordern Sie die entsprechende Genehmigungsberechtigung an.",
|
||||
"i18n:govoplan-approvals.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
|
||||
"i18n:govoplan-approvals.permission_destination": "Zugriff und Rollenzuweisungen",
|
||||
"i18n:govoplan-approvals.unsaved_title": "Ungespeicherter Genehmigungsantrag",
|
||||
"i18n:govoplan-approvals.unsaved_message": "Speichern oder verwerfen Sie den Genehmigungsentwurf, bevor Sie diese Oberfläche verlassen.",
|
||||
"Approvals": "Genehmigungen",
|
||||
"Refresh approvals": "Genehmigungen aktualisieren",
|
||||
"New request": "Neuer Antrag",
|
||||
"Loading approvals": "Genehmigungen werden geladen",
|
||||
"No approval requests": "Keine Genehmigungsanträge",
|
||||
"Select or create an approval request.": "Wählen oder erstellen Sie einen Genehmigungsantrag.",
|
||||
"No Approval creation permission": "Keine Berechtigung zum Erstellen von Genehmigungen",
|
||||
"Approve": "Genehmigen",
|
||||
"Reject": "Ablehnen",
|
||||
"Revision": "Revision",
|
||||
"Current step": "Aktueller Schritt",
|
||||
"Requested by": "Beantragt von",
|
||||
"Steps": "Schritte",
|
||||
"Complete": "Abgeschlossen",
|
||||
"Approval chain": "Genehmigungskette",
|
||||
"History": "Verlauf",
|
||||
"Signature": "Signatur",
|
||||
"Cancel": "Abbrechen",
|
||||
"Confirm": "Bestätigen",
|
||||
"Reason": "Grund",
|
||||
"Signature reference": "Signaturreferenz",
|
||||
"New Approval request": "Neuer Genehmigungsantrag",
|
||||
"Creating": "Erstellen",
|
||||
"Create request": "Antrag erstellen",
|
||||
"Title": "Titel",
|
||||
"Subject module": "Gegenstandsmodul",
|
||||
"Subject type": "Gegenstandsart",
|
||||
"Subject ID": "Gegenstands-ID",
|
||||
"Subject version": "Gegenstandsversion",
|
||||
"Subject SHA-256": "SHA-256 des Gegenstands",
|
||||
"Description": "Beschreibung",
|
||||
"Separate requester and approver": "Antragstellende und Genehmigende trennen",
|
||||
"Different actor for every step": "Unterschiedliche Stelle für jeden Schritt",
|
||||
"Add step": "Schritt hinzufügen",
|
||||
"Key": "Schlüssel",
|
||||
"Label": "Bezeichnung",
|
||||
"Actor type": "Akteursart",
|
||||
"Actor value": "Akteurswert",
|
||||
"Required": "Erforderlich",
|
||||
"Account": "Konto",
|
||||
"Group": "Gruppe",
|
||||
"Role": "Rolle",
|
||||
"Function assignment": "Funktionszuordnung",
|
||||
"Any account": "Beliebiges Konto"
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
+4
-2
@@ -1,17 +1,19 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/approvals.css";
|
||||
|
||||
const ApprovalsPage = lazy(() => import("./features/approvals/ApprovalsPage"));
|
||||
|
||||
export const approvalsModule: PlatformWebModule = {
|
||||
id: "approvals",
|
||||
label: "Approvals",
|
||||
label: "i18n:govoplan-approvals.approvals",
|
||||
version: "0.1.14",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"],
|
||||
translations: generatedTranslations,
|
||||
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }],
|
||||
navItems: [{ to: "/approvals", label: "Approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
|
||||
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 }
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
.approvals-list button > span { display: flex; min-width: 0; flex-direction: column; }
|
||||
.approvals-list small, .approvals-detail header span { color: var(--text-muted, #65717e); }
|
||||
.approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
|
||||
.approvals-workspace > .action-blocker-hint { margin: 12px 16px 0; }
|
||||
.approvals-empty { display: grid; min-height: 120px; place-items: center; padding: 16px; color: var(--text-muted, #65717e); text-align: center; }
|
||||
.approvals-detail { padding: 16px 20px 28px; }
|
||||
.approvals-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color, #d8dde3); }
|
||||
.approvals-detail header > div:last-child { display: flex; align-items: center; gap: 8px; }
|
||||
@@ -25,6 +27,7 @@
|
||||
.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-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; }
|
||||
|
||||
Reference in New Issue
Block a user