// Case detail view — drill-down into a single alert/case.
const { useState: useStateC, useEffect: useEffectC, useMemo: useMemoC } = React;
// ── Network graph ───────────────────────────────────────────────────────────
function formatNetworkLabel(id) {
if (!id) return "—";
if (id.length > 12) return id.slice(0, 6) + "…" + id.slice(-4);
return id;
}
function NetworkGraph({ network }) {
// SVG-rendered entity relationship graph
const W = 720, H = 420;
const { nodes: rawNodes = [], edges: rawEdges = [] } = network || {};
const cx = W / 2;
const cy = H / 2;
const placedNodes = useMemoC(() => {
const subject = rawNodes.find(n => n.kind === "subject");
const others = rawNodes.filter(n => n.kind !== "subject");
const nodes = [];
if (subject) {
nodes.push({ ...subject, x: cx, y: cy, r: subject.r || 28 });
}
const count = others.length || 1;
// Scale radius by number of nodes so labels don't overlap
const baseRadius = Math.min(W, H) / 2 - 100;
const radius = Math.max(baseRadius, 140 + count * 10);
others.forEach((n, i) => {
const angle = (2 * Math.PI * i) / count - Math.PI / 2;
nodes.push({
...n,
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
r: n.r || 18,
});
});
return nodes;
}, [rawNodes]);
const N = Object.fromEntries(placedNodes.map(n => [n.id, n]));
const colorFor = (k) => ({
subject: "var(--fg)",
counterparty: "var(--fg-2)",
high: "var(--risk-high)",
low: "var(--risk-low)",
cash: "var(--risk-med)",
link: "var(--info)",
normal: "var(--fg-3)",
neutral: "var(--fg-3)",
}[k] || "var(--fg-3)");
if (!rawNodes.length) {
return (
No network data available for this alert.
);
}
return (
);
}
// ── Timeline / activity histogram ───────────────────────────────────────────
function VolumeHistogram() {
// Daily volume across 30 days, with structuring spike highlighted
const data = [
2,3,2,4,3,5,4,3,4,6,5,4,7,6,5,4,6,8,7,5,4,6,8,9,7,6,9,11,9, 18, // last day = spike
];
const max = Math.max(...data);
const W = 720, H = 96;
const bw = (W - 20) / data.length;
return (
);
}
// ── Score breakdown ─────────────────────────────────────────────────────────
function ScoreBreakdown({ score }) {
const items = [
{ label: "Volume", value: score.volume, note: "deposit count + sum" },
{ label: "Velocity", value: score.velocity, note: "txns / 48h window" },
{ label: "Geographic", value: score.geo, note: "corridor risk" },
{ label: "Peer group", value: score.peer, note: "vs. similar SMB" },
];
return (
{items.map(it => (
{it.label}
{it.note}
{it.value}
))}
);
}
// ── Transaction table for the case ──────────────────────────────────────────
function formatTxnChannel(type) {
const map = { wire: "Wire", ach: "ACH", card: "Card", atm: "ATM", crypto: "Crypto", cash: "Cash" };
return map[type] || (type || "").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
}
function formatDate(ts) {
if (!ts) return "—";
const d = new Date(ts);
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function CaseTxnTable({ transactions, primaryId, loading }) {
if (loading) {
return Loading transactions…
;
}
if (!transactions || transactions.length === 0) {
return No transactions found.
;
}
return (
|
Txn ID |
Timestamp |
Dir |
Counterparty |
Account |
Amount |
Channel |
{transactions.map(t => {
const subjectId = primaryId || t.originator_id;
const isOut = t.originator_id === subjectId;
const cpId = isOut ? t.beneficiary_id : t.originator_id;
const cpType = isOut ? t.beneficiary_type : t.originator_type;
const account = isOut ? (t.originator_account || "—") : (t.beneficiary_account || "—");
const isPrimary = primaryId ? t.id === primaryId : false;
return (
| {isPrimary && } |
{t.external_id || t.id.slice(0, 8)} |
{formatDate(t.timestamp)} |
{isOut ? : }
{isOut ? "OUT" : "IN"}
|
{cpId}
{cpType && ({cpType})}
|
{account} |
${Number(t.amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{t.currency || "USD"}
|
{formatTxnChannel(t.transaction_type)} |
);
})}
);
}
// ── Simulation / backtesting panel ──────────────────────────────────────────
// ── Timeline / activity feed ────────────────────────────────────────────────
function ActivityFeed() {
const ico = {
alert: ,
assign: ,
view: ,
note: ,
request: ,
link: ,
edit: ,
};
const bg = {
alert: "var(--risk-high-bg)",
note: "var(--accent-tint)",
link: "var(--info-bg)",
};
const fg = {
alert: "var(--risk-high)",
note: "var(--accent)",
link: "var(--info)",
};
return (
{CASE_TIMELINE.slice().reverse().map((e, i) => (
))}
);
}
// ── Decisioning rail ────────────────────────────────────────────────────────
function DecisionRail({ alert, onClose, onRefresh }) {
const [note, setNote] = useStateC("");
const [posting, setPosting] = useStateC(false);
const [dismissing, setDismissing] = useStateC(false);
const [creatingCase, setCreatingCase] = useStateC(false);
const [message, setMessage] = useStateC(null);
const createCase = async () => {
setCreatingCase(true);
setMessage(null);
try {
const result = await window.mossadApi.convertAlertToCase(alert._raw?.id || alert.id);
setMessage({ type: "ok", text: `Case created · ${result.external_id || result.case_id}` });
if (onRefresh) await onRefresh();
} catch (err) {
setMessage({ type: "error", text: "Failed to create case: " + err.message });
} finally {
setCreatingCase(false);
}
};
const postNote = async () => {
if (!note.trim()) return;
setPosting(true);
setMessage(null);
try {
await window.mossadApi.addAlertNote(alert._raw?.id || alert.id, note.trim());
setNote("");
setMessage({ type: "ok", text: "Note posted" });
if (onRefresh) await onRefresh();
} catch (err) {
setMessage({ type: "error", text: "Failed to post note: " + err.message });
} finally {
setPosting(false);
}
};
const dismiss = async (resolution) => {
setDismissing(true);
setMessage(null);
try {
await window.mossadApi.updateAlert(alert._raw?.id || alert.id, {
status: "dismissed",
resolution,
resolution_note: note.trim() || resolution,
});
setMessage({ type: "ok", text: `Alert dismissed · ${resolution.replace(/_/g, " ")}` });
if (onRefresh) await onRefresh();
if (onClose) onClose();
} catch (err) {
setMessage({ type: "error", text: "Failed to dismiss alert: " + err.message });
} finally {
setDismissing(false);
}
};
const notes = alert._raw?.notes || alert.notes || [];
return (
Decision
{(() => {
const sev = alert.severity === "medium" ? "med" : alert.severity || "low";
return (
{alert.status?.toUpperCase() || "OPEN"} · {alert.sla || "—"} SLA
);
})()}
{message && (
{message.text}
)}
Activity
{notes.length + CASE_TIMELINE.length} events
{notes.length > 0 && (
{notes.slice().reverse().map((n, i) => (
{n.content}
{n.created_by_name || "Unknown"} · {new Date(n.created_at).toLocaleString()}
))}
)}
);
}
// ── Case detail composition ─────────────────────────────────────────────────
function CaseDetailView({ alert, onClose, data, backLabel = "Back to queue" }) {
const [tab, setTab] = useStateC("overview");
const [network, setNetwork] = useStateC(null);
const [networkLoading, setNetworkLoading] = useStateC(false);
const [txns, setTxns] = useStateC([]);
const [txnsLoading, setTxnsLoading] = useStateC(false);
useEffectC(() => {
let cancelled = false;
const load = async () => {
if (!alert?.id || !window.mossadApi) return;
setNetworkLoading(true);
try {
const n = await window.mossadApi.fetchAlertNetwork(alert.id);
if (!cancelled) setNetwork(n);
} catch (err) {
console.error("Failed to load alert network:", err);
} finally {
if (!cancelled) setNetworkLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [alert?.id]);
useEffectC(() => {
let cancelled = false;
const load = async () => {
if (!alert?.id || !window.mossadApi) return;
setTxnsLoading(true);
try {
const list = await window.mossadApi.fetchAlertTransactions(alert.id);
if (!cancelled) setTxns(list || []);
} catch (err) {
console.error("Failed to load alert transactions:", err);
if (!cancelled) setTxns([]);
} finally {
if (!cancelled) setTxnsLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [alert?.id]);
return (
{/* Header card */}
·
{alert.case}
·
opened 2h 14m ago
{alert.subject}
{alert.typology}
{alert.external_id}
·
{alert.rule}
Risk score
{alert.risk}
/ 100
+14 vs. baseline
{/* Tabs */}
{["overview", "transactions", "entity", "documents"].map(t => (
))}
{tab === "overview" && (
{/* Score breakdown card + key facts */}
Score breakdown
model · v4.2
Key facts
Entity type
SMB · LLC · Maritime services
Account opened
2024-02-11 · 27 months
Owners
3 · all KYC-verified
PEP / sanctions
Clear · last refresh 41d ago
Prior cases
CSE-25-3308 closed · cleared
Watchlist
Internal · since 2025-Q4
{/* Entity network */}
Entity network
Suspicious flows
Cash channel
Normal flows
Linked case
{networkLoading ? (
Loading network…
) : (
)}
{/* Volume histogram */}
Daily transaction volume · 30d
peak 18 txns on May 14
)}
{tab === "transactions" && (
Transactions that triggered this alert
{txnsLoading ? "Loading…" : `${txns.length} transaction${txns.length !== 1 ? "s" : ""}`}
)}
{tab === "entity" && (
Entity profile · Ardent Maritime LLC
)}
{tab === "documents" && (
)}
);
}
function Field({ k, v, extra }) {
const ec = extra === "med" ? "var(--risk-med)"
: extra === "high" ? "var(--risk-high)"
: extra === "info" ? "var(--info)"
: "var(--fg)";
return (
{k}
{v}
);
}
function DocRow({ name, date, status }) {
const pill = { filed: "pill low", pending: "pill med", missing: "pill high" }[status] || "pill";
return (
{name}
{date}
{status}
);
}
Object.assign(window, { CaseDetailView });