From 7c33693eaba93c720bca35e5a28f384f62201286 Mon Sep 17 00:00:00 2001 From: Johnny 5 Date: Mon, 8 Jun 2026 02:59:31 +0000 Subject: [PATCH] Issue 18: Add Entity form on the entities list - Add Entity button visible only for writer roles - Inline form: name, type (Fund/SPV/GP/Mgmt Co), vintage year, fund size - Fund size entered as dollars, stored as cents - API errors surfaced inline - Refreshes list on success --- frontend/src/pages/EntitiesList.tsx | 174 ++++++++++++++++++++++++++-- 1 file changed, 163 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/EntitiesList.tsx b/frontend/src/pages/EntitiesList.tsx index 9662968..79594d7 100644 --- a/frontend/src/pages/EntitiesList.tsx +++ b/frontend/src/pages/EntitiesList.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { api, type Entity, type Holding, type Position, type ValuationRound } from "../api"; +import { api, type Entity, type EntityType } from "../api"; +import { useAuth } from "../context/AuthContext"; import { formatMoney, formatGainLoss } from "../format"; interface EntityRow extends Entity { @@ -8,9 +9,22 @@ interface EntityRow extends Entity { lastValueCents: number; } +const TYPE_LABELS: Record = { + fund: "Fund", + spv: "SPV", + gp: "GP", + mgmt_co: "Mgmt Co", +}; + +const WRITER_ROLES = ["fund_admin", "cfo", "approver"]; + 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(); @@ -27,7 +41,6 @@ export default function EntitiesList() { 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) { @@ -35,11 +48,10 @@ export default function EntitiesList() { } } - // 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 + const latest = approved[0]; lastValueCents = latest.valuations.reduce((sum, v) => sum + v.value_cents, 0); } @@ -61,24 +73,164 @@ export default function EntitiesList() { 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-orange-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}

+ )} +
+ + +
+
+
+ ); +} + 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 = { - fund: "Fund", - spv: "SPV", - gp: "GP", - mgmt_co: "Mgmt Co", - }; - return (

{title}