// Nexa — shared dashboard pieces (helpers, hooks, badges, common rows) const { useState, useMemo, useEffect, useRef, useCallback } = React; // ── Formatters ───────────────────────────────────────── const fmtN = (n, d = 2) => Number(n).toLocaleString("en-GB", { minimumFractionDigits: d, maximumFractionDigits: d }); const CURRENCY_SYMBOLS = { GBP: "£", EUR: "€", USD: "$", CAD: "C$", AUD: "A$", JPY: "¥", CHF: "CHF " }; const symbolFor = (cur) => CURRENCY_SYMBOLS[cur] || ""; const fmtMoney = (n, cur, d = 2) => `${symbolFor(cur)}${fmtN(Number(n) || 0, d)}`; const fmtCompact = (n, cur) => { const sym = symbolFor(cur); const v = Number(n) || 0; if (Math.abs(v) >= 1_000_000) return `${sym}${(v/1_000_000).toFixed(2)}M`; if (Math.abs(v) >= 1_000) return `${sym}${(v/1_000).toFixed(1)}k`; return `${sym}${fmtN(v)}`; }; const fmtTime = (iso) => { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d)) return "—"; return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); }; const fmtDate = (iso) => { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d)) return "—"; return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short" }); }; const fmtDateLong = (iso) => { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d)) return "—"; return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); }; const fmtRelative = (iso) => { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d)) return "—"; const ms = Date.now() - d.getTime(); const m = Math.round(ms / 60000); if (m < 1) return "just now"; if (m < 60) return `${m}m ago`; const h = Math.round(m / 60); if (h < 24) return `${h}h ago`; const days = Math.round(h / 24); return `${days}d ago`; }; const fmtUntil = (iso) => { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d)) return "—"; const ms = d.getTime() - Date.now(); if (ms <= 0) return "expired"; const h = Math.round(ms / 3600000); if (h < 1) return "<1h"; return `${h}h`; }; const operationLabel = { off_ramp: "off-ramp", on_ramp: "on-ramp", "fx.spot": "fx · spot", "card.issue": "card · issue", "card.cap.set": "card · cap", "beneficiary.add": "beneficiary", }; const labelOp = (op) => operationLabel[op] || (op ? String(op).replace(/_/g, " ") : "—"); // ── Normalizers (API → UI) ───────────────────────────── // Transactions: API returns {instruction_id, timestamp, operation, agent_id, // agent_name, amount, currency, counterparty_name, destination_country, // status, payment_reason, reason_category}. Sign of amount = direction. function normTxn(t) { if (!t) return t; const amount = Number(t.amount) || 0; return { id: t.instruction_id || t.id, timestamp: t.timestamp, operation: t.operation, agentId: t.agent_id, agentName: t.agent_name, amount: Math.abs(amount), signedAmount: amount, currency: t.currency, counterparty: t.counterparty_name, country: t.destination_country, status: t.status, direction: amount >= 0 ? "in" : "out", paymentReason: t.payment_reason, reasonCategory: t.reason_category, }; } // Approvals: instruction_summary holds the human-readable bits. function normApproval(a) { if (!a) return a; const s = a.instruction_summary || {}; return { id: a.approval_id || a.id, agentId: a.agent_id, status: a.status, reason: a.reason, createdAt: a.created_at, expiresAt: a.expires_at, operation: s.operation, amount: Number(s.amount) || 0, currency: s.currency, counterparty: s.counterparty, destination: s.destination, }; } function normBeneficiary(b) { if (!b) return b; return { id: b.beneficiary_id || b.id, name: b.name, country: b.country, currency: b.currency, iban: b.iban, status: b.status, addedAt: b.created_at, totalPaid: Number(b.total_paid) || 0, paymentCount: b.payment_count || 0, lastPaidAt: b.last_paid_at, }; } function normAccount(a) { if (!a) return a; const balance = Number(a.balance) || 0; const held = Number(a.held) || 0; const available = a.available != null ? Number(a.available) : balance - held; return { id: a.account_id || a.id, currency: a.currency, label: a.label, iban: a.iban, balance, held, available, }; } function normAgent(a) { if (!a) return a; return { id: a.agent_id || a.id, name: a.name, status: a.status, apiKey: a.api_key || "", apiKeyMasked: a.api_key_masked || "", createdAt: a.created_at, lastUsedAt: a.last_used_at || null, allowedOperations: a.allowed_operations || [], allowedWalletIds: a.allowed_wallet_ids || [], walletScope: a.wallet_scope || "all", suspensionReason: a.suspension_reason, handle: a.handle || a.agent_id || "", description: a.description || "", }; } function normPolicyRule(r) { if (!r) return r; return { id: r.rule_id || r.id, type: r.policy_type, amount: r.amount, currency: r.currency, values: r.values || [], periodHours: r.period_hours, maxCount: r.max_count, startHour: r.start_hour, endHour: r.end_hour, mode: r.is_hard_block ? "block" : "approval", enabled: r.enabled !== false, }; } const RULE_TYPE_LABELS = { transaction_limit: "Per-Transaction Limit", daily_limit: "Daily Aggregate Limit", weekly_limit: "Weekly Aggregate Limit", monthly_limit: "Monthly Aggregate Limit", approval_threshold: "Approval Threshold", min_balance: "Minimum Balance Floor", counterparty_whitelist: "Counterparty Whitelist", counterparty_blacklist: "Counterparty Blacklist", currency_whitelist: "Currency Whitelist", corridor_blacklist: "Corridor Blacklist", velocity_limit: "Velocity Limit", time_window: "Time Window", }; function ruleLabel(r) { return RULE_TYPE_LABELS[r.type] || r.type; } function ruleDetail(r) { switch (r.type) { case "transaction_limit": case "daily_limit": case "weekly_limit": case "monthly_limit": case "approval_threshold": case "min_balance": return r.amount != null ? `${r.currency || ""} ${Number(r.amount).toLocaleString("en-GB")}`.trim() : "—"; case "counterparty_whitelist": case "counterparty_blacklist": case "currency_whitelist": case "corridor_blacklist": return (r.values || []).join(", ") || "—"; case "velocity_limit": return `${r.maxCount ?? "—"} txns / ${r.periodHours ?? "—"}h`; case "time_window": return `${String(r.startHour ?? "—").padStart(2,"0")}:00–${String(r.endHour ?? "—").padStart(2,"0")}:00 UTC`; default: return ""; } } // ── Hook: useFetch ───────────────────────────────────── function useFetch(fn, deps = [], { polling = 0 } = {}) { const [state, setState] = useState({ loading: true, data: null, error: null }); const [tick, setTick] = useState(0); const fnRef = useRef(fn); fnRef.current = fn; useEffect(() => { let alive = true; setState(s => ({ data: s.data, loading: true, error: null })); fnRef.current() .then(d => alive && setState({ loading: false, data: d, error: null })) .catch(e => alive && setState({ loading: false, data: null, error: e.message || String(e) })); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, tick]); useEffect(() => { if (!polling) return; const id = setInterval(() => setTick(t => t + 1), polling); return () => clearInterval(id); }, [polling]); const refetch = useCallback(() => setTick(t => t + 1), []); return { ...state, refetch }; } // ── Status / direction badges ───────────────────────── const StatusBadge = ({ status }) => { const map = { settled: { cls: "is-settled", label: "settled" }, completed: { cls: "is-settled", label: "completed" }, approved: { cls: "is-settled", label: "approved" }, pending_approval: { cls: "is-pending", label: "pending approval" }, pending_review: { cls: "is-pending", label: "pending review" }, pending: { cls: "is-pending", label: "pending" }, rejected: { cls: "is-rejected", label: "rejected" }, blocked: { cls: "is-rejected", label: "blocked" }, processing: { cls: "is-processing", label: "processing" }, submitted: { cls: "is-processing", label: "submitted" }, }; const m = map[status] || { cls: "", label: status || "—" }; return ( {m.label} ); }; const AgentBadge = ({ status }) => { const map = { active: { cls: "is-active", label: "active" }, suspended: { cls: "is-suspended", label: "suspended" }, test: { cls: "is-test", label: "test mode" }, revoked: { cls: "is-revoked", label: "revoked" }, created: { cls: "is-test", label: "created" }, }; const m = map[status] || { cls: "", label: status || "—" }; return ( {m.label} ); }; const DirectionGlyph = ({ dir }) => ( {dir === "in" ? : } ); const Flag = ({ code }) => { if (!code || code === "—") return ; return {String(code).slice(0, 3)}; }; const AgentTag = ({ agentId, agentName, agents }) => { if (!agentId) return ( human ); const a = (agents || []).find(x => x.id === agentId); return ( {a ? a.name : (agentName || agentId)} ); }; const Toggle = ({ on, onChange, disabled }) => ( } ); // Side nav items const NAV = [ { id: "overview", label: "Overview" }, { id: "transactions", label: "Transactions" }, { id: "beneficiaries", label: "Beneficiaries" }, { id: "documents", label: "Documents" }, { id: "policies", label: "Policies" }, { id: "agents", label: "Agents" }, ]; const SideIcon = ({ name }) => { const props = { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" }; switch (name) { case "overview": return (); case "transactions": return (); case "beneficiaries": return (); case "policies": return (); case "agents": return (); case "documents": return (); default: return null; } }; const PlusIcon = () => (); const ChevronIcon = () => (); const SearchIcon = () => (); const CheckIcon = () => (); const XIcon = () => (); const RefreshIcon = () => (); window.TC_HELPERS = { fmtN, fmtMoney, fmtCompact, fmtTime, fmtDate, fmtDateLong, fmtRelative, fmtUntil, operationLabel, labelOp, symbolFor, normTxn, normApproval, normBeneficiary, normAccount, normAgent, normPolicyRule, ruleLabel, ruleDetail, RULE_TYPE_LABELS, useFetch, NAV, }; window.TC_UI = { StatusBadge, AgentBadge, DirectionGlyph, Flag, AgentTag, Toggle, Spinner, LoadingBlock, ErrorBlock, SideIcon, PlusIcon, ChevronIcon, SearchIcon, CheckIcon, XIcon, RefreshIcon, };