feat: implement sanctions screening vertical

This commit is contained in:
2026-07-29 18:46:53 +02:00
parent 05c3a2257a
commit 11404ac42f
25 changed files with 6531 additions and 38 deletions
@@ -0,0 +1,810 @@
import {
CheckCircle2,
Database,
Play,
RefreshCw,
Scale,
Upload
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent
} from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
IconButton,
LoadingIndicator,
SegmentedControl,
StatusBadge,
ToggleSwitch,
hasScope,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createDisposition,
getCandidate,
importListSnapshot,
listConnectorSnapshots,
listListSnapshots,
listReviewQueue,
runScreening,
type CandidateDetail,
type ConnectorSnapshot,
type ListSnapshot,
type ReviewQueueItem,
type ScreeningRun
} from "../../api/riskCompliance";
type ViewMode = "sources" | "screen" | "review";
export default function RiskCompliancePage({
settings,
auth
}: PlatformRouteContext) {
const [view, setView] = useState<ViewMode>("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 [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
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 refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [sources, lists, reviewQueue] = await Promise.all([
listConnectorSnapshots(settings),
listListSnapshots(settings),
canReview
? listReviewQueue(settings)
: Promise.resolve({ candidates: [] })
]);
setSourcesAvailable(sources.available);
setSourceSnapshots(sources.snapshots);
setListSnapshots(lists.snapshots);
setQueue(reviewQueue.candidates);
setSelectedCandidateId((current) =>
current &&
reviewQueue.candidates.some((item) => item.id === current)
? current
: reviewQueue.candidates[0]?.id ?? ""
);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setLoading(false);
}
}, [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]);
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."
);
await refresh();
} catch (reason) {
setError(errorMessage(reason));
} finally {
setBusy(false);
}
}
return (
<main className="risk-page">
<div 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
},
{
id: "review",
label: (
<>
<Scale size={15} />
Review
{queue.length > 0 && (
<span className="risk-count">{queue.length}</span>
)}
</>
),
disabled: !canReview
}
]}
/>
<span className="risk-toolbar-spacer" />
{loading && <LoadingIndicator size="sm" label="Loading" />}
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void refresh()}
disabled={loading || busy}
/>
</div>
{(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={importSnapshot}
/>
)}
{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}
/>
)}
</div>
</main>
);
}
function SourcesPane({
available,
sources,
imported,
canImport,
busy,
onImport
}: {
available: boolean;
sources: ConnectorSnapshot[];
imported: ListSnapshot[];
canImport: boolean;
busy: boolean;
onImport: (item: ConnectorSnapshot) => Promise<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>
</header>
{!available && (
<div className="risk-empty">
The Connectors sanctions source capability is not enabled.
</div>
)}
<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}
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 && (
<div className="risk-empty">
No source snapshot has been imported.
</div>
)}
</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]);
async function submit(event: FormEvent) {
event.preventDefault();
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);
} catch (reason) {
onError(errorMessage(reason));
} 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>
</header>
<div className="risk-form-body">
<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={
submitting ||
!listId ||
(!name.trim() && !identifier.trim())
}
>
<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 && (
<div className="risk-empty">
Run a screening to inspect the result.
</div>
)}
{run && (
<div className="risk-result-body">
<div className="risk-metrics">
<span>
<strong>{run.candidate_count}</strong>
candidates
</span>
<span>
<strong>{run.matcher_version}</strong>
matcher
</span>
<span>
<strong>{run.list_snapshot.source_version}</strong>
list
</span>
</div>
{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("");
async function submitDisposition(event: FormEvent) {
event.preventDefault();
if (!detail) return;
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
});
setDialogOpen(false);
setReason("");
setReusable(false);
onNotice("The disposition was recorded as append-only evidence.");
await onRefresh();
} catch (reasonValue) {
onError(errorMessage(reasonValue));
} 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.map((item) => (
<button
type="button"
className={
item.id === selectedId
? "risk-queue-row selected"
: "risk-queue-row"
}
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} />
</button>
))}
{!queue.length && (
<div className="risk-empty">
No candidates currently need review.
</div>
)}
</div>
</aside>
<div className="risk-panel risk-evidence">
<header>
<div>
<strong>Candidate evidence</strong>
<span>Subject and immutable list entry comparison</span>
</div>
{detail && (
<Button
variant="primary"
onClick={() => setDialogOpen(true)}
>
Record disposition
</Button>
)}
</header>
{!detail && (
<div className="risk-empty">
Select a candidate from the queue.
</div>
)}
{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={() => setDialogOpen(false)}
closeDisabled={busy}
footer={
<>
<Button
type="button"
onClick={() => setDialogOpen(false)}
disabled={busy}
>
Cancel
</Button>
<Button
form="risk-disposition-form"
type="submit"
variant="primary"
disabled={
busy ||
reason.trim().length < 3 ||
(reusable && !expiresAt)
}
>
Record
</Button>
</>
}
>
<form
id="risk-disposition-form"
className="risk-disposition-form"
onSubmit={submitDisposition}
>
<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>
);
}
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.";
}