Issues 19+20: entity-aware schedule import and rollup endpoint

Issue 19:
- entity_id now optional on POST /api/import/schedule
- Resolution: explicit entity_id > name match from row 1 > auto-create
- New params: create_entity_type (default fund), create_vintage_year
- Dry-run reports resolution (will_create/matched/existing)
- Frontend: radio toggle between 'Create from file' and 'Use existing'

Issue 20:
- GET /api/entities/rollup: invested_cents and last_signed_value_cents
  per entity in one pass (SQL aggregates, no waterfall)
- EntitiesList uses rollup instead of N+1 API calls
This commit is contained in:
Johnny 5
2026-06-08 03:02:59 +00:00
parent 7c33693eab
commit 30d2284feb
5 changed files with 302 additions and 80 deletions
+21 -28
View File
@@ -1,10 +1,16 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api, type Entity, type EntityType } from "../api";
import { api, type EntityType } from "../api";
import { useAuth } from "../context/AuthContext";
import { formatMoney, formatGainLoss } from "../format";
interface EntityRow extends Entity {
interface EntityRow {
id: number;
name: string;
type: string;
vintage_year: number | null;
fund_size_cents: number | null;
status: string;
investedCents: number;
lastValueCents: number;
}
@@ -33,32 +39,19 @@ export default function EntitiesList() {
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;
for (const h of holdings) {
const positions = await api.listPositions(h.id);
for (const p of positions) {
investedCents += p.cost_cents;
}
}
const rounds = await api.listRounds(ent.id);
const approved = rounds.filter((r) => r.status === "approved");
if (approved.length > 0) {
const latest = approved[0];
lastValueCents = latest.valuations.reduce((sum, v) => sum + v.value_cents, 0);
}
enriched.push({ ...ent, investedCents, lastValueCents });
}
setEntities(enriched);
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,
investedCents: r.invested_cents,
lastValueCents: r.last_signed_value_cents,
})),
);
} finally {
setLoading(false);
}
+112 -18
View File
@@ -1,11 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { api, type Entity } from "../api";
import { api, type Entity, type EntityType } from "../api";
import { useAuth } from "../context/AuthContext";
type EntityMode = "existing" | "from_file";
export default function Import() {
const { user } = useAuth();
const [entities, setEntities] = useState<Entity[]>([]);
const [entityMode, setEntityMode] = useState<EntityMode>("from_file");
const [entityId, setEntityId] = useState("");
const [createType, setCreateType] = useState<EntityType>("fund");
const [vintageYear, setVintageYear] = useState("");
const [asOf, setAsOf] = useState("");
const [mode, setMode] = useState<"entities" | "schedule">("schedule");
const [commit, setCommit] = useState(false);
@@ -38,13 +43,25 @@ export default function Import() {
if (mode === "entities") {
url = `/api/import/entities?${params}`;
} else {
if (!entityId || !asOf) {
setError("Select an entity and quarter-end date.");
if (!asOf) {
setError("Quarter-end date is required.");
setLoading(false);
return;
}
params.set("entity_id", entityId);
params.set("as_of", asOf);
if (entityMode === "existing") {
if (!entityId) {
setError("Select an entity.");
setLoading(false);
return;
}
params.set("entity_id", entityId);
} else {
// from_file mode: let backend resolve from row 1
params.set("create_entity_type", createType);
if (vintageYear) params.set("create_vintage_year", vintageYear);
}
url = `/api/import/schedule?${params}`;
}
@@ -54,6 +71,10 @@ export default function Import() {
setError(data.detail || "Import failed");
} else {
setResult(data);
// Refresh entity list after commit
if (data.committed) {
api.listEntities().then(setEntities);
}
}
} catch (err: any) {
setError(err.message);
@@ -101,21 +122,74 @@ export default function Import() {
<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-4">
{mode === "schedule" && (
<>
{/* Entity resolution mode */}
<div>
<label className="block text-sm text-gray-700 mb-1">Entity</label>
<select
value={entityId}
onChange={(e) => setEntityId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
>
<option value="">Select entity...</option>
{entities.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
<label className="block text-sm text-gray-700 mb-2">Entity</label>
<div className="flex gap-4 mb-3">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="radio"
name="entityMode"
checked={entityMode === "from_file"}
onChange={() => setEntityMode("from_file")}
className="text-orange-500"
/>
Create from file
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="radio"
name="entityMode"
checked={entityMode === "existing"}
onChange={() => setEntityMode("existing")}
className="text-orange-500"
/>
Use existing entity
</label>
</div>
{entityMode === "existing" ? (
<select
value={entityId}
onChange={(e) => setEntityId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
>
<option value="">Select entity...</option>
{entities.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
) : (
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Type</label>
<select
value={createType}
onChange={(e) => setCreateType(e.target.value as EntityType)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
>
<option value="fund">Fund</option>
<option value="spv">SPV</option>
<option value="gp">GP</option>
<option value="mgmt_co">Mgmt Co</option>
</select>
</div>
<div className="w-32">
<label className="block text-xs text-gray-500 mb-1">Vintage Year</label>
<input
type="text"
value={vintageYear}
onChange={(e) => setVintageYear(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
placeholder="2021"
/>
</div>
</div>
)}
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">
Quarter-end date (seed round)
@@ -178,7 +252,27 @@ export default function Import() {
{result.committed ? "Import Complete" : "Dry Run Preview"}
</h3>
{result.source_entity_name && (
{/* Entity resolution info */}
{result.entity && (
<div className="mb-3 p-3 bg-gray-50 border border-gray-200 rounded text-sm">
{result.entity.resolution === "will_create" ? (
<span className="text-purple-700">
Will create entity: <strong>{result.entity.entity_name}</strong> ({result.entity.entity_type})
{result.entity.vintage_year && `, vintage ${result.entity.vintage_year}`}
</span>
) : result.entity.resolution === "matched" ? (
<span className="text-blue-700">
Matched existing entity: <strong>{result.entity.entity_name}</strong> (#{result.entity.entity_id})
</span>
) : (
<span className="text-gray-700">
Entity: <strong>{result.entity.entity_name}</strong> (#{result.entity.entity_id})
</span>
)}
</div>
)}
{result.source_entity_name && !result.entity && (
<p className="text-sm text-gray-600 mb-2">
Source: {result.source_entity_name}
</p>