// 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 ( {/* Grid */} {/* Edges */} {rawEdges.map((e, i) => { const a = N[e.from], b = N[e.to]; if (!a || !b) return null; const dx = b.x - a.x, dy = b.y - a.y; const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; const x1 = a.x + ux * a.r, y1 = a.y + uy * a.r; const x2 = b.x - ux * b.r, y2 = b.y - uy * b.r; const stroke = e.kind === "high" ? "var(--risk-high)" : "var(--fg-3)"; const weight = Math.max(1.5, Math.min(5, Math.log10((e.amount || 1) + 1))); const mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 }; return ( {e.label && (e.kind === "high" || rawNodes.length <= 8) && ( {e.label} )} ); })} {/* Nodes */} {placedNodes.map(n => { const c = colorFor(n.kind); const isSubj = n.kind === "subject"; const label = formatNetworkLabel(n.label); const sub = n.sub || ""; return ( {isSubj && ( S )} {!isSubj && ( CP )} {label} {sub} ); })} ); } // ── 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 ( {data.map((v, i) => { const h = (v / max) * (H - 24); const x = 10 + i * bw + 1; const isLast = i === data.length - 1; const isHigh = v > 12; return ( {isLast && ( {v} )} ); })} Apr 15 Apr 30 May 14 ); } // ── 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 ( {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 ( ); })}
Txn ID Timestamp Dir Counterparty Account Amount Channel
{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) => (
{ico[e.kind]}
{e.who} {e.ts}
{e.text}
))}
); } // ── 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}
)}
Add note