feat: implement assurance graph and screening evidence
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user