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:
@@ -1,17 +1,83 @@
|
|||||||
"""Entity CRUD endpoints."""
|
"""Entity CRUD and rollup endpoints."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, literal
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import get_current_user, require_writer
|
from ten31portal.auth import get_current_user, require_writer
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import Entity, EntityStatus, User
|
from ten31portal.models import (
|
||||||
|
Entity, EntityStatus, Holding, Position,
|
||||||
|
Valuation, ValuationRound, RoundStatus, User,
|
||||||
|
)
|
||||||
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
|
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
||||||
|
|
||||||
|
|
||||||
|
class EntityRollupItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
vintage_year: int | None
|
||||||
|
fund_size_cents: int | None
|
||||||
|
status: str
|
||||||
|
invested_cents: int
|
||||||
|
last_signed_value_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rollup")
|
||||||
|
def entity_rollup(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[EntityRollupItem]:
|
||||||
|
"""Per-entity invested and last-signed-value in a single pass."""
|
||||||
|
entities = session.exec(select(Entity)).all()
|
||||||
|
result: list[EntityRollupItem] = []
|
||||||
|
|
||||||
|
for ent in entities:
|
||||||
|
# Sum cost_cents across all positions in this entity's holdings
|
||||||
|
invested_row = session.exec(
|
||||||
|
select(func.coalesce(func.sum(Position.cost_cents), 0))
|
||||||
|
.join(Holding, Position.holding_id == Holding.id)
|
||||||
|
.where(Holding.entity_id == ent.id)
|
||||||
|
).one()
|
||||||
|
invested_cents = int(invested_row)
|
||||||
|
|
||||||
|
# Find the latest approved round for this entity
|
||||||
|
latest_round = session.exec(
|
||||||
|
select(ValuationRound)
|
||||||
|
.where(
|
||||||
|
ValuationRound.entity_id == ent.id,
|
||||||
|
ValuationRound.status == RoundStatus.approved,
|
||||||
|
)
|
||||||
|
.order_by(ValuationRound.quarter_end.desc()) # type: ignore[union-attr]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
last_signed_value_cents = 0
|
||||||
|
if latest_round:
|
||||||
|
val_sum = session.exec(
|
||||||
|
select(func.coalesce(func.sum(Valuation.value_cents), 0))
|
||||||
|
.where(Valuation.round_id == latest_round.id)
|
||||||
|
).one()
|
||||||
|
last_signed_value_cents = int(val_sum)
|
||||||
|
|
||||||
|
result.append(EntityRollupItem(
|
||||||
|
id=ent.id,
|
||||||
|
name=ent.name,
|
||||||
|
type=ent.type.value,
|
||||||
|
vintage_year=ent.vintage_year,
|
||||||
|
fund_size_cents=ent.fund_size_cents,
|
||||||
|
status=ent.status.value,
|
||||||
|
invested_cents=invested_cents,
|
||||||
|
last_signed_value_cents=last_signed_value_cents,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_entities(
|
def list_entities(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
|
|||||||
@@ -354,20 +354,92 @@ def _parse_schedule_xlsx(file_bytes: bytes) -> tuple[str | None, list[dict], lis
|
|||||||
@router.post("/schedule")
|
@router.post("/schedule")
|
||||||
def import_schedule(
|
def import_schedule(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
entity_id: int = Query(...),
|
|
||||||
as_of: date = Query(...),
|
as_of: date = Query(...),
|
||||||
|
entity_id: int | None = Query(default=None),
|
||||||
|
create_entity_type: EntityType = Query(default=EntityType.fund),
|
||||||
|
create_vintage_year: int | None = Query(default=None),
|
||||||
commit: bool = Query(default=True),
|
commit: bool = Query(default=True),
|
||||||
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
|
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
file_bytes = file.file.read()
|
||||||
|
filename = file.filename or ""
|
||||||
|
|
||||||
|
# Parse file first to get source_entity_name
|
||||||
|
if filename.endswith(".xlsx") or filename.endswith(".xls"):
|
||||||
|
source_entity_name, holdings_preview, positions_preview, errors = _parse_schedule_xlsx(file_bytes)
|
||||||
|
else:
|
||||||
|
source_entity_name = None
|
||||||
|
holdings_preview, positions_preview, errors = _parse_schedule_csv(file_bytes)
|
||||||
|
|
||||||
|
# Resolve entity
|
||||||
|
entity: Entity | None = None
|
||||||
|
entity_resolution: str = "existing" # "existing" | "matched" | "will_create"
|
||||||
|
|
||||||
|
if entity_id is not None:
|
||||||
|
# Explicit entity_id: use it directly
|
||||||
entity = session.get(Entity, entity_id)
|
entity = session.get(Entity, entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise HTTPException(status_code=404, detail="Entity not found")
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
entity_resolution = "existing"
|
||||||
|
elif source_entity_name:
|
||||||
|
# Try matching by name from row 1
|
||||||
|
entity = session.exec(
|
||||||
|
select(Entity).where(Entity.name == source_entity_name)
|
||||||
|
).first()
|
||||||
|
if entity:
|
||||||
|
entity_resolution = "matched"
|
||||||
|
else:
|
||||||
|
entity_resolution = "will_create"
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No entity_id provided and the file has no entity name in row 1. Provide entity_id or upload a Carta XLSX with the fund name.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dry-run: report resolution without writing
|
||||||
|
if not commit:
|
||||||
|
resolution_info: dict[str, Any] = {"resolution": entity_resolution}
|
||||||
|
if entity:
|
||||||
|
resolution_info["entity_id"] = entity.id
|
||||||
|
resolution_info["entity_name"] = entity.name
|
||||||
|
resolution_info["entity_type"] = entity.type.value
|
||||||
|
else:
|
||||||
|
resolution_info["entity_name"] = source_entity_name
|
||||||
|
resolution_info["entity_type"] = create_entity_type.value
|
||||||
|
resolution_info["vintage_year"] = create_vintage_year
|
||||||
|
return {
|
||||||
|
"committed": False,
|
||||||
|
"source_entity_name": source_entity_name,
|
||||||
|
"entity": resolution_info,
|
||||||
|
"holdings": holdings_preview,
|
||||||
|
"positions": positions_preview,
|
||||||
|
"errors": errors,
|
||||||
|
"seed_round": {"entity_id": entity.id if entity else None, "quarter_end": str(as_of)},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Commit path: create entity if needed
|
||||||
|
if entity_resolution == "will_create" and source_entity_name:
|
||||||
|
entity = Entity(
|
||||||
|
name=source_entity_name,
|
||||||
|
type=create_entity_type,
|
||||||
|
vintage_year=create_vintage_year,
|
||||||
|
)
|
||||||
|
session.add(entity)
|
||||||
|
session.flush()
|
||||||
|
record_audit(session, user.id, "create", "entity", entity.id, {
|
||||||
|
"name": source_entity_name,
|
||||||
|
"type": create_entity_type.value,
|
||||||
|
"source": "schedule_import",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert entity is not None
|
||||||
|
resolved_entity_id = entity.id
|
||||||
|
|
||||||
# Check for any existing round at this quarter
|
# Check for any existing round at this quarter
|
||||||
existing_round = session.exec(
|
existing_round = session.exec(
|
||||||
select(ValuationRound).where(
|
select(ValuationRound).where(
|
||||||
ValuationRound.entity_id == entity_id,
|
ValuationRound.entity_id == resolved_entity_id,
|
||||||
ValuationRound.quarter_end == as_of,
|
ValuationRound.quarter_end == as_of,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
@@ -378,27 +450,6 @@ def import_schedule(
|
|||||||
detail=f"A {kind} round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.",
|
detail=f"A {kind} round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.",
|
||||||
)
|
)
|
||||||
|
|
||||||
file_bytes = file.file.read()
|
|
||||||
filename = file.filename or ""
|
|
||||||
|
|
||||||
# Detect format
|
|
||||||
if filename.endswith(".xlsx") or filename.endswith(".xls"):
|
|
||||||
source_entity_name, holdings_preview, positions_preview, errors = _parse_schedule_xlsx(file_bytes)
|
|
||||||
else:
|
|
||||||
# CSV fallback
|
|
||||||
source_entity_name = None
|
|
||||||
holdings_preview, positions_preview, errors = _parse_schedule_csv(file_bytes)
|
|
||||||
|
|
||||||
if not commit:
|
|
||||||
return {
|
|
||||||
"committed": False,
|
|
||||||
"source_entity_name": source_entity_name,
|
|
||||||
"holdings": holdings_preview,
|
|
||||||
"positions": positions_preview,
|
|
||||||
"errors": errors,
|
|
||||||
"seed_round": {"entity_id": entity_id, "quarter_end": str(as_of)},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Commit: create holdings, positions, seed round
|
# Commit: create holdings, positions, seed round
|
||||||
holding_map: dict[str, Holding] = {}
|
holding_map: dict[str, Holding] = {}
|
||||||
|
|
||||||
@@ -406,21 +457,21 @@ def import_schedule(
|
|||||||
name = hp["company_name"]
|
name = hp["company_name"]
|
||||||
existing = session.exec(
|
existing = session.exec(
|
||||||
select(Holding).where(
|
select(Holding).where(
|
||||||
Holding.entity_id == entity_id,
|
Holding.entity_id == resolved_entity_id,
|
||||||
Holding.company_name == name,
|
Holding.company_name == name,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
holding_map[name] = existing
|
holding_map[name] = existing
|
||||||
else:
|
else:
|
||||||
h = Holding(entity_id=entity_id, company_name=name)
|
h = Holding(entity_id=resolved_entity_id, company_name=name)
|
||||||
session.add(h)
|
session.add(h)
|
||||||
session.flush()
|
session.flush()
|
||||||
holding_map[name] = h
|
holding_map[name] = h
|
||||||
|
|
||||||
# Create seed round
|
# Create seed round
|
||||||
seed_round = ValuationRound(
|
seed_round = ValuationRound(
|
||||||
entity_id=entity_id,
|
entity_id=resolved_entity_id,
|
||||||
quarter_end=as_of,
|
quarter_end=as_of,
|
||||||
status=RoundStatus.approved,
|
status=RoundStatus.approved,
|
||||||
is_seed=True,
|
is_seed=True,
|
||||||
@@ -438,14 +489,14 @@ def import_schedule(
|
|||||||
if holding is None:
|
if holding is None:
|
||||||
existing = session.exec(
|
existing = session.exec(
|
||||||
select(Holding).where(
|
select(Holding).where(
|
||||||
Holding.entity_id == entity_id,
|
Holding.entity_id == resolved_entity_id,
|
||||||
Holding.company_name == company,
|
Holding.company_name == company,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
holding = existing
|
holding = existing
|
||||||
else:
|
else:
|
||||||
holding = Holding(entity_id=entity_id, company_name=company)
|
holding = Holding(entity_id=resolved_entity_id, company_name=company)
|
||||||
session.add(holding)
|
session.add(holding)
|
||||||
session.flush()
|
session.flush()
|
||||||
holding_map[company] = holding
|
holding_map[company] = holding
|
||||||
@@ -487,8 +538,9 @@ def import_schedule(
|
|||||||
)
|
)
|
||||||
session.add(val)
|
session.add(val)
|
||||||
|
|
||||||
record_audit(session, user.id, "import_schedule", "entity", entity_id, {
|
record_audit(session, user.id, "import_schedule", "entity", resolved_entity_id, {
|
||||||
"source_entity_name": source_entity_name,
|
"source_entity_name": source_entity_name,
|
||||||
|
"entity_resolution": entity_resolution,
|
||||||
"holdings": len(holding_map),
|
"holdings": len(holding_map),
|
||||||
"positions_created": positions_created,
|
"positions_created": positions_created,
|
||||||
"positions_updated": positions_updated,
|
"positions_updated": positions_updated,
|
||||||
@@ -499,6 +551,12 @@ def import_schedule(
|
|||||||
return {
|
return {
|
||||||
"committed": True,
|
"committed": True,
|
||||||
"source_entity_name": source_entity_name,
|
"source_entity_name": source_entity_name,
|
||||||
|
"entity": {
|
||||||
|
"resolution": entity_resolution,
|
||||||
|
"entity_id": resolved_entity_id,
|
||||||
|
"entity_name": entity.name,
|
||||||
|
"entity_type": entity.type.value,
|
||||||
|
},
|
||||||
"holdings_count": len(holding_map),
|
"holdings_count": len(holding_map),
|
||||||
"positions_count": len(positions_preview),
|
"positions_count": len(positions_preview),
|
||||||
"positions_created": positions_created,
|
"positions_created": positions_created,
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ export const api = {
|
|||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
listEntities: () => request<Entity[]>("/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}`),
|
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
|
||||||
createEntity: (data: Partial<Entity>) =>
|
createEntity: (data: Partial<Entity>) =>
|
||||||
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
|
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 { useAuth } from "../context/AuthContext";
|
||||||
import { formatMoney, formatGainLoss } from "../format";
|
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;
|
investedCents: number;
|
||||||
lastValueCents: number;
|
lastValueCents: number;
|
||||||
}
|
}
|
||||||
@@ -33,32 +39,19 @@ export default function EntitiesList() {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const ents = await api.listEntities();
|
const rollup = await api.listEntityRollup();
|
||||||
const enriched: EntityRow[] = [];
|
setEntities(
|
||||||
|
rollup.map((r) => ({
|
||||||
for (const ent of ents) {
|
id: r.id,
|
||||||
const holdings = await api.listHoldings(ent.id);
|
name: r.name,
|
||||||
let investedCents = 0;
|
type: r.type,
|
||||||
let lastValueCents = 0;
|
vintage_year: r.vintage_year,
|
||||||
|
fund_size_cents: r.fund_size_cents,
|
||||||
for (const h of holdings) {
|
status: r.status,
|
||||||
const positions = await api.listPositions(h.id);
|
investedCents: r.invested_cents,
|
||||||
for (const p of positions) {
|
lastValueCents: r.last_signed_value_cents,
|
||||||
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);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
type EntityMode = "existing" | "from_file";
|
||||||
|
|
||||||
export default function Import() {
|
export default function Import() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [entities, setEntities] = useState<Entity[]>([]);
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
|
const [entityMode, setEntityMode] = useState<EntityMode>("from_file");
|
||||||
const [entityId, setEntityId] = useState("");
|
const [entityId, setEntityId] = useState("");
|
||||||
|
const [createType, setCreateType] = useState<EntityType>("fund");
|
||||||
|
const [vintageYear, setVintageYear] = useState("");
|
||||||
const [asOf, setAsOf] = useState("");
|
const [asOf, setAsOf] = useState("");
|
||||||
const [mode, setMode] = useState<"entities" | "schedule">("schedule");
|
const [mode, setMode] = useState<"entities" | "schedule">("schedule");
|
||||||
const [commit, setCommit] = useState(false);
|
const [commit, setCommit] = useState(false);
|
||||||
@@ -38,13 +43,25 @@ export default function Import() {
|
|||||||
if (mode === "entities") {
|
if (mode === "entities") {
|
||||||
url = `/api/import/entities?${params}`;
|
url = `/api/import/entities?${params}`;
|
||||||
} else {
|
} else {
|
||||||
if (!entityId || !asOf) {
|
if (!asOf) {
|
||||||
setError("Select an entity and quarter-end date.");
|
setError("Quarter-end date is required.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
params.set("as_of", asOf);
|
||||||
|
|
||||||
|
if (entityMode === "existing") {
|
||||||
|
if (!entityId) {
|
||||||
|
setError("Select an entity.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
params.set("entity_id", entityId);
|
params.set("entity_id", entityId);
|
||||||
params.set("as_of", asOf);
|
} 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}`;
|
url = `/api/import/schedule?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +71,10 @@ export default function Import() {
|
|||||||
setError(data.detail || "Import failed");
|
setError(data.detail || "Import failed");
|
||||||
} else {
|
} else {
|
||||||
setResult(data);
|
setResult(data);
|
||||||
|
// Refresh entity list after commit
|
||||||
|
if (data.committed) {
|
||||||
|
api.listEntities().then(setEntities);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -101,8 +122,33 @@ export default function Import() {
|
|||||||
<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-4">
|
<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-4">
|
||||||
{mode === "schedule" && (
|
{mode === "schedule" && (
|
||||||
<>
|
<>
|
||||||
|
{/* Entity resolution mode */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-700 mb-1">Entity</label>
|
<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
|
<select
|
||||||
value={entityId}
|
value={entityId}
|
||||||
onChange={(e) => setEntityId(e.target.value)}
|
onChange={(e) => setEntityId(e.target.value)}
|
||||||
@@ -115,7 +161,35 @@ export default function Import() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
|
<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>
|
<div>
|
||||||
<label className="block text-sm text-gray-700 mb-1">
|
<label className="block text-sm text-gray-700 mb-1">
|
||||||
Quarter-end date (seed round)
|
Quarter-end date (seed round)
|
||||||
@@ -178,7 +252,27 @@ export default function Import() {
|
|||||||
{result.committed ? "Import Complete" : "Dry Run Preview"}
|
{result.committed ? "Import Complete" : "Dry Run Preview"}
|
||||||
</h3>
|
</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">
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
Source: {result.source_entity_name}
|
Source: {result.source_entity_name}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user