Files
Ten31-Portal/frontend/src/portal/InvestorPortalView.tsx
T
Jonathan KirkwoodandClaude Opus 4.8 099459b2f3 0.2.25: batch historical eNAV backfill + collapsible investor chart
Add POST /api/import/capital-accounts/batch: upload several of a fund's
eNAV workbooks at once; each file's ALLOC SI roster is auto-matched to
existing members (by fund-admin investor ID, else name/username) and their
capital statement is saved at that file's own as-of date, building
trend-lines without replacing the latest figures. Members not already in
the portal are skipped and reported per file (never created). Capital
statements only -- holdings/NAV are untouched. One bad file (wrong
password, no ALLOC SI, unreadable date) is reported per-file and does not
abort the rest.

Import page gains a "Backfill historical capital" batch section (fund
picker, multi-file .xlsx input, shared password, per-file results table).

Investor portal "Capital over time" chart is now collapsed by default and
expands per fund (first login opens clean); applies to InvestorHome and
the admin Investor View via the shared component.

Tests: backend/tests/test_capital_batch.py (2). Full suite 17 passed;
frontend tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:47:01 -05:00

206 lines
7.2 KiB
TypeScript

import { useMemo, useState } from "react";
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
import { categoryLabel, formatDate, formatMoneyExact } from "../format";
import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
// The investor-facing portal, rendered purely from data. Used by the investor's own home
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
export default function InvestorPortalView({
entities,
accounts,
docs,
}: {
entities: Entity[];
accounts: CapitalAccount[];
docs: PortalDocument[];
}) {
if (entities.length === 0) {
return (
<p className="text-gray-500 text-sm">
No fund access yet.
</p>
);
}
// When this login covers several legal names (e.g. an IRA and a trust), label each block.
const showNames = new Set(accounts.map((a) => a.investor_user_id)).size > 1;
return (
<div className="space-y-8">
{entities.map((e) => (
<FundSection
key={e.id}
entity={e}
accounts={accounts.filter((a) => a.entity_id === e.id)}
docs={docs.filter((d) => d.entity_id === e.id)}
showNames={showNames}
/>
))}
</div>
);
}
function FundSection({
entity,
accounts,
docs,
showNames,
}: {
entity: Entity;
accounts: CapitalAccount[];
docs: PortalDocument[];
showNames: boolean;
}) {
const byName = useMemo(() => {
const groups = new Map<number, CapitalAccount[]>();
for (const a of accounts) {
const g = groups.get(a.investor_user_id) ?? [];
g.push(a);
groups.set(a.investor_user_id, g);
}
return [...groups.values()];
}, [accounts]);
return (
<section className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-baseline justify-between">
<h2 className="text-lg font-semibold text-gray-900">{entity.name}</h2>
<span className="text-xs text-gray-400 uppercase">{entity.type}</span>
</div>
{byName.length === 0 ? (
<p className="mt-3 text-sm text-gray-400">No capital account statement on file yet.</p>
) : (
byName.map((group, i) => (
<CapitalBlock
key={group[0].investor_user_id}
accounts={group}
label={showNames ? group[0].investor_name : null}
divider={i > 0}
/>
))
)}
<div className="mt-5">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Documents</h3>
{docs.length === 0 ? (
<p className="text-sm text-gray-400">No documents available.</p>
) : (
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
{docs.map((d) => (
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
<span className="text-gray-900">{d.title}</span>
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
Download
</a>
</li>
))}
</ul>
)}
</div>
</section>
);
}
function CapitalBlock({
accounts,
label,
divider,
}: {
accounts: CapitalAccount[];
label: string | null;
divider: boolean;
}) {
// accounts arrive newest-first; history is oldest-first for the chart/table.
const latest = accounts[0];
const history = useMemo(
() => [...accounts].sort((a, b) => a.as_of_date.localeCompare(b.as_of_date)),
[accounts],
);
const chartPoints: CapitalPoint[] = history.map((a) => ({
date: a.as_of_date,
value: a.ending_balance_cents,
paidIn: a.contributions_cents,
distributions: a.distributions_cents,
}));
// Collapsed by default so the portal opens clean; the investor expands the trend per fund.
const [showChart, setShowChart] = useState(false);
return (
<div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}>
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
<p className="text-sm text-gray-500 mt-1">As of {formatDate(latest.as_of_date)}</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
<Metric label="Commitment" value={formatMoneyExact(latest.commitment_cents)} />
<Metric label="Paid-in" value={formatMoneyExact(latest.contributions_cents)} />
{latest.distributions_cents > 0 && (
<Metric label="Distributions" value={formatMoneyExact(latest.distributions_cents)} />
)}
{latest.distributions_cents > 0 && latest.contributions_cents > 0 && (
<Metric
label="DPI"
value={(latest.distributions_cents / latest.contributions_cents).toFixed(2) + "x"}
/>
)}
</div>
<p className="text-sm text-gray-500 mt-4">Current capital value</p>
<p className="text-3xl font-semibold text-gray-900 mt-0.5">
{formatMoneyExact(latest.ending_balance_cents)}
</p>
{history.length > 1 && (
<div className="mt-5">
<button
type="button"
onClick={() => setShowChart((v) => !v)}
aria-expanded={showChart}
className="flex items-center gap-1.5 text-xs font-medium text-gray-500 uppercase hover:text-gray-700"
>
<span className={`transition-transform ${showChart ? "rotate-90" : ""}`}></span>
Capital over time
<span className="normal-case font-normal text-gray-400">
({history.length} quarters{showChart ? "" : " — show"})
</span>
</button>
{showChart && (
<div className="mt-3">
<CapitalChart points={chartPoints} />
<table className="w-full text-sm mt-4">
<thead className="text-gray-400 text-left">
<tr>
<th className="py-1 font-medium">As of</th>
<th className="py-1 font-medium text-right">Paid-in</th>
<th className="py-1 font-medium text-right">Distributions</th>
<th className="py-1 font-medium text-right">Ending balance</th>
</tr>
</thead>
<tbody>
{history.map((a) => (
<tr key={a.id} className="border-t border-gray-100">
<td className="py-1 text-gray-600">{formatDate(a.as_of_date)}</td>
<td className="py-1 text-right text-gray-600">{formatMoneyExact(a.contributions_cents)}</td>
<td className="py-1 text-right text-gray-600">{formatMoneyExact(a.distributions_cents)}</td>
<td className="py-1 text-right text-gray-900">{formatMoneyExact(a.ending_balance_cents)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-gray-400 uppercase">{label}</p>
<p className="text-lg font-medium text-gray-900">{value}</p>
</div>
);
}