Files
govoplan-risk-compliance/webui/src/features/riskCompliance/RiskCompliancePage.tsx
T
zemion e898c853e5
Module Package Release / publish-packages (push) Successful in 11s
fix(webui): bind compliance disposition to help
2026-08-24 11:36:45 +02:00

1726 lines
56 KiB
TypeScript

import { MetricGrid } from "@govoplan/core-webui";
import {
CheckCircle2,
Database,
Network,
Pencil,
Play,
Plus,
RefreshCw,
Scale,
Upload
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import { useSearchParams } from "react-router";
import { FormGrid, ActionToolbar, ToolbarSpacer,
ActionBlockerHint,
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
IconButton,
LoadingIndicator,
MetricCard,
SegmentedControl,
SelectionList,
SelectionListItem,
StatePanel,
StatusBadge,
ToggleSwitch,
hasScope,
i18nMessage,
useUnsavedDraftGuard,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createDisposition,
getAssuranceGraph,
getAssuranceSummary,
getCandidate,
importListSnapshot,
listConnectorSnapshots,
listAssuranceNodes,
listListSnapshots,
listReviewQueue,
runScreening,
saveAssuranceEdge,
saveAssuranceNode,
type AssuranceEdge,
type AssuranceEdgeWrite,
type AssuranceNode,
type AssuranceNodeKind,
type AssuranceNodeWrite,
type AssuranceSummary,
type CandidateDetail,
type ConnectorSnapshot,
type ListSnapshot,
type ReviewQueueItem,
type ScreeningRun
} from "../../api/riskCompliance";
import {
RISK_COMPLIANCE_ADMIN_DOCUMENTATION,
RISK_COMPLIANCE_BLOCKER_LABELS,
RISK_COMPLIANCE_DOCUMENTATION,
RISK_COMPLIANCE_I18N
} from "./interfacePatterns";
type ViewMode = "sources" | "screen" | "review" | "assurance";
export default function RiskCompliancePage({
settings,
auth
}: PlatformRouteContext) {
const [searchParams] = useSearchParams();
const requestedView = searchParams.get("view");
const requestedAssuranceId = searchParams.get("node")?.trim() ?? "";
const [view, setView] = useState<ViewMode>(() =>
requestedView === "assurance" ? "assurance" : "review"
);
const [sourceSnapshots, setSourceSnapshots] = useState<
ConnectorSnapshot[]
>([]);
const [sourcesAvailable, setSourcesAvailable] = useState(false);
const [listSnapshots, setListSnapshots] = useState<ListSnapshot[]>([]);
const [queue, setQueue] = useState<ReviewQueueItem[]>([]);
const [selectedCandidateId, setSelectedCandidateId] = useState("");
const [candidate, setCandidate] = useState<CandidateDetail | null>(null);
const [run, setRun] = useState<ScreeningRun | null>(null);
const [assuranceNodes, setAssuranceNodes] = useState<AssuranceNode[]>([]);
const [assuranceSummary, setAssuranceSummary] =
useState<AssuranceSummary | null>(null);
const [selectedAssuranceId, setSelectedAssuranceId] = useState(
requestedAssuranceId
);
const [assuranceGraph, setAssuranceGraph] = useState<{
nodes: AssuranceNode[];
edges: AssuranceEdge[];
truncated: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [pendingImport, setPendingImport] = useState<ConnectorSnapshot | null>(null);
const canAdmin = hasScope(
auth,
"risk_compliance:sanctions:admin"
);
const canScreen = hasScope(
auth,
"risk_compliance:sanctions:screen"
);
const canReview = hasScope(
auth,
"risk_compliance:sanctions:review"
);
const canReadAssurance = [
"risk_compliance:workspace:read",
"risk_compliance:workspace:write",
"risk_compliance:workspace:admin"
].some((scope) => hasScope(auth, scope));
const canWriteAssurance = [
"risk_compliance:workspace:write",
"risk_compliance:workspace:admin"
].some((scope) => hasScope(auth, scope));
useEffect(() => {
if (requestedView === "assurance" && canReadAssurance) {
setView("assurance");
if (requestedAssuranceId) {
setSelectedAssuranceId(requestedAssuranceId);
}
}
}, [canReadAssurance, requestedAssuranceId, requestedView]);
useEffect(() => {
if (view === "review" && !canReview) {
setView(canReadAssurance ? "assurance" : "sources");
} else if (view === "assurance" && !canReadAssurance) {
setView(canReview ? "review" : "sources");
}
}, [canReadAssurance, canReview, view]);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [sources, lists, reviewQueue, assurance] = await Promise.all([
listConnectorSnapshots(settings),
listListSnapshots(settings),
canReview
? listReviewQueue(settings)
: Promise.resolve({ candidates: [] }),
canReadAssurance
? Promise.all([
listAssuranceNodes(settings),
getAssuranceSummary(settings)
]).then(([nodes, summary]) => ({ nodes: nodes.nodes, summary }))
: Promise.resolve({ nodes: [], summary: null })
]);
setSourcesAvailable(sources.available);
setSourceSnapshots(sources.snapshots);
setListSnapshots(lists.snapshots);
setQueue(reviewQueue.candidates);
setAssuranceNodes(assurance.nodes);
setAssuranceSummary(assurance.summary);
setSelectedCandidateId((current) =>
current &&
reviewQueue.candidates.some((item) => item.id === current)
? current
: reviewQueue.candidates[0]?.id ?? ""
);
setSelectedAssuranceId((current) =>
current || assurance.nodes[0]?.stable_id || ""
);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setLoading(false);
}
}, [canReadAssurance, canReview, settings]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!selectedCandidateId) {
setCandidate(null);
return;
}
let cancelled = false;
void getCandidate(settings, selectedCandidateId)
.then((item) => {
if (!cancelled) setCandidate(item);
})
.catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
return () => {
cancelled = true;
};
}, [selectedCandidateId, settings]);
useEffect(() => {
if (!selectedAssuranceId || !canReadAssurance) {
setAssuranceGraph(null);
return;
}
let cancelled = false;
void getAssuranceGraph(settings, selectedAssuranceId)
.then((graph) => {
if (!cancelled) setAssuranceGraph(graph);
})
.catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
return () => {
cancelled = true;
};
}, [canReadAssurance, selectedAssuranceId, settings]);
async function importSnapshot(item: ConnectorSnapshot) {
setBusy(true);
setError("");
try {
const result = await importListSnapshot(settings, item.ref);
setNotice(
result.created
? `Imported ${result.snapshot.entry_count} list entries.`
: "This immutable snapshot was already imported."
);
setPendingImport(null);
await refresh();
} catch (reason) {
setError(errorMessage(reason));
} finally {
setBusy(false);
}
}
return (
<main className="risk-page">
<ActionToolbar className="risk-toolbar">
<SegmentedControl
value={view}
onChange={setView}
ariaLabel="Risk Compliance view"
options={[
{
id: "sources",
label: (
<>
<Database size={15} />
Sources
</>
)
},
{
id: "screen",
label: (
<>
<Play size={15} />
Screen
</>
),
disabled: !canScreen,
title: !canScreen ? RISK_COMPLIANCE_I18N.screenRequired : undefined
},
{
id: "review",
label: (
<>
<Scale size={15} />
Review
{queue.length > 0 && (
<span className="risk-count">{queue.length}</span>
)}
</>
),
disabled: !canReview,
title: !canReview ? RISK_COMPLIANCE_I18N.reviewRequired : undefined
},
{
id: "assurance",
label: (
<>
<Network size={15} />
Assurance
</>
),
disabled: !canReadAssurance,
title: !canReadAssurance ? RISK_COMPLIANCE_I18N.assuranceReadRequired : undefined
}
]}
/>
<ToolbarSpacer className="risk-toolbar-spacer" />
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
{loading && <LoadingIndicator size="sm" label="Loading" />}
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void refresh()}
disabled={loading || busy}
disabledReason={loading ? RISK_COMPLIANCE_I18N.loading : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
/>
</ActionToolbar>
{(error || notice) && (
<div className="risk-alerts">
{error && (
<DismissibleAlert
tone="danger"
resetKey={error}
onDismiss={() => setError("")}
>
{error}
</DismissibleAlert>
)}
{notice && !error && (
<DismissibleAlert
tone="success"
resetKey={notice}
onDismiss={() => setNotice("")}
>
{notice}
</DismissibleAlert>
)}
</div>
)}
<div className="risk-workspace">
{view === "sources" && (
<SourcesPane
available={sourcesAvailable}
sources={sourceSnapshots}
imported={listSnapshots}
canImport={canAdmin}
busy={busy}
onImport={setPendingImport}
/>
)}
{view === "screen" && (
<ScreenPane
settings={settings}
snapshots={listSnapshots}
run={run}
onRun={setRun}
onError={setError}
/>
)}
{view === "review" && (
<ReviewPane
queue={queue}
selectedId={selectedCandidateId}
detail={candidate}
busy={busy}
onSelect={setSelectedCandidateId}
onBusy={setBusy}
onError={setError}
onNotice={setNotice}
onRefresh={refresh}
settings={settings}
/>
)}
{view === "assurance" && (
<AssurancePane
nodes={assuranceNodes}
summary={assuranceSummary}
graph={assuranceGraph}
selectedId={selectedAssuranceId}
canWrite={canWriteAssurance}
busy={busy}
settings={settings}
onSelect={setSelectedAssuranceId}
onBusy={setBusy}
onError={setError}
onNotice={setNotice}
onRefresh={refresh}
/>
)}
</div>
<ConfirmDialog
open={Boolean(pendingImport)}
title="i18n:govoplan-risk-compliance.import_snapshot_title"
message={i18nMessage("i18n:govoplan-risk-compliance.import_snapshot_message", {
value0: pendingImport?.publisher ?? "",
value1: pendingImport?.source_version ?? ""
})}
confirmLabel="i18n:govoplan-risk-compliance.import_snapshot"
busy={busy}
onCancel={() => setPendingImport(null)}
onConfirm={() => pendingImport && void importSnapshot(pendingImport)}
/>
</main>
);
}
function SourcesPane({
available,
sources,
imported,
canImport,
busy,
onImport
}: {
available: boolean;
sources: ConnectorSnapshot[];
imported: ListSnapshot[];
canImport: boolean;
busy: boolean;
onImport: (item: ConnectorSnapshot) => void;
}) {
const importedRefs = useMemo(
() => new Set(imported.map((item) => item.sha256)),
[imported]
);
return (
<section className="risk-source-layout">
<div className="risk-panel">
<header>
<div>
<strong>Connector evidence</strong>
<span>Immutable acquired source snapshots</span>
</div>
<DocumentationHelpLink reference={RISK_COMPLIANCE_ADMIN_DOCUMENTATION} />
</header>
{!available && (
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-risk-compliance.connector_unavailable_summary",
details: RISK_COMPLIANCE_I18N.connectorUnavailable,
requiredAction: "i18n:govoplan-risk-compliance.connector_unavailable_action",
actor: "i18n:govoplan-risk-compliance.connector_unavailable_actor",
target: "i18n:govoplan-risk-compliance.connector_unavailable_target"
}}
labels={RISK_COMPLIANCE_BLOCKER_LABELS}
documentation={RISK_COMPLIANCE_ADMIN_DOCUMENTATION}
/>
)}
<div className="risk-list">
{sources.map((item) => {
const isImported = importedRefs.has(item.sha256);
return (
<div className="risk-list-row" key={item.ref}>
<div className="risk-list-main">
<strong>{item.publisher}</strong>
<span>
{item.source_version} · {formatDate(item.acquired_at)}
</span>
<code>{item.sha256.slice(0, 16)}</code>
</div>
{isImported ? (
<StatusBadge status="active" label="Imported" />
) : (
<IconButton
label="Import snapshot"
icon={<Upload size={16} />}
disabled={!canImport || busy}
disabledReason={!canImport ? RISK_COMPLIANCE_I18N.adminRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
onClick={() => void onImport(item)}
/>
)}
</div>
);
})}
</div>
</div>
<div className="risk-panel">
<header>
<div>
<strong>Screening catalogues</strong>
<span>Normalized, immutable list versions</span>
</div>
</header>
<div className="risk-list">
{imported.map((item) => (
<div className="risk-list-row" key={item.id}>
<div className="risk-list-main">
<strong>{item.publisher}</strong>
<span>
{item.entry_count.toLocaleString()} entries ·{" "}
{item.normalization_version}
</span>
<code>{item.source_version}</code>
</div>
<StatusBadge status={item.status} />
</div>
))}
{!imported.length && (
<StatePanel size="compact" description="No source snapshot has been imported." />
)}
</div>
</div>
</section>
);
}
function ScreenPane({
settings,
snapshots,
run,
onRun,
onError
}: {
settings: PlatformRouteContext["settings"];
snapshots: ListSnapshot[];
run: ScreeningRun | null;
onRun: (run: ScreeningRun | null) => void;
onError: (message: string) => void;
}) {
const [listId, setListId] = useState(snapshots[0]?.id ?? "");
const [subjectType, setSubjectType] = useState<"person" | "entity">(
"person"
);
const [name, setName] = useState("");
const [identifier, setIdentifier] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!snapshots.some((item) => item.id === listId)) {
setListId(snapshots[0]?.id ?? "");
}
}, [listId, snapshots]);
const draftDirty = Boolean(name.trim() || identifier.trim());
const submitDisabledReason = submitting
? RISK_COMPLIANCE_I18N.busy
: !listId
? RISK_COMPLIANCE_I18N.snapshotRequired
: !name.trim() && !identifier.trim()
? RISK_COMPLIANCE_I18N.subjectRequired
: undefined;
useUnsavedDraftGuard({
dirty: draftDirty,
onSave: executeScreening,
onDiscard: () => {
setName("");
setIdentifier("");
}
});
async function submit(event: FormEvent) {
event.preventDefault();
await executeScreening();
}
async function executeScreening(): Promise<boolean> {
if (submitDisabledReason) return false;
setSubmitting(true);
onError("");
try {
const response = await runScreening(settings, {
list_snapshot_id: listId,
idempotency_key: crypto.randomUUID(),
subject: {
subject_type: subjectType,
primary_name: name,
identifiers: identifier.trim()
? [{ type: "document", value: identifier.trim() }]
: []
}
});
onRun(response.run);
setName("");
setIdentifier("");
return true;
} catch (reason) {
onError(errorMessage(reason));
return false;
} finally {
setSubmitting(false);
}
}
return (
<section className="risk-screen-layout">
<form className="risk-panel risk-screen-form" onSubmit={submit}>
<header>
<div>
<strong>New screening</strong>
<span>Use only the data needed for comparison</span>
</div>
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
</header>
<div className="risk-form-body">
{!snapshots.length && (
<ActionBlockerHint
reason={{
summary: "i18n:govoplan-risk-compliance.snapshot_required_summary",
details: RISK_COMPLIANCE_I18N.snapshotRequired,
requiredAction: "i18n:govoplan-risk-compliance.snapshot_required_action",
actor: "i18n:govoplan-risk-compliance.snapshot_required_actor",
target: "i18n:govoplan-risk-compliance.snapshot_required_target"
}}
labels={RISK_COMPLIANCE_BLOCKER_LABELS}
documentation={RISK_COMPLIANCE_ADMIN_DOCUMENTATION}
/>
)}
<FormField label="List snapshot">
<select
value={listId}
onChange={(event) => setListId(event.target.value)}
required
>
{snapshots.map((item) => (
<option value={item.id} key={item.id}>
{item.publisher} · {item.source_version}
</option>
))}
</select>
</FormField>
<FormField label="Subject type">
<SegmentedControl
value={subjectType}
onChange={setSubjectType}
width="fill"
size="equal"
options={[
{ id: "person", label: "Person" },
{ id: "entity", label: "Entity" }
]}
/>
</FormField>
<FormField label="Name">
<input
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={1000}
/>
</FormField>
<FormField label="Identifier (optional)">
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
maxLength={1000}
/>
</FormField>
<Button
type="submit"
variant="primary"
disabled={Boolean(submitDisabledReason)}
disabledReason={submitDisabledReason}
>
<Play size={16} />
Run screening
</Button>
</div>
</form>
<div className="risk-panel risk-run-result">
<header>
<div>
<strong>Result</strong>
<span>Version-pinned candidate evidence</span>
</div>
{run && <StatusBadge status={run.outcome} />}
</header>
{!run && (
<StatePanel size="compact" description="Run a screening to inspect the result." />
)}
{run && (
<div className="risk-result-body">
<MetricGrid columns={3} density="compact" spacing="none" minimum="compact">
<MetricCard density="compact" surface="flat" label="Candidates" value={run.candidate_count} />
<MetricCard density="compact" surface="flat" label="Matcher" value={run.matcher_version} />
<MetricCard density="compact" surface="flat" label="List" value={run.list_snapshot.source_version} />
</MetricGrid>
{run.candidates.map((item) => (
<div className="risk-candidate-summary" key={item.id}>
<span className="risk-score">{item.score}</span>
<div>
<strong>{item.entry.primary_name}</strong>
<span>
{item.match_kind} · {item.entry.source_entry_id}
</span>
</div>
<StatusBadge status={item.review_status} />
</div>
))}
{run.outcome === "clear" && (
<div className="risk-clear">
<CheckCircle2 size={18} />
No candidate met the configured threshold.
</div>
)}
</div>
)}
</div>
</section>
);
}
function ReviewPane({
queue,
selectedId,
detail,
busy,
onSelect,
onBusy,
onError,
onNotice,
onRefresh,
settings
}: {
queue: ReviewQueueItem[];
selectedId: string;
detail: CandidateDetail | null;
busy: boolean;
onSelect: (id: string) => void;
onBusy: (value: boolean) => void;
onError: (message: string) => void;
onNotice: (message: string) => void;
onRefresh: () => Promise<void>;
settings: PlatformRouteContext["settings"];
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [decision, setDecision] = useState("false_positive");
const [reason, setReason] = useState("");
const [reusable, setReusable] = useState(false);
const [expiresAt, setExpiresAt] = useState("");
const dispositionDirty = dialogOpen && Boolean(
reason.trim() || reusable || expiresAt || decision !== "false_positive"
);
const dispositionDisabledReason = busy
? RISK_COMPLIANCE_I18N.busy
: !detail
? RISK_COMPLIANCE_I18N.candidateRequired
: reason.trim().length < 3
? RISK_COMPLIANCE_I18N.dispositionReasonRequired
: reusable && !expiresAt
? RISK_COMPLIANCE_I18N.exceptionExpiryRequired
: undefined;
function resetDisposition() {
setDialogOpen(false);
setDecision("false_positive");
setReason("");
setReusable(false);
setExpiresAt("");
}
useUnsavedDraftGuard({
dirty: dispositionDirty,
onSave: recordDisposition,
onDiscard: resetDisposition
});
async function submitDisposition(event: FormEvent) {
event.preventDefault();
await recordDisposition();
}
async function recordDisposition(): Promise<boolean> {
if (dispositionDisabledReason || !detail) return false;
onBusy(true);
onError("");
try {
await createDisposition(settings, detail.candidate.id, {
decision,
reason,
exception_scope: reusable ? "subject_entry" : "candidate",
expires_at:
reusable && expiresAt
? new Date(`${expiresAt}T23:59:59`).toISOString()
: null
});
resetDisposition();
onNotice("The disposition was recorded as append-only evidence.");
await onRefresh();
return true;
} catch (reasonValue) {
onError(errorMessage(reasonValue));
return false;
} finally {
onBusy(false);
}
}
return (
<section className="risk-review-layout">
<aside className="risk-panel risk-review-queue">
<header>
<div>
<strong>Review queue</strong>
<span>{queue.length} candidates need review</span>
</div>
</header>
<div className="risk-list">
{queue.length > 0 && (
<SelectionList label="Review candidates">
{queue.map((item) => (
<SelectionListItem
className="risk-queue-row"
selected={item.id === selectedId}
key={item.id}
onClick={() => onSelect(item.id)}
>
<span className="risk-score">{item.score}</span>
<span className="risk-list-main">
<strong>{item.subject_name || "Identifier-only subject"}</strong>
<span>{item.entry_name}</span>
</span>
<StatusBadge status={item.review_status} />
</SelectionListItem>
))}
</SelectionList>
)}
{!queue.length && (
<StatePanel size="compact" description="No candidates currently need review." />
)}
</div>
</aside>
<div className="risk-panel risk-evidence">
<header>
<div>
<strong>Candidate evidence</strong>
<span>Subject and immutable list entry comparison</span>
</div>
<div className="risk-header-actions">
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<Button
variant="primary"
helpContextId="risk_compliance.review.disposition"
helpModuleId="risk_compliance"
onClick={() => setDialogOpen(true)}
disabled={!detail || busy}
disabledReason={!detail ? RISK_COMPLIANCE_I18N.candidateRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
>
Record disposition
</Button>
</div>
</header>
{!detail && (
<StatePanel size="compact" description="Select a candidate from the queue." />
)}
{detail && (
<div className="risk-evidence-body">
<div className="risk-comparison">
<EvidenceColumn
title="Screening subject"
name={detail.subject.primary_name || "Identifier-only"}
type={detail.subject.subject_type}
aliases={detail.subject.aliases}
identifiers={detail.subject.identifiers.map(
(item) => item.value || ""
)}
dates={detail.subject.dates}
/>
<EvidenceColumn
title="Sanctions list entry"
name={detail.candidate.entry.primary_name}
type={detail.candidate.entry.subject_type}
aliases={detail.candidate.entry.aliases.map(
(item) => item.name
)}
identifiers={detail.candidate.entry.identifiers.map(
(item) =>
`${item.identifier_type}: ${item.value}`
)}
dates={detail.candidate.entry.dates.map(
(item) => item.value
)}
/>
</div>
<div className="risk-match-evidence">
<strong>
{detail.candidate.score}% ·{" "}
{detail.candidate.match_kind}
</strong>
<span>
List {detail.list_snapshot.source_version} ·{" "}
{detail.run.matcher_version} ·{" "}
{detail.run.normalization_version}
</span>
<code>
{detail.candidate.entry.raw_evidence_locator}
</code>
</div>
</div>
)}
</div>
<Dialog
open={dialogOpen}
title="Record screening disposition"
onClose={resetDisposition}
closeDisabled={busy}
footer={
<>
<Button
type="button"
onClick={resetDisposition}
disabled={busy}
>
Cancel
</Button>
<Button
form="risk-disposition-form"
type="submit"
variant="primary"
disabled={Boolean(dispositionDisabledReason)}
disabledReason={dispositionDisabledReason}
>
Record
</Button>
</>
}
>
<form
id="risk-disposition-form"
className="risk-disposition-form"
onSubmit={submitDisposition}
>
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-risk-compliance.append_only_summary",
details: "i18n:govoplan-risk-compliance.append_only_details",
requiredAction: "i18n:govoplan-risk-compliance.append_only_action",
actor: "i18n:govoplan-risk-compliance.append_only_actor",
target: "i18n:govoplan-risk-compliance.append_only_target"
}}
labels={RISK_COMPLIANCE_BLOCKER_LABELS}
documentation={RISK_COMPLIANCE_DOCUMENTATION}
/>
<FormField label="Decision">
<select
value={decision}
onChange={(event) => setDecision(event.target.value)}
>
<option value="false_positive">False positive</option>
<option value="true_match">Confirmed match</option>
<option value="needs_information">
More information needed
</option>
<option value="escalated">Escalate</option>
</select>
</FormField>
<FormField label="Reason">
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={5}
maxLength={10000}
required
/>
</FormField>
{decision === "false_positive" && (
<>
<ToggleSwitch
checked={reusable}
onChange={(event) => setReusable(event.target.checked)}
label="Apply as a time-bounded exception to this subject and list entry"
/>
{reusable && (
<FormField label="Exception expires">
<input
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
required
/>
</FormField>
)}
</>
)}
</form>
</Dialog>
</section>
);
}
const ASSURANCE_STATES: Record<AssuranceNodeKind, string[]> = {
obligation: ["active", "suspended", "retired"],
governed_object: ["active", "inactive", "retired"],
risk: ["identified", "assessed", "accepted", "mitigated", "closed"],
control: [
"designed",
"implemented",
"effective",
"failed",
"suspended",
"retired"
],
evidence: ["current", "stale", "invalid", "superseded"],
finding: ["open", "accepted", "exception", "remediating", "resolved"],
corrective_measure: ["planned", "in_progress", "completed", "cancelled"],
effectiveness_review: ["pending", "effective", "ineffective", "inconclusive"]
};
const ASSURANCE_RELATIONS = [
{ id: "applies_to", source: "obligation", target: "governed_object" },
{ id: "exposes_risk", source: "governed_object", target: "risk" },
{ id: "mitigated_by", source: "risk", target: "control" },
{ id: "evidenced_by", source: "control", target: "evidence" },
{ id: "results_in", source: "evidence", target: "finding" },
{ id: "addressed_by", source: "finding", target: "corrective_measure" },
{
id: "reviewed_by",
source: "corrective_measure",
target: "effectiveness_review"
}
] as const;
type AssuranceNodeDraft = {
stableId: string;
kind: AssuranceNodeKind;
label: string;
description: string;
state: string;
ownerRef: string;
scopeRef: string;
governedObjectRef: string;
validFrom: string;
validTo: string;
classification: string;
legalBasisRefs: string;
policyRefs: string;
evidenceRefs: string;
};
function AssurancePane({
nodes,
summary,
graph,
selectedId,
canWrite,
busy,
settings,
onSelect,
onBusy,
onError,
onNotice,
onRefresh
}: {
nodes: AssuranceNode[];
summary: AssuranceSummary | null;
graph: { nodes: AssuranceNode[]; edges: AssuranceEdge[]; truncated: boolean } | null;
selectedId: string;
canWrite: boolean;
busy: boolean;
settings: PlatformRouteContext["settings"];
onSelect: (id: string) => void;
onBusy: (value: boolean) => void;
onError: (message: string) => void;
onNotice: (message: string) => void;
onRefresh: () => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState("");
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [edgeDialogOpen, setEdgeDialogOpen] = useState(false);
const [editingNode, setEditingNode] = useState<AssuranceNode | null>(null);
const [nodeDraft, setNodeDraft] = useState<AssuranceNodeDraft>(
emptyAssuranceNodeDraft()
);
const [nodeSavedKey, setNodeSavedKey] = useState("");
const [edgeRelation, setEdgeRelation] = useState("");
const [edgeTarget, setEdgeTarget] = useState("");
const [edgeSavedKey, setEdgeSavedKey] = useState("");
const selected = nodes.find((item) => item.stable_id === selectedId) ?? null;
const visibleNodes = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return nodes.filter(
(item) =>
(!kind || item.kind === kind) &&
(!needle ||
item.label.toLocaleLowerCase().includes(needle) ||
item.stable_id.toLocaleLowerCase().includes(needle) ||
(item.description || "").toLocaleLowerCase().includes(needle))
);
}, [kind, nodes, query]);
const availableRelations = selected
? ASSURANCE_RELATIONS.filter((item) => item.source === selected.kind)
: [];
const activeRelation = availableRelations.find(
(item) => item.id === edgeRelation
);
const edgeTargets = activeRelation
? nodes.filter((item) => item.kind === activeRelation.target)
: [];
const nodeSaveDisabledReason = busy
? RISK_COMPLIANCE_I18N.busy
: !nodeDraft.stableId.trim() || !nodeDraft.label.trim() || !nodeDraft.ownerRef.trim() || !nodeDraft.validFrom || (nodeDraft.kind === "governed_object" && !nodeDraft.governedObjectRef.trim())
? RISK_COMPLIANCE_I18N.assuranceFieldsRequired
: undefined;
const edgeSaveDisabledReason = busy
? RISK_COMPLIANCE_I18N.busy
: !selected || !activeRelation || !edgeTarget
? RISK_COMPLIANCE_I18N.relationRequired
: undefined;
const nodeDirty = nodeDialogOpen && nodeDraftKey(nodeDraft) !== nodeSavedKey;
const edgeDirty = edgeDialogOpen && `${edgeRelation}:${edgeTarget}` !== edgeSavedKey;
function closeNodeDialog() {
setNodeDialogOpen(false);
setEditingNode(null);
}
function closeEdgeDialog() {
setEdgeDialogOpen(false);
}
useUnsavedDraftGuard({
dirty: nodeDirty,
onSave: saveNode,
onDiscard: closeNodeDialog
});
useUnsavedDraftGuard({
dirty: edgeDirty,
onSave: saveEdge,
onDiscard: closeEdgeDialog
});
function openNewNode() {
const draft = emptyAssuranceNodeDraft();
setEditingNode(null);
setNodeDraft(draft);
setNodeSavedKey(nodeDraftKey(draft));
setNodeDialogOpen(true);
}
function openEditNode(item: AssuranceNode) {
const draft = nodeDraftFromItem(item);
setEditingNode(item);
setNodeDraft(draft);
setNodeSavedKey(nodeDraftKey(draft));
setNodeDialogOpen(true);
}
async function submitNode(event: FormEvent) {
event.preventDefault();
await saveNode();
}
async function saveNode(): Promise<boolean> {
if (nodeSaveDisabledReason) return false;
onBusy(true);
onError("");
try {
const saved = await saveAssuranceNode(
settings,
assuranceNodeWrite(nodeDraft, editingNode?.provenance),
editingNode?.revision
);
closeNodeDialog();
onSelect(saved.stable_id);
onNotice(
editingNode
? `Recorded assurance revision ${saved.revision}.`
: "Created the assurance object."
);
await onRefresh();
return true;
} catch (reason) {
onError(errorMessage(reason));
return false;
} finally {
onBusy(false);
}
}
function openEdgeDialog() {
const first = availableRelations[0];
const relation = first?.id ?? "";
const target = first
? nodes.find((item) => item.kind === first.target)?.stable_id ?? ""
: "";
setEdgeRelation(relation);
setEdgeTarget(target);
setEdgeSavedKey(`${relation}:${target}`);
setEdgeDialogOpen(true);
}
async function submitEdge(event: FormEvent) {
event.preventDefault();
await saveEdge();
}
async function saveEdge(): Promise<boolean> {
if (edgeSaveDisabledReason || !selected || !activeRelation || !edgeTarget) return false;
onBusy(true);
onError("");
const value: AssuranceEdgeWrite = {
stable_id: `edge-${crypto.randomUUID()}`,
source_node_ref: selected.stable_id,
target_node_ref: edgeTarget,
relation: activeRelation.id,
state: "active",
owner_ref: selected.owner_ref,
scope_ref: selected.scope_ref,
valid_from: new Date().toISOString(),
valid_to: null,
provenance: { source: "risk-compliance-ui" },
legal_basis_refs: selected.legal_basis_refs,
policy_refs: selected.policy_refs,
evidence_refs: []
};
try {
await saveAssuranceEdge(settings, value);
closeEdgeDialog();
onNotice("Connected the assurance objects.");
await onRefresh();
return true;
} catch (reason) {
onError(errorMessage(reason));
return false;
} finally {
onBusy(false);
}
}
return (
<section className="risk-assurance-layout">
<div className="risk-assurance-summary">
{!canWrite && (
<ActionBlockerHint
tone="info"
reason={{
summary: "i18n:govoplan-risk-compliance.assurance_read_only_summary",
details: RISK_COMPLIANCE_I18N.assuranceWriteRequired,
requiredAction: "i18n:govoplan-risk-compliance.permission_action",
actor: "i18n:govoplan-risk-compliance.permission_actor",
target: "i18n:govoplan-risk-compliance.permission_target"
}}
labels={RISK_COMPLIANCE_BLOCKER_LABELS}
documentation={RISK_COMPLIANCE_DOCUMENTATION}
/>
)}
<MetricGrid minimum="fluid" className="risk-assurance-metrics">
<MetricCard label="Objects" value={summary?.node_count ?? 0} tone="neutral" />
<MetricCard label="Relationships" value={summary?.edge_count ?? 0} tone="neutral" />
<MetricCard label="Risks" value={summary?.by_kind.risk ?? 0} tone="warning" />
<MetricCard
label="Open findings"
value={summary?.by_state.open ?? 0}
tone={(summary?.by_state.open ?? 0) > 0 ? "danger" : "good"}
/>
</MetricGrid>
</div>
<div className="risk-assurance-columns">
<aside className="risk-panel">
<header>
<div>
<strong>Assurance objects</strong>
<span>Current effective revisions</span>
</div>
<div className="risk-header-actions">
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<IconButton
label="Add assurance object"
icon={<Plus size={16} />}
onClick={openNewNode}
disabled={!canWrite || busy}
disabledReason={!canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
/>
</div>
</header>
<div className="risk-assurance-filter">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search assurance objects"
aria-label="Search assurance objects"
/>
<select
value={kind}
onChange={(event) => setKind(event.target.value)}
aria-label="Filter by assurance type"
>
<option value="">All types</option>
{Object.keys(ASSURANCE_STATES).map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</div>
<div className="risk-list">
{visibleNodes.length > 0 && (
<SelectionList label="Assurance objects">
{visibleNodes.map((item) => (
<SelectionListItem
className="risk-queue-row"
selected={item.stable_id === selectedId}
key={item.stable_id}
onClick={() => onSelect(item.stable_id)}
>
<span className="risk-list-main">
<strong>{item.label}</strong>
<span>{formatToken(item.kind)} · revision {item.revision}</span>
</span>
<StatusBadge status={item.state} />
</SelectionListItem>
))}
</SelectionList>
)}
{!visibleNodes.length && (
<StatePanel size="compact" description="No assurance objects match." />
)}
</div>
</aside>
<div className="risk-panel risk-assurance-detail">
<header>
<div>
<strong>{selected?.label || "Assurance object"}</strong>
<span>{selected ? formatToken(selected.kind) : "Select an object"}</span>
</div>
<div className="risk-header-actions">
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<IconButton
label="Edit assurance object"
icon={<Pencil size={16} />}
onClick={() => selected && openEditNode(selected)}
disabled={!selected || !canWrite || selected.stable_id.startsWith("sanctions-") || busy}
disabledReason={!selected ? RISK_COMPLIANCE_I18N.assuranceObjectRequired : !canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : selected.stable_id.startsWith("sanctions-") ? RISK_COMPLIANCE_I18N.systemManagedObject : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
/>
</div>
</header>
{!selected && (
<StatePanel size="compact" description="Select an assurance object." />
)}
{selected && (
<div className="risk-assurance-detail-body">
<dl className="risk-assurance-properties">
<div><dt>State</dt><dd><StatusBadge status={selected.state} /></dd></div>
<div><dt>Owner</dt><dd>{selected.owner_ref}</dd></div>
<div><dt>Scope</dt><dd>{selected.scope_ref || "Tenant"}</dd></div>
<div><dt>Valid from</dt><dd>{formatDate(selected.valid_from)}</dd></div>
{selected.governed_object_ref && (
<div><dt>Governed object</dt><dd><code>{selected.governed_object_ref}</code></dd></div>
)}
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
</dl>
{selected.description && <p>{selected.description}</p>}
<div className="risk-assurance-links-header">
<div>
<strong>Relationships</strong>
<span>{graph?.edges.length ?? 0} in the bounded graph</span>
</div>
<Button
onClick={openEdgeDialog}
disabled={!canWrite || busy || !availableRelations.length}
disabledReason={!canWrite ? RISK_COMPLIANCE_I18N.assuranceWriteRequired : busy ? RISK_COMPLIANCE_I18N.busy : !availableRelations.length ? RISK_COMPLIANCE_I18N.relationRequired : undefined}
>
<Plus size={16} />
Connect
</Button>
</div>
<div className="risk-assurance-links">
{graph?.edges.map((edge) => (
<AssuranceEdgeRow
edge={edge}
nodes={graph.nodes}
selectedId={selected.stable_id}
onSelect={onSelect}
key={edge.stable_id}
/>
))}
{!graph?.edges.length && (
<StatePanel size="inline" description="No relationships are recorded." />
)}
</div>
{graph?.truncated && (
<div className="risk-assurance-truncated">
The bounded graph contains additional relationships.
</div>
)}
</div>
)}
</div>
</div>
<AssuranceNodeDialog
open={nodeDialogOpen}
busy={busy}
editing={Boolean(editingNode)}
draft={nodeDraft}
saveDisabledReason={nodeSaveDisabledReason}
onChange={setNodeDraft}
onClose={closeNodeDialog}
onSubmit={submitNode}
/>
<Dialog
open={edgeDialogOpen}
title="Connect assurance objects"
onClose={closeEdgeDialog}
closeDisabled={busy}
footer={
<>
<Button onClick={closeEdgeDialog} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-edge-form"
type="submit"
variant="primary"
disabled={Boolean(edgeSaveDisabledReason)}
disabledReason={edgeSaveDisabledReason}
>
Connect
</Button>
</>
}
>
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<form id="risk-assurance-edge-form" className="risk-assurance-form" onSubmit={submitEdge}>
<FormField label="Relationship">
<select
value={edgeRelation}
onChange={(event) => {
const relation = event.target.value;
setEdgeRelation(relation);
const shape = availableRelations.find((item) => item.id === relation);
setEdgeTarget(
shape
? nodes.find((item) => item.kind === shape.target)?.stable_id ?? ""
: ""
);
}}
>
{availableRelations.map((item) => (
<option value={item.id} key={item.id}>{formatToken(item.id)}</option>
))}
</select>
</FormField>
<FormField label="Target object">
<select value={edgeTarget} onChange={(event) => setEdgeTarget(event.target.value)}>
{edgeTargets.map((item) => (
<option value={item.stable_id} key={item.stable_id}>{item.label}</option>
))}
</select>
</FormField>
</form>
</Dialog>
</section>
);
}
function AssuranceEdgeRow({
edge,
nodes,
selectedId,
onSelect
}: {
edge: AssuranceEdge;
nodes: AssuranceNode[];
selectedId: string;
onSelect: (id: string) => void;
}) {
const otherId = edge.source_node_ref === selectedId
? edge.target_node_ref
: edge.source_node_ref;
const other = nodes.find((item) => item.stable_id === otherId);
return (
<button type="button" onClick={() => onSelect(otherId)}>
<span>{formatToken(edge.relation)}</span>
<strong>{other?.label || otherId}</strong>
<StatusBadge status={edge.state} />
</button>
);
}
function AssuranceNodeDialog({
open,
busy,
editing,
draft,
saveDisabledReason,
onChange,
onClose,
onSubmit
}: {
open: boolean;
busy: boolean;
editing: boolean;
draft: AssuranceNodeDraft;
saveDisabledReason?: string;
onChange: (value: AssuranceNodeDraft) => void;
onClose: () => void;
onSubmit: (event: FormEvent) => void;
}) {
const update = (values: Partial<AssuranceNodeDraft>) =>
onChange({ ...draft, ...values });
return (
<Dialog
open={open}
title={editing ? "Revise assurance object" : "Add assurance object"}
onClose={onClose}
closeDisabled={busy}
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-node-form"
type="submit"
variant="primary"
disabled={Boolean(saveDisabledReason)}
disabledReason={saveDisabledReason}
>
{editing ? "Record revision" : "Add"}
</Button>
</>
}
>
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<form id="risk-assurance-node-form" className="risk-assurance-form" onSubmit={onSubmit}>
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Stable ID">
<input
value={draft.stableId}
onChange={(event) => update({ stableId: event.target.value })}
disabled={editing}
required
maxLength={255}
/>
</FormField>
<FormField label="Type">
<select
value={draft.kind}
onChange={(event) => {
const nextKind = event.target.value as AssuranceNodeKind;
update({ kind: nextKind, state: ASSURANCE_STATES[nextKind][0] });
}}
disabled={editing}
>
{Object.keys(ASSURANCE_STATES).map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</FormField>
<FormField label="Name">
<input value={draft.label} onChange={(event) => update({ label: event.target.value })} required maxLength={500} />
</FormField>
<FormField label="State">
<select value={draft.state} onChange={(event) => update({ state: event.target.value })}>
{ASSURANCE_STATES[draft.kind].map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</FormField>
<FormField label="Owner reference">
<input value={draft.ownerRef} onChange={(event) => update({ ownerRef: event.target.value })} required maxLength={500} />
</FormField>
<FormField label="Scope reference">
<input value={draft.scopeRef} onChange={(event) => update({ scopeRef: event.target.value })} maxLength={500} />
</FormField>
{draft.kind === "governed_object" && (
<FormField label="Governed object reference">
<input value={draft.governedObjectRef} onChange={(event) => update({ governedObjectRef: event.target.value })} required maxLength={1000} />
</FormField>
)}
<FormField label="Classification">
<select value={draft.classification} onChange={(event) => update({ classification: event.target.value })}>
<option value="public">Public</option>
<option value="internal">Internal</option>
<option value="confidential">Confidential</option>
<option value="restricted">Restricted</option>
</select>
</FormField>
<FormField label="Valid from">
<input type="datetime-local" value={draft.validFrom} onChange={(event) => update({ validFrom: event.target.value })} required />
</FormField>
<FormField label="Valid to">
<input type="datetime-local" value={draft.validTo} onChange={(event) => update({ validTo: event.target.value })} />
</FormField>
</FormGrid>
<FormField label="Description">
<textarea value={draft.description} onChange={(event) => update({ description: event.target.value })} rows={4} maxLength={20000} />
</FormField>
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Legal basis references">
<textarea value={draft.legalBasisRefs} onChange={(event) => update({ legalBasisRefs: event.target.value })} rows={3} />
</FormField>
<FormField label="Policy references">
<textarea value={draft.policyRefs} onChange={(event) => update({ policyRefs: event.target.value })} rows={3} />
</FormField>
<FormField label="Evidence references">
<textarea value={draft.evidenceRefs} onChange={(event) => update({ evidenceRefs: event.target.value })} rows={3} />
</FormField>
</FormGrid>
</form>
</Dialog>
);
}
function emptyAssuranceNodeDraft(): AssuranceNodeDraft {
return {
stableId: "",
kind: "obligation",
label: "",
description: "",
state: "active",
ownerRef: "",
scopeRef: "",
governedObjectRef: "",
validFrom: dateTimeLocalValue(new Date()),
validTo: "",
classification: "internal",
legalBasisRefs: "",
policyRefs: "",
evidenceRefs: ""
};
}
function nodeDraftFromItem(item: AssuranceNode): AssuranceNodeDraft {
return {
stableId: item.stable_id,
kind: item.kind,
label: item.label,
description: item.description || "",
state: item.state,
ownerRef: item.owner_ref,
scopeRef: item.scope_ref || "",
governedObjectRef: item.governed_object_ref || "",
validFrom: dateTimeLocalValue(new Date(item.valid_from)),
validTo: item.valid_to ? dateTimeLocalValue(new Date(item.valid_to)) : "",
classification: item.classification,
legalBasisRefs: item.legal_basis_refs.join("\n"),
policyRefs: item.policy_refs.join("\n"),
evidenceRefs: item.evidence_refs.join("\n")
};
}
function nodeDraftKey(draft: AssuranceNodeDraft) {
return JSON.stringify(draft);
}
function assuranceNodeWrite(
draft: AssuranceNodeDraft,
provenance: Record<string, unknown> = { source: "risk-compliance-ui" }
): AssuranceNodeWrite {
return {
stable_id: draft.stableId.trim(),
kind: draft.kind,
label: draft.label.trim(),
description: draft.description.trim() || null,
state: draft.state,
owner_ref: draft.ownerRef.trim(),
scope_ref: draft.scopeRef.trim() || null,
governed_object_ref: draft.governedObjectRef.trim() || null,
valid_from: new Date(draft.validFrom).toISOString(),
valid_to: draft.validTo ? new Date(draft.validTo).toISOString() : null,
provenance,
legal_basis_refs: referenceLines(draft.legalBasisRefs),
policy_refs: referenceLines(draft.policyRefs),
evidence_refs: referenceLines(draft.evidenceRefs),
classification: draft.classification
};
}
function referenceLines(value: string) {
return Array.from(
new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))
);
}
function dateTimeLocalValue(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function formatToken(value: string) {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function EvidenceColumn({
title,
name,
type,
aliases,
identifiers,
dates
}: {
title: string;
name: string;
type: string;
aliases: string[];
identifiers: string[];
dates: string[];
}) {
return (
<div className="risk-evidence-column">
<span className="risk-eyebrow">{title}</span>
<strong>{name}</strong>
<span>{type}</span>
<EvidenceValues label="Aliases" values={aliases} />
<EvidenceValues label="Identifiers" values={identifiers} />
<EvidenceValues label="Dates" values={dates} />
</div>
);
}
function EvidenceValues({
label,
values
}: {
label: string;
values: string[];
}) {
return (
<div className="risk-evidence-values">
<span>{label}</span>
<strong>{values.filter(Boolean).join(", ") || "None"}</strong>
</div>
);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}
function errorMessage(reason: unknown) {
return reason instanceof Error
? reason.message
: "The Risk Compliance operation failed.";
}