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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user