feat: implement sanctions screening vertical
This commit is contained in:
28
webui/package.json
Normal file
28
webui/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/risk-compliance-webui",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/risk-compliance.css": "./src/styles/risk-compliance.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
253
webui/src/api/riskCompliance.ts
Normal file
253
webui/src/api/riskCompliance.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type ConnectorSnapshot = {
|
||||
ref: string;
|
||||
provider_id: string;
|
||||
publisher: string;
|
||||
jurisdiction: string;
|
||||
source_version: string;
|
||||
publication_at?: string | null;
|
||||
acquired_at: string;
|
||||
byte_count: number;
|
||||
sha256: string;
|
||||
parser_version: string;
|
||||
};
|
||||
|
||||
export type ListSnapshot = {
|
||||
id: string;
|
||||
visibility: string;
|
||||
provider_id: string;
|
||||
publisher: string;
|
||||
jurisdiction: string;
|
||||
list_type: string;
|
||||
source_id: string;
|
||||
source_version: string;
|
||||
publication_at?: string | null;
|
||||
effective_at?: string | null;
|
||||
acquired_at: string;
|
||||
sha256: string;
|
||||
connector_run_id: string;
|
||||
raw_evidence_ref: string;
|
||||
source_parser_version: string;
|
||||
normalization_version: string;
|
||||
signature_evidence: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
entry_count: number;
|
||||
status: string;
|
||||
imported_by?: string | null;
|
||||
imported_at: string;
|
||||
};
|
||||
|
||||
export type SubjectSnapshot = {
|
||||
id: string;
|
||||
subject_ref?: string | null;
|
||||
subject_type: string;
|
||||
primary_name?: string | null;
|
||||
normalized_name?: string | null;
|
||||
aliases: string[];
|
||||
identifiers: Array<Record<string, string>>;
|
||||
dates: string[];
|
||||
addresses: Array<Record<string, string>>;
|
||||
fingerprint: string;
|
||||
submitted_by?: string | null;
|
||||
};
|
||||
|
||||
export type SanctionsEntry = {
|
||||
id: string;
|
||||
source_entry_id: string;
|
||||
subject_type: string;
|
||||
primary_name: string;
|
||||
original_script_name?: string | null;
|
||||
reference_number?: string | null;
|
||||
listed_on?: string | null;
|
||||
programmes: string[];
|
||||
raw_evidence_locator: string;
|
||||
aliases: Array<{
|
||||
name: string;
|
||||
quality?: string | null;
|
||||
}>;
|
||||
identifiers: Array<{
|
||||
identifier_type: string;
|
||||
value: string;
|
||||
issuing_country?: string | null;
|
||||
}>;
|
||||
dates: Array<{
|
||||
date_type: string;
|
||||
value: string;
|
||||
precision: string;
|
||||
}>;
|
||||
addresses: Array<{
|
||||
street?: string | null;
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
postal_code?: string | null;
|
||||
country?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ScreeningCandidate = {
|
||||
id: string;
|
||||
score: number;
|
||||
match_kind: string;
|
||||
evidence: Array<Record<string, unknown>>;
|
||||
review_status: string;
|
||||
current_disposition_id?: string | null;
|
||||
entry: SanctionsEntry;
|
||||
};
|
||||
|
||||
export type ScreeningRun = {
|
||||
id: string;
|
||||
matcher_version: string;
|
||||
normalization_version: string;
|
||||
policy_version: string;
|
||||
policy_snapshot: Record<string, unknown>;
|
||||
status: string;
|
||||
outcome: string;
|
||||
candidate_count: number;
|
||||
started_at: string;
|
||||
completed_at?: string | null;
|
||||
created_at: string;
|
||||
subject: SubjectSnapshot;
|
||||
list_snapshot: ListSnapshot;
|
||||
candidates: ScreeningCandidate[];
|
||||
};
|
||||
|
||||
export type ReviewQueueItem = {
|
||||
id: string;
|
||||
run_id: string;
|
||||
score: number;
|
||||
match_kind: string;
|
||||
review_status: string;
|
||||
subject_name?: string | null;
|
||||
subject_type: string;
|
||||
entry_name: string;
|
||||
source_entry_id: string;
|
||||
list_source_version: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CandidateDetail = {
|
||||
candidate: ScreeningCandidate;
|
||||
subject: SubjectSnapshot;
|
||||
list_snapshot: ListSnapshot;
|
||||
run: {
|
||||
id: string;
|
||||
matcher_version: string;
|
||||
normalization_version: string;
|
||||
policy_version: string;
|
||||
outcome: string;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function listConnectorSnapshots(
|
||||
settings: ApiSettings
|
||||
) {
|
||||
return apiFetch<{
|
||||
available: boolean;
|
||||
snapshots: ConnectorSnapshot[];
|
||||
}>(
|
||||
settings,
|
||||
"/api/v1/risk-compliance/sanctions/source-snapshots"
|
||||
);
|
||||
}
|
||||
|
||||
export async function importListSnapshot(
|
||||
settings: ApiSettings,
|
||||
connectorSnapshotRef: string
|
||||
) {
|
||||
return apiFetch<{
|
||||
snapshot: ListSnapshot;
|
||||
created: boolean;
|
||||
}>(
|
||||
settings,
|
||||
"/api/v1/risk-compliance/sanctions/list-snapshots/import",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
connector_snapshot_ref: connectorSnapshotRef
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function listListSnapshots(settings: ApiSettings) {
|
||||
return apiFetch<{ snapshots: ListSnapshot[] }>(
|
||||
settings,
|
||||
"/api/v1/risk-compliance/sanctions/list-snapshots"
|
||||
);
|
||||
}
|
||||
|
||||
export async function runScreening(
|
||||
settings: ApiSettings,
|
||||
input: {
|
||||
list_snapshot_id: string;
|
||||
idempotency_key: string;
|
||||
subject: {
|
||||
subject_type: "person" | "entity";
|
||||
primary_name: string;
|
||||
identifiers?: Array<{type: string;value: string;}>;
|
||||
};
|
||||
}
|
||||
) {
|
||||
return apiFetch<{run: ScreeningRun;created: boolean;}>(
|
||||
settings,
|
||||
"/api/v1/risk-compliance/sanctions/screenings",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function listReviewQueue(
|
||||
settings: ApiSettings,
|
||||
reviewStatus = "pending"
|
||||
) {
|
||||
return apiFetch<{ candidates: ReviewQueueItem[] }>(
|
||||
settings,
|
||||
apiPath(
|
||||
"/api/v1/risk-compliance/sanctions/review-queue",
|
||||
{ review_status: reviewStatus, limit: 500 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCandidate(
|
||||
settings: ApiSettings,
|
||||
candidateId: string
|
||||
) {
|
||||
return apiFetch<CandidateDetail>(
|
||||
settings,
|
||||
`/api/v1/risk-compliance/sanctions/review-queue/${encodeURIComponent(candidateId)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function createDisposition(
|
||||
settings: ApiSettings,
|
||||
candidateId: string,
|
||||
input: {
|
||||
decision: string;
|
||||
reason: string;
|
||||
exception_scope: "candidate" | "subject_entry";
|
||||
expires_at?: string | null;
|
||||
override_reason?: string | null;
|
||||
}
|
||||
) {
|
||||
return apiFetch<{
|
||||
candidate: ScreeningCandidate;
|
||||
disposition: {id: string;decision: string;};
|
||||
}>(
|
||||
settings,
|
||||
`/api/v1/risk-compliance/sanctions/review-queue/${encodeURIComponent(candidateId)}/dispositions`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
);
|
||||
}
|
||||
810
webui/src/features/riskCompliance/RiskCompliancePage.tsx
Normal file
810
webui/src/features/riskCompliance/RiskCompliancePage.tsx
Normal file
@@ -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.";
|
||||
}
|
||||
2
webui/src/index.ts
Normal file
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default, riskComplianceModule } from "./module";
|
||||
export * from "./api/riskCompliance";
|
||||
71
webui/src/module.ts
Normal file
71
webui/src/module.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/risk-compliance.css";
|
||||
|
||||
|
||||
const RiskCompliancePage = lazy(
|
||||
() => import("./features/riskCompliance/RiskCompliancePage")
|
||||
);
|
||||
|
||||
export const riskComplianceModule: PlatformWebModule = {
|
||||
id: "risk_compliance",
|
||||
label: "Risk Compliance",
|
||||
version: "0.1.8",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: [
|
||||
"audit",
|
||||
"policy",
|
||||
"records",
|
||||
"inspections",
|
||||
"files",
|
||||
"tasks",
|
||||
"notifications",
|
||||
"connectors",
|
||||
"views",
|
||||
"workflow"
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/risk-compliance",
|
||||
label: "Risk Compliance",
|
||||
iconName: "shield-check",
|
||||
anyOf: ["risk_compliance:sanctions:read"],
|
||||
order: 115,
|
||||
surfaceId: "risk_compliance.navigation"
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "/risk-compliance",
|
||||
anyOf: ["risk_compliance:sanctions:read"],
|
||||
order: 115,
|
||||
surfaceId: "risk_compliance.workspace",
|
||||
render: (context) => createElement(RiskCompliancePage, context)
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "risk_compliance.sanctions.sources",
|
||||
moduleId: "risk_compliance",
|
||||
kind: "section",
|
||||
label: "Sanctions source snapshots",
|
||||
order: 20
|
||||
},
|
||||
{
|
||||
id: "risk_compliance.sanctions.screening",
|
||||
moduleId: "risk_compliance",
|
||||
kind: "section",
|
||||
label: "Sanctions screening",
|
||||
order: 30
|
||||
},
|
||||
{
|
||||
id: "risk_compliance.sanctions.review",
|
||||
moduleId: "risk_compliance",
|
||||
kind: "section",
|
||||
label: "Sanctions review queue",
|
||||
order: 40
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default riskComplianceModule;
|
||||
353
webui/src/styles/risk-compliance.css
Normal file
353
webui/src/styles/risk-compliance.css
Normal file
@@ -0,0 +1,353 @@
|
||||
.risk-page {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.risk-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
min-height: 50px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.risk-toolbar .segmented-control-option {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.risk-toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.risk-count {
|
||||
min-width: 18px;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.risk-alerts {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 14px 0;
|
||||
}
|
||||
|
||||
.risk-workspace {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.risk-source-layout,
|
||||
.risk-screen-layout,
|
||||
.risk-review-layout {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.risk-source-layout,
|
||||
.risk-screen-layout {
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(360px, 1.35fr);
|
||||
}
|
||||
|
||||
.risk-review-layout {
|
||||
grid-template-columns: minmax(300px, 0.7fr) minmax(480px, 1.6fr);
|
||||
}
|
||||
|
||||
.risk-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.risk-panel > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.risk-panel > header > div:first-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.risk-panel > header strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.risk-panel > header span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.risk-list,
|
||||
.risk-result-body,
|
||||
.risk-evidence-body {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.risk-list-row,
|
||||
.risk-queue-row,
|
||||
.risk-candidate-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 58px;
|
||||
border: 0;
|
||||
border-bottom: var(--border-line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.risk-queue-row {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.risk-queue-row:hover,
|
||||
.risk-queue-row.selected {
|
||||
background: var(--sidebar-hover-bg);
|
||||
}
|
||||
|
||||
.risk-list-main {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.risk-list-main strong,
|
||||
.risk-list-main span,
|
||||
.risk-list-main code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-list-main strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.risk-list-main span,
|
||||
.risk-list-main code {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.risk-empty {
|
||||
color: var(--muted);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.risk-form-body {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.risk-form-body input,
|
||||
.risk-form-body select,
|
||||
.risk-disposition-form input,
|
||||
.risk-disposition-form select,
|
||||
.risk-disposition-form textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.risk-form-body .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.risk-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.risk-metrics > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
border-right: var(--border-line);
|
||||
color: var(--muted);
|
||||
padding: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.risk-metrics > span:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.risk-metrics strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-strong);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.risk-score {
|
||||
display: inline-grid;
|
||||
width: 38px;
|
||||
height: 32px;
|
||||
flex: 0 0 38px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 4px;
|
||||
background: var(--warning-soft);
|
||||
color: var(--text-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.risk-candidate-summary > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.risk-candidate-summary span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.risk-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--success);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.risk-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.risk-evidence-column {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
border-right: var(--border-line);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.risk-evidence-column:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.risk-evidence-column > strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.risk-evidence-column > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: 10px !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.risk-evidence-values {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.risk-evidence-values span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.risk-evidence-values strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.risk-match-evidence {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.risk-match-evidence span,
|
||||
.risk-match-evidence code {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.risk-disposition-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.risk-workspace {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.risk-source-layout,
|
||||
.risk-screen-layout,
|
||||
.risk-review-layout {
|
||||
height: auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.risk-panel {
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.risk-comparison {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.risk-evidence-column {
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user