Migrate Risk Compliance interface patterns

This commit is contained in:
2026-08-03 15:47:05 +02:00
parent a23b53dc9e
commit 24d80a6d7d
8 changed files with 729 additions and 136 deletions
@@ -18,16 +18,24 @@ import {
} from "react";
import { useSearchParams } from "react-router";
import {
ActionBlockerHint,
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
IconButton,
LoadingIndicator,
MetricCard,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
hasScope,
i18nMessage,
useUnsavedDraftGuard,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
@@ -55,6 +63,12 @@ import {
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";
@@ -93,6 +107,7 @@ export default function RiskCompliancePage({
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"
@@ -221,6 +236,7 @@ export default function RiskCompliancePage({
? `Imported ${result.snapshot.entry_count} list entries.`
: "This immutable snapshot was already imported."
);
setPendingImport(null);
await refresh();
} catch (reason) {
setError(errorMessage(reason));
@@ -254,7 +270,8 @@ export default function RiskCompliancePage({
Screen
</>
),
disabled: !canScreen
disabled: !canScreen,
title: !canScreen ? RISK_COMPLIANCE_I18N.screenRequired : undefined
},
{
id: "review",
@@ -267,7 +284,8 @@ export default function RiskCompliancePage({
)}
</>
),
disabled: !canReview
disabled: !canReview,
title: !canReview ? RISK_COMPLIANCE_I18N.reviewRequired : undefined
},
{
id: "assurance",
@@ -277,17 +295,20 @@ export default function RiskCompliancePage({
Assurance
</>
),
disabled: !canReadAssurance
disabled: !canReadAssurance,
title: !canReadAssurance ? RISK_COMPLIANCE_I18N.assuranceReadRequired : undefined
}
]}
/>
<span 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}
/>
</div>
{(error || notice) && (
@@ -320,7 +341,7 @@ export default function RiskCompliancePage({
imported={listSnapshots}
canImport={canAdmin}
busy={busy}
onImport={importSnapshot}
onImport={setPendingImport}
/>
)}
{view === "screen" && (
@@ -363,6 +384,18 @@ export default function RiskCompliancePage({
/>
)}
</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>
);
}
@@ -380,7 +413,7 @@ function SourcesPane({
imported: ListSnapshot[];
canImport: boolean;
busy: boolean;
onImport: (item: ConnectorSnapshot) => Promise<void>;
onImport: (item: ConnectorSnapshot) => void;
}) {
const importedRefs = useMemo(
() => new Set(imported.map((item) => item.sha256)),
@@ -394,11 +427,21 @@ function SourcesPane({
<strong>Connector evidence</strong>
<span>Immutable acquired source snapshots</span>
</div>
<DocumentationHelpLink reference={RISK_COMPLIANCE_ADMIN_DOCUMENTATION} />
</header>
{!available && (
<div className="risk-empty">
The Connectors sanctions source capability is not enabled.
</div>
<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) => {
@@ -419,6 +462,7 @@ function SourcesPane({
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)}
/>
)}
@@ -486,8 +530,31 @@ function ScreenPane({
}
}, [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 {
@@ -503,8 +570,12 @@ function ScreenPane({
}
});
onRun(response.run);
setName("");
setIdentifier("");
return true;
} catch (reason) {
onError(errorMessage(reason));
return false;
} finally {
setSubmitting(false);
}
@@ -518,8 +589,22 @@ function ScreenPane({
<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}
@@ -562,11 +647,8 @@ function ScreenPane({
<Button
type="submit"
variant="primary"
disabled={
submitting ||
!listId ||
(!name.trim() && !identifier.trim())
}
disabled={Boolean(submitDisabledReason)}
disabledReason={submitDisabledReason}
>
<Play size={16} />
Run screening
@@ -655,10 +737,40 @@ function ReviewPane({
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();
if (!detail) return;
await recordDisposition();
}
async function recordDisposition(): Promise<boolean> {
if (dispositionDisabledReason || !detail) return false;
onBusy(true);
onError("");
try {
@@ -671,13 +783,13 @@ function ReviewPane({
? new Date(`${expiresAt}T23:59:59`).toISOString()
: null
});
setDialogOpen(false);
setReason("");
setReusable(false);
resetDisposition();
onNotice("The disposition was recorded as append-only evidence.");
await onRefresh();
return true;
} catch (reasonValue) {
onError(errorMessage(reasonValue));
return false;
} finally {
onBusy(false);
}
@@ -693,25 +805,25 @@ function ReviewPane({
</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 > 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 && (
<div className="risk-empty">
No candidates currently need review.
@@ -725,14 +837,17 @@ function ReviewPane({
<strong>Candidate evidence</strong>
<span>Subject and immutable list entry comparison</span>
</div>
{detail && (
<div className="risk-header-actions">
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<Button
variant="primary"
onClick={() => setDialogOpen(true)}
disabled={!detail || busy}
disabledReason={!detail ? RISK_COMPLIANCE_I18N.candidateRequired : busy ? RISK_COMPLIANCE_I18N.busy : undefined}
>
Record disposition
</Button>
)}
</div>
</header>
{!detail && (
<div className="risk-empty">
@@ -788,13 +903,13 @@ function ReviewPane({
<Dialog
open={dialogOpen}
title="Record screening disposition"
onClose={() => setDialogOpen(false)}
onClose={resetDisposition}
closeDisabled={busy}
footer={
<>
<Button
type="button"
onClick={() => setDialogOpen(false)}
onClick={resetDisposition}
disabled={busy}
>
Cancel
@@ -803,11 +918,8 @@ function ReviewPane({
form="risk-disposition-form"
type="submit"
variant="primary"
disabled={
busy ||
reason.trim().length < 3 ||
(reusable && !expiresAt)
}
disabled={Boolean(dispositionDisabledReason)}
disabledReason={dispositionDisabledReason}
>
Record
</Button>
@@ -819,6 +931,19 @@ function ReviewPane({
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}
@@ -950,8 +1075,10 @@ function AssurancePane({
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();
@@ -973,21 +1100,63 @@ function AssurancePane({
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(emptyAssuranceNodeDraft());
setNodeDraft(draft);
setNodeSavedKey(nodeDraftKey(draft));
setNodeDialogOpen(true);
}
function openEditNode(item: AssuranceNode) {
const draft = nodeDraftFromItem(item);
setEditingNode(item);
setNodeDraft(nodeDraftFromItem(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 {
@@ -996,7 +1165,7 @@ function AssurancePane({
assuranceNodeWrite(nodeDraft, editingNode?.provenance),
editingNode?.revision
);
setNodeDialogOpen(false);
closeNodeDialog();
onSelect(saved.stable_id);
onNotice(
editingNode
@@ -1004,8 +1173,10 @@ function AssurancePane({
: "Created the assurance object."
);
await onRefresh();
return true;
} catch (reason) {
onError(errorMessage(reason));
return false;
} finally {
onBusy(false);
}
@@ -1013,18 +1184,23 @@ function AssurancePane({
function openEdgeDialog() {
const first = availableRelations[0];
setEdgeRelation(first?.id ?? "");
setEdgeTarget(
first
? nodes.find((item) => item.kind === first.target)?.stable_id ?? ""
: ""
);
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();
if (!selected || !activeRelation || !edgeTarget) return;
await saveEdge();
}
async function saveEdge(): Promise<boolean> {
if (edgeSaveDisabledReason || !selected || !activeRelation || !edgeTarget) return false;
onBusy(true);
onError("");
const value: AssuranceEdgeWrite = {
@@ -1044,11 +1220,13 @@ function AssurancePane({
};
try {
await saveAssuranceEdge(settings, value);
setEdgeDialogOpen(false);
closeEdgeDialog();
onNotice("Connected the assurance objects.");
await onRefresh();
return true;
} catch (reason) {
onError(errorMessage(reason));
return false;
} finally {
onBusy(false);
}
@@ -1056,11 +1234,31 @@ function AssurancePane({
return (
<section className="risk-assurance-layout">
<div className="risk-assurance-metrics">
<span><strong>{summary?.node_count ?? 0}</strong>objects</span>
<span><strong>{summary?.edge_count ?? 0}</strong>relationships</span>
<span><strong>{summary?.by_kind.risk ?? 0}</strong>risks</span>
<span><strong>{summary?.by_state.open ?? 0}</strong>open findings</span>
<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}
/>
)}
<div className="metric-grid 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"}
/>
</div>
</div>
<div className="risk-assurance-columns">
<aside className="risk-panel">
@@ -1069,13 +1267,16 @@ function AssurancePane({
<strong>Assurance objects</strong>
<span>Current effective revisions</span>
</div>
{canWrite && (
<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
@@ -1097,24 +1298,24 @@ function AssurancePane({
</select>
</div>
<div className="risk-list">
{visibleNodes.map((item) => (
<button
type="button"
className={
item.stable_id === selectedId
? "risk-queue-row selected"
: "risk-queue-row"
}
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} />
</button>
))}
{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 && (
<div className="risk-empty">No assurance objects match.</div>
)}
@@ -1126,13 +1327,16 @@ function AssurancePane({
<strong>{selected?.label || "Assurance object"}</strong>
<span>{selected ? formatToken(selected.kind) : "Select an object"}</span>
</div>
{selected && canWrite && !selected.stable_id.startsWith("sanctions-") && (
<div className="risk-header-actions">
<DocumentationHelpLink reference={RISK_COMPLIANCE_DOCUMENTATION} />
<IconButton
label="Edit assurance object"
icon={<Pencil size={16} />}
onClick={() => openEditNode(selected)}
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 && (
<div className="risk-empty">Select an assurance object.</div>
@@ -1155,12 +1359,14 @@ function AssurancePane({
<strong>Relationships</strong>
<span>{graph?.edges.length ?? 0} in the bounded graph</span>
</div>
{canWrite && availableRelations.length > 0 && (
<Button onClick={openEdgeDialog} disabled={busy}>
<Plus size={16} />
Connect
</Button>
)}
<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) => (
@@ -1190,29 +1396,32 @@ function AssurancePane({
busy={busy}
editing={Boolean(editingNode)}
draft={nodeDraft}
saveDisabledReason={nodeSaveDisabledReason}
onChange={setNodeDraft}
onClose={() => setNodeDialogOpen(false)}
onClose={closeNodeDialog}
onSubmit={submitNode}
/>
<Dialog
open={edgeDialogOpen}
title="Connect assurance objects"
onClose={() => setEdgeDialogOpen(false)}
onClose={closeEdgeDialog}
closeDisabled={busy}
footer={
<>
<Button onClick={() => setEdgeDialogOpen(false)} disabled={busy}>Cancel</Button>
<Button onClick={closeEdgeDialog} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-edge-form"
type="submit"
variant="primary"
disabled={busy || !activeRelation || !edgeTarget}
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
@@ -1275,6 +1484,7 @@ function AssuranceNodeDialog({
busy,
editing,
draft,
saveDisabledReason,
onChange,
onClose,
onSubmit
@@ -1283,6 +1493,7 @@ function AssuranceNodeDialog({
busy: boolean;
editing: boolean;
draft: AssuranceNodeDraft;
saveDisabledReason?: string;
onChange: (value: AssuranceNodeDraft) => void;
onClose: () => void;
onSubmit: (event: FormEvent) => void;
@@ -1302,13 +1513,15 @@ function AssuranceNodeDialog({
form="risk-assurance-node-form"
type="submit"
variant="primary"
disabled={busy}
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}>
<div className="risk-assurance-form-grid">
<FormField label="Stable ID">
@@ -1427,6 +1640,10 @@ function nodeDraftFromItem(item: AssuranceNode): AssuranceNodeDraft {
};
}
function nodeDraftKey(draft: AssuranceNodeDraft) {
return JSON.stringify(draft);
}
function assuranceNodeWrite(
draft: AssuranceNodeDraft,
provenance: Record<string, unknown> = { source: "risk-compliance-ui" }
@@ -0,0 +1,37 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const RISK_COMPLIANCE_DOCUMENTATION = {
topicId: "risk_compliance.module-boundary",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const RISK_COMPLIANCE_ADMIN_DOCUMENTATION = {
topicId: "risk_compliance.module-boundary",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const RISK_COMPLIANCE_I18N = {
loading: "i18n:govoplan-risk-compliance.reason.loading",
busy: "i18n:govoplan-risk-compliance.reason.busy",
adminRequired: "i18n:govoplan-risk-compliance.reason.admin_required",
screenRequired: "i18n:govoplan-risk-compliance.reason.screen_required",
reviewRequired: "i18n:govoplan-risk-compliance.reason.review_required",
assuranceReadRequired: "i18n:govoplan-risk-compliance.reason.assurance_read_required",
assuranceWriteRequired: "i18n:govoplan-risk-compliance.reason.assurance_write_required",
connectorUnavailable: "i18n:govoplan-risk-compliance.reason.connector_unavailable",
snapshotRequired: "i18n:govoplan-risk-compliance.reason.snapshot_required",
subjectRequired: "i18n:govoplan-risk-compliance.reason.subject_required",
candidateRequired: "i18n:govoplan-risk-compliance.reason.candidate_required",
dispositionReasonRequired: "i18n:govoplan-risk-compliance.reason.disposition_reason_required",
exceptionExpiryRequired: "i18n:govoplan-risk-compliance.reason.exception_expiry_required",
assuranceObjectRequired: "i18n:govoplan-risk-compliance.reason.assurance_object_required",
assuranceFieldsRequired: "i18n:govoplan-risk-compliance.reason.assurance_fields_required",
relationRequired: "i18n:govoplan-risk-compliance.reason.relation_required",
systemManagedObject: "i18n:govoplan-risk-compliance.reason.system_managed_object"
} as const;
export const RISK_COMPLIANCE_BLOCKER_LABELS = {
requiredAction: "i18n:govoplan-risk-compliance.blocker.required_action",
actor: "i18n:govoplan-risk-compliance.blocker.actor",
target: "i18n:govoplan-risk-compliance.blocker.target"
} as const;
+106
View File
@@ -0,0 +1,106 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-risk-compliance.surface.sources": "Sanctions source snapshots",
"i18n:govoplan-risk-compliance.surface.screening": "Sanctions screening",
"i18n:govoplan-risk-compliance.surface.review": "Sanctions review queue",
"i18n:govoplan-risk-compliance.surface.assurance": "Assurance graph",
"i18n:govoplan-risk-compliance.surface.import_snapshot": "Import sanctions source snapshot",
"i18n:govoplan-risk-compliance.surface.run_screening": "Run sanctions screening",
"i18n:govoplan-risk-compliance.surface.disposition": "Record screening disposition",
"i18n:govoplan-risk-compliance.surface.assurance_editor": "Assurance object editor",
"i18n:govoplan-risk-compliance.surface.connect_assurance": "Connect assurance objects",
"i18n:govoplan-risk-compliance.reason.loading": "Risk Compliance data is loading.",
"i18n:govoplan-risk-compliance.reason.busy": "A Risk Compliance operation is already running.",
"i18n:govoplan-risk-compliance.reason.admin_required": "Sanctions-administration permission is required.",
"i18n:govoplan-risk-compliance.reason.screen_required": "Sanctions-screening permission is required.",
"i18n:govoplan-risk-compliance.reason.review_required": "Sanctions-review permission is required.",
"i18n:govoplan-risk-compliance.reason.assurance_read_required": "Assurance-read permission is required.",
"i18n:govoplan-risk-compliance.reason.assurance_write_required": "Assurance-write permission is required.",
"i18n:govoplan-risk-compliance.reason.connector_unavailable": "No Connectors sanctions-source provider is enabled.",
"i18n:govoplan-risk-compliance.reason.snapshot_required": "Import and select an active list snapshot first.",
"i18n:govoplan-risk-compliance.reason.subject_required": "Enter a name or identifier to screen.",
"i18n:govoplan-risk-compliance.reason.candidate_required": "Select a candidate from the review queue first.",
"i18n:govoplan-risk-compliance.reason.disposition_reason_required": "Record a reason of at least three characters.",
"i18n:govoplan-risk-compliance.reason.exception_expiry_required": "A reusable exception requires an expiry date.",
"i18n:govoplan-risk-compliance.reason.assurance_object_required": "Select an assurance object first.",
"i18n:govoplan-risk-compliance.reason.assurance_fields_required": "Stable ID, name, owner, state, and effective-from time are required.",
"i18n:govoplan-risk-compliance.reason.relation_required": "Select a valid relationship and target object.",
"i18n:govoplan-risk-compliance.reason.system_managed_object": "Sanctions-derived assurance objects are system managed.",
"i18n:govoplan-risk-compliance.import_snapshot_title": "Import immutable snapshot?",
"i18n:govoplan-risk-compliance.import_snapshot_message": "Import {value0} source version {value1} as immutable sanctions evidence? A matching existing snapshot will be reused.",
"i18n:govoplan-risk-compliance.import_snapshot": "Import snapshot",
"i18n:govoplan-risk-compliance.connector_unavailable_summary": "Sanctions source acquisition is unavailable",
"i18n:govoplan-risk-compliance.connector_unavailable_action": "Enable a compatible Connectors sanctions-source provider, or use an already imported snapshot.",
"i18n:govoplan-risk-compliance.connector_unavailable_actor": "A module administrator",
"i18n:govoplan-risk-compliance.connector_unavailable_target": "Modules and Connectors administration",
"i18n:govoplan-risk-compliance.snapshot_required_summary": "No sanctions snapshot is available",
"i18n:govoplan-risk-compliance.snapshot_required_action": "Import an immutable sanctions-list snapshot before running a screening.",
"i18n:govoplan-risk-compliance.snapshot_required_actor": "A sanctions administrator",
"i18n:govoplan-risk-compliance.snapshot_required_target": "Risk Compliance source snapshots",
"i18n:govoplan-risk-compliance.append_only_summary": "This decision becomes durable evidence",
"i18n:govoplan-risk-compliance.append_only_details": "A disposition or reusable exception is appended to the screening record and is not edited in place.",
"i18n:govoplan-risk-compliance.append_only_action": "Verify the decision, reason, and any exception expiry before recording it.",
"i18n:govoplan-risk-compliance.append_only_actor": "The assigned compliance reviewer",
"i18n:govoplan-risk-compliance.append_only_target": "This screening candidate",
"i18n:govoplan-risk-compliance.assurance_read_only_summary": "Assurance data is read-only for this account",
"i18n:govoplan-risk-compliance.permission_action": "Request the required Risk Compliance permission from an administrator.",
"i18n:govoplan-risk-compliance.permission_actor": "A tenant access administrator",
"i18n:govoplan-risk-compliance.permission_target": "Roles and permissions",
"i18n:govoplan-risk-compliance.blocker.required_action": "Required action",
"i18n:govoplan-risk-compliance.blocker.actor": "Who can fix it",
"i18n:govoplan-risk-compliance.blocker.target": "Where to go"
},
de: {
"i18n:govoplan-risk-compliance.surface.sources": "Sanktionsquellen",
"i18n:govoplan-risk-compliance.surface.screening": "Sanktionsprüfung",
"i18n:govoplan-risk-compliance.surface.review": "Prüfwarteschlange",
"i18n:govoplan-risk-compliance.surface.assurance": "Assurance-Graph",
"i18n:govoplan-risk-compliance.surface.import_snapshot": "Snapshot einer Sanktionsquelle importieren",
"i18n:govoplan-risk-compliance.surface.run_screening": "Sanktionsprüfung ausführen",
"i18n:govoplan-risk-compliance.surface.disposition": "Prüfentscheidung dokumentieren",
"i18n:govoplan-risk-compliance.surface.assurance_editor": "Assurance-Objekt bearbeiten",
"i18n:govoplan-risk-compliance.surface.connect_assurance": "Assurance-Objekte verknüpfen",
"i18n:govoplan-risk-compliance.reason.loading": "Risk-Compliance-Daten werden geladen.",
"i18n:govoplan-risk-compliance.reason.busy": "Eine Risk-Compliance-Aktion wird bereits ausgeführt.",
"i18n:govoplan-risk-compliance.reason.admin_required": "Die Berechtigung zur Verwaltung von Sanktionsprüfungen ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.screen_required": "Die Berechtigung zur Sanktionsprüfung ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.review_required": "Die Berechtigung zur Prüfung von Sanktionskandidaten ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.assurance_read_required": "Die Berechtigung zum Lesen von Assurance-Daten ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.assurance_write_required": "Die Berechtigung zum Bearbeiten von Assurance-Daten ist erforderlich.",
"i18n:govoplan-risk-compliance.reason.connector_unavailable": "Es ist kein Connectors-Anbieter für Sanktionsquellen aktiviert.",
"i18n:govoplan-risk-compliance.reason.snapshot_required": "Importieren und wählen Sie zuerst einen aktiven Listensnapshot.",
"i18n:govoplan-risk-compliance.reason.subject_required": "Geben Sie einen Namen oder eine Kennung zur Prüfung ein.",
"i18n:govoplan-risk-compliance.reason.candidate_required": "Wählen Sie zuerst einen Kandidaten aus der Prüfwarteschlange.",
"i18n:govoplan-risk-compliance.reason.disposition_reason_required": "Dokumentieren Sie eine Begründung mit mindestens drei Zeichen.",
"i18n:govoplan-risk-compliance.reason.exception_expiry_required": "Eine wiederverwendbare Ausnahme benötigt ein Ablaufdatum.",
"i18n:govoplan-risk-compliance.reason.assurance_object_required": "Wählen Sie zuerst ein Assurance-Objekt.",
"i18n:govoplan-risk-compliance.reason.assurance_fields_required": "Stabile ID, Name, zuständige Stelle, Status und Gültigkeitsbeginn sind erforderlich.",
"i18n:govoplan-risk-compliance.reason.relation_required": "Wählen Sie eine gültige Beziehung und ein Zielobjekt.",
"i18n:govoplan-risk-compliance.reason.system_managed_object": "Aus Sanktionsprüfungen abgeleitete Assurance-Objekte werden vom System verwaltet.",
"i18n:govoplan-risk-compliance.import_snapshot_title": "Unveränderlichen Snapshot importieren?",
"i18n:govoplan-risk-compliance.import_snapshot_message": "Soll {value0}, Quellversion {value1}, als unveränderlicher Sanktionsnachweis importiert werden? Ein vorhandener identischer Snapshot wird wiederverwendet.",
"i18n:govoplan-risk-compliance.import_snapshot": "Snapshot importieren",
"i18n:govoplan-risk-compliance.connector_unavailable_summary": "Sanktionsquellen können nicht abgerufen werden",
"i18n:govoplan-risk-compliance.connector_unavailable_action": "Aktivieren Sie einen kompatiblen Connectors-Anbieter für Sanktionsquellen oder verwenden Sie einen bereits importierten Snapshot.",
"i18n:govoplan-risk-compliance.connector_unavailable_actor": "Eine Moduladministration",
"i18n:govoplan-risk-compliance.connector_unavailable_target": "Modul- und Connectors-Administration",
"i18n:govoplan-risk-compliance.snapshot_required_summary": "Es ist kein Sanktionssnapshot verfügbar",
"i18n:govoplan-risk-compliance.snapshot_required_action": "Importieren Sie vor der Prüfung einen unveränderlichen Snapshot einer Sanktionsliste.",
"i18n:govoplan-risk-compliance.snapshot_required_actor": "Eine Sanktionsadministration",
"i18n:govoplan-risk-compliance.snapshot_required_target": "Sanktionsquellen in Risk Compliance",
"i18n:govoplan-risk-compliance.append_only_summary": "Diese Entscheidung wird zum dauerhaften Nachweis",
"i18n:govoplan-risk-compliance.append_only_details": "Eine Prüfentscheidung oder wiederverwendbare Ausnahme wird dem Prüfdatensatz angefügt und nicht direkt geändert.",
"i18n:govoplan-risk-compliance.append_only_action": "Prüfen Sie Entscheidung, Begründung und gegebenenfalls das Ablaufdatum, bevor Sie den Eintrag anlegen.",
"i18n:govoplan-risk-compliance.append_only_actor": "Die zuständige Compliance-Prüfung",
"i18n:govoplan-risk-compliance.append_only_target": "Dieser Prüfungskandidat",
"i18n:govoplan-risk-compliance.assurance_read_only_summary": "Assurance-Daten sind für dieses Konto schreibgeschützt",
"i18n:govoplan-risk-compliance.permission_action": "Fordern Sie die erforderliche Risk-Compliance-Berechtigung bei einer Administration an.",
"i18n:govoplan-risk-compliance.permission_actor": "Eine Zugriffsadministration des Mandanten",
"i18n:govoplan-risk-compliance.permission_target": "Rollen und Berechtigungen",
"i18n:govoplan-risk-compliance.blocker.required_action": "Erforderliche Aktion",
"i18n:govoplan-risk-compliance.blocker.actor": "Zuständige Stelle",
"i18n:govoplan-risk-compliance.blocker.target": "Ziel"
}
};
+56 -3
View File
@@ -1,5 +1,6 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/risk-compliance.css";
@@ -24,6 +25,7 @@ export const riskComplianceModule: PlatformWebModule = {
"views",
"workflow"
],
translations: generatedTranslations,
navItems: [
{
to: "/risk-compliance",
@@ -48,22 +50,73 @@ export const riskComplianceModule: PlatformWebModule = {
id: "risk_compliance.sanctions.sources",
moduleId: "risk_compliance",
kind: "section",
label: "Sanctions source snapshots",
label: "i18n:govoplan-risk-compliance.surface.sources",
parentId: "risk_compliance.workspace",
order: 20
},
{
id: "risk_compliance.sanctions.screening",
moduleId: "risk_compliance",
kind: "section",
label: "Sanctions screening",
label: "i18n:govoplan-risk-compliance.surface.screening",
parentId: "risk_compliance.workspace",
order: 30
},
{
id: "risk_compliance.sanctions.review",
moduleId: "risk_compliance",
kind: "section",
label: "Sanctions review queue",
label: "i18n:govoplan-risk-compliance.surface.review",
parentId: "risk_compliance.workspace",
order: 40
},
{
id: "risk_compliance.assurance.graph",
moduleId: "risk_compliance",
kind: "section",
label: "i18n:govoplan-risk-compliance.surface.assurance",
parentId: "risk_compliance.workspace",
order: 50
},
{
id: "risk_compliance.action.import-snapshot",
moduleId: "risk_compliance",
kind: "action",
label: "i18n:govoplan-risk-compliance.surface.import_snapshot",
parentId: "risk_compliance.sanctions.sources",
order: 60
},
{
id: "risk_compliance.action.run-screening",
moduleId: "risk_compliance",
kind: "action",
label: "i18n:govoplan-risk-compliance.surface.run_screening",
parentId: "risk_compliance.sanctions.screening",
order: 70
},
{
id: "risk_compliance.review.disposition",
moduleId: "risk_compliance",
kind: "dialog",
label: "i18n:govoplan-risk-compliance.surface.disposition",
parentId: "risk_compliance.sanctions.review",
order: 80
},
{
id: "risk_compliance.assurance.editor",
moduleId: "risk_compliance",
kind: "dialog",
label: "i18n:govoplan-risk-compliance.surface.assurance_editor",
parentId: "risk_compliance.assurance.graph",
order: 90
},
{
id: "risk_compliance.action.connect-assurance",
moduleId: "risk_compliance",
kind: "action",
label: "i18n:govoplan-risk-compliance.surface.connect_assurance",
parentId: "risk_compliance.assurance.graph",
order: 100
}
]
};
+20 -38
View File
@@ -73,30 +73,14 @@
grid-template-rows: auto minmax(0, 1fr);
}
.risk-assurance-summary {
display: grid;
min-width: 0;
gap: 10px;
}
.risk-assurance-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
}
.risk-assurance-metrics > span {
display: grid;
gap: 3px;
border-right: var(--border-line);
color: var(--muted);
padding: 10px 12px;
font-size: 11px;
}
.risk-assurance-metrics > span:last-child {
border-right: 0;
}
.risk-assurance-metrics strong {
color: var(--text-strong);
font-size: 17px;
}
.risk-assurance-columns {
@@ -287,6 +271,13 @@
font-size: 11px;
}
.risk-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.risk-list,
.risk-result-body,
.risk-evidence-body {
@@ -296,7 +287,6 @@
}
.risk-list-row,
.risk-queue-row,
.risk-candidate-summary {
display: flex;
align-items: center;
@@ -310,15 +300,15 @@
}
.risk-queue-row {
width: 100%;
cursor: pointer;
text-align: left;
font: inherit;
display: flex;
min-height: 58px;
align-items: center;
gap: 10px;
}
.risk-queue-row:hover,
.risk-queue-row.selected {
background: var(--sidebar-hover-bg);
.risk-list > .selection-list {
gap: 2px;
padding: 4px;
}
.risk-list-main {
@@ -524,14 +514,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-assurance-metrics > span:nth-child(2) {
border-right: 0;
}
.risk-assurance-metrics > span:nth-child(-n + 2) {
border-bottom: var(--border-line);
}
.risk-assurance-form-grid,
.risk-assurance-properties {
grid-template-columns: minmax(0, 1fr);