0.2.24: household-aware GP asset balances + show on Overview
Fixes a linked GP entity showing an empty Assets tab when its capital is
held under the linked account's other legal names (the eNAV often splits
one LLC across names).
- New GET /api/entities/{id}/asset-balances: household-aware, returns the
linked account's (and its linked names') capital balances per fund, plus
the linked account name for a clear empty state.
- Assets tab uses it; shows "linked to X but no balances on file" instead
of a blank table when the wrong account is linked.
- GP/mgmt Overview now surfaces the total linked balance across funds with
a "View by fund" link, so assets are visible without opening the tab.
Verified: 15/15 backend tests (incl. household case); frontend tsc + vite
build clean.
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, type CapitalAccount, type Entity } from "../api";
|
||||
import { api, type AssetBalances, type CapitalAccount, type Entity } from "../api";
|
||||
import { formatDate, formatMoney } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
|
||||
// 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).
|
||||
// pulled live from the linked investor account (household-aware).
|
||||
export default function EntityAssets() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [accounts, setAccounts] = useState<CapitalAccount[]>([]);
|
||||
const [data, setData] = useState<AssetBalances | null>(null);
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
@@ -25,18 +25,14 @@ export default function EntityAssets() {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ent = await api.getEntity(entityId);
|
||||
const [ent, bal, ents] = await Promise.all([
|
||||
api.getEntity(entityId),
|
||||
api.entityAssetBalances(entityId),
|
||||
api.listEntities(),
|
||||
]);
|
||||
setEntity(ent);
|
||||
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([]);
|
||||
}
|
||||
setData(bal);
|
||||
setEntities(ents);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to load");
|
||||
} finally {
|
||||
@@ -46,24 +42,24 @@ export default function EntityAssets() {
|
||||
|
||||
const fundName = useMemo(() => {
|
||||
const m = new Map(entities.map((e) => [e.id, e.name]));
|
||||
return (id: number) => m.get(id) ?? `Fund ${id}`;
|
||||
return (fid: number) => m.get(fid) ?? `Fund ${fid}`;
|
||||
}, [entities]);
|
||||
|
||||
// Latest statement per fund for the linked account.
|
||||
// Latest statement per fund across the linked household.
|
||||
const rows = useMemo(() => {
|
||||
const byFund = new Map<number, CapitalAccount[]>();
|
||||
for (const a of accounts) {
|
||||
for (const a of data?.balances ?? []) {
|
||||
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 };
|
||||
})
|
||||
.map(([fundId, accts]) => ({
|
||||
fundId,
|
||||
latest: [...accts].sort((a, b) => b.as_of_date.localeCompare(a.as_of_date))[0],
|
||||
}))
|
||||
.sort((a, b) => b.latest.ending_balance_cents - a.latest.ending_balance_cents);
|
||||
}, [accounts]);
|
||||
}, [data]);
|
||||
|
||||
const total = rows.reduce((s, r) => s + r.latest.ending_balance_cents, 0);
|
||||
|
||||
@@ -78,9 +74,7 @@ export default function EntityAssets() {
|
||||
|
||||
{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>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">
|
||||
@@ -90,6 +84,17 @@ export default function EntityAssets() {
|
||||
appear here automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 text-sm text-gray-600">
|
||||
<p>
|
||||
Linked to <span className="font-medium">{data?.linked_name ?? "an account"}</span>, but
|
||||
that account has no capital-account balances on file.
|
||||
</p>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Confirm you linked the account that appears in the eNAV (Overview → Edit entity), and
|
||||
that its capital accounts have been imported.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -118,19 +123,10 @@ export default function EntityAssets() {
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
</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 User, type ValuationRound } from "../api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type AssetBalances, 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";
|
||||
@@ -20,11 +20,24 @@ export default function EntityOverview() {
|
||||
const [totalInvested, setTotalInvested] = useState(0);
|
||||
const [lastValue, setLastValue] = useState(0);
|
||||
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
|
||||
const [assetBalances, setAssetBalances] = useState<AssetBalances | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
|
||||
// For a GP/mgmt entity linked to an investor account, total its balance across funds.
|
||||
const linkedTotal = useMemo(() => {
|
||||
const byFund = new Map<number, { as_of: string; bal: number }>();
|
||||
for (const a of assetBalances?.balances ?? []) {
|
||||
const cur = byFund.get(a.entity_id);
|
||||
if (!cur || a.as_of_date > cur.as_of) byFund.set(a.entity_id, { as_of: a.as_of_date, bal: a.ending_balance_cents });
|
||||
}
|
||||
let total = 0;
|
||||
byFund.forEach((v) => { total += v.bal; });
|
||||
return { total, funds: byFund.size };
|
||||
}, [assetBalances]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
loadData(parseInt(id));
|
||||
@@ -36,6 +49,10 @@ export default function EntityOverview() {
|
||||
const ent = await api.getEntity(entityId);
|
||||
setEntity(ent);
|
||||
|
||||
if ((ent.type === "gp" || ent.type === "mgmt_co") && ent.linked_user_id != null) {
|
||||
api.entityAssetBalances(entityId).then(setAssetBalances).catch(() => {});
|
||||
}
|
||||
|
||||
const holds = await api.listHoldings(entityId);
|
||||
setHoldings(holds);
|
||||
|
||||
@@ -112,6 +129,25 @@ export default function EntityOverview() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Linked fund balances (GP / mgmt entities) */}
|
||||
{assetBalances && linkedTotal.funds > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">Capital balance across funds (linked account)</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{formatMoney(linkedTotal.total)}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{linkedTotal.funds} fund{linkedTotal.funds === 1 ? "" : "s"}
|
||||
{assetBalances.linked_name ? ` · ${assetBalances.linked_name}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/entities/${entity.id}/assets`} className="text-sm text-orange-600 hover:text-orange-700">
|
||||
View by fund →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current quarter status */}
|
||||
{latestRound && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
|
||||
Reference in New Issue
Block a user