feat: implement sanctions screening vertical
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
Reference in New Issue
Block a user