Release 0.2.22: capital chart, Investor View, GP stakes, doc folders

Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.

New features:
- Investor capital-over-time chart (value, paid-in, distributions per
  quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
  (GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
  explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
  they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
  categorized correctly.

Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
This commit is contained in:
Jonathan Kirkwood
2026-07-01 14:25:50 -05:00
parent 7fc78d7058
commit f0f8fd15c6
69 changed files with 5492 additions and 740 deletions
+188
View File
@@ -0,0 +1,188 @@
import { useMemo } 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,
}));
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">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Capital over time</h3>
<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>
);
}
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>
);
}