import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { api, WRITER_ROLES, type EntityType } from "../api"; import { useAuth } from "../context/AuthContext"; import { formatMoney, formatGainLoss } from "../format"; interface EntityRow { id: number; name: string; type: string; vintage_year: number | null; fund_size_cents: number | null; status: string; committedCents: number; investedCents: number; lastValueCents: number; } const TYPE_LABELS: Record = { fund: "Fund", spv: "SPV", gp: "GP", mgmt_co: "Mgmt Co", carry: "Carry Vehicle", }; export default function EntitiesList() { const { user } = useAuth(); const [entities, setEntities] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const isWriter = user && WRITER_ROLES.includes(user.role); useEffect(() => { loadData(); }, []); async function loadData() { setLoading(true); try { const rollup = await api.listEntityRollup(); setEntities( rollup.map((r) => ({ id: r.id, name: r.name, type: r.type, vintage_year: r.vintage_year, fund_size_cents: r.fund_size_cents, status: r.status, committedCents: r.committed_cents, investedCents: r.invested_cents, lastValueCents: r.last_signed_value_cents, })), ); } finally { setLoading(false); } } if (loading) { return
Loading...
; } const funds = entities.filter((e) => e.type === "fund" || e.type === "spv"); const gps = entities.filter( (e) => e.type === "gp" || e.type === "mgmt_co" || e.type === "carry", ); return (

Entities

{isWriter && ( )}
{showForm && ( setShowForm(false)} onCreated={() => { setShowForm(false); loadData(); }} /> )}
); } function AddEntityForm({ onClose, onCreated, }: { onClose: () => void; onCreated: () => void; }) { const [name, setName] = useState(""); const [type, setType] = useState("fund"); const [vintageYear, setVintageYear] = useState(""); const [fundSizeDollars, setFundSizeDollars] = useState(""); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!name.trim()) { setError("Name is required."); return; } setError(""); setSaving(true); try { const data: any = { name: name.trim(), type }; if (vintageYear) { const vy = parseInt(vintageYear); if (isNaN(vy)) { setError("Vintage year must be a number."); setSaving(false); return; } data.vintage_year = vy; } if (fundSizeDollars) { const dollars = parseFloat(fundSizeDollars.replace(/[,$]/g, "")); if (isNaN(dollars)) { setError("Fund size must be a number."); setSaving(false); return; } data.fund_size_cents = Math.round(dollars * 100); } await api.createEntity(data); onCreated(); } catch (err: any) { setError(err.message || "Failed to create entity"); } finally { setSaving(false); } } return (

New Entity

setName(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent" placeholder="Low Time Preference Fund I, LLC" required />
setVintageYear(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded text-sm" placeholder="2021" />
setFundSizeDollars(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded text-sm" placeholder="3,300,000" />
{error && (

{error}

)}
); } type SortKey = "name" | "type" | "vintage" | "committed" | "invested" | "value" | "gain"; function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) { const [sortKey, setSortKey] = useState("name"); const [dir, setDir] = useState<1 | -1>(1); const totalCommitted = rows.reduce((s, r) => s + r.committedCents, 0); 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 sorted = useMemo(() => { const val = (r: EntityRow): string | number => { switch (sortKey) { case "name": return r.name.toLowerCase(); case "type": return r.type; case "vintage": return r.vintage_year ?? -Infinity; case "committed": return r.committedCents; case "invested": return r.investedCents; case "value": return r.lastValueCents; case "gain": return r.lastValueCents - r.investedCents; } }; return [...rows].sort((a, b) => { const av = val(a), bv = val(b); if (av < bv) return -1 * dir; if (av > bv) return 1 * dir; return 0; }); }, [rows, sortKey, dir]); const sortBy = (key: SortKey) => { if (key === sortKey) setDir((d) => (d === 1 ? -1 : 1)); else { setSortKey(key); setDir(1); } }; const arrow = (key: SortKey) => (sortKey === key ? (dir === 1 ? " ↑" : " ↓") : ""); const Th = ({ k, label, align = "left" }: { k: SortKey; label: string; align?: "left" | "right" }) => { const a = align === "right" ? "text-right" : "text-left"; return ( ); }; return (

{title}

{/* Mobile: stacked cards (a wide financial table doesn't fit a phone) */}
{sorted.map((row) => { const gl = formatGainLoss(row.lastValueCents, row.investedCents); return (
{row.name} {TYPE_LABELS[row.type] || row.type}
{row.vintage_year && (
Vintage {row.vintage_year}
)}
); })} {rows.length > 0 && (
Total
= 0 ? "+" : "-"}${formatMoney(Math.abs(totalGain))}`} valueClass={totalGain >= 0 ? "text-green-600" : "text-red-600"} />
)} {rows.length === 0 && (

No entities yet.

)}
{/* Desktop: full sortable table */}
{sorted.map((row) => { const gl = formatGainLoss(row.lastValueCents, row.investedCents); return ( ); })} {rows.length > 0 && ( )} {rows.length === 0 && ( )}
{row.name} {TYPE_LABELS[row.type] || row.type} {row.vintage_year || "—"} {formatMoney(row.committedCents)} {formatMoney(row.investedCents)} {formatMoney(row.lastValueCents)} {gl.positive ? "+" : "-"}{gl.text}
Total {formatMoney(totalCommitted)} {formatMoney(totalInvested)} {formatMoney(totalValue)} = 0 ? "text-green-600" : "text-red-600"}`}> {totalGain >= 0 ? "+" : "-"}{formatMoney(Math.abs(totalGain))}
No entities yet.
); } function CardMetric({ label, value, valueClass = "text-gray-900", }: { label: string; value: string; valueClass?: string; }) { return (
{label}
{value}
); }