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.
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
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";
|