feat: implement assurance graph and screening evidence

This commit is contained in:
2026-08-01 17:48:39 +02:00
parent bbf7288e14
commit a23b53dc9e
18 changed files with 4005 additions and 42 deletions
+182
View File
@@ -145,6 +145,97 @@ export type CandidateDetail = {
};
};
export type AssuranceNodeKind =
| "obligation"
| "governed_object"
| "risk"
| "control"
| "evidence"
| "finding"
| "corrective_measure"
| "effectiveness_review";
export type AssuranceNode = {
id: string;
stable_id: string;
kind: AssuranceNodeKind;
revision: number;
previous_revision_id?: string | null;
label: string;
description?: string | null;
state: string;
owner_ref: string;
scope_ref?: string | null;
governed_object_ref?: string | null;
valid_from: string;
valid_to?: string | null;
recorded_at: string;
superseded_at?: string | null;
provenance: Record<string, unknown>;
legal_basis_refs: string[];
policy_refs: string[];
evidence_refs: string[];
classification: string;
created_by?: string | null;
created_at: string;
updated_at: string;
};
export type AssuranceEdge = {
id: string;
stable_id: string;
revision: number;
previous_revision_id?: string | null;
source_node_ref: string;
target_node_ref: string;
relation: string;
state: "active" | "suspended" | "retired";
owner_ref: string;
scope_ref?: string | null;
valid_from: string;
valid_to?: string | null;
recorded_at: string;
superseded_at?: string | null;
provenance: Record<string, unknown>;
legal_basis_refs: string[];
policy_refs: string[];
evidence_refs: string[];
created_by?: string | null;
created_at: string;
updated_at: string;
};
export type AssuranceSummary = {
node_count: number;
edge_count: number;
by_kind: Record<string, number>;
by_state: Record<string, number>;
};
export type AssuranceNodeWrite = Omit<
AssuranceNode,
| "id"
| "revision"
| "previous_revision_id"
| "recorded_at"
| "superseded_at"
| "created_by"
| "created_at"
| "updated_at"
>;
export type AssuranceEdgeWrite = Omit<
AssuranceEdge,
| "id"
| "revision"
| "previous_revision_id"
| "recorded_at"
| "superseded_at"
| "created_by"
| "created_at"
| "updated_at"
>;
export async function listConnectorSnapshots(
settings: ApiSettings
) {
@@ -251,3 +342,94 @@ export async function createDisposition(
}
);
}
export async function listAssuranceNodes(
settings: ApiSettings,
filters: {
kind?: string;
state?: string;
query?: string;
governedObjectRef?: string;
} = {}
) {
return apiFetch<{ nodes: AssuranceNode[] }>(
settings,
apiPath("/api/v1/risk-compliance/assurance/nodes", {
kind: filters.kind,
state: filters.state,
query: filters.query,
governed_object_ref: filters.governedObjectRef,
limit: 500
})
);
}
export async function getAssuranceSummary(settings: ApiSettings) {
return apiFetch<AssuranceSummary>(
settings,
"/api/v1/risk-compliance/assurance/summary"
);
}
export async function getAssuranceGraph(
settings: ApiSettings,
rootRef: string
) {
return apiFetch<{
root_ref: string;
nodes: AssuranceNode[];
edges: AssuranceEdge[];
truncated: boolean;
}>(
settings,
apiPath("/api/v1/risk-compliance/assurance/graph", {
root_ref: rootRef,
max_depth: 8,
limit: 500
})
);
}
export async function saveAssuranceNode(
settings: ApiSettings,
value: AssuranceNodeWrite,
expectedRevision?: number
) {
return apiFetch<AssuranceNode>(
settings,
expectedRevision
? "/api/v1/risk-compliance/assurance/nodes/revise"
: "/api/v1/risk-compliance/assurance/nodes",
{
method: "POST",
body: JSON.stringify({
...value,
...(expectedRevision
? { expected_revision: expectedRevision }
: {})
})
}
);
}
export async function saveAssuranceEdge(
settings: ApiSettings,
value: AssuranceEdgeWrite,
expectedRevision?: number
) {
return apiFetch<AssuranceEdge>(
settings,
expectedRevision
? "/api/v1/risk-compliance/assurance/edges/revise"
: "/api/v1/risk-compliance/assurance/edges",
{
method: "POST",
body: JSON.stringify({
...value,
...(expectedRevision
? { expected_revision: expectedRevision }
: {})
})
}
);
}
@@ -1,7 +1,10 @@
import {
CheckCircle2,
Database,
Network,
Pencil,
Play,
Plus,
RefreshCw,
Scale,
Upload
@@ -13,6 +16,7 @@ import {
useState,
type FormEvent
} from "react";
import { useSearchParams } from "react-router";
import {
Button,
Dialog,
@@ -28,12 +32,23 @@ import {
} from "@govoplan/core-webui";
import {
createDisposition,
getAssuranceGraph,
getAssuranceSummary,
getCandidate,
importListSnapshot,
listConnectorSnapshots,
listAssuranceNodes,
listListSnapshots,
listReviewQueue,
runScreening,
saveAssuranceEdge,
saveAssuranceNode,
type AssuranceEdge,
type AssuranceEdgeWrite,
type AssuranceNode,
type AssuranceNodeKind,
type AssuranceNodeWrite,
type AssuranceSummary,
type CandidateDetail,
type ConnectorSnapshot,
type ListSnapshot,
@@ -42,13 +57,18 @@ import {
} from "../../api/riskCompliance";
type ViewMode = "sources" | "screen" | "review";
type ViewMode = "sources" | "screen" | "review" | "assurance";
export default function RiskCompliancePage({
settings,
auth
}: PlatformRouteContext) {
const [view, setView] = useState<ViewMode>("review");
const [searchParams] = useSearchParams();
const requestedView = searchParams.get("view");
const requestedAssuranceId = searchParams.get("node")?.trim() ?? "";
const [view, setView] = useState<ViewMode>(() =>
requestedView === "assurance" ? "assurance" : "review"
);
const [sourceSnapshots, setSourceSnapshots] = useState<
ConnectorSnapshot[]
>([]);
@@ -58,6 +78,17 @@ export default function RiskCompliancePage({
const [selectedCandidateId, setSelectedCandidateId] = useState("");
const [candidate, setCandidate] = useState<CandidateDetail | null>(null);
const [run, setRun] = useState<ScreeningRun | null>(null);
const [assuranceNodes, setAssuranceNodes] = useState<AssuranceNode[]>([]);
const [assuranceSummary, setAssuranceSummary] =
useState<AssuranceSummary | null>(null);
const [selectedAssuranceId, setSelectedAssuranceId] = useState(
requestedAssuranceId
);
const [assuranceGraph, setAssuranceGraph] = useState<{
nodes: AssuranceNode[];
edges: AssuranceEdge[];
truncated: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -74,34 +105,71 @@ export default function RiskCompliancePage({
auth,
"risk_compliance:sanctions:review"
);
const canReadAssurance = [
"risk_compliance:workspace:read",
"risk_compliance:workspace:write",
"risk_compliance:workspace:admin"
].some((scope) => hasScope(auth, scope));
const canWriteAssurance = [
"risk_compliance:workspace:write",
"risk_compliance:workspace:admin"
].some((scope) => hasScope(auth, scope));
useEffect(() => {
if (requestedView === "assurance" && canReadAssurance) {
setView("assurance");
if (requestedAssuranceId) {
setSelectedAssuranceId(requestedAssuranceId);
}
}
}, [canReadAssurance, requestedAssuranceId, requestedView]);
useEffect(() => {
if (view === "review" && !canReview) {
setView(canReadAssurance ? "assurance" : "sources");
} else if (view === "assurance" && !canReadAssurance) {
setView(canReview ? "review" : "sources");
}
}, [canReadAssurance, canReview, view]);
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const [sources, lists, reviewQueue] = await Promise.all([
const [sources, lists, reviewQueue, assurance] = await Promise.all([
listConnectorSnapshots(settings),
listListSnapshots(settings),
canReview
? listReviewQueue(settings)
: Promise.resolve({ candidates: [] })
: Promise.resolve({ candidates: [] }),
canReadAssurance
? Promise.all([
listAssuranceNodes(settings),
getAssuranceSummary(settings)
]).then(([nodes, summary]) => ({ nodes: nodes.nodes, summary }))
: Promise.resolve({ nodes: [], summary: null })
]);
setSourcesAvailable(sources.available);
setSourceSnapshots(sources.snapshots);
setListSnapshots(lists.snapshots);
setQueue(reviewQueue.candidates);
setAssuranceNodes(assurance.nodes);
setAssuranceSummary(assurance.summary);
setSelectedCandidateId((current) =>
current &&
reviewQueue.candidates.some((item) => item.id === current)
? current
: reviewQueue.candidates[0]?.id ?? ""
);
setSelectedAssuranceId((current) =>
current || assurance.nodes[0]?.stable_id || ""
);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setLoading(false);
}
}, [canReview, settings]);
}, [canReadAssurance, canReview, settings]);
useEffect(() => {
void refresh();
@@ -125,6 +193,24 @@ export default function RiskCompliancePage({
};
}, [selectedCandidateId, settings]);
useEffect(() => {
if (!selectedAssuranceId || !canReadAssurance) {
setAssuranceGraph(null);
return;
}
let cancelled = false;
void getAssuranceGraph(settings, selectedAssuranceId)
.then((graph) => {
if (!cancelled) setAssuranceGraph(graph);
})
.catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
return () => {
cancelled = true;
};
}, [canReadAssurance, selectedAssuranceId, settings]);
async function importSnapshot(item: ConnectorSnapshot) {
setBusy(true);
setError("");
@@ -182,6 +268,16 @@ export default function RiskCompliancePage({
</>
),
disabled: !canReview
},
{
id: "assurance",
label: (
<>
<Network size={15} />
Assurance
</>
),
disabled: !canReadAssurance
}
]}
/>
@@ -250,6 +346,22 @@ export default function RiskCompliancePage({
settings={settings}
/>
)}
{view === "assurance" && (
<AssurancePane
nodes={assuranceNodes}
summary={assuranceSummary}
graph={assuranceGraph}
selectedId={selectedAssuranceId}
canWrite={canWriteAssurance}
busy={busy}
settings={settings}
onSelect={setSelectedAssuranceId}
onBusy={setBusy}
onError={setError}
onNotice={setNotice}
onRefresh={refresh}
/>
)}
</div>
</main>
);
@@ -754,6 +866,605 @@ function ReviewPane({
);
}
const ASSURANCE_STATES: Record<AssuranceNodeKind, string[]> = {
obligation: ["active", "suspended", "retired"],
governed_object: ["active", "inactive", "retired"],
risk: ["identified", "assessed", "accepted", "mitigated", "closed"],
control: [
"designed",
"implemented",
"effective",
"failed",
"suspended",
"retired"
],
evidence: ["current", "stale", "invalid", "superseded"],
finding: ["open", "accepted", "exception", "remediating", "resolved"],
corrective_measure: ["planned", "in_progress", "completed", "cancelled"],
effectiveness_review: ["pending", "effective", "ineffective", "inconclusive"]
};
const ASSURANCE_RELATIONS = [
{ id: "applies_to", source: "obligation", target: "governed_object" },
{ id: "exposes_risk", source: "governed_object", target: "risk" },
{ id: "mitigated_by", source: "risk", target: "control" },
{ id: "evidenced_by", source: "control", target: "evidence" },
{ id: "results_in", source: "evidence", target: "finding" },
{ id: "addressed_by", source: "finding", target: "corrective_measure" },
{
id: "reviewed_by",
source: "corrective_measure",
target: "effectiveness_review"
}
] as const;
type AssuranceNodeDraft = {
stableId: string;
kind: AssuranceNodeKind;
label: string;
description: string;
state: string;
ownerRef: string;
scopeRef: string;
governedObjectRef: string;
validFrom: string;
validTo: string;
classification: string;
legalBasisRefs: string;
policyRefs: string;
evidenceRefs: string;
};
function AssurancePane({
nodes,
summary,
graph,
selectedId,
canWrite,
busy,
settings,
onSelect,
onBusy,
onError,
onNotice,
onRefresh
}: {
nodes: AssuranceNode[];
summary: AssuranceSummary | null;
graph: { nodes: AssuranceNode[]; edges: AssuranceEdge[]; truncated: boolean } | null;
selectedId: string;
canWrite: boolean;
busy: boolean;
settings: PlatformRouteContext["settings"];
onSelect: (id: string) => void;
onBusy: (value: boolean) => void;
onError: (message: string) => void;
onNotice: (message: string) => void;
onRefresh: () => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState("");
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [edgeDialogOpen, setEdgeDialogOpen] = useState(false);
const [editingNode, setEditingNode] = useState<AssuranceNode | null>(null);
const [nodeDraft, setNodeDraft] = useState<AssuranceNodeDraft>(
emptyAssuranceNodeDraft()
);
const [edgeRelation, setEdgeRelation] = useState("");
const [edgeTarget, setEdgeTarget] = useState("");
const selected = nodes.find((item) => item.stable_id === selectedId) ?? null;
const visibleNodes = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return nodes.filter(
(item) =>
(!kind || item.kind === kind) &&
(!needle ||
item.label.toLocaleLowerCase().includes(needle) ||
item.stable_id.toLocaleLowerCase().includes(needle) ||
(item.description || "").toLocaleLowerCase().includes(needle))
);
}, [kind, nodes, query]);
const availableRelations = selected
? ASSURANCE_RELATIONS.filter((item) => item.source === selected.kind)
: [];
const activeRelation = availableRelations.find(
(item) => item.id === edgeRelation
);
const edgeTargets = activeRelation
? nodes.filter((item) => item.kind === activeRelation.target)
: [];
function openNewNode() {
setEditingNode(null);
setNodeDraft(emptyAssuranceNodeDraft());
setNodeDialogOpen(true);
}
function openEditNode(item: AssuranceNode) {
setEditingNode(item);
setNodeDraft(nodeDraftFromItem(item));
setNodeDialogOpen(true);
}
async function submitNode(event: FormEvent) {
event.preventDefault();
onBusy(true);
onError("");
try {
const saved = await saveAssuranceNode(
settings,
assuranceNodeWrite(nodeDraft, editingNode?.provenance),
editingNode?.revision
);
setNodeDialogOpen(false);
onSelect(saved.stable_id);
onNotice(
editingNode
? `Recorded assurance revision ${saved.revision}.`
: "Created the assurance object."
);
await onRefresh();
} catch (reason) {
onError(errorMessage(reason));
} finally {
onBusy(false);
}
}
function openEdgeDialog() {
const first = availableRelations[0];
setEdgeRelation(first?.id ?? "");
setEdgeTarget(
first
? nodes.find((item) => item.kind === first.target)?.stable_id ?? ""
: ""
);
setEdgeDialogOpen(true);
}
async function submitEdge(event: FormEvent) {
event.preventDefault();
if (!selected || !activeRelation || !edgeTarget) return;
onBusy(true);
onError("");
const value: AssuranceEdgeWrite = {
stable_id: `edge-${crypto.randomUUID()}`,
source_node_ref: selected.stable_id,
target_node_ref: edgeTarget,
relation: activeRelation.id,
state: "active",
owner_ref: selected.owner_ref,
scope_ref: selected.scope_ref,
valid_from: new Date().toISOString(),
valid_to: null,
provenance: { source: "risk-compliance-ui" },
legal_basis_refs: selected.legal_basis_refs,
policy_refs: selected.policy_refs,
evidence_refs: []
};
try {
await saveAssuranceEdge(settings, value);
setEdgeDialogOpen(false);
onNotice("Connected the assurance objects.");
await onRefresh();
} catch (reason) {
onError(errorMessage(reason));
} finally {
onBusy(false);
}
}
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>
<div className="risk-assurance-columns">
<aside className="risk-panel">
<header>
<div>
<strong>Assurance objects</strong>
<span>Current effective revisions</span>
</div>
{canWrite && (
<IconButton
label="Add assurance object"
icon={<Plus size={16} />}
onClick={openNewNode}
/>
)}
</header>
<div className="risk-assurance-filter">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search assurance objects"
aria-label="Search assurance objects"
/>
<select
value={kind}
onChange={(event) => setKind(event.target.value)}
aria-label="Filter by assurance type"
>
<option value="">All types</option>
{Object.keys(ASSURANCE_STATES).map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</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 && (
<div className="risk-empty">No assurance objects match.</div>
)}
</div>
</aside>
<div className="risk-panel risk-assurance-detail">
<header>
<div>
<strong>{selected?.label || "Assurance object"}</strong>
<span>{selected ? formatToken(selected.kind) : "Select an object"}</span>
</div>
{selected && canWrite && !selected.stable_id.startsWith("sanctions-") && (
<IconButton
label="Edit assurance object"
icon={<Pencil size={16} />}
onClick={() => openEditNode(selected)}
/>
)}
</header>
{!selected && (
<div className="risk-empty">Select an assurance object.</div>
)}
{selected && (
<div className="risk-assurance-detail-body">
<dl className="risk-assurance-properties">
<div><dt>State</dt><dd><StatusBadge status={selected.state} /></dd></div>
<div><dt>Owner</dt><dd>{selected.owner_ref}</dd></div>
<div><dt>Scope</dt><dd>{selected.scope_ref || "Tenant"}</dd></div>
<div><dt>Valid from</dt><dd>{formatDate(selected.valid_from)}</dd></div>
{selected.governed_object_ref && (
<div><dt>Governed object</dt><dd><code>{selected.governed_object_ref}</code></dd></div>
)}
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
</dl>
{selected.description && <p>{selected.description}</p>}
<div className="risk-assurance-links-header">
<div>
<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>
)}
</div>
<div className="risk-assurance-links">
{graph?.edges.map((edge) => (
<AssuranceEdgeRow
edge={edge}
nodes={graph.nodes}
selectedId={selected.stable_id}
onSelect={onSelect}
key={edge.stable_id}
/>
))}
{!graph?.edges.length && (
<div className="risk-empty">No relationships are recorded.</div>
)}
</div>
{graph?.truncated && (
<div className="risk-assurance-truncated">
The bounded graph contains additional relationships.
</div>
)}
</div>
)}
</div>
</div>
<AssuranceNodeDialog
open={nodeDialogOpen}
busy={busy}
editing={Boolean(editingNode)}
draft={nodeDraft}
onChange={setNodeDraft}
onClose={() => setNodeDialogOpen(false)}
onSubmit={submitNode}
/>
<Dialog
open={edgeDialogOpen}
title="Connect assurance objects"
onClose={() => setEdgeDialogOpen(false)}
closeDisabled={busy}
footer={
<>
<Button onClick={() => setEdgeDialogOpen(false)} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-edge-form"
type="submit"
variant="primary"
disabled={busy || !activeRelation || !edgeTarget}
>
Connect
</Button>
</>
}
>
<form id="risk-assurance-edge-form" className="risk-assurance-form" onSubmit={submitEdge}>
<FormField label="Relationship">
<select
value={edgeRelation}
onChange={(event) => {
const relation = event.target.value;
setEdgeRelation(relation);
const shape = availableRelations.find((item) => item.id === relation);
setEdgeTarget(
shape
? nodes.find((item) => item.kind === shape.target)?.stable_id ?? ""
: ""
);
}}
>
{availableRelations.map((item) => (
<option value={item.id} key={item.id}>{formatToken(item.id)}</option>
))}
</select>
</FormField>
<FormField label="Target object">
<select value={edgeTarget} onChange={(event) => setEdgeTarget(event.target.value)}>
{edgeTargets.map((item) => (
<option value={item.stable_id} key={item.stable_id}>{item.label}</option>
))}
</select>
</FormField>
</form>
</Dialog>
</section>
);
}
function AssuranceEdgeRow({
edge,
nodes,
selectedId,
onSelect
}: {
edge: AssuranceEdge;
nodes: AssuranceNode[];
selectedId: string;
onSelect: (id: string) => void;
}) {
const otherId = edge.source_node_ref === selectedId
? edge.target_node_ref
: edge.source_node_ref;
const other = nodes.find((item) => item.stable_id === otherId);
return (
<button type="button" onClick={() => onSelect(otherId)}>
<span>{formatToken(edge.relation)}</span>
<strong>{other?.label || otherId}</strong>
<StatusBadge status={edge.state} />
</button>
);
}
function AssuranceNodeDialog({
open,
busy,
editing,
draft,
onChange,
onClose,
onSubmit
}: {
open: boolean;
busy: boolean;
editing: boolean;
draft: AssuranceNodeDraft;
onChange: (value: AssuranceNodeDraft) => void;
onClose: () => void;
onSubmit: (event: FormEvent) => void;
}) {
const update = (values: Partial<AssuranceNodeDraft>) =>
onChange({ ...draft, ...values });
return (
<Dialog
open={open}
title={editing ? "Revise assurance object" : "Add assurance object"}
onClose={onClose}
closeDisabled={busy}
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
form="risk-assurance-node-form"
type="submit"
variant="primary"
disabled={busy}
>
{editing ? "Record revision" : "Add"}
</Button>
</>
}
>
<form id="risk-assurance-node-form" className="risk-assurance-form" onSubmit={onSubmit}>
<div className="risk-assurance-form-grid">
<FormField label="Stable ID">
<input
value={draft.stableId}
onChange={(event) => update({ stableId: event.target.value })}
disabled={editing}
required
maxLength={255}
/>
</FormField>
<FormField label="Type">
<select
value={draft.kind}
onChange={(event) => {
const nextKind = event.target.value as AssuranceNodeKind;
update({ kind: nextKind, state: ASSURANCE_STATES[nextKind][0] });
}}
disabled={editing}
>
{Object.keys(ASSURANCE_STATES).map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</FormField>
<FormField label="Name">
<input value={draft.label} onChange={(event) => update({ label: event.target.value })} required maxLength={500} />
</FormField>
<FormField label="State">
<select value={draft.state} onChange={(event) => update({ state: event.target.value })}>
{ASSURANCE_STATES[draft.kind].map((item) => (
<option value={item} key={item}>{formatToken(item)}</option>
))}
</select>
</FormField>
<FormField label="Owner reference">
<input value={draft.ownerRef} onChange={(event) => update({ ownerRef: event.target.value })} required maxLength={500} />
</FormField>
<FormField label="Scope reference">
<input value={draft.scopeRef} onChange={(event) => update({ scopeRef: event.target.value })} maxLength={500} />
</FormField>
{draft.kind === "governed_object" && (
<FormField label="Governed object reference">
<input value={draft.governedObjectRef} onChange={(event) => update({ governedObjectRef: event.target.value })} required maxLength={1000} />
</FormField>
)}
<FormField label="Classification">
<select value={draft.classification} onChange={(event) => update({ classification: event.target.value })}>
<option value="public">Public</option>
<option value="internal">Internal</option>
<option value="confidential">Confidential</option>
<option value="restricted">Restricted</option>
</select>
</FormField>
<FormField label="Valid from">
<input type="datetime-local" value={draft.validFrom} onChange={(event) => update({ validFrom: event.target.value })} required />
</FormField>
<FormField label="Valid to">
<input type="datetime-local" value={draft.validTo} onChange={(event) => update({ validTo: event.target.value })} />
</FormField>
</div>
<FormField label="Description">
<textarea value={draft.description} onChange={(event) => update({ description: event.target.value })} rows={4} maxLength={20000} />
</FormField>
<div className="risk-assurance-form-grid">
<FormField label="Legal basis references">
<textarea value={draft.legalBasisRefs} onChange={(event) => update({ legalBasisRefs: event.target.value })} rows={3} />
</FormField>
<FormField label="Policy references">
<textarea value={draft.policyRefs} onChange={(event) => update({ policyRefs: event.target.value })} rows={3} />
</FormField>
<FormField label="Evidence references">
<textarea value={draft.evidenceRefs} onChange={(event) => update({ evidenceRefs: event.target.value })} rows={3} />
</FormField>
</div>
</form>
</Dialog>
);
}
function emptyAssuranceNodeDraft(): AssuranceNodeDraft {
return {
stableId: "",
kind: "obligation",
label: "",
description: "",
state: "active",
ownerRef: "",
scopeRef: "",
governedObjectRef: "",
validFrom: dateTimeLocalValue(new Date()),
validTo: "",
classification: "internal",
legalBasisRefs: "",
policyRefs: "",
evidenceRefs: ""
};
}
function nodeDraftFromItem(item: AssuranceNode): AssuranceNodeDraft {
return {
stableId: item.stable_id,
kind: item.kind,
label: item.label,
description: item.description || "",
state: item.state,
ownerRef: item.owner_ref,
scopeRef: item.scope_ref || "",
governedObjectRef: item.governed_object_ref || "",
validFrom: dateTimeLocalValue(new Date(item.valid_from)),
validTo: item.valid_to ? dateTimeLocalValue(new Date(item.valid_to)) : "",
classification: item.classification,
legalBasisRefs: item.legal_basis_refs.join("\n"),
policyRefs: item.policy_refs.join("\n"),
evidenceRefs: item.evidence_refs.join("\n")
};
}
function assuranceNodeWrite(
draft: AssuranceNodeDraft,
provenance: Record<string, unknown> = { source: "risk-compliance-ui" }
): AssuranceNodeWrite {
return {
stable_id: draft.stableId.trim(),
kind: draft.kind,
label: draft.label.trim(),
description: draft.description.trim() || null,
state: draft.state,
owner_ref: draft.ownerRef.trim(),
scope_ref: draft.scopeRef.trim() || null,
governed_object_ref: draft.governedObjectRef.trim() || null,
valid_from: new Date(draft.validFrom).toISOString(),
valid_to: draft.validTo ? new Date(draft.validTo).toISOString() : null,
provenance,
legal_basis_refs: referenceLines(draft.legalBasisRefs),
policy_refs: referenceLines(draft.policyRefs),
evidence_refs: referenceLines(draft.evidenceRefs),
classification: draft.classification
};
}
function referenceLines(value: string) {
return Array.from(
new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))
);
}
function dateTimeLocalValue(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function formatToken(value: string) {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function EvidenceColumn({
title,
name,
+205 -2
View File
@@ -51,7 +51,8 @@
.risk-source-layout,
.risk-screen-layout,
.risk-review-layout {
.risk-review-layout,
.risk-assurance-layout {
display: grid;
min-width: 0;
min-height: 0;
@@ -68,6 +69,185 @@
grid-template-columns: minmax(300px, 0.7fr) minmax(480px, 1.6fr);
}
.risk-assurance-layout {
grid-template-rows: auto minmax(0, 1fr);
}
.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 {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: minmax(300px, 0.75fr) minmax(480px, 1.55fr);
gap: 12px;
}
.risk-assurance-filter {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.45fr);
gap: 8px;
border-bottom: var(--border-line);
padding: 8px 10px;
}
.risk-assurance-filter input,
.risk-assurance-filter select,
.risk-assurance-form input,
.risk-assurance-form select,
.risk-assurance-form textarea {
width: 100%;
box-sizing: border-box;
}
.risk-assurance-detail-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: auto;
}
.risk-assurance-detail-body > p {
margin: 0;
border-bottom: var(--border-line);
color: var(--text);
padding: 12px;
line-height: 1.5;
}
.risk-assurance-properties {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
border-bottom: var(--border-line);
}
.risk-assurance-properties > div {
min-width: 0;
border-right: var(--border-line);
border-bottom: var(--border-line);
padding: 10px 12px;
}
.risk-assurance-properties > div:nth-child(2n) {
border-right: 0;
}
.risk-assurance-properties dt {
margin-bottom: 4px;
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.risk-assurance-properties dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--text-strong);
font-size: 12px;
}
.risk-assurance-links-header {
display: flex;
align-items: center;
gap: 10px;
min-height: 52px;
border-bottom: var(--border-line);
padding: 8px 12px;
}
.risk-assurance-links-header > div {
display: grid;
gap: 2px;
flex: 1;
}
.risk-assurance-links-header span {
color: var(--muted);
font-size: 11px;
}
.risk-assurance-links {
min-height: 0;
overflow: auto;
}
.risk-assurance-links > button {
display: grid;
width: 100%;
grid-template-columns: minmax(110px, 0.45fr) minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--text);
padding: 9px 12px;
text-align: left;
font: inherit;
cursor: pointer;
}
.risk-assurance-links > button:hover {
background: var(--sidebar-hover-bg);
}
.risk-assurance-links > button span {
color: var(--muted);
font-size: 11px;
}
.risk-assurance-links > button strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.risk-assurance-truncated {
border-top: var(--border-line);
color: var(--warning);
padding: 9px 12px;
font-size: 11px;
}
.risk-assurance-form {
display: grid;
gap: 12px;
}
.risk-assurance-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.risk-panel {
display: flex;
min-width: 0;
@@ -333,11 +513,34 @@
.risk-source-layout,
.risk-screen-layout,
.risk-review-layout {
.risk-review-layout,
.risk-assurance-layout,
.risk-assurance-columns {
height: auto;
grid-template-columns: minmax(0, 1fr);
}
.risk-assurance-metrics {
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);
}
.risk-assurance-properties > div {
border-right: 0;
}
.risk-panel {
min-height: 340px;
}