1726 lines
56 KiB
TypeScript
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.";
|
|
}
|