// Nexa — page components (Overview, Transactions, Beneficiaries)
// All data fetched live from window.TC_API.
const {
fmtN, fmtMoney, fmtDate, fmtDateLong, fmtTime, fmtRelative, fmtUntil,
labelOp, symbolFor,
normTxn, normApproval, normBeneficiary, normAccount,
useFetch,
} = window.TC_HELPERS;
const {
StatusBadge, DirectionGlyph, Flag, AgentTag,
Spinner, LoadingBlock, ErrorBlock,
PlusIcon, CheckIcon, XIcon, RefreshIcon,
} = window.TC_UI;
// ────────────────────────────────────────────────────────────
// OVERVIEW
// ────────────────────────────────────────────────────────────
function OverviewPage({ agents, onNavigate, onPendingCount }) {
const accountsQ = useFetch(() => TC_API.getAccounts(), [], { polling: 30000 });
const summaryQ = useFetch(() => TC_API.getSummary(), [], { polling: 15000 });
const approvalsQ = useFetch(() => TC_API.getApprovals(), [], { polling: 5000 });
const txnsQ = useFetch(() => TC_API.getTransactions({ limit: 7 }), [], { polling: 10000 });
const accounts = useMemo(() => (accountsQ.data || []).map(normAccount), [accountsQ.data]);
const approvals = useMemo(() => (approvalsQ.data || []).map(normApproval)
.filter(a => a.status === "pending_approval" || a.status === "pending"),
[approvalsQ.data]);
const recent = useMemo(() => (txnsQ.data || []).map(normTxn), [txnsQ.data]);
const summary = summaryQ.data || {};
const today = summary.today || {};
// Notify shell so the side-nav badge can show pending count
useEffect(() => { onPendingCount && onPendingCount(approvals.length); }, [approvals.length, onPendingCount]);
const onApprove = async (id) => {
try { await TC_API.approveApproval(id); approvalsQ.refetch(); txnsQ.refetch(); accountsQ.refetch(); summaryQ.refetch(); }
catch (e) { alert(`Approve failed: ${e.message}`); }
};
const onReject = async (id) => {
try { await TC_API.rejectApproval(id); approvalsQ.refetch(); txnsQ.refetch(); accountsQ.refetch(); summaryQ.refetch(); }
catch (e) { alert(`Reject failed: ${e.message}`); }
};
const paymentsEnabled = summary.payments_enabled !== false ? true : (summaryQ.loading ? null : false);
const [activating, setActivating] = React.useState(false);
const onActivatePayments = async () => {
setActivating(true);
try { await TC_API.activatePayments(); summaryQ.refetch(); }
catch (e) { alert(`Activation failed: ${e.message}`); }
finally { setActivating(false); }
};
return (
{/* Payments not active banner */}
{paymentsEnabled === false && (
Payments not yet active.{" "}
Review your policy rules and activate payments to allow your agent to start operating.
)}
{/* Account cards */}
Accounts
{accountsQ.loading ? "loading…" : `${accounts.length} ${accounts.length === 1 ? "account" : "accounts"}`}
{accountsQ.loading && !accounts.length ? :
accountsQ.error ? :
accounts.length === 0 ? No accounts yet.
:
{accounts.map(a => (
{symbolFor(a.currency) || a.currency?.[0] || "•"}
{a.currency}
{a.label || "—"}
{symbolFor(a.currency)}
{fmtN(a.balance)}
{a.iban || "—"}
AVAILABLE
{fmtMoney(a.available, a.currency)}
HELD
0 ? "is-held" : "is-zero"}`}>
{fmtMoney(a.held, a.currency)}
))}
}
{/* Summary metrics */}
{summaryQ.error ? :
Pending approvals
0 ? "is-pending" : ""}`}>
{summary.pending_approval_count ?? (summaryQ.loading ? : 0)}
{(summary.pending_approval_count || 0) > 0 ? "awaiting your sign-off" : "all clear"}
Active agents
{summary.active_agent_count ?? (summaryQ.loading ? : 0)}
registered & operating
Today · transactions
{today.total_transactions ?? (summaryQ.loading ? : 0)}
{today.approved != null ? `${today.approved} approved` : ""}
{today.rejected != null ? ` · ${today.rejected} rejected` : ""}
Open info requests
{summary.open_information_requests_count ?? (summaryQ.loading ? : 0)}
agent-initiated
}
{/* Pending approvals */}
Pending approvals
held by policy
{approvalsQ.loading && !approvals.length ?
:
approvalsQ.error ? :
{approvals.length === 0 && (
No instructions held. Agents are operating within policy.
)}
{approvals.map(t => (
{t.currency || ""} {t.operation ? `· ${labelOp(t.operation)}` : ""}
{fmtMoney(Math.abs(t.amount), t.currency)}
{t.destination && }
{t.counterparty || "—"}
id · {t.id}
{t.createdAt && · submitted {fmtRelative(t.createdAt)}}
{t.reason &&
{t.reason}
}
{t.expiresAt &&
expires {fmtUntil(t.expiresAt)}}
))}
}
{/* Recent transactions */}
Recent transactions
{txnsQ.loading && !recent.length ?
:
txnsQ.error ? :
recent.length === 0 ? No transactions yet.
:
{recent.map(t => (
{fmtTime(t.timestamp)}
{fmtDate(t.timestamp)}
{t.counterparty || "—"}
{t.country || "—"} · {labelOp(t.operation)}
{t.direction === "in" ? "+" : "−"}{fmtMoney(t.amount, t.currency)}
))}
}
);
}
// ────────────────────────────────────────────────────────────
// TRANSACTIONS
// ────────────────────────────────────────────────────────────
function TransactionsPage({ agents }) {
const [filter, setFilter] = useState("all");
const txnsQ = useFetch(
() => TC_API.getTransactions({ status: filter, limit: 200 }),
[filter],
{ polling: 15000 }
);
const txns = useMemo(() => (txnsQ.data || []).map(normTxn), [txnsQ.data]);
return (
All transactions
{[
{ k: "all", label: "All" },
{ k: "approved", label: "Approved" },
{ k: "pending_approval", label: "Pending" },
{ k: "rejected", label: "Rejected" },
].map(f => (
))}
{txnsQ.loading && !txns.length ?
:
txnsQ.error ? :
| Time |
Date |
Dir. |
Counterparty |
Agent |
Currency |
Amount |
Status |
{txns.map(t => (
| {fmtTime(t.timestamp)} |
{fmtDate(t.timestamp)} |
|
{t.counterparty || "—"}
{t.country || "—"} · {labelOp(t.operation)} · {t.id}
{t.paymentReason && (
↳ {t.paymentReason}
)}
|
|
{t.currency} |
{t.direction === "in" ? "+" : "−"}{fmtMoney(t.amount, t.currency)}
|
|
))}
{txns.length === 0 && (
| No transactions match this filter. |
)}
}
);
}
// ────────────────────────────────────────────────────────────
// BENEFICIARIES
// ────────────────────────────────────────────────────────────
function BeneficiariesPage() {
const benQ = useFetch(() => TC_API.getBeneficiaries(), [], { polling: 30000 });
const beneficiaries = useMemo(() => (benQ.data || []).map(normBeneficiary), [benQ.data]);
const [showForm, setShowForm] = useState(false);
const [busyId, setBusyId] = useState(null);
const onApprove = async (id) => {
setBusyId(id);
try { await TC_API.approveBeneficiary(id); benQ.refetch(); }
catch (e) { alert(`Approve failed: ${e.message}`); }
finally { setBusyId(null); }
};
const onBlock = async (id) => {
const reason = prompt("Reason for blocking this beneficiary?", "");
if (reason == null) return;
setBusyId(id);
try { await TC_API.blockBeneficiary(id, reason); benQ.refetch(); }
catch (e) { alert(`Block failed: ${e.message}`); }
finally { setBusyId(null); }
};
const pendingCount = beneficiaries.filter(b => b.status === "pending_review" || b.status === "pending").length;
return (
Beneficiaries
{benQ.loading ? "loading…" : `${beneficiaries.length} total · ${pendingCount} pending review`}
{showForm && (
setShowForm(false)}
onSubmit={async (b) => {
try {
await TC_API.addBeneficiary(b);
setShowForm(false);
benQ.refetch();
} catch (e) {
alert(`Add failed: ${e.message}`);
}
}}
/>
)}
{benQ.loading && !beneficiaries.length ?
:
benQ.error ? :
| Name |
Country |
Currency |
IBAN |
Added |
Last paid |
Total paid |
Status |
|
{beneficiaries.map(b => {
const isPending = b.status === "pending_review" || b.status === "pending";
const isBlocked = b.status === "blocked";
return (
|
{b.name || "—"}
{b.id}
|
|
{b.currency} |
{b.iban || "—"} |
{fmtDate(b.addedAt)} |
{b.lastPaidAt ? fmtDate(b.lastPaidAt) : "—"} |
{b.totalPaid > 0 ? fmtMoney(b.totalPaid, b.currency) : "—"} |
|
{isPending && (
)}
{!isBlocked && (
)}
|
);
})}
{beneficiaries.length === 0 && (
| No beneficiaries yet. Add one to enable agent payments. |
)}
}
New beneficiaries enter pending review by default.
Agents cannot transact to a beneficiary until a principal approves it.
);
}
// ── Client-side IBAN validation (ISO 7064 mod-97, no external API) ──────────
const IBAN_LENGTHS = {
AL:28,AD:24,AT:20,AZ:28,BH:22,BE:16,BA:20,BR:29,BG:22,BY:28,CR:22,HR:21,
CY:28,CZ:24,DK:18,DO:28,SV:28,EE:20,FO:18,FI:18,FR:27,GE:22,DE:22,GI:23,
GR:27,GL:18,GT:28,HU:28,IS:26,IQ:23,IE:22,IL:23,IT:27,JO:30,KZ:20,XK:20,
KW:30,LV:21,LB:28,LI:21,LT:20,LU:20,MK:19,MT:31,MR:27,MU:30,MC:27,MD:24,
ME:22,NL:18,NO:15,PK:24,PS:29,PL:28,PT:25,QA:29,RO:24,SM:27,SA:24,RS:22,
SC:31,SK:24,SI:19,ES:24,SE:24,CH:21,TN:24,TR:26,AE:23,GB:22,VA:22,VG:24,
};
const IBAN_COUNTRY_NAMES = {
AL:"Albania",AD:"Andorra",AT:"Austria",AZ:"Azerbaijan",BH:"Bahrain",BE:"Belgium",
BA:"Bosnia",BR:"Brazil",BG:"Bulgaria",BY:"Belarus",CR:"Costa Rica",HR:"Croatia",
CY:"Cyprus",CZ:"Czech Republic",DK:"Denmark",DO:"Dominican Republic",SV:"El Salvador",
EE:"Estonia",FO:"Faroe Islands",FI:"Finland",FR:"France",GE:"Georgia",DE:"Germany",
GI:"Gibraltar",GR:"Greece",GL:"Greenland",GT:"Guatemala",HU:"Hungary",IS:"Iceland",
IQ:"Iraq",IE:"Ireland",IL:"Israel",IT:"Italy",JO:"Jordan",KZ:"Kazakhstan",XK:"Kosovo",
KW:"Kuwait",LV:"Latvia",LB:"Lebanon",LI:"Liechtenstein",LT:"Lithuania",LU:"Luxembourg",
MK:"North Macedonia",MT:"Malta",MR:"Mauritania",MU:"Mauritius",MC:"Monaco",MD:"Moldova",
ME:"Montenegro",NL:"Netherlands",NO:"Norway",PK:"Pakistan",PS:"Palestine",PL:"Poland",
PT:"Portugal",QA:"Qatar",RO:"Romania",SM:"San Marino",SA:"Saudi Arabia",RS:"Serbia",
SC:"Seychelles",SK:"Slovakia",SI:"Slovenia",ES:"Spain",SE:"Sweden",CH:"Switzerland",
TN:"Tunisia",TR:"Turkey",AE:"UAE",GB:"United Kingdom",VA:"Vatican City",VG:"British Virgin Islands",
};
function validateIban(raw) {
if (!raw || !raw.trim()) return { valid: false, error: null }; // empty = no error shown
const norm = raw.replace(/[\s\-]/g, "").toUpperCase();
if (norm.length < 5) return { valid: false, error: "Too short" };
const cc = norm.slice(0, 2);
if (!/^[A-Z]{2}$/.test(cc)) return { valid: false, error: "Must start with a 2-letter country code" };
if (!/^\d{2}/.test(norm.slice(2))) return { valid: false, error: "Positions 3–4 must be digits" };
const expected = IBAN_LENGTHS[cc];
if (expected && norm.length !== expected) return { valid: false, error: `${cc} IBANs must be ${expected} characters (got ${norm.length})` };
// Mod-97
const rearranged = norm.slice(4) + norm.slice(0, 4);
const digits = rearranged.split("").map(c => /[A-Z]/.test(c) ? String(c.charCodeAt(0) - 55) : c).join("");
let remainder = 0;
for (const chunk of digits.match(/.{1,9}/g) || []) {
remainder = Number(String(remainder) + chunk) % 97;
}
if (remainder !== 1) return { valid: false, error: "Check digits are incorrect — please re-check the IBAN" };
return { valid: true, country: cc, countryName: IBAN_COUNTRY_NAMES[cc] || cc, formatted: norm.match(/.{1,4}/g).join(" ") };
}
function AddBeneficiaryForm({ onCancel, onSubmit }) {
const [name, setName] = useState("");
const [country, setCountry] = useState("");
const [currency, setCurrency] = useState("GBP");
const [iban, setIban] = useState("");
const [ibanTouched, setIbanTouched] = useState(false);
const [submitting, setSubmitting] = useState(false);
const ibanValidation = validateIban(iban);
const submit = async (e) => {
e.preventDefault();
if (!name.trim()) return;
if (iban.trim() && !ibanValidation.valid) return;
setSubmitting(true);
await onSubmit({
name: name.trim(),
country: country.trim().toUpperCase(),
currency: currency.trim().toUpperCase(),
iban: iban.trim().replace(/\s+/g, ""),
});
setSubmitting(false);
};
const ibanNorm = iban.replace(/[\s\-]/g, "").toUpperCase();
const showIbanError = ibanTouched && ibanNorm.length > 4 && !ibanValidation.valid;
const showIbanOk = ibanNorm.length > 4 && ibanValidation.valid;
return (
);
}
// ────────────────────────────────────────────────────────────
// DOCUMENTS PAGE
// ────────────────────────────────────────────────────────────
const DOC_TYPES = [
"Certificate of Incorporation",
"Articles of Association",
"Proof of Address",
"UBO Declaration",
"Director ID",
"Audited Financials",
"Bank Statement",
"Source of Funds Declaration",
"KYC Document",
"Other",
];
const DOC_CATEGORIES = ["corporate","financial","identity","regulatory","operational","compliance","correspondence"];
function DocumentUploadForm({ onCancel, onUploaded }) {
const [file, setFile] = useState(null);
const [docType, setType] = useState(DOC_TYPES[0]);
const [category, setCat] = useState("corporate");
const [progress, setP] = useState(null);
const [error, setError] = useState("");
const submit = async (e) => {
e.preventDefault();
if (!file) { setError("Select a file first."); return; }
setP("uploading"); setError("");
try {
const result = await TC_API.uploadDocument(file, docType, category, null, "");
setP("done");
setTimeout(() => onUploaded(result), 1200);
} catch (err) {
setP("error");
setError(err.message || String(err));
}
};
return (
);
}
function DocumentsPage() {
const docsQ = useFetch(() => TC_API.getDocuments(), []);
const [showUpload, setShowUpload] = useState(false);
const docs = useMemo(() => docsQ.data || [], [docsQ.data]);
const fmtSize = (bytes) => {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes/1024).toFixed(1)} KB`;
return `${(bytes/1048576).toFixed(1)} MB`;
};
return (
Documents on file
{docs.length} document{docs.length !== 1 ? "s" : ""}
{!showUpload && (
)}
{showUpload && (
setShowUpload(false)}
onUploaded={() => { setShowUpload(false); docsQ.refetch(); }}
/>
)}
{docsQ.loading && !docs.length
?
: docsQ.error
?
: docs.length === 0
? No documents on file. Upload your first document above.
: (
| Document |
Category |
Uploaded |
By |
Size |
|
{docs.map(d => (
|
{d.doc_type || "Document"}
{d.file_name}
|
{d.category} |
{fmtDate(d.uploaded_at)} |
{d.uploaded_by || "—"} |
{fmtSize(d.file_size)} |
{d.has_file && (
Download
)}
|
))}
)
}
{docs.some(d => d.extracted_data && Object.keys(d.extracted_data).length > 0) && (
Extracted data
structured data from uploaded documents
{docs.filter(d => d.extracted_data && Object.keys(d.extracted_data).length > 0).map(d => (
{d.doc_type}
{d.file_name}
{Object.entries(d.extracted_data).slice(0,12).map(([k,v]) => (
{k.replace(/_/g," ")}
{String(v).slice(0,80)}
))}
))}
)}
);
}
window.TC_PAGES_A = { OverviewPage, TransactionsPage, BeneficiariesPage, DocumentsPage };