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>
206 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|