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,91 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, type Entity, type PortalDocument, type User } from "../api";
|
||||
import { categoryLabel, formatDate } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
|
||||
export default function EntityDocuments() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||
|
||||
const loadDocs = (eid: number) =>
|
||||
api.listDocuments({ entity_id: eid }).then(setDocs).catch((e) => setError(e.message));
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const eid = parseInt(id);
|
||||
api.getEntity(eid).then(setEntity).catch((e) => setError(e.message));
|
||||
api.listUsers().then(setUsers).catch(() => {});
|
||||
loadDocs(eid).finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
||||
const eid = entity.id!;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EntityHeader entity={entity} active="documents" />
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
All documents for this fund. As admin you see everything; each investor only sees documents
|
||||
shared to the fund or addressed to them. Upload from the Documents admin screen.
|
||||
</p>
|
||||
|
||||
<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">Title</th>
|
||||
<th className="px-4 py-2 font-medium">Category</th>
|
||||
<th className="px-4 py-2 font-medium">Visibility</th>
|
||||
<th className="px-4 py-2 font-medium">Uploaded</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((d) => (
|
||||
<tr key={d.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">{d.title}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{categoryLabel(d.category)}</td>
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{d.investor_user_id == null
|
||||
? "Shared (all investors)"
|
||||
: `Private · ${userById.get(d.investor_user_id)?.name ?? d.investor_user_id}`}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
|
||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700 mr-3">
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${d.title}"?`))
|
||||
api.deleteDocument(d.id).then(() => loadDocs(eid)).catch((e) => setError(e.message));
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{docs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-6 text-center text-gray-400">
|
||||
No documents for this fund yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user