0.2.23: link GP entities to their investor account for auto asset balances
Ten31 LLC (and any GP/mgmt entity) can be linked to its investor account, so its Assets tab shows its real capital-account balance in each fund, pulled live from the eNAV capital accounts instead of manual entry. - entities.linked_user_id (migration c9d0e1f2a3b4) + EntityCreate/Update/ Response fields; validated to be an investor account. - Edit-entity form gains a "Linked investor account" picker for GP/mgmt. - Assets tab now auto-lists the linked account's balance per fund (the earlier manual-stakes API remains but is no longer used by the UI). Verified: 13/13 backend tests pass; alembic head c9d0e1f2a3b4; frontend tsc + vite build clean.
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
"""add entities.linked_user_id (link a GP entity to its investor account)
|
||||||
|
|
||||||
|
Revision ID: c9d0e1f2a3b4
|
||||||
|
Revises: b8c9d0e1f2a3
|
||||||
|
Create Date: 2026-07-01 10:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'c9d0e1f2a3b4'
|
||||||
|
down_revision: Union[str, None] = 'b8c9d0e1f2a3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('linked_user_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_index('ix_entities_linked_user_id', 'entities', ['linked_user_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_entities_linked_user_id', table_name='entities')
|
||||||
|
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('linked_user_id')
|
||||||
@@ -87,6 +87,9 @@ class Entity(SQLModel, table=True):
|
|||||||
vintage_year: int | None = None
|
vintage_year: int | None = None
|
||||||
fund_size_cents: int | None = None
|
fund_size_cents: int | None = None
|
||||||
status: EntityStatus = Field(default=EntityStatus.active)
|
status: EntityStatus = Field(default=EntityStatus.active)
|
||||||
|
# For a GP/mgmt entity that is also an LP with capital accounts (e.g. Ten31 LLC), link to
|
||||||
|
# its investor account so its Assets view can pull real per-fund balances from the eNAV.
|
||||||
|
linked_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -174,12 +174,22 @@ def get_entity(
|
|||||||
return EntityResponse.model_validate(entity, from_attributes=True)
|
return EntityResponse.model_validate(entity, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_linked_user(linked_user_id: int | None, session: Session) -> None:
|
||||||
|
"""A linked account (for a GP entity that is also an LP) must be an investor account."""
|
||||||
|
if linked_user_id is None:
|
||||||
|
return
|
||||||
|
linked = session.get(User, linked_user_id)
|
||||||
|
if linked is None or linked.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="Linked account must be an investor account.")
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_entity(
|
def create_entity(
|
||||||
body: EntityCreate,
|
body: EntityCreate,
|
||||||
user: User = Depends(require_writer),
|
user: User = Depends(require_writer),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> EntityResponse:
|
) -> EntityResponse:
|
||||||
|
_validate_linked_user(body.linked_user_id, session)
|
||||||
entity = Entity(**body.model_dump())
|
entity = Entity(**body.model_dump())
|
||||||
session.add(entity)
|
session.add(entity)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -200,6 +210,8 @@ def update_entity(
|
|||||||
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")
|
||||||
changes = body.model_dump(exclude_unset=True)
|
changes = body.model_dump(exclude_unset=True)
|
||||||
|
if "linked_user_id" in changes:
|
||||||
|
_validate_linked_user(changes["linked_user_id"], session)
|
||||||
for key, val in changes.items():
|
for key, val in changes.items():
|
||||||
setattr(entity, key, val)
|
setattr(entity, key, val)
|
||||||
session.add(entity)
|
session.add(entity)
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class EntityCreate(BaseModel):
|
|||||||
type: EntityType
|
type: EntityType
|
||||||
vintage_year: int | None = None
|
vintage_year: int | None = None
|
||||||
fund_size_cents: int | None = None
|
fund_size_cents: int | None = None
|
||||||
|
linked_user_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class EntityUpdate(BaseModel):
|
class EntityUpdate(BaseModel):
|
||||||
@@ -99,6 +100,7 @@ class EntityUpdate(BaseModel):
|
|||||||
vintage_year: int | None = None
|
vintage_year: int | None = None
|
||||||
fund_size_cents: int | None = None
|
fund_size_cents: int | None = None
|
||||||
status: EntityStatus | None = None
|
status: EntityStatus | None = None
|
||||||
|
linked_user_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class EntityResponse(BaseModel):
|
class EntityResponse(BaseModel):
|
||||||
@@ -108,6 +110,7 @@ class EntityResponse(BaseModel):
|
|||||||
vintage_year: int | None
|
vintage_year: int | None
|
||||||
fund_size_cents: int | None
|
fund_size_cents: int | None
|
||||||
status: EntityStatus
|
status: EntityStatus
|
||||||
|
linked_user_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Linking a GP entity to its investor account (so Assets can pull real balances)."""
|
||||||
|
|
||||||
|
from tests.conftest import make_user
|
||||||
|
from ten31portal.models import Entity, EntityType, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_entity_to_investor(auth_client, session):
|
||||||
|
inv = make_user(session, username="ten31llc", role=UserRole.investor, name="Ten31 LLC")
|
||||||
|
gp = Entity(name="Ten31 LLC", type=EntityType.gp)
|
||||||
|
session.add(gp)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(gp)
|
||||||
|
|
||||||
|
linked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": inv.id})
|
||||||
|
assert linked.status_code == 200, linked.text
|
||||||
|
assert linked.json()["linked_user_id"] == inv.id
|
||||||
|
|
||||||
|
# Unlink.
|
||||||
|
unlinked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": None})
|
||||||
|
assert unlinked.status_code == 200
|
||||||
|
assert unlinked.json()["linked_user_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_rejects_non_investor(auth_client, session):
|
||||||
|
staff = make_user(session, username="ops2", role=UserRole.operations)
|
||||||
|
gp = Entity(name="Mgmt Co", type=EntityType.mgmt_co)
|
||||||
|
session.add(gp)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(gp)
|
||||||
|
resp = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": staff.id})
|
||||||
|
assert resp.status_code == 400
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.2.22",
|
"version": "0.2.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { v_0_2_22 as current } from './v_0_2_22'
|
export { v_0_2_23 as current } from './v_0_2_23'
|
||||||
import { v_0_1_0 } from './v_0_1_0'
|
import { v_0_1_0 } from './v_0_1_0'
|
||||||
import { v_0_2_0 } from './v_0_2_0'
|
import { v_0_2_0 } from './v_0_2_0'
|
||||||
import { v_0_2_1 } from './v_0_2_1'
|
import { v_0_2_1 } from './v_0_2_1'
|
||||||
@@ -21,4 +21,5 @@ import { v_0_2_18 } from './v_0_2_18'
|
|||||||
import { v_0_2_19 } from './v_0_2_19'
|
import { v_0_2_19 } from './v_0_2_19'
|
||||||
import { v_0_2_20 } from './v_0_2_20'
|
import { v_0_2_20 } from './v_0_2_20'
|
||||||
import { v_0_2_21 } from './v_0_2_21'
|
import { v_0_2_21 } from './v_0_2_21'
|
||||||
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]
|
import { v_0_2_22 } from './v_0_2_22'
|
||||||
|
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]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_23 = VersionInfo.of({
|
||||||
|
version: '0.2.23:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'You can link a GP or management-company entity to its investor account. Its Assets tab then shows its real capital-account balance in each fund, pulled from the eNAV, with no double entry. Set this on the entity\'s Edit form.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||||
// - /api/* is never cached
|
// - /api/* is never cached
|
||||||
// Bump CACHE on each release so old entries are purged.
|
// Bump CACHE on each release so old entries are purged.
|
||||||
const CACHE = 'ten31-portal-0.2.22'
|
const CACHE = 'ten31-portal-0.2.23'
|
||||||
|
|
||||||
self.addEventListener('install', () => self.skipWaiting())
|
self.addEventListener('install', () => self.skipWaiting())
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export interface Entity {
|
|||||||
vintage_year: number | null;
|
vintage_year: number | null;
|
||||||
fund_size_cents: number | null;
|
fund_size_cents: number | null;
|
||||||
status: EntityStatus;
|
status: EntityStatus;
|
||||||
|
linked_user_id: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,42 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { api, canEditRound, type Entity, type EntityStake } from "../api";
|
import { api, type CapitalAccount, type Entity } from "../api";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { formatDate, formatMoney } from "../format";
|
||||||
import { formatMoney } from "../format";
|
|
||||||
import EntityHeader from "../components/EntityHeader";
|
import EntityHeader from "../components/EntityHeader";
|
||||||
|
|
||||||
// A GP / management company's assets: its ownership interest in the funds it manages.
|
// A GP / management company's assets: its capital-account balance in each fund it holds,
|
||||||
|
// pulled live from the linked investor account (the same LLC on the eNAV / LP side).
|
||||||
export default function EntityAssets() {
|
export default function EntityAssets() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { user } = useAuth();
|
|
||||||
const [entity, setEntity] = useState<Entity | null>(null);
|
const [entity, setEntity] = useState<Entity | null>(null);
|
||||||
const [stakes, setStakes] = useState<EntityStake[]>([]);
|
const [accounts, setAccounts] = useState<CapitalAccount[]>([]);
|
||||||
const [allEntities, setAllEntities] = useState<Entity[]>([]);
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const entityId = id ? parseInt(id) : 0;
|
const entityId = id ? parseInt(id) : 0;
|
||||||
const isWriter = !!user && canEditRound(user.role);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!entityId) return;
|
if (!entityId) return;
|
||||||
load();
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [entityId]);
|
}, [entityId]);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [ent, stk, all] = await Promise.all([
|
const ent = await api.getEntity(entityId);
|
||||||
api.getEntity(entityId),
|
setEntity(ent);
|
||||||
api.listStakes(entityId),
|
if (ent.linked_user_id != null) {
|
||||||
|
const [accts, ents] = await Promise.all([
|
||||||
|
api.listCapitalAccounts({ investor_user_id: ent.linked_user_id }),
|
||||||
api.listEntities(),
|
api.listEntities(),
|
||||||
]);
|
]);
|
||||||
setEntity(ent);
|
setAccounts(accts);
|
||||||
setStakes(stk);
|
setEntities(ents);
|
||||||
setAllEntities(all);
|
} else {
|
||||||
|
setAccounts([]);
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || "Failed to load");
|
setError(e.message || "Failed to load");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,196 +44,97 @@ export default function EntityAssets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Funds/SPVs available to add (exclude self and ones already staked).
|
const fundName = useMemo(() => {
|
||||||
const candidates = useMemo(() => {
|
const m = new Map(entities.map((e) => [e.id, e.name]));
|
||||||
const staked = new Set(stakes.map((s) => s.fund_entity_id));
|
return (id: number) => m.get(id) ?? `Fund ${id}`;
|
||||||
return allEntities.filter(
|
}, [entities]);
|
||||||
(e) => e.id !== entityId && !staked.has(e.id) && (e.type === "fund" || e.type === "spv"),
|
|
||||||
);
|
|
||||||
}, [allEntities, stakes, entityId]);
|
|
||||||
|
|
||||||
const totalValue = stakes.reduce((s, k) => s + (k.value_cents ?? 0), 0);
|
// Latest statement per fund for the linked account.
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const byFund = new Map<number, CapitalAccount[]>();
|
||||||
|
for (const a of accounts) {
|
||||||
|
const g = byFund.get(a.entity_id) ?? [];
|
||||||
|
g.push(a);
|
||||||
|
byFund.set(a.entity_id, g);
|
||||||
|
}
|
||||||
|
return [...byFund.entries()]
|
||||||
|
.map(([fundId, accts]) => {
|
||||||
|
const latest = [...accts].sort((a, b) => b.as_of_date.localeCompare(a.as_of_date))[0];
|
||||||
|
return { fundId, latest };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.latest.ending_balance_cents - a.latest.ending_balance_cents);
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
const total = rows.reduce((s, r) => s + r.latest.ending_balance_cents, 0);
|
||||||
|
|
||||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading...</div>;
|
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading...</div>;
|
||||||
|
|
||||||
async function remove(stakeId: number) {
|
|
||||||
if (!confirm("Remove this stake?")) return;
|
|
||||||
try {
|
|
||||||
await api.deleteStake(entityId, stakeId);
|
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message || "Failed to remove");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<EntityHeader entity={entity} active="assets" />
|
<EntityHeader entity={entity} active="assets" />
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">Fund balances</h2>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Fund interests</h2>
|
|
||||||
{isWriter && candidates.length > 0 && (
|
{entity.linked_user_id == null ? (
|
||||||
<button
|
<div className="bg-white border border-gray-200 rounded-lg p-6 text-sm text-gray-600">
|
||||||
onClick={() => setShowForm(true)}
|
<p>
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
This entity isn't linked to an investor account yet, so there are no balances to show.
|
||||||
>
|
</p>
|
||||||
Add stake
|
<p className="mt-2 text-gray-500">
|
||||||
</button>
|
Link it on the{" "}
|
||||||
)}
|
<Link to={`/entities/${entityId}`} className="text-orange-600 hover:text-orange-700">
|
||||||
|
Overview tab → Edit entity
|
||||||
|
</Link>{" "}
|
||||||
|
(choose its investor account). Its capital-account balance in each fund will then
|
||||||
|
appear here automatically.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{showForm && (
|
|
||||||
<AddStakeForm
|
|
||||||
entityId={entityId}
|
|
||||||
candidates={candidates}
|
|
||||||
onClose={() => setShowForm(false)}
|
|
||||||
onSaved={() => {
|
|
||||||
setShowForm(false);
|
|
||||||
load();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 text-gray-500 text-left">
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">Fund / SPV</th>
|
<th className="px-4 py-2 font-medium">Fund / SPV</th>
|
||||||
<th className="px-4 py-2 font-medium">Type</th>
|
<th className="px-4 py-2 font-medium">As of</th>
|
||||||
<th className="px-4 py-2 font-medium text-right">Ownership</th>
|
<th className="px-4 py-2 font-medium text-right">Commitment</th>
|
||||||
<th className="px-4 py-2 font-medium text-right">Value</th>
|
<th className="px-4 py-2 font-medium text-right">Balance</th>
|
||||||
<th className="px-4 py-2 font-medium">Note</th>
|
|
||||||
{isWriter && <th className="px-4 py-2" />}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{stakes.map((s) => (
|
{rows.map((r) => (
|
||||||
<tr key={s.id} className="border-t border-gray-100">
|
<tr key={r.fundId} className="border-t border-gray-100">
|
||||||
<td className="px-4 py-2 text-gray-900">{s.fund_name ?? s.fund_entity_id}</td>
|
<td className="px-4 py-2 text-gray-900">
|
||||||
<td className="px-4 py-2 text-gray-600 uppercase text-xs">{s.fund_type}</td>
|
<Link to={`/entities/${r.fundId}`} className="hover:text-orange-600">
|
||||||
<td className="px-4 py-2 text-right text-gray-700">
|
{fundName(r.fundId)}
|
||||||
{s.ownership_pct != null ? `${s.ownership_pct}%` : "—"}
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">{formatDate(r.latest.as_of_date)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-600">
|
||||||
|
{formatMoney(r.latest.commitment_cents)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right text-gray-900">
|
<td className="px-4 py-2 text-right text-gray-900">
|
||||||
{s.value_cents != null ? formatMoney(s.value_cents) : "—"}
|
{formatMoney(r.latest.ending_balance_cents)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-500">{s.note || "—"}</td>
|
|
||||||
{isWriter && (
|
|
||||||
<td className="px-4 py-2 text-right">
|
|
||||||
<button onClick={() => remove(s.id)} className="text-gray-400 hover:text-red-600">
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{stakes.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<tr className="bg-gray-50 font-medium">
|
<tr className="bg-gray-50 font-medium">
|
||||||
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total</td>
|
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total balance</td>
|
||||||
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(totalValue)}</td>
|
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(total)}</td>
|
||||||
<td className="px-4 py-2" colSpan={isWriter ? 2 : 1} />
|
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{stakes.length === 0 && (
|
{rows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={isWriter ? 6 : 5} className="px-4 py-6 text-center text-gray-400">
|
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">
|
||||||
No fund interests recorded yet.
|
The linked account has no capital-account balances yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddStakeForm({
|
|
||||||
entityId,
|
|
||||||
candidates,
|
|
||||||
onClose,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
entityId: number;
|
|
||||||
candidates: Entity[];
|
|
||||||
onClose: () => void;
|
|
||||||
onSaved: () => void;
|
|
||||||
}) {
|
|
||||||
const [fundId, setFundId] = useState<number | "">("");
|
|
||||||
const [pct, setPct] = useState("");
|
|
||||||
const [valueDollars, setValueDollars] = useState("");
|
|
||||||
const [note, setNote] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (fundId === "") {
|
|
||||||
setError("Choose a fund.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
await api.createStake(entityId, {
|
|
||||||
fund_entity_id: Number(fundId),
|
|
||||||
ownership_pct: pct ? parseFloat(pct) : null,
|
|
||||||
value_dollars: valueDollars ? parseFloat(valueDollars.replace(/[,$]/g, "")) : null,
|
|
||||||
note: note.trim() || null,
|
|
||||||
});
|
|
||||||
onSaved();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || "Failed to add stake");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4 max-w-lg">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Add a fund interest</h3>
|
|
||||||
<form onSubmit={submit} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-500 mb-1">Fund / SPV</label>
|
|
||||||
<select
|
|
||||||
className={inputCls}
|
|
||||||
value={fundId}
|
|
||||||
onChange={(e) => setFundId(e.target.value === "" ? "" : Number(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">Select…</option>
|
|
||||||
{candidates.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-xs text-gray-500 mb-1">Ownership % (optional)</label>
|
|
||||||
<input className={inputCls} value={pct} onChange={(e) => setPct(e.target.value)} placeholder="20" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-xs text-gray-500 mb-1">Value $ (optional)</label>
|
|
||||||
<input className={inputCls} value={valueDollars} onChange={(e) => setValueDollars(e.target.value)} placeholder="1,000,000" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-500 mb-1">Note (optional)</label>
|
|
||||||
<input className={inputCls} value={note} onChange={(e) => setNote(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
|
||||||
{saving ? "Adding…" : "Add"}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { api, canEditRound, type Entity, type EntityType, type Holding, type ValuationRound } from "../api";
|
import { api, canEditRound, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { formatMoney, formatDate, formatQuarter } from "../format";
|
import { formatMoney, formatDate, formatQuarter } from "../format";
|
||||||
import EntityHeader from "../components/EntityHeader";
|
import EntityHeader from "../components/EntityHeader";
|
||||||
@@ -149,10 +149,20 @@ function EditEntityForm({
|
|||||||
const [fundSizeDollars, setFundSizeDollars] = useState(
|
const [fundSizeDollars, setFundSizeDollars] = useState(
|
||||||
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
|
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
|
||||||
);
|
);
|
||||||
|
const [linkedUserId, setLinkedUserId] = useState<number | "">(entity.linked_user_id ?? "");
|
||||||
|
const [investors, setInvestors] = useState<User[]>([]);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||||
|
|
||||||
|
// A GP / management company can be linked to its own investor account, so its Assets view
|
||||||
|
// shows its real per-fund balances.
|
||||||
|
const isGp = type === "gp" || type === "mgmt_co";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGp) return;
|
||||||
|
api.listUsers().then((us) => setInvestors(us.filter((u) => u.role === "investor"))).catch(() => {});
|
||||||
|
}, [isGp]);
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -170,6 +180,7 @@ function EditEntityForm({
|
|||||||
fund_size_cents: fundSizeDollars
|
fund_size_cents: fundSizeDollars
|
||||||
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
|
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
|
||||||
: null,
|
: null,
|
||||||
|
linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null,
|
||||||
};
|
};
|
||||||
const updated = await api.updateEntity(entity.id, data);
|
const updated = await api.updateEntity(entity.id, data);
|
||||||
onSaved(updated);
|
onSaved(updated);
|
||||||
@@ -216,6 +227,23 @@ function EditEntityForm({
|
|||||||
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
|
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isGp && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">
|
||||||
|
Linked investor account (its Assets tab shows this account's fund balances)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={linkedUserId}
|
||||||
|
onChange={(e) => setLinkedUserId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{investors.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.name} ({u.username})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
||||||
|
|||||||
Reference in New Issue
Block a user