Migrate Risk Compliance interface patterns
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Risk Compliance interface pattern migration
|
||||
|
||||
Risk Compliance uses the platform source-evidence, governed-operation, review-queue, and revisioned-record patterns. It owns sanctions screening and assurance evidence; Connectors owns optional external source acquisition.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- `risk_compliance.workspace` is the stable route surface and `risk_compliance.navigation` is its navigation entry.
|
||||
- `risk_compliance.sanctions.sources` lists immutable source snapshots. `risk_compliance.action.import-snapshot` confirms copying acquired connector evidence into the normalized sanctions store.
|
||||
- `risk_compliance.sanctions.screening` collects the minimum subject data needed for a version-pinned run. `risk_compliance.action.run-screening` creates durable screening evidence.
|
||||
- `risk_compliance.sanctions.review` is a list-detail review queue. `risk_compliance.review.disposition` appends a legal disposition or time-bounded reusable exception.
|
||||
- `risk_compliance.assurance.graph` exposes current effective assurance revisions and their bounded relationships. `risk_compliance.assurance.editor` appends object revisions and `risk_compliance.action.connect-assurance` appends typed relationships.
|
||||
|
||||
Backend and WebUI manifests publish the same surface identifiers and parent hierarchy so Views can reduce the workspace without private module knowledge.
|
||||
|
||||
## Consequences and recovery
|
||||
|
||||
Snapshot import is confirmed because it creates immutable normalized evidence. Importing the same content reuses the existing snapshot. A screening run pins that snapshot version and clears the transient subject draft after the backend has accepted the run.
|
||||
|
||||
Review dispositions are append-only. Reusable exceptions require an explicit expiry. Assurance edits preserve earlier effective-dated revisions, while system-managed sanctions projections remain read-only. A failed request leaves the draft available; navigating away from a changed screening, disposition, assurance object, or relationship invokes the shared unsaved-change guard.
|
||||
|
||||
Unavailable actions remain visible with selection, permission, busy-state, missing-snapshot, system-managed-record, or optional-provider reasons. Contextual help resolves through `govoplan-docs` when enabled and through the hosted fallback otherwise.
|
||||
|
||||
## Optional boundaries
|
||||
|
||||
Connectors may announce sanctions-source snapshots through its public capability. Risk Compliance does not import connector internals or credentials. Audit, Policy, Records, Files, Tasks, Notifications, Views, and Workflow integrations remain optional and communicate through declared capabilities, interfaces, and stable references.
|
||||
@@ -336,6 +336,13 @@ DOCUMENTATION = (
|
||||
),
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Interface pattern migration",
|
||||
href=(
|
||||
"govoplan-risk-compliance/docs/INTERFACE_PATTERN_MIGRATION.md"
|
||||
),
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"domain_objects": [
|
||||
@@ -363,6 +370,28 @@ DOCUMENTATION = (
|
||||
"Every node and edge is effective-dated, revisioned, tenant-scoped, "
|
||||
"and linked through opaque governed-object references."
|
||||
),
|
||||
"help_contexts": [
|
||||
"risk_compliance.workspace",
|
||||
"risk_compliance.sanctions.sources",
|
||||
"risk_compliance.action.import-snapshot",
|
||||
"risk_compliance.sanctions.screening",
|
||||
"risk_compliance.action.run-screening",
|
||||
"risk_compliance.sanctions.review",
|
||||
"risk_compliance.review.disposition",
|
||||
"risk_compliance.assurance.graph",
|
||||
"risk_compliance.assurance.editor",
|
||||
"risk_compliance.action.connect-assurance",
|
||||
"risk_compliance.state.source-unavailable",
|
||||
"risk_compliance.state.read-only",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"import_snapshot": "copy connector evidence into an immutable normalized sanctions-list snapshot",
|
||||
"run_screening": "create immutable version-pinned screening evidence from the minimum submitted subject data",
|
||||
"record_disposition": "append an evidence-backed legal review disposition that is not edited in place",
|
||||
"record_exception": "append a time-bounded subject-and-entry exception with explicit expiry",
|
||||
"revise_assurance_object": "append a new effective-dated revision while preserving prior evidence",
|
||||
"connect_assurance_objects": "append a governed typed relationship between assurance objects",
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -431,6 +460,7 @@ manifest = ModuleManifest(
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Sanctions source snapshots",
|
||||
parent_id="risk_compliance.workspace",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
@@ -438,6 +468,7 @@ manifest = ModuleManifest(
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Sanctions screening",
|
||||
parent_id="risk_compliance.workspace",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
@@ -445,6 +476,7 @@ manifest = ModuleManifest(
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Sanctions review queue",
|
||||
parent_id="risk_compliance.workspace",
|
||||
order=40,
|
||||
),
|
||||
ViewSurface(
|
||||
@@ -452,8 +484,49 @@ manifest = ModuleManifest(
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Assurance graph",
|
||||
parent_id="risk_compliance.workspace",
|
||||
order=50,
|
||||
),
|
||||
ViewSurface(
|
||||
id="risk_compliance.action.import-snapshot",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Import sanctions source snapshot",
|
||||
parent_id="risk_compliance.sanctions.sources",
|
||||
order=60,
|
||||
),
|
||||
ViewSurface(
|
||||
id="risk_compliance.action.run-screening",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Run sanctions screening",
|
||||
parent_id="risk_compliance.sanctions.screening",
|
||||
order=70,
|
||||
),
|
||||
ViewSurface(
|
||||
id="risk_compliance.review.disposition",
|
||||
module_id=MODULE_ID,
|
||||
kind="dialog",
|
||||
label="Record screening disposition",
|
||||
parent_id="risk_compliance.sanctions.review",
|
||||
order=80,
|
||||
),
|
||||
ViewSurface(
|
||||
id="risk_compliance.assurance.editor",
|
||||
module_id=MODULE_ID,
|
||||
kind="dialog",
|
||||
label="Assurance object editor",
|
||||
parent_id="risk_compliance.assurance.graph",
|
||||
order=90,
|
||||
),
|
||||
ViewSurface(
|
||||
id="risk_compliance.action.connect-assurance",
|
||||
module_id=MODULE_ID,
|
||||
kind="action",
|
||||
label="Connect assurance objects",
|
||||
parent_id="risk_compliance.assurance.graph",
|
||||
order=100,
|
||||
),
|
||||
),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from govoplan_risk_compliance.backend.manifest import get_manifest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class RiskComplianceInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
|
||||
frontend = get_manifest().frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
|
||||
expected = {
|
||||
"risk_compliance.sanctions.sources",
|
||||
"risk_compliance.sanctions.screening",
|
||||
"risk_compliance.sanctions.review",
|
||||
"risk_compliance.assurance.graph",
|
||||
"risk_compliance.action.import-snapshot",
|
||||
"risk_compliance.action.run-screening",
|
||||
"risk_compliance.review.disposition",
|
||||
"risk_compliance.assurance.editor",
|
||||
"risk_compliance.action.connect-assurance",
|
||||
}
|
||||
self.assertEqual(expected, set(surfaces))
|
||||
self.assertEqual(
|
||||
"risk_compliance.workspace",
|
||||
frontend.routes[0].surface_id, # type: ignore[union-attr]
|
||||
)
|
||||
for surface_id in (
|
||||
"risk_compliance.sanctions.sources",
|
||||
"risk_compliance.sanctions.screening",
|
||||
"risk_compliance.sanctions.review",
|
||||
"risk_compliance.assurance.graph",
|
||||
):
|
||||
self.assertEqual("risk_compliance.workspace", surfaces[surface_id].parent_id)
|
||||
self.assertEqual(
|
||||
"risk_compliance.sanctions.sources",
|
||||
surfaces["risk_compliance.action.import-snapshot"].parent_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
"risk_compliance.sanctions.screening",
|
||||
surfaces["risk_compliance.action.run-screening"].parent_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
"risk_compliance.sanctions.review",
|
||||
surfaces["risk_compliance.review.disposition"].parent_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
"risk_compliance.assurance.graph",
|
||||
surfaces["risk_compliance.assurance.editor"].parent_id,
|
||||
)
|
||||
|
||||
def test_help_and_consequence_metadata_remain_published(self) -> None:
|
||||
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||
topic = topics["risk_compliance.module-boundary"]
|
||||
|
||||
for context in (
|
||||
"risk_compliance.action.import-snapshot",
|
||||
"risk_compliance.action.run-screening",
|
||||
"risk_compliance.review.disposition",
|
||||
"risk_compliance.assurance.editor",
|
||||
"risk_compliance.action.connect-assurance",
|
||||
):
|
||||
self.assertIn(context, topic.metadata["help_contexts"])
|
||||
for consequence in (
|
||||
"import_snapshot",
|
||||
"run_screening",
|
||||
"record_disposition",
|
||||
"record_exception",
|
||||
"revise_assurance_object",
|
||||
"connect_assurance_objects",
|
||||
):
|
||||
self.assertIn(consequence, topic.metadata["consequence_classes"])
|
||||
|
||||
def test_webui_uses_shared_governed_operation_patterns(self) -> None:
|
||||
page = (
|
||||
REPO_ROOT
|
||||
/ "webui/src/features/riskCompliance/RiskCompliancePage.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
module = (REPO_ROOT / "webui/src/module.ts").read_text(encoding="utf-8")
|
||||
|
||||
for component in (
|
||||
"ActionBlockerHint",
|
||||
"ConfirmDialog",
|
||||
"DocumentationHelpLink",
|
||||
"MetricCard",
|
||||
"SelectionList",
|
||||
"useUnsavedDraftGuard",
|
||||
):
|
||||
self.assertIn(component, page)
|
||||
self.assertIn("risk_compliance.assurance.graph", module)
|
||||
self.assertIn("risk_compliance.action.connect-assurance", module)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user