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:
@@ -118,6 +118,17 @@ export const api = {
|
||||
|
||||
// Entities
|
||||
listEntities: () => request<Entity[]>("/api/entities"),
|
||||
listEntityRollup: () =>
|
||||
request<{
|
||||
id: number;
|
||||
name: string;
|
||||
type: EntityType;
|
||||
vintage_year: number | null;
|
||||
fund_size_cents: number | null;
|
||||
status: EntityStatus;
|
||||
invested_cents: number;
|
||||
last_signed_value_cents: number;
|
||||
}[]>("/api/entities/rollup"),
|
||||
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
|
||||
createEntity: (data: Partial<Entity>) =>
|
||||
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user