0.2.23: link GP entities to their investor account for auto asset balances
Ten31 LLC (and any GP/mgmt entity) can be linked to its investor account, so its Assets tab shows its real capital-account balance in each fund, pulled live from the eNAV capital accounts instead of manual entry. - entities.linked_user_id (migration c9d0e1f2a3b4) + EntityCreate/Update/ Response fields; validated to be an investor account. - Edit-entity form gains a "Linked investor account" picker for GP/mgmt. - Assets tab now auto-lists the linked account's balance per fund (the earlier manual-stakes API remains but is no longer used by the UI). Verified: 13/13 backend tests pass; alembic head c9d0e1f2a3b4; frontend tsc + vite build clean.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||
// - /api/* is never cached
|
||||
// Bump CACHE on each release so old entries are purged.
|
||||
const CACHE = 'ten31-portal-0.2.22'
|
||||
const CACHE = 'ten31-portal-0.2.23'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface Entity {
|
||||
vintage_year: number | null;
|
||||
fund_size_cents: number | null;
|
||||
status: EntityStatus;
|
||||
linked_user_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type Entity, type EntityStake } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatMoney } from "../format";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, type CapitalAccount, type Entity } from "../api";
|
||||
import { formatDate, formatMoney } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
|
||||
// A GP / management company's assets: its ownership interest in the funds it manages.
|
||||
// A GP / management company's assets: its capital-account balance in each fund it holds,
|
||||
// pulled live from the linked investor account (the same LLC on the eNAV / LP side).
|
||||
export default function EntityAssets() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [stakes, setStakes] = useState<EntityStake[]>([]);
|
||||
const [allEntities, setAllEntities] = useState<Entity[]>([]);
|
||||
const [accounts, setAccounts] = useState<CapitalAccount[]>([]);
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const entityId = id ? parseInt(id) : 0;
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
|
||||
useEffect(() => {
|
||||
if (!entityId) return;
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entityId]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ent, stk, all] = await Promise.all([
|
||||
api.getEntity(entityId),
|
||||
api.listStakes(entityId),
|
||||
api.listEntities(),
|
||||
]);
|
||||
const ent = await api.getEntity(entityId);
|
||||
setEntity(ent);
|
||||
setStakes(stk);
|
||||
setAllEntities(all);
|
||||
if (ent.linked_user_id != null) {
|
||||
const [accts, ents] = await Promise.all([
|
||||
api.listCapitalAccounts({ investor_user_id: ent.linked_user_id }),
|
||||
api.listEntities(),
|
||||
]);
|
||||
setAccounts(accts);
|
||||
setEntities(ents);
|
||||
} else {
|
||||
setAccounts([]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to load");
|
||||
} finally {
|
||||
@@ -42,196 +44,97 @@ export default function EntityAssets() {
|
||||
}
|
||||
}
|
||||
|
||||
// Funds/SPVs available to add (exclude self and ones already staked).
|
||||
const candidates = useMemo(() => {
|
||||
const staked = new Set(stakes.map((s) => s.fund_entity_id));
|
||||
return allEntities.filter(
|
||||
(e) => e.id !== entityId && !staked.has(e.id) && (e.type === "fund" || e.type === "spv"),
|
||||
);
|
||||
}, [allEntities, stakes, entityId]);
|
||||
const fundName = useMemo(() => {
|
||||
const m = new Map(entities.map((e) => [e.id, e.name]));
|
||||
return (id: number) => m.get(id) ?? `Fund ${id}`;
|
||||
}, [entities]);
|
||||
|
||||
const totalValue = stakes.reduce((s, k) => s + (k.value_cents ?? 0), 0);
|
||||
// Latest statement per fund for the linked account.
|
||||
const rows = useMemo(() => {
|
||||
const byFund = new Map<number, CapitalAccount[]>();
|
||||
for (const a of accounts) {
|
||||
const g = byFund.get(a.entity_id) ?? [];
|
||||
g.push(a);
|
||||
byFund.set(a.entity_id, g);
|
||||
}
|
||||
return [...byFund.entries()]
|
||||
.map(([fundId, accts]) => {
|
||||
const latest = [...accts].sort((a, b) => b.as_of_date.localeCompare(a.as_of_date))[0];
|
||||
return { fundId, latest };
|
||||
})
|
||||
.sort((a, b) => b.latest.ending_balance_cents - a.latest.ending_balance_cents);
|
||||
}, [accounts]);
|
||||
|
||||
const total = rows.reduce((s, r) => s + r.latest.ending_balance_cents, 0);
|
||||
|
||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading...</div>;
|
||||
|
||||
async function remove(stakeId: number) {
|
||||
if (!confirm("Remove this stake?")) return;
|
||||
try {
|
||||
await api.deleteStake(entityId, stakeId);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to remove");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EntityHeader entity={entity} active="assets" />
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Fund interests</h2>
|
||||
{isWriter && candidates.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||
>
|
||||
Add stake
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Fund balances</h2>
|
||||
|
||||
{showForm && (
|
||||
<AddStakeForm
|
||||
entityId={entityId}
|
||||
candidates={candidates}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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">Fund / SPV</th>
|
||||
<th className="px-4 py-2 font-medium">Type</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Ownership</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Value</th>
|
||||
<th className="px-4 py-2 font-medium">Note</th>
|
||||
{isWriter && <th className="px-4 py-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stakes.map((s) => (
|
||||
<tr key={s.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">{s.fund_name ?? s.fund_entity_id}</td>
|
||||
<td className="px-4 py-2 text-gray-600 uppercase text-xs">{s.fund_type}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-700">
|
||||
{s.ownership_pct != null ? `${s.ownership_pct}%` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">
|
||||
{s.value_cents != null ? formatMoney(s.value_cents) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{s.note || "—"}</td>
|
||||
{isWriter && (
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button onClick={() => remove(s.id)} className="text-gray-400 hover:text-red-600">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{stakes.length > 0 && (
|
||||
<tr className="bg-gray-50 font-medium">
|
||||
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(totalValue)}</td>
|
||||
<td className="px-4 py-2" colSpan={isWriter ? 2 : 1} />
|
||||
</tr>
|
||||
)}
|
||||
{stakes.length === 0 && (
|
||||
{entity.linked_user_id == null ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 text-sm text-gray-600">
|
||||
<p>
|
||||
This entity isn't linked to an investor account yet, so there are no balances to show.
|
||||
</p>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Link it on the{" "}
|
||||
<Link to={`/entities/${entityId}`} className="text-orange-600 hover:text-orange-700">
|
||||
Overview tab → Edit entity
|
||||
</Link>{" "}
|
||||
(choose its investor account). Its capital-account balance in each fund will then
|
||||
appear here automatically.
|
||||
</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>
|
||||
<td colSpan={isWriter ? 6 : 5} className="px-4 py-6 text-center text-gray-400">
|
||||
No fund interests recorded yet.
|
||||
</td>
|
||||
<th className="px-4 py-2 font-medium">Fund / SPV</th>
|
||||
<th className="px-4 py-2 font-medium">As of</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Commitment</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Balance</th>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddStakeForm({
|
||||
entityId,
|
||||
candidates,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
entityId: number;
|
||||
candidates: Entity[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [fundId, setFundId] = useState<number | "">("");
|
||||
const [pct, setPct] = useState("");
|
||||
const [valueDollars, setValueDollars] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (fundId === "") {
|
||||
setError("Choose a fund.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.createStake(entityId, {
|
||||
fund_entity_id: Number(fundId),
|
||||
ownership_pct: pct ? parseFloat(pct) : null,
|
||||
value_dollars: valueDollars ? parseFloat(valueDollars.replace(/[,$]/g, "")) : null,
|
||||
note: note.trim() || null,
|
||||
});
|
||||
onSaved();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to add stake");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4 max-w-lg">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Add a fund interest</h3>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Fund / SPV</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={fundId}
|
||||
onChange={(e) => setFundId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.fundId} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">
|
||||
<Link to={`/entities/${r.fundId}`} className="hover:text-orange-600">
|
||||
{fundName(r.fundId)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{formatDate(r.latest.as_of_date)}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-600">
|
||||
{formatMoney(r.latest.commitment_cents)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">
|
||||
{formatMoney(r.latest.ending_balance_cents)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length > 0 && (
|
||||
<tr className="bg-gray-50 font-medium">
|
||||
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total balance</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(total)}</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">
|
||||
The linked account has no capital-account balances yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-1">Ownership % (optional)</label>
|
||||
<input className={inputCls} value={pct} onChange={(e) => setPct(e.target.value)} placeholder="20" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-1">Value $ (optional)</label>
|
||||
<input className={inputCls} value={valueDollars} onChange={(e) => setValueDollars(e.target.value)} placeholder="1,000,000" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Note (optional)</label>
|
||||
<input className={inputCls} value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
||||
{saving ? "Adding…" : "Add"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type Entity, type EntityType, type Holding, type ValuationRound } from "../api";
|
||||
import { api, canEditRound, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatMoney, formatDate, formatQuarter } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
@@ -149,10 +149,20 @@ function EditEntityForm({
|
||||
const [fundSizeDollars, setFundSizeDollars] = useState(
|
||||
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
|
||||
);
|
||||
const [linkedUserId, setLinkedUserId] = useState<number | "">(entity.linked_user_id ?? "");
|
||||
const [investors, setInvestors] = useState<User[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||
|
||||
// A GP / management company can be linked to its own investor account, so its Assets view
|
||||
// shows its real per-fund balances.
|
||||
const isGp = type === "gp" || type === "mgmt_co";
|
||||
useEffect(() => {
|
||||
if (!isGp) return;
|
||||
api.listUsers().then((us) => setInvestors(us.filter((u) => u.role === "investor"))).catch(() => {});
|
||||
}, [isGp]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
@@ -170,6 +180,7 @@ function EditEntityForm({
|
||||
fund_size_cents: fundSizeDollars
|
||||
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
|
||||
: null,
|
||||
linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null,
|
||||
};
|
||||
const updated = await api.updateEntity(entity.id, data);
|
||||
onSaved(updated);
|
||||
@@ -216,6 +227,23 @@ function EditEntityForm({
|
||||
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
|
||||
</div>
|
||||
</div>
|
||||
{isGp && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
Linked investor account (its Assets tab shows this account's fund balances)
|
||||
</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={linkedUserId}
|
||||
onChange={(e) => setLinkedUserId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{investors.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.name} ({u.username})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
||||
|
||||
Reference in New Issue
Block a user