// 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 }) => (
);
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 }) => (