0.2.25: batch historical eNAV backfill + collapsible investor chart
Add POST /api/import/capital-accounts/batch: upload several of a fund's eNAV workbooks at once; each file's ALLOC SI roster is auto-matched to existing members (by fund-admin investor ID, else name/username) and their capital statement is saved at that file's own as-of date, building trend-lines without replacing the latest figures. Members not already in the portal are skipped and reported per file (never created). Capital statements only -- holdings/NAV are untouched. One bad file (wrong password, no ALLOC SI, unreadable date) is reported per-file and does not abort the rest. Import page gains a "Backfill historical capital" batch section (fund picker, multi-file .xlsx input, shared password, per-file results table). Investor portal "Capital over time" chart is now collapsed by default and expands per fund (first login opens clean); applies to InvestorHome and the admin Investor View via the shared component. Tests: backend/tests/test_capital_batch.py (2). Full suite 17 passed; frontend tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
33776d42f4
commit
099459b2f3
@@ -27,6 +27,7 @@ from ten31portal.models import (
|
||||
)
|
||||
from ten31portal.routers.import_router import _open_workbook, _enav_as_of
|
||||
from ten31portal.schemas import (
|
||||
BatchCapitalFileResult, BatchCapitalImportResult,
|
||||
CapitalImportCommit, CapitalImportPreview, ImportInvestorPreview, ImportValueRow,
|
||||
)
|
||||
|
||||
@@ -346,3 +347,118 @@ def commit_import(
|
||||
"matched_accounts_updated": updated_accounts,
|
||||
"statements_written": statements,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def batch_import(
|
||||
files: list[UploadFile] = File(...),
|
||||
entity_id: int = Form(...),
|
||||
password: str | None = Form(None),
|
||||
admin: User = Depends(require_internal_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BatchCapitalImportResult:
|
||||
"""Backfill several quarters of capital history for one fund from a batch of eNAV files.
|
||||
|
||||
Each file is a full eNAV workbook; its ALLOC SI roster is auto-matched (by fund-admin
|
||||
investor ID, else name/username) against existing members and each member's capital
|
||||
statement is upserted at the file's own as-of date — so older files add historical points
|
||||
without touching the latest. Members not already in the system are skipped and reported
|
||||
(no account creation). One file failing (bad password, no ALLOC SI, unreadable date) is
|
||||
reported per-file and does not abort the rest.
|
||||
"""
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
investors = session.exec(select(User).where(User.role == UserRole.investor)).all()
|
||||
by_name = {u.name.strip().lower(): u for u in investors}
|
||||
by_username = {u.username.strip().lower(): u for u in investors}
|
||||
by_extid = {u.external_investor_id: u for u in investors if u.external_investor_id}
|
||||
|
||||
def match_for(name: str, external_id: str | None) -> User | None:
|
||||
if external_id and external_id in by_extid:
|
||||
return by_extid[external_id]
|
||||
return by_name.get(name.strip().lower()) or by_username.get(name.strip().lower())
|
||||
|
||||
cents = lambda d: round((d or 0.0) * 100)
|
||||
results: list[BatchCapitalFileResult] = []
|
||||
total_statements = 0
|
||||
|
||||
for upload in files:
|
||||
fname = upload.filename or "(unnamed)"
|
||||
res = BatchCapitalFileResult(filename=fname)
|
||||
try:
|
||||
wb = _open_workbook(upload.file.read(), password)
|
||||
if "ALLOC SI" not in wb.sheetnames:
|
||||
raise HTTPException(status_code=422, detail="No ALLOC SI tab found in this workbook.")
|
||||
as_of, roster = _parse_alloc_si(wb)
|
||||
if as_of is None:
|
||||
raise HTTPException(status_code=422, detail="Could not determine the as-of date from the file.")
|
||||
res.as_of_date = as_of
|
||||
|
||||
for inv in roster:
|
||||
user = match_for(inv["name"], inv["external_id"])
|
||||
if user is None:
|
||||
res.skipped.append(inv["name"])
|
||||
continue
|
||||
|
||||
# Record the fund-admin ID on first sighting so later files match by ID too.
|
||||
if inv["external_id"] and not user.external_investor_id:
|
||||
user.external_investor_id = inv["external_id"]
|
||||
by_extid[inv["external_id"]] = user
|
||||
session.add(user)
|
||||
|
||||
has_access = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.user_id == user.id, EntityAccess.entity_id == entity_id
|
||||
)
|
||||
).first()
|
||||
if has_access is None:
|
||||
session.add(EntityAccess(user_id=user.id, entity_id=entity_id))
|
||||
|
||||
existing = session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.entity_id == entity_id,
|
||||
CapitalAccountStatement.investor_user_id == user.id,
|
||||
CapitalAccountStatement.as_of_date == as_of,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
existing.commitment_cents = cents(inv["commitment"])
|
||||
existing.contributions_cents = cents(inv["contributions"])
|
||||
existing.distributions_cents = cents(inv["distributions"])
|
||||
existing.ending_balance_cents = cents(inv["ending"])
|
||||
session.add(existing)
|
||||
res.updated += 1
|
||||
else:
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=entity_id,
|
||||
investor_user_id=user.id,
|
||||
as_of_date=as_of,
|
||||
commitment_cents=cents(inv["commitment"]),
|
||||
contributions_cents=cents(inv["contributions"]),
|
||||
distributions_cents=cents(inv["distributions"]),
|
||||
ending_balance_cents=cents(inv["ending"]),
|
||||
))
|
||||
res.matched += 1
|
||||
res.statements_written += 1
|
||||
|
||||
record_audit(session, admin.id, "import_batch", "capital_account", entity_id, {
|
||||
"file": fname,
|
||||
"as_of_date": str(as_of),
|
||||
"matched": res.matched,
|
||||
"skipped": len(res.skipped),
|
||||
})
|
||||
session.commit()
|
||||
total_statements += res.statements_written
|
||||
except HTTPException as e:
|
||||
session.rollback()
|
||||
res.error = e.detail
|
||||
except Exception as e: # noqa: BLE001 — surface any parse failure per-file, keep going
|
||||
session.rollback()
|
||||
res.error = str(e) or "Could not process this file."
|
||||
results.append(res)
|
||||
|
||||
return BatchCapitalImportResult(
|
||||
entity_id=entity_id, files=results, total_statements=total_statements
|
||||
)
|
||||
|
||||
@@ -320,6 +320,24 @@ class CapitalImportCommit(BaseModel):
|
||||
investors: list[ImportCommitInvestor]
|
||||
|
||||
|
||||
# --- Batch historical capital backfill (one eNAV file per quarter, auto-matched) ---
|
||||
|
||||
class BatchCapitalFileResult(BaseModel):
|
||||
filename: str
|
||||
as_of_date: date | None = None
|
||||
matched: int = 0 # existing members whose statement was written
|
||||
statements_written: int = 0 # created + updated
|
||||
updated: int = 0 # matched a statement already at this as-of date
|
||||
skipped: list[str] = [] # roster names with no existing member (not created)
|
||||
error: str | None = None # file-level failure (bad password, no ALLOC SI, etc.)
|
||||
|
||||
|
||||
class BatchCapitalImportResult(BaseModel):
|
||||
entity_id: int
|
||||
files: list[BatchCapitalFileResult]
|
||||
total_statements: int
|
||||
|
||||
|
||||
# --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) ---
|
||||
|
||||
class EntityStakeCreate(BaseModel):
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Batch historical capital backfill: several eNAV files → per-quarter statements, auto-matched."""
|
||||
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
|
||||
import openpyxl
|
||||
from sqlmodel import select
|
||||
|
||||
from ten31portal.models import CapitalAccountStatement, Entity, EntityType, User, UserRole
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def _alloc_si_file(report: datetime, rows: list[dict]) -> bytes:
|
||||
"""Build a minimal eNAV workbook with an ALLOC SI roster and a report date in A1."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ALLOC SI"
|
||||
ws["A1"] = report # _enav_as_of reads the report date from the top-left cells
|
||||
header = ["INVESTOR ID", "INVESTOR TYPE", "INVESTOR NAME",
|
||||
"COMMITTED CAPITAL", "CONTRIBUTIONS", "(DISTRIBUTIONS)", "ENDING BALANCE"]
|
||||
ws.append([None] * 7) # row 2 spacer
|
||||
ws.append(header) # row 3 header
|
||||
for r in rows:
|
||||
ws.append([
|
||||
r.get("id"), "LP", r["name"],
|
||||
r.get("commit", 0), r.get("contrib", 0), r.get("distrib", 0), r["ending"],
|
||||
])
|
||||
out = io.BytesIO()
|
||||
wb.save(out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_batch_backfill_builds_history(auth_client, session):
|
||||
fund = Entity(name="LTPF III", type=EntityType.fund)
|
||||
session.add(fund)
|
||||
session.commit()
|
||||
session.refresh(fund)
|
||||
|
||||
# One member matched by fund-admin ID, one by name; both already exist.
|
||||
alice = make_user(session, username="alice", name="Alice Trust",
|
||||
role=UserRole.investor, external_investor_id="INV-100")
|
||||
make_user(session, username="bob", name="Bob Llc", role=UserRole.investor)
|
||||
|
||||
q3 = _alloc_si_file(datetime(2025, 9, 30), [
|
||||
{"id": "INV-100", "name": "Alice Trust", "commit": 1_000_000, "contrib": 400_000, "ending": 420_000},
|
||||
{"id": "INV-200", "name": "Bob LLC", "commit": 500_000, "contrib": 200_000, "ending": 205_000},
|
||||
{"id": "INV-999", "name": "Ghost Capital", "commit": 999, "contrib": 999, "ending": 999}, # no account → skipped
|
||||
])
|
||||
q4 = _alloc_si_file(datetime(2025, 12, 31), [
|
||||
{"id": "INV-100", "name": "Alice Trust", "commit": 1_000_000, "contrib": 400_000, "ending": 455_000},
|
||||
{"id": "INV-200", "name": "Bob LLC", "commit": 500_000, "contrib": 200_000, "ending": 210_000},
|
||||
])
|
||||
|
||||
resp = auth_client.post(
|
||||
"/api/import/capital-accounts/batch",
|
||||
data={"entity_id": fund.id},
|
||||
files=[
|
||||
("files", ("LTPF_III_Q3.xlsx", q3, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")),
|
||||
("files", ("LTPF_III_Q4.xlsx", q4, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")),
|
||||
],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
assert body["total_statements"] == 4 # 2 members × 2 quarters
|
||||
q3res = next(f for f in body["files"] if "Q3" in f["filename"])
|
||||
assert q3res["as_of_date"] == "2025-09-30"
|
||||
assert q3res["matched"] == 2
|
||||
assert q3res["skipped"] == ["Ghost Capital"] # unknown member reported, not created
|
||||
|
||||
# No account was created for the unknown roster name.
|
||||
assert session.exec(select(User).where(User.name == "Ghost Capital")).first() is None
|
||||
|
||||
# Alice now has two statements — a real trend line — and the latest (Q4) is intact.
|
||||
stmts = session.exec(
|
||||
select(CapitalAccountStatement)
|
||||
.where(CapitalAccountStatement.investor_user_id == alice.id)
|
||||
.order_by(CapitalAccountStatement.as_of_date) # type: ignore[arg-type]
|
||||
).all()
|
||||
assert [s.as_of_date for s in stmts] == [date(2025, 9, 30), date(2025, 12, 31)]
|
||||
assert [s.ending_balance_cents for s in stmts] == [42_000_000, 45_500_000]
|
||||
|
||||
|
||||
def test_batch_reimport_updates_in_place(auth_client, session):
|
||||
fund = Entity(name="LTPF IV", type=EntityType.fund)
|
||||
session.add(fund)
|
||||
session.commit()
|
||||
session.refresh(fund)
|
||||
carol = make_user(session, username="carol", name="Carol Ira", role=UserRole.investor)
|
||||
|
||||
def one_file(ending: int) -> bytes:
|
||||
return _alloc_si_file(datetime(2025, 12, 31),
|
||||
[{"id": "INV-1", "name": "Carol Ira", "ending": ending}])
|
||||
|
||||
for ending in (300_000, 315_000): # corrected figure re-imported at the same quarter
|
||||
resp = auth_client.post(
|
||||
"/api/import/capital-accounts/batch",
|
||||
data={"entity_id": fund.id},
|
||||
files=[("files", ("f.xlsx", one_file(ending), "application/octet-stream"))],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
stmts = session.exec(
|
||||
select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == carol.id)
|
||||
).all()
|
||||
assert len(stmts) == 1 # upsert, not a duplicate
|
||||
assert stmts[0].ending_balance_cents == 31_500_000
|
||||
@@ -1,4 +1,4 @@
|
||||
export { v_0_2_24 as current } from './v_0_2_24'
|
||||
export { v_0_2_25 as current } from './v_0_2_25'
|
||||
import { v_0_1_0 } from './v_0_1_0'
|
||||
import { v_0_2_0 } from './v_0_2_0'
|
||||
import { v_0_2_1 } from './v_0_2_1'
|
||||
@@ -23,4 +23,5 @@ import { v_0_2_20 } from './v_0_2_20'
|
||||
import { v_0_2_21 } from './v_0_2_21'
|
||||
import { v_0_2_22 } from './v_0_2_22'
|
||||
import { v_0_2_23 } from './v_0_2_23'
|
||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23]
|
||||
import { v_0_2_24 } from './v_0_2_24'
|
||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_25 = VersionInfo.of({
|
||||
version: '0.2.25:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Load several past quarters of investor capital at once: the Import page now has a "Backfill historical capital" batch — drop a fund\'s eNAV workbooks and each file\'s members are matched to existing accounts and saved at that file\'s own as-of date, building trend-lines without replacing the latest figures (members not already in the portal are skipped, never created). Investors\' "Capital over time" chart is now collapsed by default and expands per fund.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||
// - /api/* is never cached
|
||||
// Bump CACHE on each release so old entries are purged.
|
||||
const CACHE = 'ten31-portal-0.2.24'
|
||||
const CACHE = 'ten31-portal-0.2.25'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
|
||||
|
||||
@@ -130,6 +130,22 @@ export interface CapitalImportResult {
|
||||
statements_written: number;
|
||||
}
|
||||
|
||||
export interface BatchCapitalFileResult {
|
||||
filename: string;
|
||||
as_of_date: string | null;
|
||||
matched: number;
|
||||
statements_written: number;
|
||||
updated: number;
|
||||
skipped: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BatchCapitalImportResult {
|
||||
entity_id: number;
|
||||
files: BatchCapitalFileResult[];
|
||||
total_statements: number;
|
||||
}
|
||||
|
||||
export interface CapitalAccount {
|
||||
id: number;
|
||||
entity_id: number;
|
||||
@@ -440,6 +456,22 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Batch backfill: several historical eNAV files → capital statements per quarter, auto-matched.
|
||||
capitalImportBatch: async (
|
||||
entityId: number,
|
||||
files: File[],
|
||||
password?: string,
|
||||
): Promise<BatchCapitalImportResult> => {
|
||||
const form = new FormData();
|
||||
form.set("entity_id", String(entityId));
|
||||
if (password) form.set("password", password);
|
||||
for (const f of files) form.append("files", f);
|
||||
const res = await fetch("/api/import/capital-accounts/batch", { method: "POST", body: form });
|
||||
const data = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
if (!res.ok) throw new ApiError(res.status, data.detail || "Batch import failed");
|
||||
return data;
|
||||
},
|
||||
getUser: (id: number) => request<UserDetail>(`/api/users/${id}`),
|
||||
createUser: (data: {
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, isAdmin, type Entity, type EntityType, type CapitalImportPreview } from "../api";
|
||||
import {
|
||||
api, isAdmin,
|
||||
type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult,
|
||||
} from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatMoneyExact } from "../format";
|
||||
import { formatDate, formatMoneyExact } from "../format";
|
||||
|
||||
type EntityMode = "existing" | "from_file";
|
||||
|
||||
@@ -377,6 +380,8 @@ export default function Import() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!memberPv && !result && <BatchBackfill entities={entities} />}
|
||||
|
||||
{confirmReplace && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
@@ -406,3 +411,133 @@ export default function Import() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Backfill several past quarters of investor capital in one shot. Each eNAV file's ALLOC SI
|
||||
// roster is auto-matched to existing members and their statement is written at the file's own
|
||||
// as-of date, so older files add trend-line history without disturbing the latest figures.
|
||||
// Members not already in the system are skipped (never created here) and reported per file.
|
||||
function BatchBackfill({ entities }: { entities: Entity[] }) {
|
||||
const [entityId, setEntityId] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState<BatchCapitalImportResult | null>(null);
|
||||
|
||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||
|
||||
async function run() {
|
||||
if (!entityId || files.length === 0) {
|
||||
setError("Pick a fund and at least one eNAV file.");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setResult(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
setResult(await api.capitalImportBatch(Number(entityId), files, password || undefined));
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Batch import failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-10 border-t border-gray-200 pt-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">Backfill historical capital</h2>
|
||||
<p className="text-sm text-gray-500 mb-4 max-w-3xl">
|
||||
Load several past quarters at once to build investors' trend-lines. Drop the eNAV
|
||||
workbooks for one fund; each file's members are matched to existing accounts and their
|
||||
capital statement is saved at that file's own as-of date. The latest figures are never
|
||||
replaced, and members not already in the portal are skipped (not created).
|
||||
</p>
|
||||
|
||||
{error && <div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700 mb-4">{error}</div>}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Fund</label>
|
||||
<select value={entityId} onChange={(e) => setEntityId(e.target.value)} className={inputCls}>
|
||||
<option value="">Select fund…</option>
|
||||
{entities.map((e) => <option key={e.id} value={e.id}>{e.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">eNAV workbooks (.xlsx — select several)</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
multiple
|
||||
onChange={(e) => { setFiles(Array.from(e.target.files ?? [])); setResult(null); }}
|
||||
className="text-sm"
|
||||
/>
|
||||
{files.length > 0 && <p className="text-xs text-gray-400 mt-1">{files.length} file(s) selected</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Spreadsheet password</label>
|
||||
<input
|
||||
type="text"
|
||||
className={inputCls}
|
||||
value={password}
|
||||
placeholder="Shared open password, if protected"
|
||||
autoComplete="off"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={busy || !entityId || files.length === 0}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Loading history…" : "Load history"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="mt-4 bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 text-sm text-gray-600 border-b border-gray-100">
|
||||
{result.total_statements} statement(s) written across {result.files.length} file(s)
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-gray-500 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">File</th>
|
||||
<th className="px-4 py-2 font-medium">As-of</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Loaded</th>
|
||||
<th className="px-4 py-2 font-medium">Skipped (no account)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.files.map((f, i) => (
|
||||
<tr key={i} className="border-t border-gray-100 align-top">
|
||||
<td className="px-4 py-2 text-gray-900">{f.filename}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{f.as_of_date ? formatDate(f.as_of_date) : "—"}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">
|
||||
{f.error ? "—" : f.statements_written}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">
|
||||
{f.error ? (
|
||||
<span className="text-red-600">{f.error}</span>
|
||||
) : f.skipped.length === 0 ? (
|
||||
<span className="text-gray-400">none</span>
|
||||
) : (
|
||||
<span title={f.skipped.join(", ")}>
|
||||
{f.skipped.length}: {f.skipped.slice(0, 3).join(", ")}
|
||||
{f.skipped.length > 3 ? "…" : ""}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
|
||||
import { categoryLabel, formatDate, formatMoneyExact } from "../format";
|
||||
import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
|
||||
@@ -125,6 +125,8 @@ function CapitalBlock({
|
||||
paidIn: a.contributions_cents,
|
||||
distributions: a.distributions_cents,
|
||||
}));
|
||||
// Collapsed by default so the portal opens clean; the investor expands the trend per fund.
|
||||
const [showChart, setShowChart] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}>
|
||||
@@ -150,7 +152,20 @@ function CapitalBlock({
|
||||
|
||||
{history.length > 1 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Capital over time</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChart((v) => !v)}
|
||||
aria-expanded={showChart}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-gray-500 uppercase hover:text-gray-700"
|
||||
>
|
||||
<span className={`transition-transform ${showChart ? "rotate-90" : ""}`}>▸</span>
|
||||
Capital over time
|
||||
<span className="normal-case font-normal text-gray-400">
|
||||
({history.length} quarters{showChart ? "" : " — show"})
|
||||
</span>
|
||||
</button>
|
||||
{showChart && (
|
||||
<div className="mt-3">
|
||||
<CapitalChart points={chartPoints} />
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead className="text-gray-400 text-left">
|
||||
@@ -172,6 +187,8 @@ function CapitalBlock({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Bumped each release so the running build is visible in the UI.
|
||||
// If the number shown in the app doesn't match the installed s9pk version,
|
||||
// the new frontend isn't actually being served.
|
||||
export const APP_VERSION = "0.2.20";
|
||||
export const APP_VERSION = "0.2.25";
|
||||
|
||||
Reference in New Issue
Block a user