Issue 1: repo scaffold and project structure
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type Entity, type Holding, type Position, type ValuationRound } from "../api";
|
||||
import { formatMoney, formatGainLoss } from "../format";
|
||||
|
||||
interface EntityRow extends Entity {
|
||||
investedCents: number;
|
||||
lastValueCents: number;
|
||||
}
|
||||
|
||||
export default function EntitiesList() {
|
||||
const [entities, setEntities] = useState<EntityRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ents = await api.listEntities();
|
||||
const enriched: EntityRow[] = [];
|
||||
|
||||
for (const ent of ents) {
|
||||
const holdings = await api.listHoldings(ent.id);
|
||||
let investedCents = 0;
|
||||
let lastValueCents = 0;
|
||||
|
||||
// Get all positions for cost
|
||||
for (const h of holdings) {
|
||||
const positions = await api.listPositions(h.id);
|
||||
for (const p of positions) {
|
||||
investedCents += p.cost_cents;
|
||||
}
|
||||
}
|
||||
|
||||
// Get latest approved round for value
|
||||
const rounds = await api.listRounds(ent.id);
|
||||
const approved = rounds.filter((r) => r.status === "approved");
|
||||
if (approved.length > 0) {
|
||||
const latest = approved[0]; // Already sorted desc by quarter_end
|
||||
lastValueCents = latest.valuations.reduce((sum, v) => sum + v.value_cents, 0);
|
||||
}
|
||||
|
||||
enriched.push({ ...ent, investedCents, lastValueCents });
|
||||
}
|
||||
|
||||
setEntities(enriched);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-500 text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const funds = entities.filter((e) => e.type === "fund" || e.type === "spv");
|
||||
const gps = entities.filter((e) => e.type === "gp" || e.type === "mgmt_co");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<EntityTable title="Funds and SPVs" rows={funds} />
|
||||
<EntityTable title="GP Entities and Management Companies" rows={gps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
|
||||
const totalInvested = rows.reduce((s, r) => s + r.investedCents, 0);
|
||||
const totalValue = rows.reduce((s, r) => s + r.lastValueCents, 0);
|
||||
const totalGain = totalValue - totalInvested;
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
fund: "Fund",
|
||||
spv: "SPV",
|
||||
gp: "GP",
|
||||
mgmt_co: "Mgmt Co",
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">{title}</h2>
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Type</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Vintage</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Invested</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Last Signed Value</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Gain/Loss</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const gl = formatGainLoss(row.lastValueCents, row.investedCents);
|
||||
return (
|
||||
<tr key={row.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/entities/${row.id}`}
|
||||
className="text-gray-900 font-medium hover:text-orange-600"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-block px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
{TYPE_LABELS[row.type] || row.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{row.vintage_year || "—"}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-900">{formatMoney(row.investedCents)}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-900">{formatMoney(row.lastValueCents)}</td>
|
||||
<td className={`px-4 py-3 text-right font-medium ${gl.positive ? "text-green-600" : "text-red-600"}`}>
|
||||
{gl.positive ? "+" : "-"}{gl.text}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{rows.length > 0 && (
|
||||
<tr className="bg-gray-50 font-medium">
|
||||
<td className="px-4 py-3 text-gray-900" colSpan={3}>
|
||||
Total
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-900">{formatMoney(totalInvested)}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-900">{formatMoney(totalValue)}</td>
|
||||
<td className={`px-4 py-3 text-right ${totalGain >= 0 ? "text-green-600" : "text-red-600"}`}>
|
||||
{totalGain >= 0 ? "+" : "-"}{formatMoney(Math.abs(totalGain))}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-4 py-6 text-center text-gray-400" colSpan={6}>
|
||||
No entities yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user