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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user