Implement adjudicated DO items across backend, frontend, deploy

From the ROADMAP adjudication (12 of 13 DO items; D2 is a commit action).

Backend:
- B3: pytest suite (auth, entity CRUD, rollup) + dev deps + pytest config
- B4: cap document uploads at TEN31_MAX_UPLOAD_SIZE (default 50MB), stream-
  checked with partial-file cleanup, 413 on overflow
- B7: type AuditLog.detail as dict|list|str|None to match the JSON column
- B10: index foreign-key columns (migration a7b8c9d0e1f2 + index=True)
- B11: cli delete-user logs file-removal errors instead of swallowing them

Frontend:
- F2: distinguish "server unreachable" from "logged out"; retry prompt
- F4: confirm before destructive holdings-replace on import; step progress
- F6: expandable audit-log detail with full JSON
- F7: empty-state on the Investments page
- F8: shared role helpers (WRITER_ROLES/canEditRound/isApprover), used by
  EntitiesList, AuditLog, Import, ValuationWorkflow

Deploy:
- D5: run tsc --noEmit before packaging (build script)
- D6: TEN31_LOG_LEVEL env var (defaults to info)

Verified: 8/8 backend tests pass; alembic upgrades to head with 13 FK
indexes; upload limit rejects oversized + cleans up; frontend tsc + vite
build clean; dev server serves and proxies to the API.
This commit is contained in:
Jonathan Kirkwood
2026-07-01 13:33:40 -05:00
parent 77eeb3bd7f
commit 8247c28243
22 changed files with 1757 additions and 465 deletions
+131 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api, type EntityType } from "../api";
import { api, WRITER_ROLES, type EntityType } from "../api";
import { useAuth } from "../context/AuthContext";
import { formatMoney, formatGainLoss } from "../format";
@@ -11,6 +11,7 @@ interface EntityRow {
vintage_year: number | null;
fund_size_cents: number | null;
status: string;
committedCents: number;
investedCents: number;
lastValueCents: number;
}
@@ -22,8 +23,6 @@ const TYPE_LABELS: Record<string, string> = {
mgmt_co: "Mgmt Co",
};
const WRITER_ROLES = ["fund_admin", "cfo", "approver"];
export default function EntitiesList() {
const { user } = useAuth();
const [entities, setEntities] = useState<EntityRow[]>([]);
@@ -48,6 +47,7 @@ export default function EntitiesList() {
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,
})),
@@ -219,28 +219,126 @@ function AddEntityForm({
);
}
type SortKey = "name" | "type" | "vintage" | "committed" | "invested" | "value" | "gain";
function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
const [sortKey, setSortKey] = useState<SortKey>("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 (
<th className={`px-4 py-3 font-medium text-gray-600 select-none ${a}`}>
<button onClick={() => sortBy(k)} className={`w-full hover:text-gray-900 ${a}`}>
{label}<span className="text-orange-500">{arrow(k)}</span>
</button>
</th>
);
};
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">
{/* Mobile: stacked cards (a wide financial table doesn't fit a phone) */}
<div className="md:hidden space-y-3">
{sorted.map((row) => {
const gl = formatGainLoss(row.lastValueCents, row.investedCents);
return (
<Link
key={row.id}
to={`/entities/${row.id}`}
className="block bg-white border border-gray-200 rounded-lg p-4 active:bg-gray-50"
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium text-gray-900">{row.name}</span>
<span className="shrink-0 inline-block px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
{TYPE_LABELS[row.type] || row.type}
</span>
</div>
{row.vintage_year && (
<div className="mt-0.5 text-xs text-gray-400">Vintage {row.vintage_year}</div>
)}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 mt-3">
<CardMetric label="Committed" value={formatMoney(row.committedCents)} />
<CardMetric label="Invested" value={formatMoney(row.investedCents)} />
<CardMetric label="Last value" value={formatMoney(row.lastValueCents)} />
<CardMetric
label="Gain/Loss"
value={`${gl.positive ? "+" : "-"}${gl.text}`}
valueClass={gl.positive ? "text-green-600" : "text-red-600"}
/>
</div>
</Link>
);
})}
{rows.length > 0 && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="text-xs font-medium text-gray-500 uppercase mb-2">Total</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<CardMetric label="Committed" value={formatMoney(totalCommitted)} />
<CardMetric label="Invested" value={formatMoney(totalInvested)} />
<CardMetric label="Last value" value={formatMoney(totalValue)} />
<CardMetric
label="Gain/Loss"
value={`${totalGain >= 0 ? "+" : "-"}${formatMoney(Math.abs(totalGain))}`}
valueClass={totalGain >= 0 ? "text-green-600" : "text-red-600"}
/>
</div>
</div>
)}
{rows.length === 0 && (
<p className="text-sm text-gray-400 px-1">No entities yet.</p>
)}
</div>
{/* Desktop: full sortable table */}
<div className="hidden md:block bg-white border border-gray-200 rounded-lg overflow-x-auto">
<table className="w-full min-w-[46rem] 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>
<Th k="name" label="Name" />
<Th k="type" label="Type" />
<Th k="vintage" label="Vintage" />
<Th k="committed" label="Committed" align="right" />
<Th k="invested" label="Invested" align="right" />
<Th k="value" label="Last Signed Value" align="right" />
<Th k="gain" label="Gain/Loss" align="right" />
</tr>
</thead>
<tbody>
{rows.map((row) => {
{sorted.map((row) => {
const gl = formatGainLoss(row.lastValueCents, row.investedCents);
return (
<tr key={row.id} className="border-b border-gray-100 hover:bg-gray-50">
@@ -258,6 +356,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
</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.committedCents)}</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"}`}>
@@ -271,6 +370,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
<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(totalCommitted)}</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"}`}>
@@ -280,7 +380,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
)}
{rows.length === 0 && (
<tr>
<td className="px-4 py-6 text-center text-gray-400" colSpan={6}>
<td className="px-4 py-6 text-center text-gray-400" colSpan={7}>
No entities yet.
</td>
</tr>
@@ -291,3 +391,20 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
</div>
);
}
function CardMetric({
label,
value,
valueClass = "text-gray-900",
}: {
label: string;
value: string;
valueClass?: string;
}) {
return (
<div>
<div className="text-xs text-gray-400 uppercase">{label}</div>
<div className={`text-sm font-medium ${valueClass}`}>{value}</div>
</div>
);
}