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
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { api, type Entity, type Partner } from "../api";
import { formatDate, formatMoneyExact } from "../format";
import EntityHeader from "../components/EntityHeader";
export default function EntityPartners() {
const { id } = useParams<{ id: string }>();
const [entity, setEntity] = useState<Entity | null>(null);
const [partners, setPartners] = useState<Partner[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
const eid = parseInt(id);
Promise.all([api.getEntity(eid), api.listPartners(eid)])
.then(([e, p]) => {
setEntity(e);
setPartners(p);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading</div>;
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
const totalCapital = partners.reduce((s, p) => s + (p.latest_value_cents || 0), 0);
return (
<div>
<EntityHeader entity={entity} active="partners" />
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-gray-500">
{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
</p>
<p className="text-sm text-gray-700">
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
<span className="mx-2 text-gray-300">·</span>
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
</p>
</div>
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<th className="px-4 py-2 font-medium">Member</th>
<th className="px-4 py-2 font-medium">Investor ID</th>
<th className="px-4 py-2 font-medium">Login</th>
<th className="px-4 py-2 font-medium">As of</th>
<th className="px-4 py-2 font-medium text-right">Committed</th>
<th className="px-4 py-2 font-medium text-right">Paid-in</th>
<th className="px-4 py-2 font-medium text-right">Distributions</th>
<th className="px-4 py-2 font-medium text-right">Capital value</th>
</tr>
</thead>
<tbody>
{partners.map((p) => (
<tr key={p.user_id} className="border-t border-gray-100">
<td className="px-4 py-2">
<div className="text-gray-900">{p.name}</div>
<div className="text-xs text-gray-400">{p.username}</div>
</td>
<td className="px-4 py-2 text-gray-600">{p.external_investor_id ?? "—"}</td>
<td className="px-4 py-2">
{!p.is_active ? (
<span className="text-gray-400">Disabled</span>
) : p.login_enabled ? (
<span className="text-green-600">Active</span>
) : (
<span className="text-amber-600">No login yet</span>
)}
</td>
<td className="px-4 py-2 text-gray-500">
{p.latest_as_of ? formatDate(p.latest_as_of) : "—"}
</td>
<td className="px-4 py-2 text-right text-gray-900">
{p.latest_commitment_cents != null ? formatMoneyExact(p.latest_commitment_cents) : "—"}
</td>
<td className="px-4 py-2 text-right text-gray-600">
{p.latest_contributions_cents != null ? formatMoneyExact(p.latest_contributions_cents) : "—"}
</td>
<td className="px-4 py-2 text-right text-gray-600">
{p.latest_distributions_cents != null ? formatMoneyExact(p.latest_distributions_cents) : "—"}
</td>
<td className="px-4 py-2 text-right text-gray-900">
{p.latest_value_cents != null ? formatMoneyExact(p.latest_value_cents) : "—"}
</td>
</tr>
))}
{partners.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-6 text-center text-gray-400">
No members yet. Import the eNAV ALLOC SI tab from Import Statements, or grant
access on the Access Grid.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}