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
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useState } from "react";
import {
api,
type DocumentCategory,
type Entity,
type PortalDocument,
type User,
} from "../api";
import { categoryLabel, formatBytes, formatDate } from "../format";
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
export default function FundAdminHome() {
const [entities, setEntities] = useState<Entity[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.listEntities()
.then(setEntities)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p className="text-gray-500 text-sm">Loading</p>;
if (error) return <p className="text-red-600 text-sm">{error}</p>;
if (entities.length === 0)
return <p className="text-gray-500 text-sm">You don't have access to any entities yet.</p>;
return (
<div className="space-y-8">
{entities.map((e) => (
<EntityDocs key={e.id} entity={e} />
))}
</div>
);
}
function EntityDocs({ entity }: { entity: Entity }) {
const [docs, setDocs] = useState<PortalDocument[]>([]);
const [investors, setInvestors] = useState<User[]>([]);
const [error, setError] = useState("");
const load = () => {
api.listDocuments({ entity_id: entity.id }).then(setDocs).catch((e) => setError(e.message));
};
useEffect(() => {
load();
api.investorsForEntity(entity.id).then(setInvestors).catch(() => {});
}, [entity.id]);
const investorName = (id: number | null) =>
id == null ? "Shared (all investors)" : investors.find((i) => i.id === id)?.name ?? `Investor ${id}`;
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>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
<UploadForm entity={entity} investors={investors} onUploaded={load} setError={setError} />
<h3 className="text-xs font-medium text-gray-500 uppercase mt-5 mb-2">Documents</h3>
{docs.length === 0 ? (
<p className="text-sm text-gray-400">No documents yet.</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-2 text-xs text-gray-400">· {investorName(d.investor_user_id)}</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>
)}
</section>
);
}
function UploadForm({
entity,
investors,
onUploaded,
setError,
}: {
entity: Entity;
investors: User[];
onUploaded: () => void;
setError: (s: string) => void;
}) {
const [category, setCategory] = useState<DocumentCategory>("statement");
const [investorId, setInvestorId] = useState<number | "">("");
const [title, setTitle] = useState("");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!file) return;
setError("");
setBusy(true);
try {
const form = new FormData();
form.set("entity_id", String(entity.id));
form.set("category", category);
if (title) form.set("title", title);
if (investorId !== "") form.set("investor_user_id", String(investorId));
form.set("file", file);
await api.uploadDocument(form);
setTitle("");
setFile(null);
setInvestorId("");
onUploaded();
} catch (e: any) {
setError(e.message || "Upload failed");
} finally {
setBusy(false);
}
};
return (
<div className="mt-3 bg-gray-50 border border-gray-200 rounded p-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Category</label>
<select className={inputCls} value={category} onChange={(e) => setCategory(e.target.value as DocumentCategory)}>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Visibility</label>
<select
className={inputCls}
value={investorId}
onChange={(e) => setInvestorId(e.target.value === "" ? "" : Number(e.target.value))}
>
<option value="">Shared all investors</option>
{investors.map((u) => (
<option key={u.id} value={u.id}>
Private {u.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Title (optional)</label>
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">File</label>
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} className="text-sm" />
{file && <span className="text-xs text-gray-400 ml-2">{formatBytes(file.size)}</span>}
</div>
</div>
<div className="flex justify-end mt-2">
<button
onClick={submit}
disabled={busy || !file}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
>
{busy ? "Uploading…" : "Upload"}
</button>
</div>
</div>
);
}
const inputCls =
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";