import { useEffect, useState } from "react"; import { api, isAdministrator, isManager, type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult, type BtcPricesStatus, } from "../api"; import { useAuth } from "../context/AuthContext"; import { formatDate, formatMoneyExact } from "../format"; type EntityMode = "existing" | "from_file"; type Decision = { action: "match" | "create" | "skip"; source_name: string; external_id: string | null; matched_username: string | null; user_id: number | null; value_dollars: number; commitment_dollars: number; contributions_dollars: number; distributions_dollars: number; // create fields name: string; username: string; email: string; }; export default function Import() { const { user } = useAuth(); const [entities, setEntities] = useState([]); const [entityMode, setEntityMode] = useState("existing"); const [entityId, setEntityId] = useState(""); const [createType, setCreateType] = useState("fund"); const [vintageYear, setVintageYear] = useState(""); const [password, setPassword] = useState(""); const [file, setFile] = useState(null); const [replaceExisting, setReplaceExisting] = useState(false); const [holdings, setHoldings] = useState<{ count: number; nav: number; sourceName: string | null } | null>(null); const [memberPv, setMemberPv] = useState(null); const [decisions, setDecisions] = useState([]); const [asOf, setAsOf] = useState(""); const [result, setResult] = useState(null); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [step, setStep] = useState(""); const [confirmReplace, setConfirmReplace] = useState(false); const canImport = user && isManager(user.role); // An Administrator imports into their own funds only — no fund creation, and the // portal-wide BTC price table stays internal. const administrator = !!user && isAdministrator(user.role); const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm"; useEffect(() => { api.listEntities().then(setEntities); }, []); function reset() { setHoldings(null); setMemberPv(null); setDecisions([]); setResult(null); setReplaceExisting(false); } async function readEnav() { if (!file) return; if (entityMode === "existing" && !entityId) { setError("Select the fund, or choose 'Create from file'."); return; } setError(""); setBusy(true); setResult(null); try { const eid = entityMode === "existing" ? Number(entityId) : undefined; // Holdings (dry run) + members preview from the same file. const [sched, members] = await Promise.all([ api.scheduleImport(file, { commit: false, entityId: eid, password: password || undefined, createType, vintageYear, }), api.capitalImportPreview(file, eid as number, undefined, password || undefined), ]); const nav = (sched.positions || []).reduce((s: number, p: any) => s + (p.value_cents || 0), 0); setHoldings({ count: sched.positions?.length ?? 0, nav, sourceName: sched.source_entity_name ?? null, }); setAsOf(members.as_of_date ?? sched.seed_round?.quarter_end ?? ""); setMemberPv(members); setDecisions( members.investors.map((i) => ({ action: i.matched_user_id ? "match" : "create", source_name: i.source_name, external_id: i.external_id, matched_username: i.matched_username, user_id: i.matched_user_id, value_dollars: i.value_dollars, commitment_dollars: i.commitment_dollars, contributions_dollars: i.contributions_dollars, distributions_dollars: i.distributions_dollars, name: i.source_name, username: i.suggested_username ?? "", email: "", })), ); } catch (e: any) { setError(e.message || "Could not read the workbook"); reset(); } finally { setBusy(false); } } const update = (idx: number, patch: Partial) => setDecisions((d) => d.map((row, i) => (i === idx ? { ...row, ...patch } : row))); // The "Confirm import" button routes through here so a destructive holdings-replace gets a // final confirmation before anything is cleared. function handleConfirmClick() { if (entityMode === "existing" && replaceExisting) { setConfirmReplace(true); return; } confirmImport(); } async function confirmImport() { if (!file || !asOf) return; setConfirmReplace(false); setError(""); setBusy(true); try { const memberPayload = { as_of_date: asOf, investors: decisions.map((d) => ({ action: d.action, value_dollars: d.value_dollars, commitment_dollars: d.commitment_dollars, contributions_dollars: d.contributions_dollars, distributions_dollars: d.distributions_dollars, user_id: d.action === "match" ? d.user_id : undefined, name: d.action === "create" ? d.name : undefined, username: d.action === "create" ? d.username : undefined, email: d.action === "create" ? d.email || undefined : undefined, external_id: d.external_id || undefined, })), }; let resolvedEntityId: number; let holdingsNote = ""; if (entityMode === "existing") { resolvedEntityId = Number(entityId); // Members first (idempotent), then holdings. setStep("Saving members…"); await api.capitalImportCommit({ entity_id: resolvedEntityId, ...memberPayload }); setStep(replaceExisting ? "Replacing holdings…" : "Loading holdings…"); try { await api.scheduleImport(file, { commit: true, entityId: resolvedEntityId, password: password || undefined, asOf, replaceExisting, }); } catch (e: any) { if (String(e.message).toLowerCase().includes("already exists")) { holdingsNote = "A signed valuation round exists for this quarter — holdings left unchanged."; } else { throw e; } } } else { // Create-from-file: holdings commit creates the fund, then members. setStep("Creating fund and loading holdings…"); const sres = await api.scheduleImport(file, { commit: true, password: password || undefined, asOf, createType, vintageYear, }); resolvedEntityId = sres.entity.entity_id; setStep("Saving members…"); await api.capitalImportCommit({ entity_id: resolvedEntityId, ...memberPayload }); } setResult({ holdings: holdings?.count ?? 0, nav: holdings?.nav ?? 0, members: decisions.filter((d) => d.action !== "skip").length, created: decisions.filter((d) => d.action === "create").length, note: holdingsNote, }); reset(); api.listEntities().then(setEntities); } catch (e: any) { setError(e.message || "Import failed"); } finally { setBusy(false); setStep(""); } } if (!canImport) { return
Import requires an admin role.
; } return (

Import eNAV

Upload the fund administrator's eNAV workbook once. Portal loads the fund's holdings and NAV (HLD tab) and the members with their capital — commitment, paid-in, distributions, and current value (ALLOC SI tab). You confirm new members before anything is saved.

{error &&
{error}
} {result ? (

Import complete.

  • {result.holdings} holdings loaded · NAV {formatMoneyExact(result.nav)}
  • {result.members} members ({result.created} newly created)
  • {result.note &&
  • {result.note}
  • }
) : (
{!administrator && (
)} {entityMode === "existing" ? ( ) : (
setVintageYear(e.target.value)} />
)}
{ setFile(e.target.files?.[0] ?? null); reset(); }} className="text-sm" />
{ setPassword(e.target.value); reset(); }} />
)} {memberPv && !result && (
{/* Holdings summary */}
Holdings: {holdings?.count ?? 0}
Fund NAV: {formatMoneyExact(holdings?.nav ?? 0)}
setAsOf(e.target.value)} />
{entityMode === "existing" && ( )} {/* Members review */}
{decisions.map((d, i) => ( ))}
Member Commitment Paid-in Distrib. Value Action New account
{d.source_name} {formatMoneyExact(Math.round(d.commitment_dollars * 100))} {formatMoneyExact(Math.round(d.contributions_dollars * 100))} {formatMoneyExact(Math.round(d.distributions_dollars * 100))} {formatMoneyExact(Math.round(d.value_dollars * 100))} {d.action === "create" ? (
update(i, { username: e.target.value })} /> no login until set
) : d.action === "match" ? ( updates {d.matched_username} ) : ( ignored )}
{decisions.filter((d) => d.action === "create").length} new ·{" "} {decisions.filter((d) => d.action === "match").length} matched ·{" "} {decisions.filter((d) => d.action === "skip").length} skipped
)} {!memberPv && !result && } {!memberPv && !result && !administrator && } {confirmReplace && (

Replace existing holdings?

This will permanently clear this fund's current holdings, positions, and valuation history, then rebuild them from this file. Investor capital accounts are not affected. This can't be undone.

)}
); } // Backfill several past quarters of investor capital in one shot. Each eNAV file's ALLOC SI // roster is auto-matched to existing members and their statement is written at the file's own // as-of date, so older files add trend-line history without disturbing the latest figures. // Members not already in the system are skipped (never created here) and reported per file. function BatchBackfill({ entities }: { entities: Entity[] }) { const [entityId, setEntityId] = useState(""); const [files, setFiles] = useState([]); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [result, setResult] = useState(null); const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm"; async function run() { if (!entityId || files.length === 0) { setError("Pick a fund and at least one eNAV file."); return; } setError(""); setResult(null); setBusy(true); try { setResult(await api.capitalImportBatch(Number(entityId), files, password || undefined)); } catch (e: any) { setError(e.message || "Batch import failed"); } finally { setBusy(false); } } return (

Backfill historical capital

Load several past quarters at once to build investors' trend-lines. Drop the eNAV workbooks for one fund; each file's members are matched to existing accounts and their capital statement is saved at that file's own as-of date. The latest figures are never replaced, and members not already in the portal are skipped (not created).

{error &&
{error}
}
{ setFiles(Array.from(e.target.files ?? [])); setResult(null); }} className="text-sm" /> {files.length > 0 &&

{files.length} file(s) selected

}
setPassword(e.target.value)} />
{result && (
{result.total_statements} statement(s) written across {result.files.length} file(s)
{result.files.map((f, i) => ( ))}
File As-of Loaded Skipped (no account)
{f.filename} {f.as_of_date ? formatDate(f.as_of_date) : "—"} {f.error ? "—" : f.statements_written} {f.error ? ( {f.error} ) : f.skipped.length === 0 ? ( none ) : ( {f.skipped.length}: {f.skipped.slice(0, 3).join(", ")} {f.skipped.length > 3 ? "…" : ""} )}
)}
); } // BTC/USD price history for the bitcoin-denominated LP view: upload a CSV (date + close // price); statements are valued at the newest price on or before their date, and paid-in // at the price on each fund's close date (set on the fund's overview page). function BtcPrices() { const [status, setStatus] = useState(null); const [file, setFile] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [msg, setMsg] = useState(""); useEffect(() => { api.btcPricesStatus().then(setStatus).catch(() => {}); }, []); async function run() { if (!file) return; setError(""); setMsg(""); setBusy(true); try { const r = await api.importBtcPrices(file); setStatus(r); setMsg( `Loaded ${r.imported} price${r.imported === 1 ? "" : "s"}` + (r.skipped_rows ? ` (${r.skipped_rows} unreadable row${r.skipped_rows === 1 ? "" : "s"} skipped)` : "") + ".", ); setFile(null); } catch (e: any) { setError(e.message || "Price import failed"); } finally { setBusy(false); } } return (

Bitcoin prices

Upload a CSV of BTC/USD prices (a date column and a close/price column). Investors then see their capital in bitcoin terms; paid-in is valued at the price on each fund's close date — set that on the fund's overview page.

{status && status.count > 0 && (

{status.count} prices on file {status.first_date && status.last_date && ( <> · {formatDate(status.first_date)} → {formatDate(status.last_date)} )} {status.latest_price_cents != null && ( <> · latest ${(status.latest_price_cents / 100).toLocaleString()} )}

)}
setFile(e.target.files?.[0] ?? null)} className="text-sm" />
{error &&

{error}

} {msg &&

{msg}

}
); }