Release 0.2.22: capital chart, Investor View, GP stakes, doc folders
Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.
New features:
- Investor capital-over-time chart (value, paid-in, distributions per
quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
(GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
categorized correctly.
Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"cwd": "frontend",
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ dist/
|
|||||||
node_modules/
|
node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
|
# Built frontend served by backend (produced by the deploy build)
|
||||||
|
backend/static/
|
||||||
|
|
||||||
# DB
|
# DB
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
@@ -17,6 +20,16 @@ frontend/dist/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Packaged service artifacts
|
||||||
|
*.s9pk
|
||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
deploy/node_modules/
|
deploy/node_modules/
|
||||||
deploy/javascript/
|
deploy/javascript/
|
||||||
|
|
||||||
|
# Confidential fund-admin spreadsheets (never commit)
|
||||||
|
*.xlsx
|
||||||
|
*.xls
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off.
|
Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off.
|
||||||
|
|
||||||
|
## Accounts and access
|
||||||
|
|
||||||
|
Two kinds of accounts:
|
||||||
|
|
||||||
|
- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) — the full back-office app
|
||||||
|
(entities, holdings, valuations, import, audit). `approver` and `cfo` also get the
|
||||||
|
admin screens below.
|
||||||
|
- **External accounts** (`investor`, `fund_administrator`) — a separate, entity-scoped
|
||||||
|
portal. An external account only sees the entities granted to it.
|
||||||
|
- **Investor** — sees, per fund, their latest capital-account value and history, plus
|
||||||
|
documents shared to the fund or addressed privately to them (e.g. their K-1).
|
||||||
|
- **Fund administrator** — sees assigned entities and can upload documents for them
|
||||||
|
(shared or addressed to a specific investor).
|
||||||
|
|
||||||
|
Admin screens (Users / Documents / Capital Accounts, visible to `approver` and `cfo`)
|
||||||
|
let you create an account with a username and password, check off which entities it can
|
||||||
|
view, upload documents, and enter each investor's capital-account figures.
|
||||||
|
|
||||||
|
Login accepts a username **or** an email. The first admin is created from the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ten31portal-cli create-user --name "You" --username admin --role cfo --password '...'
|
||||||
|
# --email is optional; external accounts are normally created from the Users screen.
|
||||||
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""investor access: username, entity access, documents, capital accounts
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 2792c4ff4612
|
||||||
|
Create Date: 2026-06-26 14:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a1b2c3d4e5f6'
|
||||||
|
down_revision: Union[str, None] = '2792c4ff4612'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# users: add username (login handle), make email optional.
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.alter_column('email', existing_type=sa.String(), nullable=True)
|
||||||
|
|
||||||
|
# Backfill username for any existing internal accounts so the NOT NULL holds.
|
||||||
|
op.execute("UPDATE users SET username = email WHERE username IS NULL")
|
||||||
|
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'username',
|
||||||
|
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
batch_op.create_unique_constraint('uq_users_username', ['username'])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'entity_access',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'entity_id', name='uq_access_user_entity'),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'documents',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('investor_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('category', sa.Enum('capital_account', 'k1', 'statement', 'tax', 'other', name='documentcategory'), nullable=False),
|
||||||
|
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('original_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('storage_path', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
sa.Column('uploaded_by', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'capital_account_statements',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('investor_user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('as_of_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('beginning_balance_cents', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('contributions_cents', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('distributions_cents', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('ending_balance_cents', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('document_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('entity_id', 'investor_user_id', 'as_of_date', name='uq_capacct_entity_investor_date'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_table('capital_account_statements')
|
||||||
|
op.drop_table('documents')
|
||||||
|
op.drop_table('entity_access')
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('uq_users_username', type_='unique')
|
||||||
|
batch_op.alter_column('email', existing_type=sa.String(), nullable=False)
|
||||||
|
batch_op.drop_column('username')
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add users.external_investor_id
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-06-28 11:10:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'b2c3d4e5f6a7'
|
||||||
|
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('external_investor_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('external_investor_id')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""add entity_stakes (a holder entity's stake in the funds it manages)
|
||||||
|
|
||||||
|
Revision ID: b8c9d0e1f2a3
|
||||||
|
Revises: a7b8c9d0e1f2
|
||||||
|
Create Date: 2026-07-01 10:15:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'b8c9d0e1f2a3'
|
||||||
|
down_revision: Union[str, None] = 'a7b8c9d0e1f2'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'entity_stakes',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, nullable=False),
|
||||||
|
sa.Column('holder_entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('fund_entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('ownership_pct', sa.Float(), nullable=True),
|
||||||
|
sa.Column('value_cents', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('note', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['holder_entity_id'], ['entities.id']),
|
||||||
|
sa.ForeignKeyConstraint(['fund_entity_id'], ['entities.id']),
|
||||||
|
sa.UniqueConstraint('holder_entity_id', 'fund_entity_id', name='uq_stake_holder_fund'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_entity_stakes_holder_entity_id', 'entity_stakes', ['holder_entity_id'])
|
||||||
|
op.create_index('ix_entity_stakes_fund_entity_id', 'entity_stakes', ['fund_entity_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_entity_stakes_fund_entity_id', table_name='entity_stakes')
|
||||||
|
op.drop_index('ix_entity_stakes_holder_entity_id', table_name='entity_stakes')
|
||||||
|
op.drop_table('entity_stakes')
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add users.login_enabled
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: b2c3d4e5f6a7
|
||||||
|
Create Date: 2026-06-28 11:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'c3d4e5f6a7b8'
|
||||||
|
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('login_enabled', sa.Boolean(), nullable=False, server_default=sa.true())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('login_enabled')
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add capital_account_statements.commitment_cents
|
||||||
|
|
||||||
|
Revision ID: d4e5f6a7b8c9
|
||||||
|
Revises: c3d4e5f6a7b8
|
||||||
|
Create Date: 2026-06-28 16:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'd4e5f6a7b8c9'
|
||||||
|
down_revision: Union[str, None] = 'c3d4e5f6a7b8'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('capital_account_statements', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('commitment_cents', sa.Integer(), nullable=False, server_default='0')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('capital_account_statements', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('commitment_cents')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add users.primary_account_id (linked investor logins)
|
||||||
|
|
||||||
|
Revision ID: e5f6a7b8c9d0
|
||||||
|
Revises: d4e5f6a7b8c9
|
||||||
|
Create Date: 2026-06-28 17:10:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'e5f6a7b8c9d0'
|
||||||
|
down_revision: Union[str, None] = 'd4e5f6a7b8c9'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('primary_account_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.create_foreign_key(
|
||||||
|
'fk_users_primary_account_id', 'users', ['primary_account_id'], ['id']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('fk_users_primary_account_id', type_='foreignkey')
|
||||||
|
batch_op.drop_column('primary_account_id')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add users.is_service_admin and flag the bootstrap admin
|
||||||
|
|
||||||
|
Revision ID: f6a7b8c9d0e1
|
||||||
|
Revises: e5f6a7b8c9d0
|
||||||
|
Create Date: 2026-06-29 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'f6a7b8c9d0e1'
|
||||||
|
down_revision: Union[str, None] = 'e5f6a7b8c9d0'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('is_service_admin', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||||
|
)
|
||||||
|
# The first account created (the first-boot bootstrap admin) is the Service Admin.
|
||||||
|
op.execute(
|
||||||
|
"UPDATE users SET is_service_admin = 1 "
|
||||||
|
"WHERE id = (SELECT MIN(id) FROM users)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_service_admin')
|
||||||
@@ -5,10 +5,10 @@ from typing import Annotated
|
|||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import User, UserRole
|
from ten31portal.models import EntityAccess, User, UserRole, EXTERNAL_ROLES
|
||||||
|
|
||||||
ph = PasswordHasher()
|
ph = PasswordHasher()
|
||||||
|
|
||||||
@@ -44,8 +44,51 @@ def require_role(*roles: UserRole):
|
|||||||
return checker
|
return checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_internal(user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Block external (entity-scoped) accounts from internal staff endpoints."""
|
||||||
|
if user.role in EXTERNAL_ROLES:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def household_user_ids(user: User, session: Session) -> list[int]:
|
||||||
|
"""All account ids that share this user's login.
|
||||||
|
|
||||||
|
An investor who invests under several legal names has one "primary" account (the login)
|
||||||
|
and one or more secondary accounts linked to it via ``primary_account_id``. Signing in as
|
||||||
|
the primary should surface every linked name's entities, statements, and documents. For a
|
||||||
|
standalone account this is just ``[user.id]``.
|
||||||
|
"""
|
||||||
|
root_id = user.primary_account_id or user.id
|
||||||
|
linked = session.exec(
|
||||||
|
select(User.id).where(User.primary_account_id == root_id)
|
||||||
|
).all()
|
||||||
|
return list({root_id, user.id, *linked})
|
||||||
|
|
||||||
|
|
||||||
|
def accessible_entity_ids(user: User, session: Session) -> set[int] | None:
|
||||||
|
"""Entity ids an external account may view. None means unrestricted (internal staff)."""
|
||||||
|
if user.role not in EXTERNAL_ROLES:
|
||||||
|
return None
|
||||||
|
rows = session.exec(
|
||||||
|
select(EntityAccess.entity_id).where(
|
||||||
|
col(EntityAccess.user_id).in_(household_user_ids(user, session))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_entity(user: User, entity_id: int, session: Session) -> bool:
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
return allowed is None or entity_id in allowed
|
||||||
|
|
||||||
|
|
||||||
# Convenience aliases
|
# Convenience aliases
|
||||||
require_user = get_current_user
|
require_user = get_current_user
|
||||||
require_writer = require_role(UserRole.fund_admin, UserRole.cfo, UserRole.approver)
|
require_writer = require_role(
|
||||||
require_approver = require_role(UserRole.approver)
|
UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations
|
||||||
require_audit_reader = require_role(UserRole.approver, UserRole.cfo)
|
)
|
||||||
|
require_approver = require_role(UserRole.approver) # final sign-off — Managing Partners only
|
||||||
|
require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
|
||||||
|
# Account administration (create/manage users, documents, capital accounts)
|
||||||
|
require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
@@ -18,16 +19,26 @@ from ten31portal.routers.holding_router import router as holding_router
|
|||||||
from ten31portal.routers.position_router import router as position_router
|
from ten31portal.routers.position_router import router as position_router
|
||||||
from ten31portal.routers.round_router import router as round_router
|
from ten31portal.routers.round_router import router as round_router
|
||||||
from ten31portal.routers.import_router import router as import_router
|
from ten31portal.routers.import_router import router as import_router
|
||||||
|
from ten31portal.routers.user_router import router as user_router
|
||||||
|
from ten31portal.routers.document_router import router as document_router
|
||||||
|
from ten31portal.routers.capital_account_router import router as capital_account_router
|
||||||
|
from ten31portal.routers.capital_import_router import router as capital_import_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
run_migrations()
|
run_migrations()
|
||||||
|
from ten31portal.storage import ensure_docs_dir
|
||||||
|
ensure_docs_dir()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
|
||||||
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
|
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
|
||||||
|
# Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after
|
||||||
|
# SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips
|
||||||
|
# tiny payloads (health checks, small JSON) where compression isn't worth it.
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(audit_router)
|
app.include_router(audit_router)
|
||||||
app.include_router(entity_router)
|
app.include_router(entity_router)
|
||||||
@@ -35,6 +46,10 @@ app.include_router(holding_router)
|
|||||||
app.include_router(position_router)
|
app.include_router(position_router)
|
||||||
app.include_router(round_router)
|
app.include_router(round_router)
|
||||||
app.include_router(import_router)
|
app.include_router(import_router)
|
||||||
|
app.include_router(user_router)
|
||||||
|
app.include_router(document_router)
|
||||||
|
app.include_router(capital_account_router)
|
||||||
|
app.include_router(capital_import_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@@ -45,12 +60,29 @@ def health() -> dict[str, str]:
|
|||||||
# Serve built frontend in production (when static/ dir exists next to the app)
|
# Serve built frontend in production (when static/ dir exists next to the app)
|
||||||
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||||
if _static_dir.is_dir():
|
if _static_dir.is_dir():
|
||||||
@app.get("/{path:path}")
|
# index.html must always be revalidated so a new build is picked up right after an
|
||||||
|
# upgrade (its hashed asset references change). Hashed assets themselves are immutable.
|
||||||
|
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
|
||||||
|
# Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever.
|
||||||
|
_IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||||
|
_ONE_DAY = {"Cache-Control": "public, max-age=86400"}
|
||||||
|
|
||||||
|
def _index() -> FileResponse:
|
||||||
|
return FileResponse(_static_dir / "index.html", headers=_NO_CACHE)
|
||||||
|
|
||||||
|
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
|
||||||
async def serve_spa(path: str):
|
async def serve_spa(path: str):
|
||||||
file = _static_dir / path
|
file = _static_dir / path
|
||||||
if file.is_file():
|
if file.is_file():
|
||||||
|
# Don't let the HTML entrypoint get cached; fingerprinted assets can cache.
|
||||||
|
if file.name == "index.html":
|
||||||
|
return _index()
|
||||||
|
if path.startswith("assets/"):
|
||||||
|
return FileResponse(file, headers=_IMMUTABLE)
|
||||||
|
if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"):
|
||||||
|
return FileResponse(file, headers=_ONE_DAY)
|
||||||
return FileResponse(file)
|
return FileResponse(file)
|
||||||
return FileResponse(_static_dir / "index.html")
|
return _index()
|
||||||
|
|
||||||
|
|
||||||
def cli() -> None:
|
def cli() -> None:
|
||||||
|
|||||||
@@ -207,3 +207,23 @@ class CapitalAccountStatement(SQLModel, table=True):
|
|||||||
ending_balance_cents: int = 0 # current capital value
|
ending_balance_cents: int = 0 # current capital value
|
||||||
document_id: int | None = Field(default=None, foreign_key="documents.id", index=True)
|
document_id: int | None = Field(default=None, foreign_key="documents.id", index=True)
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class EntityStake(SQLModel, table=True):
|
||||||
|
"""A holder entity's ownership stake in a fund/SPV it manages.
|
||||||
|
|
||||||
|
Models the assets of a GP or management company: its interest in each fund it manages,
|
||||||
|
which are entities in their own right rather than portfolio-company holdings.
|
||||||
|
"""
|
||||||
|
__tablename__ = "entity_stakes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("holder_entity_id", "fund_entity_id", name="uq_stake_holder_fund"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
holder_entity_id: int = Field(foreign_key="entities.id", index=True) # the GP / mgmt co
|
||||||
|
fund_entity_id: int = Field(foreign_key="entities.id", index=True) # the fund/SPV held
|
||||||
|
ownership_pct: float | None = None # e.g. 20.0 for a 20% interest
|
||||||
|
value_cents: int | None = None # optional current value of the stake
|
||||||
|
note: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlmodel import Session, select
|
|||||||
from ten31portal.auth import get_current_user, hash_password, verify_password
|
from ten31portal.auth import get_current_user, hash_password, verify_password
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import User
|
from ten31portal.models import User
|
||||||
from ten31portal.schemas import LoginRequest, UserResponse
|
from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -17,9 +17,20 @@ def login(
|
|||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
user = session.exec(select(User).where(User.email == body.email)).first()
|
# Accept either a username or an email in the login field.
|
||||||
|
handle = body.login.strip()
|
||||||
|
user = session.exec(select(User).where(User.username == handle)).first()
|
||||||
|
if user is None:
|
||||||
|
user = session.exec(select(User).where(User.email == handle)).first()
|
||||||
if user is None or not verify_password(body.password, user.password_hash):
|
if user is None or not verify_password(body.password, user.password_hash):
|
||||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
if user.primary_account_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="This account signs in under another login. Use that account's credentials.",
|
||||||
|
)
|
||||||
|
if not user.login_enabled:
|
||||||
|
raise HTTPException(status_code=401, detail="This account does not have a login yet.")
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(status_code=401, detail="Account disabled")
|
raise HTTPException(status_code=401, detail="Account disabled")
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
@@ -35,3 +46,20 @@ def logout(request: Request) -> dict[str, str]:
|
|||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
def me(user: User = Depends(get_current_user)) -> UserResponse:
|
def me(user: User = Depends(get_current_user)) -> UserResponse:
|
||||||
return UserResponse.model_validate(user, from_attributes=True)
|
return UserResponse.model_validate(user, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Let the signed-in user set their own password (after confirming the current one)."""
|
||||||
|
if not verify_password(body.current_password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect.")
|
||||||
|
if len(body.new_password) < 4:
|
||||||
|
raise HTTPException(status_code=400, detail="New password must be at least 4 characters.")
|
||||||
|
user.password_hash = hash_password(body.new_password)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Capital account statements: admin entry, investor read of their own figures."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
|
from ten31portal.audit import record_audit
|
||||||
|
from ten31portal.auth import (
|
||||||
|
accessible_entity_ids, get_current_user, household_user_ids,
|
||||||
|
require_internal_admin,
|
||||||
|
)
|
||||||
|
from ten31portal.database import get_session
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, User, UserRole,
|
||||||
|
)
|
||||||
|
from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/capital-accounts", tags=["capital-accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _dollars_to_cents(dollars: float) -> int:
|
||||||
|
return round(dollars * 100)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_statements(
|
||||||
|
entity_id: int | None = None,
|
||||||
|
investor_user_id: int | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[CapitalAccountResponse]:
|
||||||
|
query = select(CapitalAccountStatement)
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
|
||||||
|
if allowed is not None:
|
||||||
|
# External accounts see statements for every legal name linked to their login.
|
||||||
|
query = query.where(
|
||||||
|
col(CapitalAccountStatement.investor_user_id).in_(household_user_ids(user, session))
|
||||||
|
)
|
||||||
|
if allowed:
|
||||||
|
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
if investor_user_id is not None:
|
||||||
|
query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id)
|
||||||
|
|
||||||
|
if entity_id is not None:
|
||||||
|
query = query.where(CapitalAccountStatement.entity_id == entity_id)
|
||||||
|
|
||||||
|
rows = session.exec(
|
||||||
|
query.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||||
|
).all()
|
||||||
|
# Attach each statement's legal name so the portal can label/group accounts held under
|
||||||
|
# different names without an admin-only user lookup.
|
||||||
|
names = dict(session.exec(
|
||||||
|
select(User.id, User.name).where(
|
||||||
|
col(User.id).in_({r.investor_user_id for r in rows})
|
||||||
|
)
|
||||||
|
).all()) if rows else {}
|
||||||
|
out: list[CapitalAccountResponse] = []
|
||||||
|
for r in rows:
|
||||||
|
data = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||||
|
data.investor_name = names.get(r.investor_user_id)
|
||||||
|
out.append(data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
def create_statement(
|
||||||
|
body: CapitalAccountCreate,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> CapitalAccountResponse:
|
||||||
|
if session.get(Entity, body.entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
investor = session.get(User, body.investor_user_id)
|
||||||
|
if investor is None or investor.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="investor_user_id must be an investor account")
|
||||||
|
|
||||||
|
existing = session.exec(
|
||||||
|
select(CapitalAccountStatement).where(
|
||||||
|
CapitalAccountStatement.entity_id == body.entity_id,
|
||||||
|
CapitalAccountStatement.investor_user_id == body.investor_user_id,
|
||||||
|
CapitalAccountStatement.as_of_date == body.as_of_date,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="A statement for this investor, fund, and date already exists.",
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = CapitalAccountStatement(
|
||||||
|
entity_id=body.entity_id,
|
||||||
|
investor_user_id=body.investor_user_id,
|
||||||
|
as_of_date=body.as_of_date,
|
||||||
|
commitment_cents=_dollars_to_cents(body.commitment_dollars),
|
||||||
|
beginning_balance_cents=_dollars_to_cents(body.beginning_balance_dollars),
|
||||||
|
contributions_cents=_dollars_to_cents(body.contributions_dollars),
|
||||||
|
distributions_cents=_dollars_to_cents(body.distributions_dollars),
|
||||||
|
ending_balance_cents=_dollars_to_cents(body.ending_balance_dollars),
|
||||||
|
document_id=body.document_id,
|
||||||
|
)
|
||||||
|
session.add(stmt)
|
||||||
|
session.flush()
|
||||||
|
record_audit(session, admin.id, "create", "capital_account", stmt.id, {
|
||||||
|
"entity_id": body.entity_id,
|
||||||
|
"investor_user_id": body.investor_user_id,
|
||||||
|
"as_of_date": str(body.as_of_date),
|
||||||
|
"ending_balance_cents": stmt.ending_balance_cents,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(stmt)
|
||||||
|
return CapitalAccountResponse.model_validate(stmt, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{statement_id}")
|
||||||
|
def delete_statement(
|
||||||
|
statement_id: int,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
stmt = session.get(CapitalAccountStatement, statement_id)
|
||||||
|
if stmt is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Statement not found")
|
||||||
|
record_audit(session, admin.id, "delete", "capital_account", statement_id, None)
|
||||||
|
session.delete(stmt)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
"""Import fund members and their capital figures from a fund-administrator workbook.
|
||||||
|
|
||||||
|
Primary path: an eNAV workbook's "ALLOC SI" tab (one row per investor with INVESTOR ID,
|
||||||
|
INVESTOR NAME, COMMITTED CAPITAL, CONTRIBUTIONS, (DISTRIBUTIONS), ENDING BALANCE).
|
||||||
|
Fallback: a subaccounts sheet (investor names across columns, per-vehicle value rows).
|
||||||
|
|
||||||
|
Encrypted workbooks are decrypted with the open password. Nothing is written on preview.
|
||||||
|
Commit matches existing members (by fund-admin investor ID, else name), creates new ones
|
||||||
|
(without a login unless a password is given), grants entity access, and loads each member's
|
||||||
|
capital-account statement (commitment, contributions, distributions, current value).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ten31portal.audit import record_audit
|
||||||
|
from ten31portal.auth import hash_password, require_internal_admin
|
||||||
|
from ten31portal.database import get_session
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
||||||
|
)
|
||||||
|
from ten31portal.routers.import_router import _open_workbook, _enav_as_of
|
||||||
|
from ten31portal.schemas import (
|
||||||
|
CapitalImportCommit, CapitalImportPreview, ImportInvestorPreview, ImportValueRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/import/capital-accounts", tags=["import"])
|
||||||
|
|
||||||
|
MAX_ROWS = 400
|
||||||
|
MAX_COLS = 90
|
||||||
|
|
||||||
|
|
||||||
|
def _slug_username(name: str) -> str:
|
||||||
|
base = re.sub(r"[^a-z0-9]+", "", name.lower())
|
||||||
|
return base or "investor"
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(v) -> float | None:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
try:
|
||||||
|
return float(str(v).replace(",", "").replace("$", "").strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- ALLOC SI (eNAV investor roster) ---
|
||||||
|
|
||||||
|
def _parse_alloc_si(wb):
|
||||||
|
"""Return (as_of, investors) from an eNAV ALLOC SI sheet.
|
||||||
|
|
||||||
|
investors: [{name, external_id, commitment, contributions, distributions, ending}].
|
||||||
|
"""
|
||||||
|
ws = wb["ALLOC SI"]
|
||||||
|
rows = list(ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
|
||||||
|
|
||||||
|
header_idx = None
|
||||||
|
header: list[str] = []
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
cells = [str(c).strip().upper() if isinstance(c, str) else "" for c in row]
|
||||||
|
if "INVESTOR ID" in cells and "INVESTOR NAME" in cells:
|
||||||
|
header_idx, header = i, cells
|
||||||
|
break
|
||||||
|
if header_idx is None:
|
||||||
|
raise HTTPException(status_code=422, detail="Could not find the ALLOC SI header row.")
|
||||||
|
|
||||||
|
def col(*names: str) -> int | None:
|
||||||
|
for n in names:
|
||||||
|
if n in header:
|
||||||
|
return header.index(n)
|
||||||
|
return None
|
||||||
|
|
||||||
|
c_id = col("INVESTOR ID")
|
||||||
|
c_type = col("INVESTOR TYPE")
|
||||||
|
c_name = col("INVESTOR NAME")
|
||||||
|
c_commit = col("COMMITTED CAPITAL")
|
||||||
|
c_contrib = col("CONTRIBUTIONS")
|
||||||
|
c_distrib = col("(DISTRIBUTIONS)", "DISTRIBUTIONS")
|
||||||
|
c_ending = col("ENDING BALANCE")
|
||||||
|
if c_ending is None:
|
||||||
|
raise HTTPException(status_code=422, detail="ALLOC SI is missing an ENDING BALANCE column.")
|
||||||
|
|
||||||
|
def amount(row, ci):
|
||||||
|
if ci is None or ci >= len(row):
|
||||||
|
return 0.0
|
||||||
|
return _as_float(row[ci]) or 0.0
|
||||||
|
|
||||||
|
investors: list[dict] = []
|
||||||
|
for r in range(header_idx + 1, len(rows)):
|
||||||
|
row = rows[r]
|
||||||
|
name_raw = row[c_name] if c_name is not None and c_name < len(row) else None
|
||||||
|
if not (isinstance(name_raw, str) and name_raw.strip()):
|
||||||
|
continue
|
||||||
|
nm = name_raw.strip()
|
||||||
|
if nm.upper() in ("GP", "LP", "TOTAL"):
|
||||||
|
continue
|
||||||
|
itype = row[c_type] if c_type is not None and c_type < len(row) else None
|
||||||
|
if itype is not None and str(itype).strip().upper() not in ("LP", ""):
|
||||||
|
continue # investors only (skip the GP entity)
|
||||||
|
|
||||||
|
display = None
|
||||||
|
if c_name is not None and c_name + 1 < len(row) and isinstance(row[c_name + 1], str) and row[c_name + 1].strip():
|
||||||
|
display = row[c_name + 1].strip()
|
||||||
|
name = display or nm.title()
|
||||||
|
|
||||||
|
inv_id = row[c_id] if c_id is not None and c_id < len(row) else None
|
||||||
|
external_id = str(inv_id).strip() if inv_id not in (None, "") else None
|
||||||
|
|
||||||
|
investors.append({
|
||||||
|
"name": name,
|
||||||
|
"external_id": external_id,
|
||||||
|
"commitment": amount(row, c_commit),
|
||||||
|
"contributions": amount(row, c_contrib),
|
||||||
|
"distributions": abs(amount(row, c_distrib)), # sheet may show as a credit
|
||||||
|
"ending": amount(row, c_ending),
|
||||||
|
})
|
||||||
|
|
||||||
|
return _enav_as_of(wb), investors
|
||||||
|
|
||||||
|
|
||||||
|
# --- Subaccounts (names-across-columns) ---
|
||||||
|
|
||||||
|
def _parse_grid(wb):
|
||||||
|
ws = wb[wb.sheetnames[0]]
|
||||||
|
grid: list[list] = []
|
||||||
|
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True):
|
||||||
|
grid.append(list(row))
|
||||||
|
|
||||||
|
header_idx = None
|
||||||
|
total_col = None
|
||||||
|
for i, row in enumerate(grid):
|
||||||
|
for j, cell in enumerate(row):
|
||||||
|
if isinstance(cell, str) and cell.strip().lower() == "total":
|
||||||
|
header_idx, total_col = i, j
|
||||||
|
break
|
||||||
|
if header_idx is not None:
|
||||||
|
break
|
||||||
|
if header_idx is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail='Could not find a header row with a "Total" column. Check the spreadsheet layout.',
|
||||||
|
)
|
||||||
|
|
||||||
|
header = grid[header_idx]
|
||||||
|
investor_cols = [
|
||||||
|
j for j in range(len(header))
|
||||||
|
if j < total_col
|
||||||
|
and isinstance(header[j], str)
|
||||||
|
and header[j].strip().lower() not in ("", "total")
|
||||||
|
]
|
||||||
|
if not investor_cols:
|
||||||
|
raise HTTPException(status_code=422, detail="No investor name columns found left of the Total column.")
|
||||||
|
|
||||||
|
as_of: date | None = None
|
||||||
|
for row in grid[: header_idx + 2]:
|
||||||
|
for cell in row:
|
||||||
|
if isinstance(cell, (datetime, date)):
|
||||||
|
as_of = cell.date() if isinstance(cell, datetime) else cell
|
||||||
|
break
|
||||||
|
if as_of:
|
||||||
|
break
|
||||||
|
|
||||||
|
value_rows: list[ImportValueRow] = []
|
||||||
|
for i in range(header_idx + 1, len(grid)):
|
||||||
|
row = grid[i]
|
||||||
|
label = row[0]
|
||||||
|
if not (isinstance(label, str) and label.strip()):
|
||||||
|
continue
|
||||||
|
if any(_as_float(row[j]) is not None for j in investor_cols if j < len(row)):
|
||||||
|
value_rows.append(ImportValueRow(row_index=i, label=label.strip()))
|
||||||
|
if not value_rows:
|
||||||
|
raise HTTPException(status_code=422, detail="No value rows found under the header.")
|
||||||
|
|
||||||
|
return header_idx, total_col, investor_cols, as_of, value_rows, grid
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
def preview_import(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
entity_id: int | None = Form(None),
|
||||||
|
row_index: int | None = Form(None),
|
||||||
|
password: str | None = Form(None),
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> CapitalImportPreview:
|
||||||
|
if entity_id is not None and session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|
||||||
|
wb = _open_workbook(file.file.read(), password)
|
||||||
|
|
||||||
|
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):
|
||||||
|
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())
|
||||||
|
|
||||||
|
if "ALLOC SI" in wb.sheetnames:
|
||||||
|
as_of, roster = _parse_alloc_si(wb)
|
||||||
|
previews: list[ImportInvestorPreview] = []
|
||||||
|
for inv in roster:
|
||||||
|
m = match_for(inv["name"], inv["external_id"])
|
||||||
|
previews.append(ImportInvestorPreview(
|
||||||
|
source_name=inv["name"],
|
||||||
|
column_index=0,
|
||||||
|
value_dollars=inv["ending"],
|
||||||
|
commitment_dollars=inv["commitment"],
|
||||||
|
contributions_dollars=inv["contributions"],
|
||||||
|
distributions_dollars=inv["distributions"],
|
||||||
|
external_id=inv["external_id"],
|
||||||
|
matched_user_id=m.id if m else None,
|
||||||
|
matched_username=m.username if m else None,
|
||||||
|
suggested_username=None if m else _slug_username(inv["name"]),
|
||||||
|
))
|
||||||
|
# Roster mode: no per-row value picker needed.
|
||||||
|
return CapitalImportPreview(as_of_date=as_of, value_rows=[], chosen_row_index=0, investors=previews)
|
||||||
|
|
||||||
|
# Fallback: subaccounts layout (single value figure per investor).
|
||||||
|
header_idx, total_col, investor_cols, as_of, value_rows, grid = _parse_grid(wb)
|
||||||
|
valid_indexes = {vr.row_index for vr in value_rows}
|
||||||
|
chosen = row_index if row_index in valid_indexes else value_rows[0].row_index
|
||||||
|
chosen_row = grid[chosen]
|
||||||
|
|
||||||
|
previews = []
|
||||||
|
for col_i in investor_cols:
|
||||||
|
source_name = str(grid[header_idx][col_i]).strip()
|
||||||
|
value = _as_float(chosen_row[col_i]) if col_i < len(chosen_row) else None
|
||||||
|
m = match_for(source_name, None)
|
||||||
|
previews.append(ImportInvestorPreview(
|
||||||
|
source_name=source_name,
|
||||||
|
column_index=col_i,
|
||||||
|
value_dollars=value or 0.0,
|
||||||
|
matched_user_id=m.id if m else None,
|
||||||
|
matched_username=m.username if m else None,
|
||||||
|
suggested_username=None if m else _slug_username(source_name),
|
||||||
|
))
|
||||||
|
return CapitalImportPreview(as_of_date=as_of, value_rows=value_rows, chosen_row_index=chosen, investors=previews)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commit")
|
||||||
|
def commit_import(
|
||||||
|
body: CapitalImportCommit,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
entity = session.get(Entity, body.entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|
||||||
|
created_accounts = 0
|
||||||
|
updated_accounts = 0
|
||||||
|
statements = 0
|
||||||
|
|
||||||
|
for inv in body.investors:
|
||||||
|
if inv.action == "skip":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if inv.action == "create":
|
||||||
|
if not inv.username or not inv.name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"New member '{inv.name or inv.username}' needs a name and username.",
|
||||||
|
)
|
||||||
|
if session.exec(select(User).where(User.username == inv.username)).first():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.")
|
||||||
|
if inv.email and session.exec(select(User).where(User.email == inv.email)).first():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.")
|
||||||
|
pw = inv.password or secrets.token_urlsafe(32)
|
||||||
|
user = User(
|
||||||
|
name=inv.name,
|
||||||
|
username=inv.username,
|
||||||
|
email=inv.email or None,
|
||||||
|
password_hash=hash_password(pw),
|
||||||
|
role=UserRole.investor,
|
||||||
|
login_enabled=bool(inv.password),
|
||||||
|
external_investor_id=inv.external_id,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
created_accounts += 1
|
||||||
|
elif inv.action == "match":
|
||||||
|
user = session.get(User, inv.user_id) if inv.user_id else None
|
||||||
|
if user is None or user.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="match requires a valid investor user_id.")
|
||||||
|
if inv.external_id and not user.external_investor_id:
|
||||||
|
user.external_investor_id = inv.external_id
|
||||||
|
session.add(user)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown action '{inv.action}'.")
|
||||||
|
|
||||||
|
has_access = session.exec(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
EntityAccess.user_id == user.id, EntityAccess.entity_id == body.entity_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if has_access is None:
|
||||||
|
session.add(EntityAccess(user_id=user.id, entity_id=body.entity_id))
|
||||||
|
|
||||||
|
cents = lambda d: round(d * 100)
|
||||||
|
existing = session.exec(
|
||||||
|
select(CapitalAccountStatement).where(
|
||||||
|
CapitalAccountStatement.entity_id == body.entity_id,
|
||||||
|
CapitalAccountStatement.investor_user_id == user.id,
|
||||||
|
CapitalAccountStatement.as_of_date == body.as_of_date,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.commitment_cents = cents(inv.commitment_dollars)
|
||||||
|
existing.contributions_cents = cents(inv.contributions_dollars)
|
||||||
|
existing.distributions_cents = cents(inv.distributions_dollars)
|
||||||
|
existing.ending_balance_cents = cents(inv.value_dollars)
|
||||||
|
session.add(existing)
|
||||||
|
updated_accounts += 1
|
||||||
|
else:
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=body.entity_id,
|
||||||
|
investor_user_id=user.id,
|
||||||
|
as_of_date=body.as_of_date,
|
||||||
|
commitment_cents=cents(inv.commitment_dollars),
|
||||||
|
contributions_cents=cents(inv.contributions_dollars),
|
||||||
|
distributions_cents=cents(inv.distributions_dollars),
|
||||||
|
ending_balance_cents=cents(inv.value_dollars),
|
||||||
|
))
|
||||||
|
statements += 1
|
||||||
|
|
||||||
|
record_audit(session, admin.id, "import", "capital_account", body.entity_id, {
|
||||||
|
"as_of_date": str(body.as_of_date),
|
||||||
|
"created_accounts": created_accounts,
|
||||||
|
"statements": statements,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"created_accounts": created_accounts,
|
||||||
|
"matched_accounts_updated": updated_accounts,
|
||||||
|
"statements_written": statements,
|
||||||
|
}
|
||||||
@@ -3,16 +3,21 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, literal
|
from sqlalchemy import func, literal
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, col, 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 (
|
||||||
|
accessible_entity_ids, get_current_user, require_internal, require_writer,
|
||||||
|
)
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
Entity, EntityStatus, Holding, Position,
|
CapitalAccountStatement, Entity, EntityAccess, EntityStake, EntityStatus, Holding,
|
||||||
Valuation, ValuationRound, RoundStatus, User,
|
Position, UserRole, Valuation, ValuationRound, RoundStatus, User,
|
||||||
|
)
|
||||||
|
from ten31portal.schemas import (
|
||||||
|
EntityCreate, EntityResponse, EntityStakeCreate, EntityStakeResponse, EntityUpdate,
|
||||||
|
PartnerResponse,
|
||||||
)
|
)
|
||||||
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ class EntityRollupItem(BaseModel):
|
|||||||
vintage_year: int | None
|
vintage_year: int | None
|
||||||
fund_size_cents: int | None
|
fund_size_cents: int | None
|
||||||
status: str
|
status: str
|
||||||
|
committed_cents: int # total LP commitments (latest per investor)
|
||||||
invested_cents: int
|
invested_cents: int
|
||||||
last_signed_value_cents: int
|
last_signed_value_cents: int
|
||||||
|
|
||||||
@@ -34,7 +40,10 @@ def entity_rollup(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[EntityRollupItem]:
|
) -> list[EntityRollupItem]:
|
||||||
"""Per-entity invested and last-signed-value in a single pass."""
|
"""Per-entity invested and last-signed-value in a single pass."""
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
entities = session.exec(select(Entity)).all()
|
entities = session.exec(select(Entity)).all()
|
||||||
|
if allowed is not None:
|
||||||
|
entities = [e for e in entities if e.id in allowed]
|
||||||
result: list[EntityRollupItem] = []
|
result: list[EntityRollupItem] = []
|
||||||
|
|
||||||
for ent in entities:
|
for ent in entities:
|
||||||
@@ -64,6 +73,20 @@ def entity_rollup(
|
|||||||
).one()
|
).one()
|
||||||
last_signed_value_cents = int(val_sum)
|
last_signed_value_cents = int(val_sum)
|
||||||
|
|
||||||
|
# Total committed capital = each investor's most recent commitment for this entity.
|
||||||
|
stmts = session.exec(
|
||||||
|
select(CapitalAccountStatement)
|
||||||
|
.where(CapitalAccountStatement.entity_id == ent.id)
|
||||||
|
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
|
||||||
|
).all()
|
||||||
|
committed_cents = 0
|
||||||
|
seen_investors: set[int] = set()
|
||||||
|
for st in stmts:
|
||||||
|
if st.investor_user_id in seen_investors:
|
||||||
|
continue
|
||||||
|
seen_investors.add(st.investor_user_id)
|
||||||
|
committed_cents += st.commitment_cents
|
||||||
|
|
||||||
result.append(EntityRollupItem(
|
result.append(EntityRollupItem(
|
||||||
id=ent.id,
|
id=ent.id,
|
||||||
name=ent.name,
|
name=ent.name,
|
||||||
@@ -71,6 +94,7 @@ def entity_rollup(
|
|||||||
vintage_year=ent.vintage_year,
|
vintage_year=ent.vintage_year,
|
||||||
fund_size_cents=ent.fund_size_cents,
|
fund_size_cents=ent.fund_size_cents,
|
||||||
status=ent.status.value,
|
status=ent.status.value,
|
||||||
|
committed_cents=committed_cents,
|
||||||
invested_cents=invested_cents,
|
invested_cents=invested_cents,
|
||||||
last_signed_value_cents=last_signed_value_cents,
|
last_signed_value_cents=last_signed_value_cents,
|
||||||
))
|
))
|
||||||
@@ -78,12 +102,60 @@ def entity_rollup(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_id}/partners")
|
||||||
|
def list_partners(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(require_internal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[PartnerResponse]:
|
||||||
|
"""Members (investors) granted access to this entity, with their latest capital value."""
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|
||||||
|
members = session.exec(
|
||||||
|
select(User)
|
||||||
|
.join(EntityAccess, EntityAccess.user_id == User.id)
|
||||||
|
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
|
||||||
|
.order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result: list[PartnerResponse] = []
|
||||||
|
for m in members:
|
||||||
|
stmts = session.exec(
|
||||||
|
select(CapitalAccountStatement)
|
||||||
|
.where(
|
||||||
|
CapitalAccountStatement.entity_id == entity_id,
|
||||||
|
CapitalAccountStatement.investor_user_id == m.id,
|
||||||
|
)
|
||||||
|
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
|
||||||
|
).all()
|
||||||
|
latest = stmts[0] if stmts else None
|
||||||
|
result.append(PartnerResponse(
|
||||||
|
user_id=m.id,
|
||||||
|
name=m.name,
|
||||||
|
username=m.username,
|
||||||
|
external_investor_id=m.external_investor_id,
|
||||||
|
is_active=m.is_active,
|
||||||
|
login_enabled=m.login_enabled,
|
||||||
|
latest_commitment_cents=latest.commitment_cents if latest else None,
|
||||||
|
latest_contributions_cents=latest.contributions_cents if latest else None,
|
||||||
|
latest_distributions_cents=latest.distributions_cents if latest else None,
|
||||||
|
latest_value_cents=latest.ending_balance_cents if latest else None,
|
||||||
|
latest_as_of=latest.as_of_date if latest else None,
|
||||||
|
statements_count=len(stmts),
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_entities(
|
def list_entities(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[EntityResponse]:
|
) -> list[EntityResponse]:
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
rows = session.exec(select(Entity)).all()
|
rows = session.exec(select(Entity)).all()
|
||||||
|
if allowed is not None:
|
||||||
|
rows = [r for r in rows if r.id in allowed]
|
||||||
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
|
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +165,9 @@ def get_entity(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> EntityResponse:
|
) -> EntityResponse:
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
if allowed is not None and entity_id not in allowed:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
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")
|
||||||
@@ -133,3 +208,96 @@ def update_entity(
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(entity)
|
session.refresh(entity)
|
||||||
return EntityResponse.model_validate(entity, from_attributes=True)
|
return EntityResponse.model_validate(entity, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Entity stakes: a GP/mgmt entity's interest in the funds it manages ---
|
||||||
|
|
||||||
|
def _stake_response(stake: EntityStake, funds: dict[int, Entity]) -> EntityStakeResponse:
|
||||||
|
data = EntityStakeResponse.model_validate(stake, from_attributes=True)
|
||||||
|
fund = funds.get(stake.fund_entity_id)
|
||||||
|
if fund is not None:
|
||||||
|
data.fund_name = fund.name
|
||||||
|
data.fund_type = fund.type
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_id}/stakes")
|
||||||
|
def list_stakes(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[EntityStakeResponse]:
|
||||||
|
"""The funds this entity holds a stake in (e.g. a GP's interest in its funds)."""
|
||||||
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
if allowed is not None and entity_id not in allowed:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
rows = session.exec(
|
||||||
|
select(EntityStake).where(EntityStake.holder_entity_id == entity_id)
|
||||||
|
).all()
|
||||||
|
funds = {
|
||||||
|
f.id: f for f in session.exec(
|
||||||
|
select(Entity).where(col(Entity.id).in_({r.fund_entity_id for r in rows}))
|
||||||
|
).all()
|
||||||
|
} if rows else {}
|
||||||
|
return [_stake_response(r, funds) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{entity_id}/stakes", status_code=201)
|
||||||
|
def create_stake(
|
||||||
|
entity_id: int,
|
||||||
|
body: EntityStakeCreate,
|
||||||
|
user: User = Depends(require_writer),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> EntityStakeResponse:
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
if body.fund_entity_id == entity_id:
|
||||||
|
raise HTTPException(status_code=400, detail="An entity cannot hold a stake in itself.")
|
||||||
|
fund = session.get(Entity, body.fund_entity_id)
|
||||||
|
if fund is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Fund not found")
|
||||||
|
if session.exec(
|
||||||
|
select(EntityStake).where(
|
||||||
|
EntityStake.holder_entity_id == entity_id,
|
||||||
|
EntityStake.fund_entity_id == body.fund_entity_id,
|
||||||
|
)
|
||||||
|
).first():
|
||||||
|
raise HTTPException(status_code=409, detail="A stake in this fund already exists.")
|
||||||
|
|
||||||
|
stake = EntityStake(
|
||||||
|
holder_entity_id=entity_id,
|
||||||
|
fund_entity_id=body.fund_entity_id,
|
||||||
|
ownership_pct=body.ownership_pct,
|
||||||
|
value_cents=round(body.value_dollars * 100) if body.value_dollars is not None else None,
|
||||||
|
note=body.note,
|
||||||
|
)
|
||||||
|
session.add(stake)
|
||||||
|
session.flush()
|
||||||
|
record_audit(session, user.id, "create", "entity_stake", stake.id, {
|
||||||
|
"holder_entity_id": entity_id,
|
||||||
|
"fund_entity_id": body.fund_entity_id,
|
||||||
|
})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(stake)
|
||||||
|
return _stake_response(stake, {fund.id: fund})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entity_id}/stakes/{stake_id}")
|
||||||
|
def delete_stake(
|
||||||
|
entity_id: int,
|
||||||
|
stake_id: int,
|
||||||
|
user: User = Depends(require_writer),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
stake = session.get(EntityStake, stake_id)
|
||||||
|
if stake is None or stake.holder_entity_id != entity_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Stake not found")
|
||||||
|
record_audit(session, user.id, "delete", "entity_stake", stake_id, {
|
||||||
|
"holder_entity_id": entity_id,
|
||||||
|
"fund_entity_id": stake.fund_entity_id,
|
||||||
|
})
|
||||||
|
session.delete(stake)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
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 require_internal, require_writer
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import Entity, Holding, Position, User
|
from ten31portal.models import Entity, Holding, Position, User
|
||||||
from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate
|
from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate
|
||||||
@@ -15,7 +15,7 @@ router = APIRouter(tags=["holdings"])
|
|||||||
@router.get("/api/entities/{entity_id}/holdings")
|
@router.get("/api/entities/{entity_id}/holdings")
|
||||||
def list_holdings(
|
def list_holdings(
|
||||||
entity_id: int,
|
entity_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_internal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[HoldingResponse]:
|
) -> list[HoldingResponse]:
|
||||||
entity = session.get(Entity, entity_id)
|
entity = session.get(Entity, entity_id)
|
||||||
|
|||||||
@@ -1,88 +1,32 @@
|
|||||||
"""CSV/XLSX import endpoints for entities and schedule of investments."""
|
"""XLSX/CSV import of a fund's holdings and NAV from a fund-administrator eNAV pack.
|
||||||
|
|
||||||
|
Reads the "HLD" (Holdings Report) sheet of an administrator eNAV workbook: each
|
||||||
|
security row becomes a holding + position, and its market value (book) becomes the
|
||||||
|
valuation for the quarter. Encrypted workbooks are decrypted with the open password.
|
||||||
|
A plain holdings CSV is also accepted.
|
||||||
|
"""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import re
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import msoffcrypto
|
||||||
import openpyxl
|
import openpyxl
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import require_role
|
from ten31portal.auth import require_role
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
Entity, EntityStatus, EntityType, Holding, Position,
|
Entity, EntityType, Holding, Position,
|
||||||
User, UserRole, Valuation, ValuationRound, RoundStatus,
|
User, UserRole, Valuation, ValuationRound, RoundStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||||
|
|
||||||
|
|
||||||
# --- Column maps ---
|
|
||||||
# Confirmed against real Carta exports 2026-06-07.
|
|
||||||
|
|
||||||
# Entity CSV import (Issue 9) — still needs a real entity-level export to confirm.
|
|
||||||
ENTITY_COLUMN_MAP: dict[str, str] = {
|
|
||||||
# UNCONFIRMED: update after inspecting a real Carta entities export
|
|
||||||
"Entity Name": "name",
|
|
||||||
"Entity Type": "type",
|
|
||||||
"Vintage Year": "vintage_year",
|
|
||||||
"Fund Size": "fund_size_cents",
|
|
||||||
}
|
|
||||||
|
|
||||||
ENTITY_TYPE_MAP: dict[str, EntityType] = {
|
|
||||||
"Fund": EntityType.fund,
|
|
||||||
"fund": EntityType.fund,
|
|
||||||
"SPV": EntityType.spv,
|
|
||||||
"spv": EntityType.spv,
|
|
||||||
"GP": EntityType.gp,
|
|
||||||
"gp": EntityType.gp,
|
|
||||||
"Mgmt Co": EntityType.mgmt_co,
|
|
||||||
"mgmt_co": EntityType.mgmt_co,
|
|
||||||
"Management Company": EntityType.mgmt_co,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Schedule of Investments XLSX import (Issue 10)
|
|
||||||
# CONFIRMED against Carta export: "Low Time Preference Fund I, LLC" 2026-06-07
|
|
||||||
#
|
|
||||||
# Carta XLSX layout:
|
|
||||||
# Row 1: Entity name (e.g. "Low Time Preference Fund I, LLC")
|
|
||||||
# Row 2: Metadata line ("As of MM/DD/YYYY • Generated by ...")
|
|
||||||
# Row 3: Empty
|
|
||||||
# Row 4: Headers
|
|
||||||
# Row 5: Empty
|
|
||||||
# Rows 6+: Data (company rows alternate with position rows, separated by empty rows)
|
|
||||||
# Last data row: "Total" summary
|
|
||||||
#
|
|
||||||
# Column mapping (0-indexed from Row 4 headers):
|
|
||||||
# A (0): "Investment" — company name on company/subtotal rows
|
|
||||||
# B (1): "Asset" — security name on position rows
|
|
||||||
# C (2): "Investment date" — datetime on position rows
|
|
||||||
# D (3): "Shares" — number (0 for SAFEs/membership interests)
|
|
||||||
# E (4): "Cost" — dollar amount (float)
|
|
||||||
# F (5): "Value" — dollar amount (float)
|
|
||||||
# G (6): "Last Valuation date" — datetime on position rows
|
|
||||||
# H (7): "Gain/Loss" — derived, skip
|
|
||||||
# I (8): "Cost per share" — derived, skip
|
|
||||||
# J (9): "FMV per share" — derived, skip
|
|
||||||
# K (10): "Percent of partners' capital" — skip (phase 2)
|
|
||||||
#
|
|
||||||
# Company rows: A has name, B is empty, D/E/F have subtotals
|
|
||||||
# Position rows: A is empty, B has security name, all columns populated
|
|
||||||
# SAFEs: shares = 0, cost per share = 0, FMV per share = 0
|
|
||||||
|
|
||||||
SCHEDULE_COLUMNS = {
|
|
||||||
"investment": 0, # A — company name
|
|
||||||
"asset": 1, # B — security name
|
|
||||||
"inv_date": 2, # C — investment date
|
|
||||||
"shares": 3, # D — share count
|
|
||||||
"cost": 4, # E — cost in dollars
|
|
||||||
"value": 5, # F — value in dollars
|
|
||||||
"val_date": 6, # G — last valuation date
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_money(raw: str) -> int | None:
|
def _parse_money(raw: str) -> int | None:
|
||||||
@@ -121,268 +65,304 @@ def _dollars_to_cents(val: float | int) -> int:
|
|||||||
return round(float(val) * 100)
|
return round(float(val) * 100)
|
||||||
|
|
||||||
|
|
||||||
# --- Entity import (CSV) ---
|
# --- eNAV holdings import (XLSX) ---
|
||||||
|
|
||||||
@router.post("/entities")
|
def _open_workbook(file_bytes: bytes, password: str | None):
|
||||||
def import_entities(
|
"""Load an xlsx workbook, decrypting an encrypted (password-protected) file if needed."""
|
||||||
file: UploadFile = File(...),
|
# A normal .xlsx is a zip ("PK"); an encrypted Office file is an OLE2 container.
|
||||||
commit: bool = Query(default=True),
|
if file_bytes[:2] == b"PK":
|
||||||
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
|
return openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||||
session: Session = Depends(get_session),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
content = file.file.read().decode("utf-8-sig")
|
|
||||||
reader = csv.DictReader(io.StringIO(content))
|
|
||||||
|
|
||||||
results: list[dict] = []
|
|
||||||
errors: list[dict] = []
|
|
||||||
created = 0
|
|
||||||
updated = 0
|
|
||||||
|
|
||||||
for i, row in enumerate(reader, start=2):
|
|
||||||
mapped: dict[str, Any] = {}
|
|
||||||
|
|
||||||
for csv_col, model_field in ENTITY_COLUMN_MAP.items():
|
|
||||||
val = row.get(csv_col)
|
|
||||||
if val is None:
|
|
||||||
for k, v in row.items():
|
|
||||||
if k.strip().lower() == csv_col.lower():
|
|
||||||
val = v
|
|
||||||
break
|
|
||||||
if val is not None:
|
|
||||||
mapped[model_field] = val.strip()
|
|
||||||
|
|
||||||
parsed: dict[str, Any] = {}
|
|
||||||
row_errors: list[str] = []
|
|
||||||
|
|
||||||
name = mapped.get("name")
|
|
||||||
if not name:
|
|
||||||
row_errors.append("Missing entity name")
|
|
||||||
else:
|
|
||||||
parsed["name"] = name
|
|
||||||
|
|
||||||
type_raw = mapped.get("type", "")
|
|
||||||
entity_type = ENTITY_TYPE_MAP.get(type_raw)
|
|
||||||
if entity_type is None and type_raw:
|
|
||||||
row_errors.append(f"Unknown entity type: {type_raw}")
|
|
||||||
elif entity_type:
|
|
||||||
parsed["type"] = entity_type
|
|
||||||
|
|
||||||
vy = mapped.get("vintage_year")
|
|
||||||
if vy:
|
|
||||||
try:
|
try:
|
||||||
parsed["vintage_year"] = int(vy)
|
office = msoffcrypto.OfficeFile(io.BytesIO(file_bytes))
|
||||||
except ValueError:
|
office.load_key(password=password or "VelvetSweatshop")
|
||||||
row_errors.append(f"Invalid vintage year: {vy}")
|
out = io.BytesIO()
|
||||||
|
office.decrypt(out)
|
||||||
fs = mapped.get("fund_size_cents")
|
out.seek(0)
|
||||||
if fs:
|
return openpyxl.load_workbook(out, data_only=True)
|
||||||
cents = _parse_money(fs)
|
except Exception:
|
||||||
if cents is None:
|
if not password:
|
||||||
row_errors.append(f"Cannot parse fund size: {fs}")
|
raise HTTPException(
|
||||||
else:
|
status_code=400,
|
||||||
parsed["fund_size_cents"] = cents
|
detail="This spreadsheet is password-protected. Enter the open password and try again.",
|
||||||
|
)
|
||||||
if row_errors:
|
raise HTTPException(
|
||||||
errors.append({"row": i, "errors": row_errors, "raw": dict(row)})
|
status_code=400,
|
||||||
continue
|
detail="Could not open the spreadsheet. The password may be incorrect.",
|
||||||
|
|
||||||
if not parsed.get("name"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
existing = session.exec(select(Entity).where(Entity.name == parsed["name"])).first()
|
|
||||||
action = "update" if existing else "create"
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
"row": i,
|
|
||||||
"action": action,
|
|
||||||
"name": parsed["name"],
|
|
||||||
"type": parsed.get("type", EntityType.fund).value if parsed.get("type") else None,
|
|
||||||
"vintage_year": parsed.get("vintage_year"),
|
|
||||||
"fund_size_cents": parsed.get("fund_size_cents"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
if existing:
|
|
||||||
if "type" in parsed:
|
|
||||||
existing.type = parsed["type"]
|
|
||||||
if "vintage_year" in parsed:
|
|
||||||
existing.vintage_year = parsed["vintage_year"]
|
|
||||||
if "fund_size_cents" in parsed:
|
|
||||||
existing.fund_size_cents = parsed["fund_size_cents"]
|
|
||||||
session.add(existing)
|
|
||||||
updated += 1
|
|
||||||
else:
|
|
||||||
entity = Entity(
|
|
||||||
name=parsed["name"],
|
|
||||||
type=parsed.get("type", EntityType.fund),
|
|
||||||
vintage_year=parsed.get("vintage_year"),
|
|
||||||
fund_size_cents=parsed.get("fund_size_cents"),
|
|
||||||
)
|
)
|
||||||
session.add(entity)
|
|
||||||
created += 1
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
record_audit(session, user.id, "import_entities", "entity", None, {
|
|
||||||
"created": created, "updated": updated, "errors": len(errors),
|
|
||||||
})
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"committed": commit,
|
|
||||||
"preview": results,
|
|
||||||
"errors": errors,
|
|
||||||
"summary": {"created": created, "updated": updated, "error_rows": len(errors)},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Schedule of investments import (XLSX) ---
|
def _enav_as_of(wb) -> date | None:
|
||||||
|
"""Find the report date, e.g. a 'DECEMBER 31, 2025' / 'AS OF ...' cell near the top."""
|
||||||
|
for sheet in (["MENU", "HLD"] if "MENU" in wb.sheetnames else wb.sheetnames):
|
||||||
|
ws = wb[sheet]
|
||||||
|
for row in ws.iter_rows(min_row=1, max_row=6, max_col=2, values_only=True):
|
||||||
|
for cell in row:
|
||||||
|
if isinstance(cell, datetime):
|
||||||
|
return cell.date()
|
||||||
|
if isinstance(cell, date):
|
||||||
|
return cell
|
||||||
|
if isinstance(cell, str):
|
||||||
|
text = cell.replace("AS OF", "").strip()
|
||||||
|
for fmt in ("%B %d, %Y", "%b %d, %Y", "%m/%d/%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
def _parse_schedule_xlsx(file_bytes: bytes) -> tuple[str | None, list[dict], list[dict], list[dict]]:
|
|
||||||
|
def _parse_schedule_xlsx(file_bytes: bytes, password: str | None = None) -> tuple[str | None, date | None, list[dict], list[dict], list[dict]]:
|
||||||
"""
|
"""
|
||||||
Parse a Carta Schedule of Investments XLSX export.
|
Parse the HLD (Holdings Report) sheet of a fund-administrator eNAV workbook.
|
||||||
|
|
||||||
Returns: (entity_name, holdings_preview, positions_preview, errors)
|
Each security row becomes a holding (issuer) + position (security), with cost basis
|
||||||
|
and market value (book). Returns: (entity_name, as_of, holdings, positions, errors).
|
||||||
"""
|
"""
|
||||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
wb = _open_workbook(file_bytes, password)
|
||||||
ws = wb.active
|
|
||||||
|
|
||||||
entity_name: str | None = None
|
entity_name: str | None = None
|
||||||
holdings_preview: list[dict] = []
|
holdings_preview: list[dict] = []
|
||||||
positions_preview: list[dict] = []
|
positions_preview: list[dict] = []
|
||||||
errors: list[dict] = []
|
errors: list[dict] = []
|
||||||
|
|
||||||
# Row 1: entity name
|
# Fund name from the MENU cover sheet (row 2) when present.
|
||||||
row1_val = ws.cell(row=1, column=1).value
|
if "MENU" in wb.sheetnames:
|
||||||
if row1_val:
|
v = wb["MENU"].cell(row=2, column=1).value
|
||||||
entity_name = str(row1_val).strip()
|
if v:
|
||||||
|
entity_name = str(v).strip()
|
||||||
|
|
||||||
# Row 4: headers — validate
|
as_of = _enav_as_of(wb)
|
||||||
expected_headers = {0: "Investment", 1: "Asset", 4: "Cost", 5: "Value"}
|
|
||||||
for col_idx, expected in expected_headers.items():
|
if "HLD" not in wb.sheetnames:
|
||||||
actual = ws.cell(row=4, column=col_idx + 1).value
|
errors.append({"row": 0, "errors": [
|
||||||
if actual and str(actual).strip() != expected:
|
"No 'HLD' (Holdings Report) sheet found. This does not look like an eNAV workbook."
|
||||||
errors.append({
|
]})
|
||||||
"row": 4,
|
return entity_name, as_of, holdings_preview, positions_preview, errors
|
||||||
"errors": [f"Expected header '{expected}' in column {chr(65 + col_idx)}, got '{actual}'"],
|
|
||||||
})
|
ws = wb["HLD"]
|
||||||
|
rows = list(ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=20, values_only=True))
|
||||||
|
|
||||||
|
# Locate the header row (the one whose first cell is "SECURITY NAME").
|
||||||
|
header_idx = None
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
if row and isinstance(row[0], str) and row[0].strip().upper() == "SECURITY NAME":
|
||||||
|
header_idx = i
|
||||||
|
break
|
||||||
|
if header_idx is None:
|
||||||
|
errors.append({"row": 0, "errors": ["Could not find the holdings header row in the HLD sheet."]})
|
||||||
|
return entity_name, as_of, holdings_preview, positions_preview, errors
|
||||||
|
|
||||||
|
header = [str(c).strip().upper() if isinstance(c, str) else "" for c in rows[header_idx]]
|
||||||
|
|
||||||
|
def col(*names: str) -> int | None:
|
||||||
|
for n in names:
|
||||||
|
if n in header:
|
||||||
|
return header.index(n)
|
||||||
|
return None
|
||||||
|
|
||||||
|
c_name = col("SECURITY NAME")
|
||||||
|
c_qty = col("QUANTITY")
|
||||||
|
c_cost = col("COST BASIS - BOOK", "COST BASIS - LOCAL")
|
||||||
|
c_value = col("MARKET VALUE (BOOK)", "MARKET VALUE (LOCAL)", "MARKET VALUE - BOOK")
|
||||||
|
|
||||||
|
if c_value is None or c_name is None:
|
||||||
|
errors.append({"row": header_idx + 1, "errors": [
|
||||||
|
"HLD sheet is missing a SECURITY NAME or MARKET VALUE column."
|
||||||
|
]})
|
||||||
|
return entity_name, as_of, holdings_preview, positions_preview, errors
|
||||||
|
|
||||||
# Parse data rows (starting at row 6)
|
|
||||||
current_company: str | None = None
|
|
||||||
seen_companies: set[str] = set()
|
seen_companies: set[str] = set()
|
||||||
# Track (company, security) occurrences to disambiguate duplicate tranches
|
for r in range(header_idx + 1, len(rows)):
|
||||||
security_counts: dict[tuple[str, str], int] = {}
|
row = rows[r]
|
||||||
|
raw_name = row[c_name] if c_name < len(row) else None
|
||||||
for row_idx in range(6, ws.max_row + 1):
|
if raw_name is None or not str(raw_name).strip():
|
||||||
col_a = ws.cell(row=row_idx, column=1).value # Investment (company)
|
continue
|
||||||
col_b = ws.cell(row=row_idx, column=2).value # Asset (security)
|
name = str(raw_name).strip()
|
||||||
col_c = ws.cell(row=row_idx, column=3).value # Investment date
|
# Stop at total / report-total summary rows.
|
||||||
col_d = ws.cell(row=row_idx, column=4).value # Shares
|
if name.upper().startswith("TOTAL") or name.upper().startswith("REPORT TOTAL"):
|
||||||
col_e = ws.cell(row=row_idx, column=5).value # Cost
|
|
||||||
col_f = ws.cell(row=row_idx, column=6).value # Value
|
|
||||||
col_g = ws.cell(row=row_idx, column=7).value # Last valuation date
|
|
||||||
|
|
||||||
# Skip empty rows
|
|
||||||
if col_a is None and col_b is None:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip total row
|
# Group tranches of the same issuer: holding = issuer (before " - "); position = full name.
|
||||||
if col_a and str(col_a).strip().lower() == "total":
|
company = name.split(" - ")[0].strip() or name
|
||||||
continue
|
security = name
|
||||||
|
|
||||||
# Company subtotal row: col A has name, col B is empty
|
if company not in seen_companies:
|
||||||
if col_a and not col_b:
|
holdings_preview.append({"row": r + 1, "company_name": company})
|
||||||
company_name = str(col_a).strip()
|
seen_companies.add(company)
|
||||||
current_company = company_name
|
|
||||||
if company_name not in seen_companies:
|
|
||||||
holdings_preview.append({
|
|
||||||
"row": row_idx,
|
|
||||||
"company_name": company_name,
|
|
||||||
})
|
|
||||||
seen_companies.add(company_name)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Position row: col B has security name
|
# Quantity -> shares (skip non-numeric like "N/A").
|
||||||
if col_b:
|
|
||||||
raw_security_name = str(col_b).strip()
|
|
||||||
company = current_company or "(unknown)"
|
|
||||||
|
|
||||||
# Disambiguate duplicate (company, security) pairs
|
|
||||||
# e.g. two "Warrants" under BIP21 become "Warrants" and "Warrants (2)"
|
|
||||||
pair_key = (company, raw_security_name)
|
|
||||||
security_counts[pair_key] = security_counts.get(pair_key, 0) + 1
|
|
||||||
if security_counts[pair_key] == 1:
|
|
||||||
security_name = raw_security_name
|
|
||||||
else:
|
|
||||||
security_name = f"{raw_security_name} ({security_counts[pair_key]})"
|
|
||||||
|
|
||||||
# Parse investment date
|
|
||||||
inv_date: date | None = None
|
|
||||||
if isinstance(col_c, datetime):
|
|
||||||
inv_date = col_c.date()
|
|
||||||
elif col_c:
|
|
||||||
inv_date = _parse_date(str(col_c))
|
|
||||||
|
|
||||||
# Parse shares (could be 0 for SAFEs)
|
|
||||||
shares: str | None = None
|
shares: str | None = None
|
||||||
if col_d is not None:
|
if c_qty is not None and c_qty < len(row) and row[c_qty] is not None:
|
||||||
shares_val = float(col_d)
|
qv = row[c_qty]
|
||||||
if shares_val != 0:
|
if isinstance(qv, (int, float)) and qv != 0:
|
||||||
# Preserve precision: use string repr
|
shares = f"{qv:g}"
|
||||||
shares = str(col_d) if not isinstance(col_d, float) else f"{col_d:g}"
|
|
||||||
# shares stays None for zero (SAFEs)
|
|
||||||
|
|
||||||
# Parse cost (dollars)
|
cost_cents = None
|
||||||
cost_cents: int | None = None
|
if c_cost is not None and c_cost < len(row) and isinstance(row[c_cost], (int, float)):
|
||||||
if col_e is not None:
|
cost_cents = _dollars_to_cents(row[c_cost])
|
||||||
cost_cents = _dollars_to_cents(col_e)
|
|
||||||
|
|
||||||
# Parse value (dollars)
|
value_cents = None
|
||||||
value_cents: int | None = None
|
if isinstance(row[c_value], (int, float)):
|
||||||
if col_f is not None:
|
value_cents = _dollars_to_cents(row[c_value])
|
||||||
value_cents = _dollars_to_cents(col_f)
|
|
||||||
|
|
||||||
# Parse valuation date
|
|
||||||
val_date: date | None = None
|
|
||||||
if isinstance(col_g, datetime):
|
|
||||||
val_date = col_g.date()
|
|
||||||
elif col_g:
|
|
||||||
val_date = _parse_date(str(col_g))
|
|
||||||
|
|
||||||
positions_preview.append({
|
positions_preview.append({
|
||||||
"row": row_idx,
|
"row": r + 1,
|
||||||
"company_name": company,
|
"company_name": company,
|
||||||
"security_name": security_name,
|
"security_name": security,
|
||||||
"investment_date": str(inv_date) if inv_date else None,
|
"investment_date": None, # eNAV holdings report has no acquisition date
|
||||||
"shares": shares,
|
"shares": shares,
|
||||||
"cost_cents": cost_cents,
|
"cost_cents": cost_cents,
|
||||||
"value_cents": value_cents,
|
"value_cents": value_cents,
|
||||||
"valuation_date": str(val_date) if val_date else None,
|
"valuation_date": str(as_of) if as_of else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
return entity_name, holdings_preview, positions_preview, errors
|
return entity_name, as_of, holdings_preview, positions_preview, errors
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def reset_entity_holdings(entity_id: int, session: Session) -> dict[str, int]:
|
||||||
|
"""Delete all holdings, positions, valuations, and rounds for one entity.
|
||||||
|
|
||||||
|
For a clean restart — e.g. after switching the source workbook (Carta → eNAV) renamed every
|
||||||
|
position, so the old and new rows can't be matched and both get counted. Capital-account
|
||||||
|
statements (investor data) are left untouched. The caller should re-import afterwards.
|
||||||
|
"""
|
||||||
|
rounds = session.exec(
|
||||||
|
select(ValuationRound).where(ValuationRound.entity_id == entity_id)
|
||||||
|
).all()
|
||||||
|
round_ids = [r.id for r in rounds]
|
||||||
|
valuations = 0
|
||||||
|
if round_ids:
|
||||||
|
for v in session.exec(
|
||||||
|
select(Valuation).where(col(Valuation.round_id).in_(round_ids))
|
||||||
|
).all():
|
||||||
|
session.delete(v)
|
||||||
|
valuations += 1
|
||||||
|
|
||||||
|
holdings = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all()
|
||||||
|
holding_ids = [h.id for h in holdings]
|
||||||
|
positions = 0
|
||||||
|
if holding_ids:
|
||||||
|
for p in session.exec(
|
||||||
|
select(Position).where(col(Position.holding_id).in_(holding_ids))
|
||||||
|
).all():
|
||||||
|
session.delete(p)
|
||||||
|
positions += 1
|
||||||
|
|
||||||
|
for r in rounds:
|
||||||
|
session.delete(r)
|
||||||
|
for h in holdings:
|
||||||
|
session.delete(h)
|
||||||
|
session.flush()
|
||||||
|
return {
|
||||||
|
"rounds": len(rounds),
|
||||||
|
"holdings": len(holdings),
|
||||||
|
"positions": positions,
|
||||||
|
"valuations": valuations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_entity(entity_id: int, session: Session) -> dict[str, int]:
|
||||||
|
"""Collapse exact-duplicate holdings and positions for one entity.
|
||||||
|
|
||||||
|
Repeated imports on older builds created a second copy of each holding/position, which
|
||||||
|
inflated the entity's "Invested" total (the rollup sums cost across every position). This
|
||||||
|
keeps the lowest-id copy, re-points its positions/valuations, removes duplicate valuations
|
||||||
|
in the same round, and deletes the leftovers. Safe to run repeatedly (a no-op once clean).
|
||||||
|
"""
|
||||||
|
removed_holdings = 0
|
||||||
|
removed_positions = 0
|
||||||
|
|
||||||
|
# 1) Merge holdings with the same name (case-insensitive) into the lowest-id one.
|
||||||
|
holdings = session.exec(
|
||||||
|
select(Holding).where(Holding.entity_id == entity_id).order_by(Holding.id) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
keep_by_name: dict[str, Holding] = {}
|
||||||
|
for h in holdings:
|
||||||
|
key = h.company_name.strip().lower()
|
||||||
|
keeper = keep_by_name.get(key)
|
||||||
|
if keeper is None:
|
||||||
|
keep_by_name[key] = h
|
||||||
|
continue
|
||||||
|
for p in session.exec(select(Position).where(Position.holding_id == h.id)).all():
|
||||||
|
p.holding_id = keeper.id
|
||||||
|
session.add(p)
|
||||||
|
session.flush()
|
||||||
|
session.delete(h)
|
||||||
|
removed_holdings += 1
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# 2) Within each surviving holding, merge positions with the same security name.
|
||||||
|
for keeper_holding in keep_by_name.values():
|
||||||
|
positions = session.exec(
|
||||||
|
select(Position)
|
||||||
|
.where(Position.holding_id == keeper_holding.id)
|
||||||
|
.order_by(Position.id) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
keep_by_sec: dict[str, Position] = {}
|
||||||
|
for p in positions:
|
||||||
|
key = p.security_name.strip().lower()
|
||||||
|
keeper = keep_by_sec.get(key)
|
||||||
|
if keeper is None:
|
||||||
|
keep_by_sec[key] = p
|
||||||
|
continue
|
||||||
|
# Move this duplicate's valuations onto the keeper, dropping any that would collide
|
||||||
|
# with an existing valuation for the same round (that collision IS the double-count).
|
||||||
|
for v in session.exec(select(Valuation).where(Valuation.position_id == p.id)).all():
|
||||||
|
clash = session.exec(
|
||||||
|
select(Valuation).where(
|
||||||
|
Valuation.round_id == v.round_id,
|
||||||
|
Valuation.position_id == keeper.id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if clash is not None:
|
||||||
|
session.delete(v)
|
||||||
|
else:
|
||||||
|
v.position_id = keeper.id
|
||||||
|
session.add(v)
|
||||||
|
session.flush()
|
||||||
|
session.delete(p)
|
||||||
|
removed_positions += 1
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
return {"removed_holdings": removed_holdings, "removed_positions": removed_positions}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/schedule")
|
@router.post("/schedule")
|
||||||
def import_schedule(
|
def import_schedule(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
as_of: date = Query(...),
|
as_of: date | None = Query(default=None),
|
||||||
entity_id: int | None = Query(default=None),
|
entity_id: int | None = Query(default=None),
|
||||||
create_entity_type: EntityType = Query(default=EntityType.fund),
|
create_entity_type: EntityType = Query(default=EntityType.fund),
|
||||||
create_vintage_year: int | None = Query(default=None),
|
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)),
|
replace_existing: bool = Query(default=False),
|
||||||
|
password: str | None = Form(default=None),
|
||||||
|
user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
file_bytes = file.file.read()
|
file_bytes = file.file.read()
|
||||||
filename = file.filename or ""
|
filename = file.filename or ""
|
||||||
|
|
||||||
# Parse file first to get source_entity_name
|
# Parse the file into holdings/positions previews.
|
||||||
if filename.endswith(".xlsx") or filename.endswith(".xls"):
|
if filename.lower().endswith((".xlsx", ".xls")):
|
||||||
source_entity_name, holdings_preview, positions_preview, errors = _parse_schedule_xlsx(file_bytes)
|
source_entity_name, parsed_as_of, holdings_preview, positions_preview, errors = (
|
||||||
|
_parse_schedule_xlsx(file_bytes, password)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
source_entity_name = None
|
source_entity_name = None
|
||||||
|
parsed_as_of = None
|
||||||
holdings_preview, positions_preview, errors = _parse_schedule_csv(file_bytes)
|
holdings_preview, positions_preview, errors = _parse_schedule_csv(file_bytes)
|
||||||
|
|
||||||
|
# As-of date: use the explicit value, else the date read from the sheet.
|
||||||
|
as_of = as_of or parsed_as_of
|
||||||
|
if as_of is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Could not determine the quarter-end date. Set the as-of date and try again.",
|
||||||
|
)
|
||||||
|
|
||||||
# Resolve entity
|
# Resolve entity
|
||||||
entity: Entity | None = None
|
entity: Entity | None = None
|
||||||
entity_resolution: str = "existing" # "existing" | "matched" | "will_create"
|
entity_resolution: str = "existing" # "existing" | "matched" | "will_create"
|
||||||
@@ -405,7 +385,7 @@ def import_schedule(
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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.",
|
detail="No entity selected and no fund name found in the file. Choose the fund to import into.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dry-run: report resolution without writing
|
# Dry-run: report resolution without writing
|
||||||
@@ -447,19 +427,36 @@ def import_schedule(
|
|||||||
assert entity is not None
|
assert entity is not None
|
||||||
resolved_entity_id = entity.id
|
resolved_entity_id = entity.id
|
||||||
|
|
||||||
# Check for any existing round at this quarter
|
# Replace mode: wipe the fund's existing holdings/positions/rounds first so the file becomes
|
||||||
|
# the single source of truth. Use this after a source change (e.g. Carta → eNAV) renamed the
|
||||||
|
# positions, leaving un-matchable rows that inflate the totals.
|
||||||
|
if replace_existing:
|
||||||
|
reset_entity_holdings(resolved_entity_id, session)
|
||||||
|
|
||||||
|
# Self-heal any duplicate holdings/positions left by imports on older builds, so this
|
||||||
|
# import's upsert lands on a single clean copy and "Invested" stops double-counting.
|
||||||
|
dedupe_entity(resolved_entity_id, session)
|
||||||
|
|
||||||
|
# An existing round at this quarter: a NAV re-import should UPDATE it in place (refresh
|
||||||
|
# to the latest file) instead of stacking a second round and doubling the totals. Only
|
||||||
|
# import-created seed rounds are refreshable; a manually-signed valuation round is left
|
||||||
|
# protected.
|
||||||
existing_round = session.exec(
|
existing_round = session.exec(
|
||||||
select(ValuationRound).where(
|
select(ValuationRound).where(
|
||||||
ValuationRound.entity_id == resolved_entity_id,
|
ValuationRound.entity_id == resolved_entity_id,
|
||||||
ValuationRound.quarter_end == as_of,
|
ValuationRound.quarter_end == as_of,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if existing_round:
|
reused_round = False
|
||||||
kind = "seed" if existing_round.is_seed else "valuation"
|
seed_round: ValuationRound | None = None
|
||||||
|
if existing_round is not None:
|
||||||
|
if not existing_round.is_seed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"A {kind} round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.",
|
detail=f"A signed valuation round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.",
|
||||||
)
|
)
|
||||||
|
seed_round = existing_round
|
||||||
|
reused_round = True
|
||||||
|
|
||||||
# Commit: create holdings, positions, seed round
|
# Commit: create holdings, positions, seed round
|
||||||
holding_map: dict[str, Holding] = {}
|
holding_map: dict[str, Holding] = {}
|
||||||
@@ -480,7 +477,8 @@ def import_schedule(
|
|||||||
session.flush()
|
session.flush()
|
||||||
holding_map[name] = h
|
holding_map[name] = h
|
||||||
|
|
||||||
# Create seed round
|
# Create the seed round, or reuse the existing one for this quarter (re-import).
|
||||||
|
if seed_round is None:
|
||||||
seed_round = ValuationRound(
|
seed_round = ValuationRound(
|
||||||
entity_id=resolved_entity_id,
|
entity_id=resolved_entity_id,
|
||||||
quarter_end=as_of,
|
quarter_end=as_of,
|
||||||
@@ -494,6 +492,7 @@ def import_schedule(
|
|||||||
|
|
||||||
positions_created = 0
|
positions_created = 0
|
||||||
positions_updated = 0
|
positions_updated = 0
|
||||||
|
seen_position_ids: set[int] = set()
|
||||||
for pp in positions_preview:
|
for pp in positions_preview:
|
||||||
company = pp["company_name"]
|
company = pp["company_name"]
|
||||||
holding = holding_map.get(company)
|
holding = holding_map.get(company)
|
||||||
@@ -541,17 +540,39 @@ def import_schedule(
|
|||||||
session.flush()
|
session.flush()
|
||||||
positions_updated += 1
|
positions_updated += 1
|
||||||
|
|
||||||
# Attach valuation to seed round
|
# Upsert this position's valuation in the round, so a re-import refreshes the value
|
||||||
val = Valuation(
|
# in place instead of adding a second one (which would double the quarter's NAV).
|
||||||
|
val = session.exec(
|
||||||
|
select(Valuation).where(
|
||||||
|
Valuation.round_id == seed_round.id,
|
||||||
|
Valuation.position_id == pos.id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if val is None:
|
||||||
|
session.add(Valuation(
|
||||||
round_id=seed_round.id,
|
round_id=seed_round.id,
|
||||||
position_id=pos.id,
|
position_id=pos.id,
|
||||||
value_cents=pp["value_cents"] or 0,
|
value_cents=pp["value_cents"] or 0,
|
||||||
)
|
))
|
||||||
|
else:
|
||||||
|
val.value_cents = pp["value_cents"] or 0
|
||||||
session.add(val)
|
session.add(val)
|
||||||
|
seen_position_ids.add(pos.id)
|
||||||
|
|
||||||
|
# On re-import, drop valuations for holdings no longer in the file so the quarter's NAV
|
||||||
|
# equals the new file's total (no leftovers from the prior import).
|
||||||
|
if reused_round:
|
||||||
|
stale_q = select(Valuation).where(Valuation.round_id == seed_round.id)
|
||||||
|
if seen_position_ids:
|
||||||
|
stale_q = stale_q.where(col(Valuation.position_id).not_in(seen_position_ids))
|
||||||
|
for stale_val in session.exec(stale_q).all():
|
||||||
|
session.delete(stale_val)
|
||||||
|
|
||||||
record_audit(session, user.id, "import_schedule", "entity", resolved_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,
|
"entity_resolution": entity_resolution,
|
||||||
|
"replaced_existing": replace_existing,
|
||||||
|
"round_updated": reused_round,
|
||||||
"holdings": len(holding_map),
|
"holdings": len(holding_map),
|
||||||
"positions_created": positions_created,
|
"positions_created": positions_created,
|
||||||
"positions_updated": positions_updated,
|
"positions_updated": positions_updated,
|
||||||
@@ -573,25 +594,35 @@ def import_schedule(
|
|||||||
"positions_created": positions_created,
|
"positions_created": positions_created,
|
||||||
"positions_updated": positions_updated,
|
"positions_updated": positions_updated,
|
||||||
"seed_round_id": seed_round.id,
|
"seed_round_id": seed_round.id,
|
||||||
|
"round_updated": reused_round,
|
||||||
|
"replaced_existing": replace_existing,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list[dict]]:
|
def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list[dict]]:
|
||||||
"""Legacy CSV parser fallback."""
|
"""Parse a plain holdings CSV (generic columns or an eNAV HLD export)."""
|
||||||
content = file_bytes.decode("utf-8-sig")
|
content = file_bytes.decode("utf-8-sig")
|
||||||
reader = csv.DictReader(io.StringIO(content))
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
|
|
||||||
CSV_COLUMN_MAP = {
|
CSV_COLUMN_MAP = {
|
||||||
"Company": "company_name",
|
"Company": "company_name",
|
||||||
"Investment": "company_name",
|
"Investment": "company_name",
|
||||||
|
"Issuer": "company_name",
|
||||||
"Security": "security_name",
|
"Security": "security_name",
|
||||||
"Asset": "security_name",
|
"Asset": "security_name",
|
||||||
|
"Security Name": "security_name",
|
||||||
"Investment Date": "investment_date",
|
"Investment Date": "investment_date",
|
||||||
"Investment date": "investment_date",
|
"Investment date": "investment_date",
|
||||||
"Shares": "shares",
|
"Shares": "shares",
|
||||||
|
"Quantity": "shares",
|
||||||
"Cost": "cost_cents",
|
"Cost": "cost_cents",
|
||||||
|
"Cost Basis - Book": "cost_cents",
|
||||||
|
"Cost Basis - Local": "cost_cents",
|
||||||
"Value": "value_cents",
|
"Value": "value_cents",
|
||||||
|
"Market Value (Book)": "value_cents",
|
||||||
|
"Market Value (Local)": "value_cents",
|
||||||
|
"Market Value - Book": "value_cents",
|
||||||
}
|
}
|
||||||
|
|
||||||
holdings_preview: list[dict] = []
|
holdings_preview: list[dict] = []
|
||||||
@@ -608,11 +639,18 @@ def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list
|
|||||||
if k.strip().lower() == csv_col.lower():
|
if k.strip().lower() == csv_col.lower():
|
||||||
val = v
|
val = v
|
||||||
break
|
break
|
||||||
if val is not None:
|
if val is not None and model_field not in mapped:
|
||||||
mapped[model_field] = val.strip()
|
mapped[model_field] = (val or "").strip()
|
||||||
|
|
||||||
company = mapped.get("company_name", "").strip()
|
company = mapped.get("company_name", "").strip()
|
||||||
security = mapped.get("security_name", "").strip()
|
security = mapped.get("security_name", "").strip()
|
||||||
|
# Skip total / summary rows.
|
||||||
|
if security.upper().startswith("TOTAL") or company.upper().startswith("TOTAL"):
|
||||||
|
continue
|
||||||
|
# eNAV-style rows have only a security name; derive the issuer from its prefix.
|
||||||
|
if security and not company:
|
||||||
|
company = security.split(" - ")[0].strip()
|
||||||
|
mapped["company_name"] = company
|
||||||
|
|
||||||
if company and not security:
|
if company and not security:
|
||||||
current_company = company
|
current_company = company
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
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 require_internal, require_writer
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User
|
from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User
|
||||||
from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate
|
from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate
|
||||||
@@ -22,7 +22,7 @@ def _dollars_to_cents(dollars: float) -> int:
|
|||||||
@router.get("/api/holdings/{holding_id}/positions")
|
@router.get("/api/holdings/{holding_id}/positions")
|
||||||
def list_positions(
|
def list_positions(
|
||||||
holding_id: int,
|
holding_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_internal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[PositionResponse]:
|
) -> list[PositionResponse]:
|
||||||
holding = session.get(Holding, holding_id)
|
holding = session.get(Holding, holding_id)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlmodel import Session, select, col
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import get_current_user, require_writer, require_approver
|
from ten31portal.auth import require_internal, require_writer, require_approver
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
Entity, Holding, Position, Valuation, ValuationRound,
|
Entity, Holding, Position, Valuation, ValuationRound,
|
||||||
@@ -30,7 +30,7 @@ def _round_response(round: ValuationRound, session: Session) -> RoundResponse:
|
|||||||
@router.get("/api/entities/{entity_id}/rounds")
|
@router.get("/api/entities/{entity_id}/rounds")
|
||||||
def list_rounds(
|
def list_rounds(
|
||||||
entity_id: int,
|
entity_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_internal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[RoundResponse]:
|
) -> list[RoundResponse]:
|
||||||
entity = session.get(Entity, entity_id)
|
entity = session.get(Entity, entity_id)
|
||||||
@@ -47,7 +47,7 @@ def list_rounds(
|
|||||||
@router.get("/api/rounds/{round_id}")
|
@router.get("/api/rounds/{round_id}")
|
||||||
def get_round(
|
def get_round(
|
||||||
round_id: int,
|
round_id: int,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(require_internal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> RoundResponse:
|
) -> RoundResponse:
|
||||||
round = session.get(ValuationRound, round_id)
|
round = session.get(ValuationRound, round_id)
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""User administration: create and manage accounts and their entity access."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
|
from ten31portal.audit import record_audit
|
||||||
|
from ten31portal.auth import (
|
||||||
|
accessible_entity_ids, can_access_entity, get_current_user, hash_password,
|
||||||
|
household_user_ids, require_internal_admin,
|
||||||
|
)
|
||||||
|
from ten31portal.database import get_session
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES, User, UserRole,
|
||||||
|
)
|
||||||
|
from ten31portal.schemas import (
|
||||||
|
AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse,
|
||||||
|
DocumentResponse, EntityResponse, InvestorViewResponse, LinkedAccount, PasswordReset,
|
||||||
|
UserCreate, UserDetailResponse, UserResponse, UserUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/access-matrix")
|
||||||
|
def access_matrix(
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> AccessMatrixResponse:
|
||||||
|
"""External accounts, all entities, and the grants linking them."""
|
||||||
|
users = session.exec(
|
||||||
|
select(User).where(col(User.role).in_(EXTERNAL_ROLES)).order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
|
||||||
|
grants = session.exec(select(EntityAccess)).all()
|
||||||
|
return AccessMatrixResponse(
|
||||||
|
users=[UserResponse.model_validate(u, from_attributes=True) for u in users],
|
||||||
|
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
|
||||||
|
grants=[AccessGrant(user_id=g.user_id, entity_id=g.entity_id) for g in grants],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/access/{entity_id}", status_code=200)
|
||||||
|
def grant_access(
|
||||||
|
user_id: int,
|
||||||
|
entity_id: int,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
existing = session.exec(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing is None:
|
||||||
|
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
|
||||||
|
record_audit(session, admin.id, "grant_access", "user", user_id, {"entity_id": entity_id})
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}/access/{entity_id}")
|
||||||
|
def revoke_access(
|
||||||
|
user_id: int,
|
||||||
|
entity_id: int,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
existing = session.exec(
|
||||||
|
select(EntityAccess).where(
|
||||||
|
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
session.delete(existing)
|
||||||
|
record_audit(session, admin.id, "revoke_access", "user", user_id, {"entity_id": entity_id})
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/investors-for-entity/{entity_id}")
|
||||||
|
def investors_for_entity(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[UserResponse]:
|
||||||
|
"""Investor accounts with access to an entity. For internal staff and the entity's fund admins."""
|
||||||
|
if user.role not in (UserRole.approver, UserRole.cfo, UserRole.operations) and not (
|
||||||
|
user.role == UserRole.fund_administrator
|
||||||
|
and can_access_entity(user, entity_id, session)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
rows = session.exec(
|
||||||
|
select(User)
|
||||||
|
.join(EntityAccess, EntityAccess.user_id == User.id)
|
||||||
|
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
|
||||||
|
.order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/investor-view")
|
||||||
|
def investor_view(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> InvestorViewResponse:
|
||||||
|
"""Reconstruct exactly what an investor sees in their portal — read-only, for admins.
|
||||||
|
|
||||||
|
No session impersonation: this returns the same data the investor's own portal would load
|
||||||
|
(their accessible entities, their capital statements, and the documents visible to them),
|
||||||
|
scoped with the same access helpers.
|
||||||
|
"""
|
||||||
|
target = session.get(User, user_id)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if target.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="Investor View is for investor accounts.")
|
||||||
|
|
||||||
|
allowed = accessible_entity_ids(target, session) or set()
|
||||||
|
household = household_user_ids(target, session)
|
||||||
|
|
||||||
|
entities = session.exec(
|
||||||
|
select(Entity).where(col(Entity.id).in_(allowed)).order_by(Entity.name) # type: ignore[arg-type]
|
||||||
|
).all() if allowed else []
|
||||||
|
|
||||||
|
caps: list[CapitalAccountResponse] = []
|
||||||
|
docs: list[DocumentResponse] = []
|
||||||
|
if allowed:
|
||||||
|
cap_rows = session.exec(
|
||||||
|
select(CapitalAccountStatement)
|
||||||
|
.where(
|
||||||
|
col(CapitalAccountStatement.investor_user_id).in_(household),
|
||||||
|
col(CapitalAccountStatement.entity_id).in_(allowed),
|
||||||
|
)
|
||||||
|
.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||||
|
).all()
|
||||||
|
names = dict(session.exec(
|
||||||
|
select(User.id, User.name).where(
|
||||||
|
col(User.id).in_({r.investor_user_id for r in cap_rows})
|
||||||
|
)
|
||||||
|
).all()) if cap_rows else {}
|
||||||
|
for r in cap_rows:
|
||||||
|
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||||
|
d.investor_name = names.get(r.investor_user_id)
|
||||||
|
caps.append(d)
|
||||||
|
|
||||||
|
doc_rows = session.exec(
|
||||||
|
select(Document)
|
||||||
|
.where(col(Document.entity_id).in_(allowed))
|
||||||
|
.order_by(col(Document.created_at).desc())
|
||||||
|
).all()
|
||||||
|
# Investor sees shared docs and those addressed to any of their linked names.
|
||||||
|
docs = [
|
||||||
|
DocumentResponse.model_validate(d, from_attributes=True)
|
||||||
|
for d in doc_rows
|
||||||
|
if d.investor_user_id is None or d.investor_user_id in household
|
||||||
|
]
|
||||||
|
|
||||||
|
return InvestorViewResponse(
|
||||||
|
user=UserResponse.model_validate(target, from_attributes=True),
|
||||||
|
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
|
||||||
|
capital_accounts=caps,
|
||||||
|
documents=docs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_ids_for(user_id: int, session: Session) -> list[int]:
|
||||||
|
return list(session.exec(
|
||||||
|
select(EntityAccess.entity_id).where(EntityAccess.user_id == user_id)
|
||||||
|
).all())
|
||||||
|
|
||||||
|
|
||||||
|
def _user_detail(user: User, session: Session) -> UserDetailResponse:
|
||||||
|
"""Build a full user detail, including the linked-account relationships."""
|
||||||
|
data = UserResponse.model_validate(user, from_attributes=True).model_dump()
|
||||||
|
primary_name = None
|
||||||
|
if user.primary_account_id:
|
||||||
|
primary = session.get(User, user.primary_account_id)
|
||||||
|
primary_name = primary.name if primary else None
|
||||||
|
linked = session.exec(
|
||||||
|
select(User).where(User.primary_account_id == user.id).order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
return UserDetailResponse(
|
||||||
|
**data,
|
||||||
|
primary_account_name=primary_name,
|
||||||
|
linked_accounts=[
|
||||||
|
LinkedAccount(id=u.id, name=u.name, username=u.username) for u in linked
|
||||||
|
],
|
||||||
|
entity_ids=_entity_ids_for(user.id, session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> None:
|
||||||
|
"""Replace a user's entity grants with the given set, ignoring unknown ids."""
|
||||||
|
valid = set(session.exec(
|
||||||
|
select(Entity.id).where(Entity.id.in_(entity_ids)) # type: ignore[union-attr]
|
||||||
|
).all()) if entity_ids else set()
|
||||||
|
existing = session.exec(
|
||||||
|
select(EntityAccess).where(EntityAccess.user_id == user_id)
|
||||||
|
).all()
|
||||||
|
current = {a.entity_id: a for a in existing}
|
||||||
|
# Remove grants no longer wanted.
|
||||||
|
for eid, access in current.items():
|
||||||
|
if eid not in valid:
|
||||||
|
session.delete(access)
|
||||||
|
# Add new grants.
|
||||||
|
for eid in valid:
|
||||||
|
if eid not in current:
|
||||||
|
session.add(EntityAccess(user_id=user_id, entity_id=eid))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_users(
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> list[UserResponse]:
|
||||||
|
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
|
||||||
|
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}")
|
||||||
|
def get_user(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserDetailResponse:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return _user_detail(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
def create_user(
|
||||||
|
body: UserCreate,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserDetailResponse:
|
||||||
|
if session.exec(select(User).where(User.username == body.username)).first():
|
||||||
|
raise HTTPException(status_code=409, detail="Username already taken")
|
||||||
|
if body.email and session.exec(select(User).where(User.email == body.email)).first():
|
||||||
|
raise HTTPException(status_code=409, detail="Email already in use")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
name=body.name,
|
||||||
|
username=body.username,
|
||||||
|
email=body.email or None,
|
||||||
|
password_hash=hash_password(body.password),
|
||||||
|
role=body.role,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
_set_entity_access(user.id, body.entity_ids, session)
|
||||||
|
record_audit(session, admin.id, "create", "user", user.id,
|
||||||
|
{"username": body.username, "role": body.role.value,
|
||||||
|
"entity_ids": body.entity_ids})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return _user_detail(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
body: UserUpdate,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserDetailResponse:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
changes = body.model_dump(exclude_unset=True)
|
||||||
|
entity_ids = changes.pop("entity_ids", None)
|
||||||
|
|
||||||
|
if "username" in changes:
|
||||||
|
new_username = (changes["username"] or "").strip()
|
||||||
|
if not new_username:
|
||||||
|
raise HTTPException(status_code=400, detail="Username cannot be blank")
|
||||||
|
clash = session.exec(select(User).where(User.username == new_username)).first()
|
||||||
|
if clash and clash.id != user_id:
|
||||||
|
raise HTTPException(status_code=409, detail="Username already taken")
|
||||||
|
changes["username"] = new_username
|
||||||
|
|
||||||
|
if "email" in changes and changes["email"]:
|
||||||
|
clash = session.exec(select(User).where(User.email == changes["email"])).first()
|
||||||
|
if clash and clash.id != user_id:
|
||||||
|
raise HTTPException(status_code=409, detail="Email already in use")
|
||||||
|
|
||||||
|
for key, val in changes.items():
|
||||||
|
setattr(user, key, val)
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
if entity_ids is not None:
|
||||||
|
_set_entity_access(user_id, entity_ids, session)
|
||||||
|
|
||||||
|
record_audit(session, admin.id, "update", "user", user_id,
|
||||||
|
{**changes, **({"entity_ids": entity_ids} if entity_ids is not None else {})})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return _user_detail(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/primary-account")
|
||||||
|
def link_account(
|
||||||
|
user_id: int,
|
||||||
|
body: AccountLink,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> UserDetailResponse:
|
||||||
|
"""Link an investor account to a primary login (or detach it when null).
|
||||||
|
|
||||||
|
The primary becomes the single sign-on that sees every linked name's investments. The
|
||||||
|
linked account's own login is disabled so there is one set of credentials per person.
|
||||||
|
"""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if user.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="Only investor accounts can be linked.")
|
||||||
|
|
||||||
|
primary_id = body.primary_account_id
|
||||||
|
if primary_id is not None:
|
||||||
|
if primary_id == user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="An account cannot link to itself.")
|
||||||
|
primary = session.get(User, primary_id)
|
||||||
|
if primary is None or primary.role != UserRole.investor:
|
||||||
|
raise HTTPException(status_code=400, detail="Primary must be an investor account.")
|
||||||
|
if primary.primary_account_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="That account is itself linked to another login. Link to a primary instead.",
|
||||||
|
)
|
||||||
|
# Prevent chains: an account that other names log in under can't become a secondary.
|
||||||
|
if session.exec(select(User).where(User.primary_account_id == user_id)).first():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This account is a primary for other names. Detach those first.",
|
||||||
|
)
|
||||||
|
# Login is blocked while primary_account_id is set (see auth_router.login), so we leave
|
||||||
|
# login_enabled untouched — unlinking then restores the account's own sign-in cleanly.
|
||||||
|
user.primary_account_id = primary_id
|
||||||
|
else:
|
||||||
|
user.primary_account_id = None
|
||||||
|
|
||||||
|
session.add(user)
|
||||||
|
record_audit(session, admin.id, "link_account", "user", user_id,
|
||||||
|
{"primary_account_id": primary_id})
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return _user_detail(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/reset-password")
|
||||||
|
def reset_password(
|
||||||
|
user_id: int,
|
||||||
|
body: PasswordReset,
|
||||||
|
admin: User = Depends(require_internal_admin),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
user.password_hash = hash_password(body.password)
|
||||||
|
user.login_enabled = True # setting a password enables login
|
||||||
|
session.add(user)
|
||||||
|
record_audit(session, admin.id, "reset_password", "user", user_id, None)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -5,25 +5,85 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ten31portal.models import UserRole, EntityType, EntityStatus, RoundStatus
|
from ten31portal.models import (
|
||||||
|
UserRole, EntityType, EntityStatus, RoundStatus, DocumentCategory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Auth ---
|
# --- Auth ---
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
email: str
|
login: str # username or email
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
email: str
|
username: str
|
||||||
|
email: str | None
|
||||||
role: UserRole
|
role: UserRole
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_service_admin: bool = False
|
||||||
|
primary_account_id: int | None = None # set when this account logs in under another
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- User administration ---
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
role: UserRole
|
||||||
|
email: str | None = None
|
||||||
|
entity_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
role: UserRole | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
entity_ids: list[int] | None = None # full replacement of grants when provided
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordReset(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LinkedAccount(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserDetailResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
email: str | None
|
||||||
|
role: UserRole
|
||||||
|
is_active: bool
|
||||||
|
is_service_admin: bool = False
|
||||||
|
primary_account_id: int | None = None
|
||||||
|
primary_account_name: str | None = None # the login this account is linked under, if any
|
||||||
|
linked_accounts: list[LinkedAccount] = [] # secondary names that log in under this account
|
||||||
|
created_at: datetime
|
||||||
|
entity_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AccountLink(BaseModel):
|
||||||
|
# null detaches the account so it logs in on its own again
|
||||||
|
primary_account_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Entity ---
|
# --- Entity ---
|
||||||
|
|
||||||
class EntityCreate(BaseModel):
|
class EntityCreate(BaseModel):
|
||||||
@@ -136,6 +196,157 @@ class RoundResponse(BaseModel):
|
|||||||
valuations: list[ValuationResponse] = []
|
valuations: list[ValuationResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
# --- Document ---
|
||||||
|
|
||||||
|
class DocumentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
entity_id: int
|
||||||
|
investor_user_id: int | None
|
||||||
|
category: DocumentCategory
|
||||||
|
title: str
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
uploaded_by: int | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Capital account ---
|
||||||
|
|
||||||
|
class CapitalAccountCreate(BaseModel):
|
||||||
|
entity_id: int
|
||||||
|
investor_user_id: int
|
||||||
|
as_of_date: date
|
||||||
|
commitment_dollars: float = 0
|
||||||
|
beginning_balance_dollars: float = 0
|
||||||
|
contributions_dollars: float = 0
|
||||||
|
distributions_dollars: float = 0
|
||||||
|
ending_balance_dollars: float = 0
|
||||||
|
document_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalAccountResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
entity_id: int
|
||||||
|
investor_user_id: int
|
||||||
|
investor_name: str | None = None # legal name of the account this statement belongs to
|
||||||
|
as_of_date: date
|
||||||
|
commitment_cents: int
|
||||||
|
beginning_balance_cents: int
|
||||||
|
contributions_cents: int
|
||||||
|
distributions_cents: int
|
||||||
|
ending_balance_cents: int
|
||||||
|
document_id: int | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Partners (members of an entity) ---
|
||||||
|
|
||||||
|
class PartnerResponse(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
name: str
|
||||||
|
username: str
|
||||||
|
external_investor_id: str | None
|
||||||
|
is_active: bool
|
||||||
|
login_enabled: bool
|
||||||
|
latest_commitment_cents: int | None = None
|
||||||
|
latest_contributions_cents: int | None = None
|
||||||
|
latest_distributions_cents: int | None = None
|
||||||
|
latest_value_cents: int | None
|
||||||
|
latest_as_of: date | None
|
||||||
|
statements_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# --- Access matrix ---
|
||||||
|
|
||||||
|
class AccessGrant(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
entity_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class AccessMatrixResponse(BaseModel):
|
||||||
|
users: list[UserResponse]
|
||||||
|
entities: list[EntityResponse]
|
||||||
|
grants: list[AccessGrant]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Capital account import (review-and-confirm) ---
|
||||||
|
|
||||||
|
class ImportInvestorPreview(BaseModel):
|
||||||
|
source_name: str # name as it appears in the spreadsheet
|
||||||
|
column_index: int # 0-based column it was read from
|
||||||
|
value_dollars: float # current capital value (ending balance)
|
||||||
|
commitment_dollars: float = 0
|
||||||
|
contributions_dollars: float = 0
|
||||||
|
distributions_dollars: float = 0
|
||||||
|
external_id: str | None = None # fund-admin INVESTOR ID, when present
|
||||||
|
matched_user_id: int | None = None
|
||||||
|
matched_username: str | None = None
|
||||||
|
suggested_username: str | None = None # for unmatched: a safe default
|
||||||
|
|
||||||
|
|
||||||
|
class ImportValueRow(BaseModel):
|
||||||
|
row_index: int # 0-based sheet row
|
||||||
|
label: str # col A label (e.g. "LTPF1")
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalImportPreview(BaseModel):
|
||||||
|
as_of_date: date | None
|
||||||
|
value_rows: list[ImportValueRow] # candidate rows holding per-investor balances
|
||||||
|
chosen_row_index: int # the row used for the values below
|
||||||
|
investors: list[ImportInvestorPreview]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportCommitInvestor(BaseModel):
|
||||||
|
action: str # "match" | "create" | "skip"
|
||||||
|
value_dollars: float # current capital value (ending balance)
|
||||||
|
commitment_dollars: float = 0
|
||||||
|
contributions_dollars: float = 0
|
||||||
|
distributions_dollars: float = 0
|
||||||
|
user_id: int | None = None # for action=match
|
||||||
|
name: str | None = None # for action=create
|
||||||
|
username: str | None = None # for action=create
|
||||||
|
email: str | None = None
|
||||||
|
password: str | None = None # for action=create; omit to create without a login
|
||||||
|
external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalImportCommit(BaseModel):
|
||||||
|
entity_id: int
|
||||||
|
as_of_date: date
|
||||||
|
investors: list[ImportCommitInvestor]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) ---
|
||||||
|
|
||||||
|
class EntityStakeCreate(BaseModel):
|
||||||
|
fund_entity_id: int
|
||||||
|
ownership_pct: float | None = None
|
||||||
|
value_dollars: float | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EntityStakeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
holder_entity_id: int
|
||||||
|
fund_entity_id: int
|
||||||
|
fund_name: str | None = None
|
||||||
|
fund_type: EntityType | None = None
|
||||||
|
ownership_pct: float | None
|
||||||
|
value_cents: int | None
|
||||||
|
note: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Investor View (admin reconstruction of what one investor sees) ---
|
||||||
|
|
||||||
|
class InvestorViewResponse(BaseModel):
|
||||||
|
user: UserResponse
|
||||||
|
entities: list[EntityResponse] = []
|
||||||
|
capital_accounts: list[CapitalAccountResponse] = []
|
||||||
|
documents: list[DocumentResponse] = []
|
||||||
|
|
||||||
|
|
||||||
# --- Audit ---
|
# --- Audit ---
|
||||||
|
|
||||||
class AuditLogResponse(BaseModel):
|
class AuditLogResponse(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Admin Investor View reconstructs what one investor sees, read-only."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from tests.conftest import make_user
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_investor_view_reconstructs(auth_client, session):
|
||||||
|
inv = make_user(session, username="lp1", role=UserRole.investor)
|
||||||
|
ent = Entity(name="LTPF I", type=EntityType.fund)
|
||||||
|
other = Entity(name="Not Theirs", type=EntityType.fund)
|
||||||
|
session.add(ent)
|
||||||
|
session.add(other)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ent)
|
||||||
|
session.refresh(other)
|
||||||
|
|
||||||
|
session.add(EntityAccess(user_id=inv.id, entity_id=ent.id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=ent.id, investor_user_id=inv.id, as_of_date=date(2026, 3, 31),
|
||||||
|
commitment_cents=500_000, ending_balance_cents=600_000,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = auth_client.get(f"/api/users/{inv.id}/investor-view")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["user"]["username"] == "lp1"
|
||||||
|
# Only the granted entity is visible, not the other one.
|
||||||
|
assert [e["id"] for e in body["entities"]] == [ent.id]
|
||||||
|
assert len(body["capital_accounts"]) == 1
|
||||||
|
assert body["capital_accounts"][0]["ending_balance_cents"] == 600_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_investor_view_rejects_non_investor(auth_client, session):
|
||||||
|
staff = make_user(session, username="ops", role=UserRole.operations)
|
||||||
|
assert auth_client.get(f"/api/users/{staff.id}/investor-view").status_code == 400
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Entity stakes: a GP entity's interest in the funds it manages."""
|
||||||
|
|
||||||
|
from ten31portal.models import Entity, EntityType
|
||||||
|
|
||||||
|
|
||||||
|
def test_stake_crud(auth_client, session):
|
||||||
|
gp = Entity(name="Ten31 LLC", type=EntityType.gp)
|
||||||
|
fund = Entity(name="LTPF I", type=EntityType.fund)
|
||||||
|
session.add(gp)
|
||||||
|
session.add(fund)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(gp)
|
||||||
|
session.refresh(fund)
|
||||||
|
|
||||||
|
created = auth_client.post(
|
||||||
|
f"/api/entities/{gp.id}/stakes",
|
||||||
|
json={"fund_entity_id": fund.id, "ownership_pct": 20, "value_dollars": 1000},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
body = created.json()
|
||||||
|
assert body["fund_name"] == "LTPF I"
|
||||||
|
assert body["fund_type"] == "fund"
|
||||||
|
assert body["value_cents"] == 100_000 # dollars -> cents
|
||||||
|
stake_id = body["id"]
|
||||||
|
|
||||||
|
listed = auth_client.get(f"/api/entities/{gp.id}/stakes")
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
# An entity cannot hold a stake in itself.
|
||||||
|
assert auth_client.post(
|
||||||
|
f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": gp.id}
|
||||||
|
).status_code == 400
|
||||||
|
|
||||||
|
# Duplicate stake rejected.
|
||||||
|
assert auth_client.post(
|
||||||
|
f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": fund.id}
|
||||||
|
).status_code == 409
|
||||||
|
|
||||||
|
assert auth_client.delete(f"/api/entities/{gp.id}/stakes/{stake_id}").status_code == 200
|
||||||
|
assert auth_client.get(f"/api/entities/{gp.id}/stakes").json() == []
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
# Build context is the repo root (docker build -f deploy/Dockerfile .)
|
# Build context is the repo root (docker build -f deploy/Dockerfile .)
|
||||||
|
|
||||||
# --- Frontend build stage ---
|
# --- Frontend build stage ---
|
||||||
FROM node:20-slim AS frontend-build
|
# Build on the native builder arch ($BUILDPLATFORM) so the JS bundler runs without
|
||||||
|
# cross-arch emulation. The output is static assets (HTML/CSS/JS), which are
|
||||||
|
# architecture-independent and copied into the target-arch runtime stage below.
|
||||||
|
FROM --platform=$BUILDPLATFORM node:20-slim AS frontend-build
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
@@ -32,7 +35,8 @@ RUN chmod +x ./start.sh
|
|||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
||||||
ENV TEN31_DB_PATH=/data/portal.db
|
ENV TEN31_DB_PATH=/data/portal.db
|
||||||
ENV TEN31_SESSION_SECRET=***
|
ENV TEN31_DOCS_DIR=/data/documents
|
||||||
|
ENV TEN31_SESSION_SECRET=change-me
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Ten31
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
Before Width: | Height: | Size: 856 B After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
# Ten31Portal
|
||||||
|
|
||||||
|
Internal system of record for Ten31 entities, holdings, positions, and quarterly
|
||||||
|
valuation sign-off, plus an investor / fund-administrator portal.
|
||||||
|
|
||||||
|
## First login
|
||||||
|
|
||||||
|
On first boot a default administrator (approver) account is created. Unless you set
|
||||||
|
the environment variables below, the defaults are:
|
||||||
|
|
||||||
|
- **Username:** `admin`
|
||||||
|
- **Password:** `Ten31`
|
||||||
|
|
||||||
|
Open the web interface and sign in. Change this password immediately from the Users
|
||||||
|
screen, or create a new admin and disable the default one.
|
||||||
|
|
||||||
|
The login field accepts a username **or** an email address.
|
||||||
|
|
||||||
|
## Accounts and access
|
||||||
|
|
||||||
|
- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) use the back-office
|
||||||
|
app: entities, holdings, positions, valuation rounds, import, and audit log.
|
||||||
|
Approvers and the CFO also get the admin screens below.
|
||||||
|
- **External accounts** (`investor`, `fund_administrator`) get an entity-scoped
|
||||||
|
portal and only ever see the entities granted to them.
|
||||||
|
- **Investors** see, per fund, their latest capital-account value and history, plus
|
||||||
|
documents shared to the fund or addressed privately to them (e.g. their K-1).
|
||||||
|
- **Fund administrators** see their assigned entities and can upload documents.
|
||||||
|
|
||||||
|
From the **Users** screen, create an account with a username and password and check
|
||||||
|
off which entities it can view. Use **Documents** to upload statements and K-1s
|
||||||
|
(shared to a fund or private to one investor), and **Capital Accounts** to enter each
|
||||||
|
investor's figures.
|
||||||
|
|
||||||
|
## Data and backups
|
||||||
|
|
||||||
|
All data — the database, uploaded documents, and the generated session secret — lives
|
||||||
|
on the service's data volume and is included in StartOS platform backups. Create a
|
||||||
|
backup before uninstalling; uninstalling removes all fund data.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.1.0",
|
"version": "0.2.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.1.0",
|
"version": "0.2.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@start9labs/start-sdk": "^0.4.0-beta.58"
|
"@start9labs/start-sdk": "^0.4.0-beta.58"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.2.21",
|
"version": "0.2.22",
|
||||||
"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",
|
||||||
|
|||||||
@@ -2,6 +2,29 @@ import { sdk } from '../sdk'
|
|||||||
|
|
||||||
const { InputSpec, Value, Action } = sdk
|
const { InputSpec, Value, Action } = sdk
|
||||||
|
|
||||||
|
// Run a ten31portal CLI subcommand inside the service container against the data volume.
|
||||||
|
async function runCli(effects: any, args: string[], taskName: string) {
|
||||||
|
const sub = await sdk.SubContainer.of(
|
||||||
|
effects,
|
||||||
|
{ imageId: 'main' },
|
||||||
|
sdk.Mounts.of().mountVolume({
|
||||||
|
volumeId: 'main',
|
||||||
|
subpath: null,
|
||||||
|
mountpoint: '/data',
|
||||||
|
readonly: false,
|
||||||
|
}),
|
||||||
|
taskName,
|
||||||
|
)
|
||||||
|
return sub.exec(
|
||||||
|
['python3', '-m', 'ten31portal.cli', ...args],
|
||||||
|
{ env: { TEN31_DB_PATH: '/data/portal.db' } },
|
||||||
|
30000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResult = (message: string) =>
|
||||||
|
({ version: '1' as const, title: 'Error', message, result: null })
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Action: Create User
|
// Action: Create User
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -26,6 +49,13 @@ const createUserInputSpec = InputSpec.of({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
username: Value.text({
|
||||||
|
name: 'Username',
|
||||||
|
description: 'Login username',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'jsmith',
|
||||||
|
}),
|
||||||
password: Value.text({
|
password: Value.text({
|
||||||
name: 'Password',
|
name: 'Password',
|
||||||
description: 'Initial password (user should change after first login)',
|
description: 'Initial password (user should change after first login)',
|
||||||
@@ -36,13 +66,12 @@ const createUserInputSpec = InputSpec.of({
|
|||||||
role: Value.select({
|
role: Value.select({
|
||||||
name: 'Role',
|
name: 'Role',
|
||||||
description:
|
description:
|
||||||
'approver: full access including sign-off. cfo: read + edit + submit. fund_admin: read + edit + submit. viewer: read only.',
|
'Managing Partner: full access including valuation sign-off. Operations: full access except final sign-off. Fund Admin: edit holdings/NAV and submit, no user or document admin.',
|
||||||
default: 'viewer',
|
default: 'operations',
|
||||||
values: {
|
values: {
|
||||||
approver: 'Approver',
|
approver: 'Managing Partner',
|
||||||
cfo: 'CFO',
|
operations: 'Operations',
|
||||||
fund_admin: 'Fund Admin',
|
fund_admin: 'Fund Admin',
|
||||||
viewer: 'Viewer',
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -51,7 +80,7 @@ const createUserAction = Action.withInput(
|
|||||||
'create-user',
|
'create-user',
|
||||||
{
|
{
|
||||||
name: 'Create User',
|
name: 'Create User',
|
||||||
description: 'Add a new user account with a role',
|
description: 'Add a new staff user account with a role',
|
||||||
warning: null,
|
warning: null,
|
||||||
allowedStatuses: 'only-running',
|
allowedStatuses: 'only-running',
|
||||||
group: null,
|
group: null,
|
||||||
@@ -61,69 +90,297 @@ const createUserAction = Action.withInput(
|
|||||||
async () => ({
|
async () => ({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'viewer' as const,
|
role: 'operations' as const,
|
||||||
}),
|
}),
|
||||||
async ({ input, effects }) => {
|
async ({ input, effects }) => {
|
||||||
try {
|
try {
|
||||||
const sub = await sdk.SubContainer.of(
|
const result = await runCli(
|
||||||
effects,
|
effects,
|
||||||
{ imageId: 'main' },
|
[
|
||||||
sdk.Mounts.of().mountVolume({
|
'create-user',
|
||||||
volumeId: 'main',
|
'--name', input.name,
|
||||||
subpath: null,
|
'--username', input.username,
|
||||||
mountpoint: '/data',
|
'--email', input.email,
|
||||||
readonly: false,
|
'--role', input.role,
|
||||||
}),
|
'--password', input.password,
|
||||||
|
],
|
||||||
'create-user-task',
|
'create-user-task',
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await sub.exec(
|
|
||||||
[
|
|
||||||
'python3',
|
|
||||||
'-m',
|
|
||||||
'ten31portal.cli',
|
|
||||||
'create-user',
|
|
||||||
'--name',
|
|
||||||
input.name,
|
|
||||||
'--email',
|
|
||||||
input.email,
|
|
||||||
'--role',
|
|
||||||
input.role,
|
|
||||||
'--password',
|
|
||||||
input.password,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
env: { TEN31_DB_PATH: '/data/portal.db' },
|
|
||||||
},
|
|
||||||
30000,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
const stderr = result.stderr?.toString() || 'Unknown error'
|
return errorResult(`Failed to create user: ${result.stderr?.toString() || 'Unknown error'}`)
|
||||||
return {
|
|
||||||
version: '1' as const,
|
|
||||||
title: 'Error',
|
|
||||||
message: `Failed to create user: ${stderr}`,
|
|
||||||
result: null,
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: '1' as const,
|
version: '1' as const,
|
||||||
title: 'User Created',
|
title: 'User Created',
|
||||||
message: `Created user ${input.name} (${input.email}) with role: ${input.role}`,
|
message: `Created ${input.name} (${input.username}) with role: ${input.role}`,
|
||||||
result: null,
|
result: null,
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return {
|
return errorResult(`Failed to create user: ${e.message || e}`)
|
||||||
version: '1' as const,
|
|
||||||
title: 'Error',
|
|
||||||
message: `Failed to create user: ${e.message || e}`,
|
|
||||||
result: null,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export const actions = sdk.Actions.of().addAction(createUserAction)
|
// ============================================
|
||||||
|
// Action: Reset Password
|
||||||
|
// ============================================
|
||||||
|
const resetPasswordInputSpec = InputSpec.of({
|
||||||
|
username: Value.text({
|
||||||
|
name: 'Username',
|
||||||
|
description: 'Username of the account to reset (e.g. the Admin / Service Admin)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'admin',
|
||||||
|
}),
|
||||||
|
password: Value.text({
|
||||||
|
name: 'New Password',
|
||||||
|
description: 'The new password for this account',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'minimum 4 characters',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPasswordAction = Action.withInput(
|
||||||
|
'reset-password',
|
||||||
|
{
|
||||||
|
name: 'Reset Password',
|
||||||
|
description: "Reset any user's password (including the Admin / Service Admin)",
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
resetPasswordInputSpec,
|
||||||
|
async () => ({ username: '', password: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(
|
||||||
|
effects,
|
||||||
|
['reset-password', '--username', input.username, '--password', input.password],
|
||||||
|
'reset-password-task',
|
||||||
|
)
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to reset password')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Password Reset',
|
||||||
|
message: `Password reset for ${input.username}. Their login is enabled.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to reset password: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: List Users
|
||||||
|
// ============================================
|
||||||
|
const listUsersAction = Action.withoutInput(
|
||||||
|
'list-users',
|
||||||
|
{
|
||||||
|
name: 'List Users',
|
||||||
|
description: 'Show every user account and role',
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['list-users'], 'list-users-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to list users')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Users',
|
||||||
|
message: 'The Service Admin cannot be deleted.',
|
||||||
|
result: {
|
||||||
|
type: 'single' as const,
|
||||||
|
value: result.stdout?.toString() || 'No users.',
|
||||||
|
copyable: true,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to list users: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Delete User
|
||||||
|
// ============================================
|
||||||
|
const deleteUserInputSpec = InputSpec.of({
|
||||||
|
username: Value.text({
|
||||||
|
name: 'Username',
|
||||||
|
description: 'Username of the account to delete (use List Users to see them)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'jsmith',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteUserAction = Action.withInput(
|
||||||
|
'delete-user',
|
||||||
|
{
|
||||||
|
name: 'Delete User',
|
||||||
|
description: 'Permanently delete a user account',
|
||||||
|
warning:
|
||||||
|
'This permanently deletes the user and their data (access grants, capital statements, private documents). The Service Admin cannot be deleted.',
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
deleteUserInputSpec,
|
||||||
|
async () => ({ username: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(
|
||||||
|
effects,
|
||||||
|
['delete-user', '--username', input.username],
|
||||||
|
'delete-user-task',
|
||||||
|
)
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to delete user')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'User Deleted',
|
||||||
|
message: result.stdout?.toString() || `Deleted ${input.username}.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to delete user: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Fix Duplicate Holdings
|
||||||
|
// ============================================
|
||||||
|
const dedupeAction = Action.withoutInput(
|
||||||
|
'dedupe-holdings',
|
||||||
|
{
|
||||||
|
name: 'Fix Duplicate Holdings',
|
||||||
|
description:
|
||||||
|
'Remove duplicate holdings/positions left by repeated imports on older versions, which inflated the Invested totals on the Entities view. Safe to run anytime.',
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['dedupe-holdings'], 'dedupe-holdings-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to clean up duplicates')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Duplicates Cleaned Up',
|
||||||
|
message: result.stdout?.toString() || 'Done.',
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to clean up duplicates: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: List Funds
|
||||||
|
// ============================================
|
||||||
|
const listFundsAction = Action.withoutInput(
|
||||||
|
'list-funds',
|
||||||
|
{
|
||||||
|
name: 'List Funds',
|
||||||
|
description: 'Show every fund/SPV and its exact name (for Reset Fund Holdings)',
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['list-funds'], 'list-funds-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to list funds')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Funds',
|
||||||
|
message: null,
|
||||||
|
result: {
|
||||||
|
type: 'single' as const,
|
||||||
|
value: result.stdout?.toString() || 'No funds.',
|
||||||
|
copyable: true,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to list funds: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Reset Fund Holdings
|
||||||
|
// ============================================
|
||||||
|
const resetHoldingsInputSpec = InputSpec.of({
|
||||||
|
name: Value.text({
|
||||||
|
name: 'Fund Name',
|
||||||
|
description: 'Exact name of the fund to clear (see List Funds)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Low Time Preference Fund III, LP',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetHoldingsAction = Action.withInput(
|
||||||
|
'reset-holdings',
|
||||||
|
{
|
||||||
|
name: 'Reset Fund Holdings',
|
||||||
|
description:
|
||||||
|
'Clear a fund\'s holdings, positions, and valuation rounds so it can be re-imported from scratch. Use after switching source workbooks (e.g. Carta → eNAV) renamed the positions. Investor capital accounts are NOT affected.',
|
||||||
|
warning:
|
||||||
|
'This permanently deletes the fund\'s holdings, positions, and valuation history. Re-import the fund\'s NAV afterward to repopulate it. Investor capital accounts are kept.',
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
resetHoldingsInputSpec,
|
||||||
|
async () => ({ name: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['reset-holdings', '--name', input.name], 'reset-holdings-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to reset fund')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Fund Cleared',
|
||||||
|
message: result.stdout?.toString() || `Cleared ${input.name}.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to reset fund: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const actions = sdk.Actions.of()
|
||||||
|
.addAction(createUserAction)
|
||||||
|
.addAction(resetPasswordAction)
|
||||||
|
.addAction(listUsersAction)
|
||||||
|
.addAction(deleteUserAction)
|
||||||
|
.addAction(dedupeAction)
|
||||||
|
.addAction(listFundsAction)
|
||||||
|
.addAction(resetHoldingsAction)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { v_0_2_21 as current } from './v_0_2_21'
|
export { v_0_2_22 as current } from './v_0_2_22'
|
||||||
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'
|
||||||
@@ -20,4 +20,5 @@ import { v_0_2_17 } from './v_0_2_17'
|
|||||||
import { v_0_2_18 } from './v_0_2_18'
|
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'
|
||||||
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]
|
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]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_22 = VersionInfo.of({
|
||||||
|
version: '0.2.22:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Investors see a graph of their capital over time (value, paid-in, and distributions per quarter). A new admin Investor View shows exactly what an investor sees, read-only. Document uploads are scoped to the chosen fund\'s own investors with a clear target, so a file cannot go to the wrong person. GP and management-company entities get an Assets tab that lists their interests in the funds they manage. You can now edit an existing entity, including its type.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -23,6 +23,7 @@ export const main = sdk.setupMain(async ({ effects }) => {
|
|||||||
command: ['sh', '-c', '/app/start.sh'],
|
command: ['sh', '-c', '/app/start.sh'],
|
||||||
env: {
|
env: {
|
||||||
TEN31_DB_PATH: '/data/portal.db',
|
TEN31_DB_PATH: '/data/portal.db',
|
||||||
|
TEN31_DOCS_DIR: '/data/documents',
|
||||||
TEN31_SESSION_SECRET: process.env.TEN31_SESSION_SECRET || 'change-me',
|
TEN31_SESSION_SECRET: process.env.TEN31_SESSION_SECRET || 'change-me',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,9 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/png" href="/ten31-logo.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<meta name="theme-color" content="#16243A" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Ten31 Portal" />
|
||||||
|
<title>Ten31 Portal</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Ten31 Portal",
|
||||||
|
"short_name": "Ten31",
|
||||||
|
"description": "Ten31 fund portal — entities, valuations, and investor capital accounts.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#16243A",
|
||||||
|
"theme_color": "#16243A",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Ten31 Portal service worker. Deliberately conservative so it never serves a stale app:
|
||||||
|
// - navigations are network-first (always get the latest index.html), cache only as offline fallback
|
||||||
|
// - 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.22'
|
||||||
|
|
||||||
|
self.addEventListener('install', () => self.skipWaiting())
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys()
|
||||||
|
await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
await self.clients.claim()
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const req = event.request
|
||||||
|
if (req.method !== 'GET') return
|
||||||
|
const url = new URL(req.url)
|
||||||
|
if (url.origin !== self.location.origin) return
|
||||||
|
if (url.pathname.startsWith('/api/')) return
|
||||||
|
|
||||||
|
if (req.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const fresh = await fetch(req)
|
||||||
|
const cache = await caches.open(CACHE)
|
||||||
|
cache.put('/', fresh.clone())
|
||||||
|
return fresh
|
||||||
|
} catch {
|
||||||
|
const cache = await caches.open(CACHE)
|
||||||
|
return (await cache.match('/')) || Response.error()
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/assets/')) {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE)
|
||||||
|
const hit = await cache.match(req)
|
||||||
|
if (hit) return hit
|
||||||
|
const res = await fetch(req)
|
||||||
|
if (res.ok) cache.put(req, res.clone())
|
||||||
|
return res
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -8,6 +8,7 @@ import EntityOverview from "./pages/EntityOverview";
|
|||||||
import EntityPartners from "./pages/EntityPartners";
|
import EntityPartners from "./pages/EntityPartners";
|
||||||
import EntityDocuments from "./pages/EntityDocuments";
|
import EntityDocuments from "./pages/EntityDocuments";
|
||||||
import Investments from "./pages/Investments";
|
import Investments from "./pages/Investments";
|
||||||
|
import EntityAssets from "./pages/EntityAssets";
|
||||||
import ValuationWorkflow from "./pages/ValuationWorkflow";
|
import ValuationWorkflow from "./pages/ValuationWorkflow";
|
||||||
import Import from "./pages/Import";
|
import Import from "./pages/Import";
|
||||||
import AuditLog from "./pages/AuditLog";
|
import AuditLog from "./pages/AuditLog";
|
||||||
@@ -15,6 +16,7 @@ import Users from "./pages/Users";
|
|||||||
import Documents from "./pages/Documents";
|
import Documents from "./pages/Documents";
|
||||||
import CapitalAccounts from "./pages/CapitalAccounts";
|
import CapitalAccounts from "./pages/CapitalAccounts";
|
||||||
import AccessGrid from "./pages/AccessGrid";
|
import AccessGrid from "./pages/AccessGrid";
|
||||||
|
import InvestorView from "./pages/InvestorView";
|
||||||
import PortalLayout from "./portal/PortalLayout";
|
import PortalLayout from "./portal/PortalLayout";
|
||||||
import InvestorHome from "./portal/InvestorHome";
|
import InvestorHome from "./portal/InvestorHome";
|
||||||
import FundAdminHome from "./portal/FundAdminHome";
|
import FundAdminHome from "./portal/FundAdminHome";
|
||||||
@@ -28,10 +30,12 @@ function InternalApp() {
|
|||||||
<Route path="/entities/:id/partners" element={<EntityPartners />} />
|
<Route path="/entities/:id/partners" element={<EntityPartners />} />
|
||||||
<Route path="/entities/:id/documents" element={<EntityDocuments />} />
|
<Route path="/entities/:id/documents" element={<EntityDocuments />} />
|
||||||
<Route path="/entities/:id/investments" element={<Investments />} />
|
<Route path="/entities/:id/investments" element={<Investments />} />
|
||||||
|
<Route path="/entities/:id/assets" element={<EntityAssets />} />
|
||||||
<Route path="/entities/:id/valuation" element={<ValuationWorkflow />} />
|
<Route path="/entities/:id/valuation" element={<ValuationWorkflow />} />
|
||||||
<Route path="/import" element={<Import />} />
|
<Route path="/import" element={<Import />} />
|
||||||
<Route path="/audit" element={<AuditLog />} />
|
<Route path="/audit" element={<AuditLog />} />
|
||||||
<Route path="/users" element={<Users />} />
|
<Route path="/users" element={<Users />} />
|
||||||
|
<Route path="/investor-view" element={<InvestorView />} />
|
||||||
<Route path="/access" element={<AccessGrid />} />
|
<Route path="/access" element={<AccessGrid />} />
|
||||||
<Route path="/documents" element={<Documents />} />
|
<Route path="/documents" element={<Documents />} />
|
||||||
<Route path="/capital-accounts" element={<CapitalAccounts />} />
|
<Route path="/capital-accounts" element={<CapitalAccounts />} />
|
||||||
|
|||||||
@@ -205,6 +205,25 @@ export interface AuditEntry {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntityStake {
|
||||||
|
id: number;
|
||||||
|
holder_entity_id: number;
|
||||||
|
fund_entity_id: number;
|
||||||
|
fund_name: string | null;
|
||||||
|
fund_type: EntityType | null;
|
||||||
|
ownership_pct: number | null;
|
||||||
|
value_cents: number | null;
|
||||||
|
note: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvestorView {
|
||||||
|
user: User;
|
||||||
|
entities: Entity[];
|
||||||
|
capital_accounts: CapitalAccount[];
|
||||||
|
documents: PortalDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
// --- API helpers ---
|
// --- API helpers ---
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -270,6 +289,18 @@ export const api = {
|
|||||||
updateEntity: (id: number, data: Partial<Entity>) =>
|
updateEntity: (id: number, data: Partial<Entity>) =>
|
||||||
request<Entity>(`/api/entities/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
request<Entity>(`/api/entities/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Entity stakes (a GP/mgmt entity's interest in the funds it manages)
|
||||||
|
listStakes: (entityId: number) => request<EntityStake[]>(`/api/entities/${entityId}/stakes`),
|
||||||
|
createStake: (
|
||||||
|
entityId: number,
|
||||||
|
data: { fund_entity_id: number; ownership_pct?: number | null; value_dollars?: number | null; note?: string | null },
|
||||||
|
) => request<EntityStake>(`/api/entities/${entityId}/stakes`, { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
deleteStake: (entityId: number, stakeId: number) =>
|
||||||
|
request<{ status: string }>(`/api/entities/${entityId}/stakes/${stakeId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Investor View (admin read-only reconstruction of an investor's portal)
|
||||||
|
investorView: (userId: number) => request<InvestorView>(`/api/users/${userId}/investor-view`),
|
||||||
|
|
||||||
// Holdings
|
// Holdings
|
||||||
listHoldings: (entityId: number) =>
|
listHoldings: (entityId: number) =>
|
||||||
request<Holding[]>(`/api/entities/${entityId}/holdings`),
|
request<Holding[]>(`/api/entities/${entityId}/holdings`),
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { formatMoney, formatQuarter } from "../format";
|
||||||
|
|
||||||
|
export interface CapitalPoint {
|
||||||
|
date: string; // as-of date (quarter end)
|
||||||
|
value: number; // ending balance, cents
|
||||||
|
paidIn: number; // cumulative contributions, cents
|
||||||
|
distributions: number; // cumulative distributions, cents
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERIES = [
|
||||||
|
{ key: "value" as const, label: "Capital value", color: "#111827" },
|
||||||
|
{ key: "paidIn" as const, label: "Paid-in", color: "#2563eb" },
|
||||||
|
{ key: "distributions" as const, label: "Distributions", color: "#16a34a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// A compact, dependency-free multi-line SVG chart of an investor's capital over time.
|
||||||
|
export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
||||||
|
const data = useMemo(
|
||||||
|
() => [...points].sort((a, b) => a.date.localeCompare(b.date)),
|
||||||
|
[points],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.length < 2) return null;
|
||||||
|
|
||||||
|
const W = 640;
|
||||||
|
const H = 240;
|
||||||
|
const padL = 64;
|
||||||
|
const padR = 16;
|
||||||
|
const padT = 16;
|
||||||
|
const padB = 32;
|
||||||
|
const innerW = W - padL - padR;
|
||||||
|
const innerH = H - padT - padB;
|
||||||
|
|
||||||
|
const maxVal = Math.max(1, ...data.flatMap((d) => [d.value, d.paidIn, d.distributions]));
|
||||||
|
const x = (i: number) => padL + (data.length === 1 ? innerW / 2 : (innerW * i) / (data.length - 1));
|
||||||
|
const y = (v: number) => padT + innerH - (innerH * v) / maxVal;
|
||||||
|
|
||||||
|
// 4 horizontal gridlines with dollar labels.
|
||||||
|
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(maxVal * f));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full min-w-[28rem]" role="img" aria-label="Capital over time">
|
||||||
|
{ticks.map((t) => (
|
||||||
|
<g key={t}>
|
||||||
|
<line x1={padL} y1={y(t)} x2={W - padR} y2={y(t)} stroke="#f3f4f6" strokeWidth={1} />
|
||||||
|
<text x={padL - 8} y={y(t) + 4} textAnchor="end" fontSize={11} fill="#9ca3af">
|
||||||
|
{formatMoney(t)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{SERIES.map((s) => (
|
||||||
|
<polyline
|
||||||
|
key={s.key}
|
||||||
|
fill="none"
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
points={data.map((d, i) => `${x(i)},${y(d[s.key])}`).join(" ")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{SERIES.map((s) =>
|
||||||
|
data.map((d, i) => (
|
||||||
|
<circle key={`${s.key}-${i}`} cx={x(i)} cy={y(d[s.key])} r={2.5} fill={s.color}>
|
||||||
|
<title>{`${formatQuarter(d.date)} · ${s.label}: ${formatMoney(d[s.key])}`}</title>
|
||||||
|
</circle>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<text key={`xl-${i}`} x={x(i)} y={H - 10} textAnchor="middle" fontSize={11} fill="#9ca3af">
|
||||||
|
{formatQuarter(d.date)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 mt-2 pl-2">
|
||||||
|
{SERIES.map((s) => (
|
||||||
|
<span key={s.key} className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<span className="inline-block w-3 h-0.5" style={{ backgroundColor: s.color }} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import PasswordInput from "./PasswordInput";
|
||||||
|
|
||||||
|
/** Self-service password change for the signed-in user. */
|
||||||
|
export default function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError("");
|
||||||
|
if (next.length < 4) return setError("New password must be at least 4 characters.");
|
||||||
|
if (next !== confirm) return setError("New passwords don't match.");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.changePassword(current, next);
|
||||||
|
setDone(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Could not change password");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||||
|
<div className="bg-white rounded-lg shadow-lg w-full max-w-sm p-6" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Change password</h3>
|
||||||
|
{done ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-green-700">Your password has been updated.</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={onClose} className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Current password</label>
|
||||||
|
<PasswordInput value={current} onChange={setCurrent} autoComplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">New password</label>
|
||||||
|
<PasswordInput value={next} onChange={setNext} placeholder="minimum 4 characters" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Confirm new password</label>
|
||||||
|
<PasswordInput value={confirm} onChange={setConfirm} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={busy || !current || !next}
|
||||||
|
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Saving…" : "Update password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { Entity } from "../api";
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
fund: "Fund",
|
||||||
|
spv: "SPV",
|
||||||
|
gp: "GP",
|
||||||
|
mgmt_co: "Mgmt Co",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: { key: string; label: string; path: (id: number) => string }[] = [
|
||||||
|
{ key: "overview", label: "Overview", path: (id) => `/entities/${id}` },
|
||||||
|
{ key: "investments", label: "Investments", path: (id) => `/entities/${id}/investments` },
|
||||||
|
{ key: "partners", label: "Partners", path: (id) => `/entities/${id}/partners` },
|
||||||
|
{ key: "documents", label: "Documents", path: (id) => `/entities/${id}/documents` },
|
||||||
|
];
|
||||||
|
|
||||||
|
// GP entities and management companies hold interests in the funds they manage; that's their
|
||||||
|
// "Assets" tab. Funds/SPVs use the Investments tab instead.
|
||||||
|
const ASSETS_TAB = {
|
||||||
|
key: "assets",
|
||||||
|
label: "Assets",
|
||||||
|
path: (id: number) => `/entities/${id}/assets`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EntityHeader({ entity, active }: { entity: Entity; active: string }) {
|
||||||
|
const tabs =
|
||||||
|
entity.type === "gp" || entity.type === "mgmt_co" ? [...TABS, ASSETS_TAB] : TABS;
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">{entity.name}</h1>
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
||||||
|
{TYPE_LABELS[entity.type] || entity.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 mt-4 border-b border-gray-200">
|
||||||
|
{tabs.map((t) =>
|
||||||
|
active === t.key ? (
|
||||||
|
<span
|
||||||
|
key={t.key}
|
||||||
|
className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600"
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={t.key}
|
||||||
|
to={t.path(entity.id!)}
|
||||||
|
className="pb-2 text-sm text-gray-500 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,61 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { isAdmin } from "../api";
|
||||||
|
import { roleLabel } from "../format";
|
||||||
|
import { APP_VERSION } from "../version";
|
||||||
|
import ChangePasswordModal from "./ChangePasswordModal";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const TOP_NAV = [
|
||||||
{ label: "Entities", path: "/" },
|
{ label: "Entities", path: "/" },
|
||||||
{ label: "Import", path: "/import" },
|
{ label: "Import", path: "/import" },
|
||||||
{ label: "Audit Log", path: "/audit" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ADMIN_NAV = [
|
||||||
|
{ label: "Users", path: "/users" },
|
||||||
|
{ label: "Investor View", path: "/investor-view" },
|
||||||
|
{ label: "Access Grid", path: "/access" },
|
||||||
|
{ label: "Documents", path: "/documents" },
|
||||||
|
{ label: "Capital Accounts", path: "/capital-accounts" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BOTTOM_NAV = [{ label: "Audit Log", path: "/audit" }];
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const [changingPw, setChangingPw] = useState(false);
|
||||||
|
const navItems = user && isAdmin(user.role)
|
||||||
|
? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV]
|
||||||
|
: [...TOP_NAV, ...BOTTOM_NAV];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-50">
|
<div className="flex h-screen bg-gray-50">
|
||||||
{/* Left nav */}
|
{/* Dimmed backdrop behind the mobile drawer */}
|
||||||
<nav className="w-56 bg-white border-r border-gray-200 flex flex-col">
|
{navOpen && (
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="fixed inset-0 z-30 bg-black/30 md:hidden" onClick={() => setNavOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left nav — static on desktop, slide-over drawer on mobile */}
|
||||||
|
<nav
|
||||||
|
className={`${navOpen ? "flex" : "hidden"} md:flex fixed md:static inset-y-0 left-0 z-40 w-56 bg-white border-r border-gray-200 flex-col`}
|
||||||
|
>
|
||||||
|
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200">
|
||||||
|
<img src="/ten31-logo.png" alt="" className="w-7 h-7 rounded-md" />
|
||||||
<h1 className="text-lg font-semibold text-gray-900">Ten31 Portal</h1>
|
<h1 className="text-lg font-semibold text-gray-900">Ten31 Portal</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setNavOpen(false)}
|
||||||
|
className="ml-auto md:hidden text-gray-400 hover:text-gray-700"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex-1 py-2">
|
<ul className="flex-1 py-2 overflow-auto">
|
||||||
{NAV_ITEMS.map((item) => {
|
{navItems.map((item) => {
|
||||||
const active = item.path === "/"
|
const active = item.path === "/"
|
||||||
? location.pathname === "/"
|
? location.pathname === "/"
|
||||||
: location.pathname.startsWith(item.path);
|
: location.pathname.startsWith(item.path);
|
||||||
@@ -27,6 +63,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<Link
|
<Link
|
||||||
to={item.path}
|
to={item.path}
|
||||||
|
onClick={() => setNavOpen(false)}
|
||||||
className={`block px-4 py-2 text-sm ${
|
className={`block px-4 py-2 text-sm ${
|
||||||
active
|
active
|
||||||
? "bg-orange-50 text-orange-600 border-r-2 border-orange-500 font-medium"
|
? "bg-orange-50 text-orange-600 border-r-2 border-orange-500 font-medium"
|
||||||
@@ -39,16 +76,37 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div className="px-4 py-3 border-t border-gray-200 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setChangingPw(true); setNavOpen(false); }}
|
||||||
|
className="block text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Change password
|
||||||
|
</button>
|
||||||
|
<div className="text-xs text-gray-400">v{APP_VERSION}</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Main area */}
|
{/* Main area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="h-14 bg-white border-b border-gray-200 flex items-center justify-end px-6">
|
<header className="h-14 bg-white border-b border-gray-200 flex items-center gap-2 px-4 sm:px-6">
|
||||||
<div className="flex items-center gap-4">
|
<button
|
||||||
<span className="text-sm text-gray-600">{user?.name}</span>
|
onClick={() => setNavOpen(true)}
|
||||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
className="md:hidden text-gray-600 hover:text-gray-900 -ml-1"
|
||||||
{user?.role}
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h16" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<img src="/ten31-logo.png" alt="" className="md:hidden w-7 h-7 rounded-md" />
|
||||||
|
<span className="md:hidden text-base font-semibold text-gray-900">Ten31 Portal</span>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<span className="hidden sm:inline text-sm text-gray-600">{user?.name}</span>
|
||||||
|
<span className="hidden sm:inline text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||||
|
{user ? roleLabel(user.role) : ""}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -60,8 +118,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
<main className="flex-1 overflow-auto p-4 sm:p-6 min-w-0">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/** A password field with a show/hide eye toggle. */
|
||||||
|
export default function PasswordInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className = "",
|
||||||
|
autoComplete = "new-password",
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
autoComplete?: string;
|
||||||
|
}) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const base =
|
||||||
|
"w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={show ? "text" : "password"}
|
||||||
|
value={value}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={`${base} ${className}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShow((s) => !s)}
|
||||||
|
aria-label={show ? "Hide password" : "Show password"}
|
||||||
|
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{show ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
<path d="M10.6 10.6a2 2 0 002.8 2.8" />
|
||||||
|
<path d="M9.36 5.18A9.3 9.3 0 0112 5c5 0 9 4 10 7a12.4 12.4 0 01-2.6 3.6M6.1 6.1A12.5 12.5 0 002 12c1 3 5 7 10 7a9.3 9.3 0 003.6-.72" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,39 @@ export function formatDate(dateStr: string | null | undefined): string {
|
|||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human label for a document category. */
|
||||||
|
export function categoryLabel(cat: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
capital_account: "Capital account",
|
||||||
|
k1: "K-1",
|
||||||
|
statement: "Statement",
|
||||||
|
tax: "Tax",
|
||||||
|
other: "Other",
|
||||||
|
};
|
||||||
|
return map[cat] ?? cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a user role. */
|
||||||
|
export function roleLabel(role: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
approver: "Managing Partner",
|
||||||
|
operations: "Operations",
|
||||||
|
cfo: "CFO",
|
||||||
|
fund_admin: "Fund Admin",
|
||||||
|
viewer: "Viewer (internal)",
|
||||||
|
investor: "Investor",
|
||||||
|
fund_administrator: "Fund administrator",
|
||||||
|
};
|
||||||
|
return map[role] ?? role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes to human size. */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Format quarter-end date as "Q2 2026" */
|
/** Format quarter-end date as "Q2 2026" */
|
||||||
export function formatQuarter(dateStr: string): string {
|
export function formatQuarter(dateStr: string): string {
|
||||||
const d = new Date(dateStr + "T00:00:00");
|
const d = new Date(dateStr + "T00:00:00");
|
||||||
|
|||||||
@@ -8,3 +8,9 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<App />
|
<App />
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type AccessMatrix } from "../api";
|
||||||
|
import { roleLabel } from "../format";
|
||||||
|
|
||||||
|
export default function AccessGrid() {
|
||||||
|
const [matrix, setMatrix] = useState<AccessMatrix | null>(null);
|
||||||
|
const [granted, setGranted] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const key = (u: number, e: number) => `${u}:${e}`;
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
api
|
||||||
|
.accessMatrix()
|
||||||
|
.then((m) => {
|
||||||
|
setMatrix(m);
|
||||||
|
setGranted(new Set(m.grants.map((g) => key(g.user_id, g.entity_id))));
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
};
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
const toggle = async (userId: number, entityId: number) => {
|
||||||
|
const k = key(userId, entityId);
|
||||||
|
const has = granted.has(k);
|
||||||
|
setBusy((b) => new Set(b).add(k));
|
||||||
|
setError("");
|
||||||
|
// optimistic update
|
||||||
|
setGranted((g) => {
|
||||||
|
const n = new Set(g);
|
||||||
|
has ? n.delete(k) : n.add(k);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (has) await api.revokeAccess(userId, entityId);
|
||||||
|
else await api.grantAccess(userId, entityId);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Failed to update access");
|
||||||
|
// revert
|
||||||
|
setGranted((g) => {
|
||||||
|
const n = new Set(g);
|
||||||
|
has ? n.add(k) : n.delete(k);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy((b) => {
|
||||||
|
const n = new Set(b);
|
||||||
|
n.delete(k);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!matrix) {
|
||||||
|
return <p className="text-gray-500 text-sm">{error || "Loading…"}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { users, entities } = matrix;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-1">Access grid</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Who can see what. Click a cell to grant or revoke an account's access to a fund or SPV.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No investor or fund-administrator accounts yet.</p>
|
||||||
|
) : entities.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No entities yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-auto border border-gray-200 rounded-lg bg-white">
|
||||||
|
<table className="text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky left-0 z-10 bg-gray-50 text-left px-4 py-2 font-medium text-gray-500 border-b border-gray-200 min-w-48">
|
||||||
|
Account
|
||||||
|
</th>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<th
|
||||||
|
key={e.id}
|
||||||
|
className="px-3 py-2 font-medium text-gray-600 border-b border-l border-gray-100 whitespace-nowrap align-bottom"
|
||||||
|
title={e.name}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span>{e.name}</span>
|
||||||
|
<span className="text-[10px] text-gray-400 uppercase">{e.type}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="hover:bg-gray-50/50">
|
||||||
|
<td className="sticky left-0 z-10 bg-white px-4 py-2 border-b border-gray-100">
|
||||||
|
<div className="font-medium text-gray-900">{u.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{u.username} · {roleLabel(u.role)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{entities.map((e) => {
|
||||||
|
const k = key(u.id, e.id);
|
||||||
|
const on = granted.has(k);
|
||||||
|
const loading = busy.has(k);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={e.id}
|
||||||
|
className="text-center border-b border-l border-gray-100 p-0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(u.id, e.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full h-10 flex items-center justify-center transition-colors ${
|
||||||
|
on
|
||||||
|
? "bg-orange-50 text-orange-600 hover:bg-orange-100"
|
||||||
|
: "text-gray-300 hover:bg-gray-100"
|
||||||
|
} ${loading ? "opacity-50" : ""}`}
|
||||||
|
title={on ? "Click to revoke" : "Click to grant"}
|
||||||
|
>
|
||||||
|
{on ? "✓" : "·"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { api, type CapitalAccount, type Entity, type User } from "../api";
|
||||||
|
import { formatDate, formatMoneyExact } from "../format";
|
||||||
|
|
||||||
|
export default function CapitalAccounts() {
|
||||||
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [rows, setRows] = useState<CapitalAccount[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const investors = useMemo(() => users.filter((u) => u.role === "investor"), [users]);
|
||||||
|
const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||||
|
const entityById = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
api.listCapitalAccounts().then(setRows).catch((e) => setError(e.message));
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
api.listEntities().then(setEntities).catch(() => {});
|
||||||
|
api.listUsers().then(setUsers).catch(() => {});
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Capital accounts</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Enter each investor's figures from their latest capital account statement. Investors see
|
||||||
|
only their own.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
|
||||||
|
<EntryForm
|
||||||
|
entities={entities}
|
||||||
|
investors={investors}
|
||||||
|
onSaved={load}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mt-8 mb-2">Statements</h3>
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Investor</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Entity</th>
|
||||||
|
<th className="px-4 py-2 font-medium">As of</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Contributions</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Distributions</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Ending balance</th>
|
||||||
|
<th className="px-4 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2 text-gray-900">
|
||||||
|
{userById.get(r.investor_user_id)?.name ?? r.investor_user_id}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{entityById.get(r.entity_id)?.name ?? r.entity_id}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{formatDate(r.as_of_date)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-600">{formatMoneyExact(r.contributions_cents)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-600">{formatMoneyExact(r.distributions_cents)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900 font-medium">
|
||||||
|
{formatMoneyExact(r.ending_balance_cents)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Delete this statement?"))
|
||||||
|
api.deleteCapitalAccount(r.id).then(load).catch((e) => setError(e.message));
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-6 text-center text-gray-400">
|
||||||
|
No statements yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryForm({
|
||||||
|
entities,
|
||||||
|
investors,
|
||||||
|
onSaved,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
entities: Entity[];
|
||||||
|
investors: User[];
|
||||||
|
onSaved: () => void;
|
||||||
|
setError: (s: string) => void;
|
||||||
|
}) {
|
||||||
|
const [entityId, setEntityId] = useState<number | "">("");
|
||||||
|
const [investorId, setInvestorId] = useState<number | "">("");
|
||||||
|
const [asOf, setAsOf] = useState("");
|
||||||
|
const [beginning, setBeginning] = useState("");
|
||||||
|
const [contributions, setContributions] = useState("");
|
||||||
|
const [distributions, setDistributions] = useState("");
|
||||||
|
const [ending, setEnding] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const num = (s: string) => (s.trim() === "" ? 0 : Number(s));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (entityId === "" || investorId === "" || !asOf) return;
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.createCapitalAccount({
|
||||||
|
entity_id: entityId,
|
||||||
|
investor_user_id: investorId,
|
||||||
|
as_of_date: asOf,
|
||||||
|
beginning_balance_dollars: num(beginning),
|
||||||
|
contributions_dollars: num(contributions),
|
||||||
|
distributions_dollars: num(distributions),
|
||||||
|
ending_balance_dollars: num(ending),
|
||||||
|
});
|
||||||
|
setBeginning("");
|
||||||
|
setContributions("");
|
||||||
|
setDistributions("");
|
||||||
|
setEnding("");
|
||||||
|
onSaved();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Add a statement</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Investor</label>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={investorId}
|
||||||
|
onChange={(e) => setInvestorId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{investors.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Entity</label>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={entityId}
|
||||||
|
onChange={(e) => setEntityId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">As-of date</label>
|
||||||
|
<input type="date" className={inputCls} value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<DollarField label="Beginning balance" value={beginning} onChange={setBeginning} />
|
||||||
|
<DollarField label="Contributions" value={contributions} onChange={setContributions} />
|
||||||
|
<DollarField label="Distributions" value={distributions} onChange={setDistributions} />
|
||||||
|
<DollarField label="Ending balance" value={ending} onChange={setEnding} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-3">
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || entityId === "" || investorId === "" || !asOf}
|
||||||
|
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Saving…" : "Save statement"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DollarField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (s: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">{label} ($)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className={inputCls}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type DocumentCategory,
|
||||||
|
type Entity,
|
||||||
|
type PortalDocument,
|
||||||
|
type User,
|
||||||
|
} from "../api";
|
||||||
|
import { categoryLabel, formatBytes, formatDate } from "../format";
|
||||||
|
|
||||||
|
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
|
||||||
|
|
||||||
|
export default function Documents() {
|
||||||
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||||
|
const [filterEntity, setFilterEntity] = useState<number | "">("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||||
|
const entityById = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]);
|
||||||
|
|
||||||
|
const loadDocs = () => {
|
||||||
|
api
|
||||||
|
.listDocuments(filterEntity === "" ? undefined : { entity_id: filterEntity })
|
||||||
|
.then(setDocs)
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listEntities().then(setEntities).catch(() => {});
|
||||||
|
api.listUsers().then(setUsers).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(loadDocs, [filterEntity]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Documents</h2>
|
||||||
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
|
||||||
|
<UploadForm
|
||||||
|
entities={entities}
|
||||||
|
onUploaded={loadDocs}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-8 mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">All documents</h3>
|
||||||
|
<select
|
||||||
|
className="ml-auto px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
value={filterEntity}
|
||||||
|
onChange={(e) => setFilterEntity(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">All entities</option>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Title</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Entity</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Category</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Visibility</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Uploaded</th>
|
||||||
|
<th className="px-4 py-2 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{docs.map((d) => (
|
||||||
|
<tr key={d.id} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2 text-gray-900">{d.title}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{entityById.get(d.entity_id)?.name ?? d.entity_id}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{categoryLabel(d.category)}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">
|
||||||
|
{d.investor_user_id == null
|
||||||
|
? "Shared (all investors)"
|
||||||
|
: `Private · ${userById.get(d.investor_user_id)?.name ?? d.investor_user_id}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
|
||||||
|
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href={api.downloadUrl(d.id)}
|
||||||
|
className="text-orange-600 hover:text-orange-700 mr-3"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete "${d.title}"?`))
|
||||||
|
api.deleteDocument(d.id).then(loadDocs).catch((e) => setError(e.message));
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{docs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-6 text-center text-gray-400">
|
||||||
|
No documents.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadForm({
|
||||||
|
entities,
|
||||||
|
onUploaded,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
entities: Entity[];
|
||||||
|
onUploaded: () => void;
|
||||||
|
setError: (s: string) => void;
|
||||||
|
}) {
|
||||||
|
const [entityId, setEntityId] = useState<number | "">("");
|
||||||
|
const [category, setCategory] = useState<DocumentCategory>("statement");
|
||||||
|
const [investorId, setInvestorId] = useState<number | "">("");
|
||||||
|
const [entityInvestors, setEntityInvestors] = useState<User[]>([]);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const entityName = entities.find((e) => e.id === entityId)?.name ?? "";
|
||||||
|
|
||||||
|
// Load only THIS entity's investors, so you can't attach a doc to someone outside the fund.
|
||||||
|
useEffect(() => {
|
||||||
|
setInvestorId("");
|
||||||
|
setEntityInvestors([]);
|
||||||
|
if (entityId === "") return;
|
||||||
|
api
|
||||||
|
.investorsForEntity(Number(entityId))
|
||||||
|
.then(setEntityInvestors)
|
||||||
|
.catch(() => setEntityInvestors([]));
|
||||||
|
}, [entityId]);
|
||||||
|
|
||||||
|
const targetLabel =
|
||||||
|
entityId === ""
|
||||||
|
? null
|
||||||
|
: investorId === ""
|
||||||
|
? `everyone in ${entityName}`
|
||||||
|
: `${entityInvestors.find((u) => u.id === investorId)?.name ?? investorId} only`;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (entityId === "" || !file) return;
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("entity_id", String(entityId));
|
||||||
|
form.set("category", category);
|
||||||
|
if (title) form.set("title", title);
|
||||||
|
if (investorId !== "") form.set("investor_user_id", String(investorId));
|
||||||
|
form.set("file", file);
|
||||||
|
await api.uploadDocument(form);
|
||||||
|
setTitle("");
|
||||||
|
setFile(null);
|
||||||
|
setInvestorId("");
|
||||||
|
onUploaded();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Upload a document</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Fund / entity</label>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={entityId}
|
||||||
|
onChange={(e) => setEntityId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">Select fund…</option>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as DocumentCategory)}
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{categoryLabel(c)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">
|
||||||
|
Folder {entityId !== "" && `(investors in ${entityName})`}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={investorId}
|
||||||
|
disabled={entityId === ""}
|
||||||
|
onChange={(e) => setInvestorId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">Whole fund — all investors</option>
|
||||||
|
{entityInvestors.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}'s folder
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Title (optional)</label>
|
||||||
|
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">File</label>
|
||||||
|
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} className="text-sm" />
|
||||||
|
{file && <span className="text-xs text-gray-400 ml-2">{formatBytes(file.size)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
{targetLabel ? (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Uploading to <span className="font-medium text-gray-700">{entityName}</span> ·{" "}
|
||||||
|
<span className={investorId === "" ? "text-gray-700" : "text-orange-600 font-medium"}>
|
||||||
|
{targetLabel}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">Choose a fund to begin.</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || entityId === "" || !file}
|
||||||
|
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Uploading…" : "Upload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { api, canEditRound, type Entity, type EntityStake } from "../api";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { formatMoney } from "../format";
|
||||||
|
import EntityHeader from "../components/EntityHeader";
|
||||||
|
|
||||||
|
// A GP / management company's assets: its ownership interest in the funds it manages.
|
||||||
|
export default function EntityAssets() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [entity, setEntity] = useState<Entity | null>(null);
|
||||||
|
const [stakes, setStakes] = useState<EntityStake[]>([]);
|
||||||
|
const [allEntities, setAllEntities] = useState<Entity[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const entityId = id ? parseInt(id) : 0;
|
||||||
|
const isWriter = !!user && canEditRound(user.role);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entityId) return;
|
||||||
|
load();
|
||||||
|
}, [entityId]);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [ent, stk, all] = await Promise.all([
|
||||||
|
api.getEntity(entityId),
|
||||||
|
api.listStakes(entityId),
|
||||||
|
api.listEntities(),
|
||||||
|
]);
|
||||||
|
setEntity(ent);
|
||||||
|
setStakes(stk);
|
||||||
|
setAllEntities(all);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Failed to load");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funds/SPVs available to add (exclude self and ones already staked).
|
||||||
|
const candidates = useMemo(() => {
|
||||||
|
const staked = new Set(stakes.map((s) => s.fund_entity_id));
|
||||||
|
return allEntities.filter(
|
||||||
|
(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);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<EntityHeader entity={entity} active="assets" />
|
||||||
|
|
||||||
|
{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">Fund interests</h2>
|
||||||
|
{isWriter && candidates.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Add stake
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
|
<tr>
|
||||||
|
<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 text-right">Ownership</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Value</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Note</th>
|
||||||
|
{isWriter && <th className="px-4 py-2" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stakes.map((s) => (
|
||||||
|
<tr key={s.id} 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-600 uppercase text-xs">{s.fund_type}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-700">
|
||||||
|
{s.ownership_pct != null ? `${s.ownership_pct}%` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900">
|
||||||
|
{s.value_cents != null ? formatMoney(s.value_cents) : "—"}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
{stakes.length > 0 && (
|
||||||
|
<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-right text-gray-900">{formatMoney(totalValue)}</td>
|
||||||
|
<td className="px-4 py-2" colSpan={isWriter ? 2 : 1} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{stakes.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={isWriter ? 6 : 5} className="px-4 py-6 text-center text-gray-400">
|
||||||
|
No fund interests recorded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { api, type Entity, type PortalDocument, type User } from "../api";
|
||||||
|
import { categoryLabel, formatDate } from "../format";
|
||||||
|
import EntityHeader from "../components/EntityHeader";
|
||||||
|
|
||||||
|
export default function EntityDocuments() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [entity, setEntity] = useState<Entity | null>(null);
|
||||||
|
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||||
|
|
||||||
|
const loadDocs = (eid: number) =>
|
||||||
|
api.listDocuments({ entity_id: eid }).then(setDocs).catch((e) => setError(e.message));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
const eid = parseInt(id);
|
||||||
|
api.getEntity(eid).then(setEntity).catch((e) => setError(e.message));
|
||||||
|
api.listUsers().then(setUsers).catch(() => {});
|
||||||
|
loadDocs(eid).finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
||||||
|
const eid = entity.id!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<EntityHeader entity={entity} active="documents" />
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
All documents for this fund. As admin you see everything; each investor only sees documents
|
||||||
|
shared to the fund or addressed to them. Upload from the Documents admin screen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Title</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Category</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Visibility</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Uploaded</th>
|
||||||
|
<th className="px-4 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{docs.map((d) => (
|
||||||
|
<tr key={d.id} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2 text-gray-900">{d.title}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{categoryLabel(d.category)}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">
|
||||||
|
{d.investor_user_id == null
|
||||||
|
? "Shared (all investors)"
|
||||||
|
: `Private · ${userById.get(d.investor_user_id)?.name ?? d.investor_user_id}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
|
||||||
|
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||||
|
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700 mr-3">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete "${d.title}"?`))
|
||||||
|
api.deleteDocument(d.id).then(() => loadDocs(eid)).catch((e) => setError(e.message));
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{docs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-6 text-center text-gray-400">
|
||||||
|
No documents for this fund yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { api, type Entity, type Holding, type Position, type ValuationRound } from "../api";
|
import { api, canEditRound, type Entity, type EntityType, type Holding, type ValuationRound } from "../api";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { formatMoney, formatDate, formatQuarter } from "../format";
|
import { formatMoney, formatDate, formatQuarter } from "../format";
|
||||||
|
import EntityHeader from "../components/EntityHeader";
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
|
||||||
fund: "Fund",
|
|
||||||
spv: "SPV",
|
|
||||||
gp: "GP",
|
|
||||||
mgmt_co: "Mgmt Co",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
draft: "bg-gray-100 text-gray-600",
|
draft: "bg-gray-100 text-gray-600",
|
||||||
@@ -19,12 +14,16 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
export default function EntityOverview() {
|
export default function EntityOverview() {
|
||||||
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 [holdings, setHoldings] = useState<Holding[]>([]);
|
const [holdings, setHoldings] = useState<Holding[]>([]);
|
||||||
const [totalInvested, setTotalInvested] = useState(0);
|
const [totalInvested, setTotalInvested] = useState(0);
|
||||||
const [lastValue, setLastValue] = useState(0);
|
const [lastValue, setLastValue] = useState(0);
|
||||||
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
|
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const isWriter = !!user && canEditRound(user.role);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -75,29 +74,29 @@ export default function EntityOverview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
<EntityHeader entity={entity} active="overview" />
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
{isWriter && (
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">{entity.name}</h1>
|
<div className="flex justify-end mb-3">
|
||||||
<span className="inline-block px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
<button
|
||||||
{TYPE_LABELS[entity.type] || entity.type}
|
onClick={() => setEditing(true)}
|
||||||
</span>
|
className="px-3 py-1.5 border border-gray-300 text-sm rounded hover:bg-gray-50"
|
||||||
</div>
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-6 mt-4 border-b border-gray-200">
|
|
||||||
<span className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600">
|
|
||||||
Overview
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
to={`/entities/${entity.id}/investments`}
|
|
||||||
className="pb-2 text-sm text-gray-500 hover:text-gray-800"
|
|
||||||
>
|
>
|
||||||
Investments
|
Edit entity
|
||||||
</Link>
|
</button>
|
||||||
<span className="pb-2 text-sm text-gray-400 cursor-not-allowed">Partners</span>
|
|
||||||
<span className="pb-2 text-sm text-gray-400 cursor-not-allowed">Documents</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditEntityForm
|
||||||
|
entity={entity}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
onSaved={(updated) => {
|
||||||
|
setEntity(updated);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
|
||||||
@@ -134,6 +133,103 @@ export default function EntityOverview() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditEntityForm({
|
||||||
|
entity,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
entity: Entity;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (updated: Entity) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(entity.name);
|
||||||
|
const [type, setType] = useState<EntityType>(entity.type);
|
||||||
|
const [status, setStatus] = useState(entity.status);
|
||||||
|
const [vintageYear, setVintageYear] = useState(entity.vintage_year?.toString() ?? "");
|
||||||
|
const [fundSizeDollars, setFundSizeDollars] = useState(
|
||||||
|
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
|
||||||
|
);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data: Partial<Entity> = {
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
vintage_year: vintageYear ? parseInt(vintageYear) : null,
|
||||||
|
fund_size_cents: fundSizeDollars
|
||||||
|
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
const updated = await api.updateEntity(entity.id, data);
|
||||||
|
onSaved(updated);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to update entity");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6 max-w-lg">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-4">Edit entity</h3>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Name</label>
|
||||||
|
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Type</label>
|
||||||
|
<select className={inputCls} value={type} onChange={(e) => setType(e.target.value as EntityType)}>
|
||||||
|
<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="flex-1">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Status</label>
|
||||||
|
<select className={inputCls} value={status} onChange={(e) => setStatus(e.target.value as Entity["status"])}>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Vintage Year</label>
|
||||||
|
<input className={inputCls} value={vintageYear} onChange={(e) => setVintageYear(e.target.value)} placeholder="2021" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Fund Size ($)</label>
|
||||||
|
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<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">
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SummaryCard({
|
function SummaryCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { api, type Entity, type Partner } from "../api";
|
||||||
|
import { formatDate, formatMoneyExact } from "../format";
|
||||||
|
import EntityHeader from "../components/EntityHeader";
|
||||||
|
|
||||||
|
export default function EntityPartners() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [entity, setEntity] = useState<Entity | null>(null);
|
||||||
|
const [partners, setPartners] = useState<Partner[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
const eid = parseInt(id);
|
||||||
|
Promise.all([api.getEntity(eid), api.listPartners(eid)])
|
||||||
|
.then(([e, p]) => {
|
||||||
|
setEntity(e);
|
||||||
|
setPartners(p);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
||||||
|
|
||||||
|
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
|
||||||
|
const totalCapital = partners.reduce((s, p) => s + (p.latest_value_cents || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<EntityHeader entity={entity} active="partners" />
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
|
||||||
|
<span className="mx-2 text-gray-300">·</span>
|
||||||
|
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Member</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Investor ID</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Login</th>
|
||||||
|
<th className="px-4 py-2 font-medium">As of</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Committed</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Paid-in</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Distributions</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Capital value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{partners.map((p) => (
|
||||||
|
<tr key={p.user_id} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="text-gray-900">{p.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{p.username}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">{p.external_investor_id ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{!p.is_active ? (
|
||||||
|
<span className="text-gray-400">Disabled</span>
|
||||||
|
) : p.login_enabled ? (
|
||||||
|
<span className="text-green-600">Active</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-600">No login yet</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">
|
||||||
|
{p.latest_as_of ? formatDate(p.latest_as_of) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900">
|
||||||
|
{p.latest_commitment_cents != null ? formatMoneyExact(p.latest_commitment_cents) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-600">
|
||||||
|
{p.latest_contributions_cents != null ? formatMoneyExact(p.latest_contributions_cents) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-600">
|
||||||
|
{p.latest_distributions_cents != null ? formatMoneyExact(p.latest_distributions_cents) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900">
|
||||||
|
{p.latest_value_cents != null ? formatMoneyExact(p.latest_value_cents) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{partners.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-6 text-center text-gray-400">
|
||||||
|
No members yet. Import the eNAV ALLOC SI tab from “Import Statements,” or grant
|
||||||
|
access on the Access Grid.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { api, type InvestorView as InvestorViewData, type User } from "../api";
|
||||||
|
import InvestorPortalView from "../portal/InvestorPortalView";
|
||||||
|
|
||||||
|
// Admin-only: pick an investor and see exactly what their portal shows, read-only.
|
||||||
|
export default function InvestorView() {
|
||||||
|
const [investors, setInvestors] = useState<User[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | "">("");
|
||||||
|
const [data, setData] = useState<InvestorViewData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.listUsers()
|
||||||
|
.then((us) => setInvestors(us.filter((u) => u.role === "investor")))
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...investors].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[investors],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId === "") {
|
||||||
|
setData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
api
|
||||||
|
.investorView(selectedId)
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 mb-1">Investor View</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
See exactly what an investor sees in their portal. Read-only — you are not signed in as them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Investor</label>
|
||||||
|
<select
|
||||||
|
className="w-full max-w-sm px-3 py-2 border border-gray-300 rounded text-sm"
|
||||||
|
value={selectedId}
|
||||||
|
onChange={(e) => setSelectedId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">Select an investor…</option>
|
||||||
|
{sorted.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name} ({u.username})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
{loading && <p className="text-sm text-gray-500">Loading…</p>}
|
||||||
|
|
||||||
|
{data && !loading && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-sm bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
||||||
|
<span className="font-medium text-amber-800">Viewing as {data.user.name}</span>
|
||||||
|
<span className="text-amber-700">— read-only reconstruction of their portal.</span>
|
||||||
|
</div>
|
||||||
|
<InvestorPortalView
|
||||||
|
entities={data.entities}
|
||||||
|
accounts={data.capital_accounts}
|
||||||
|
docs={data.documents}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import PasswordInput from "../components/PasswordInput";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [email, setEmail] = useState("");
|
const [handle, setHandle] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -13,7 +14,7 @@ export default function Login() {
|
|||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(handle, password);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Login failed");
|
setError(err.message || "Login failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -24,27 +25,25 @@ export default function Login() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-96">
|
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-96">
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-6">Ten31Portal</h1>
|
<div className="flex items-center gap-2.5 mb-6">
|
||||||
|
<img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" />
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">Ten31 Portal</h1>
|
||||||
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-700 mb-1">Email</label>
|
<label className="block text-sm text-gray-700 mb-1">Username or email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
value={email}
|
autoComplete="username"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
value={handle}
|
||||||
|
onChange={(e) => setHandle(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-700 mb-1">Password</label>
|
<label className="block text-sm text-gray-700 mb-1">Password</label>
|
||||||
<input
|
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
EXTERNAL_ROLES,
|
||||||
|
isInternal,
|
||||||
|
type Entity,
|
||||||
|
type User,
|
||||||
|
type UserDetail,
|
||||||
|
type UserRole,
|
||||||
|
} from "../api";
|
||||||
|
import { roleLabel } from "../format";
|
||||||
|
import PasswordInput from "../components/PasswordInput";
|
||||||
|
|
||||||
|
const CREATABLE_ROLES: UserRole[] = [
|
||||||
|
"investor",
|
||||||
|
"fund_administrator",
|
||||||
|
"fund_admin",
|
||||||
|
"operations",
|
||||||
|
"approver",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<UserDetail | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
api.listUsers().then(setUsers).catch((e) => setError(e.message));
|
||||||
|
api.listEntities().then(setEntities).catch(() => {});
|
||||||
|
};
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Users</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
New user
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Username</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Role</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-2 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2 text-gray-900">{u.name}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">
|
||||||
|
{u.username}
|
||||||
|
{u.primary_account_id != null && (
|
||||||
|
<span
|
||||||
|
className="ml-2 text-xs text-gray-400"
|
||||||
|
title="Signs in under this account"
|
||||||
|
>
|
||||||
|
→ {usernameById.get(u.primary_account_id) ?? "linked"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600">
|
||||||
|
{roleLabel(u.role)}
|
||||||
|
{u.is_service_admin && (
|
||||||
|
<span className="ml-2 text-xs text-gray-400" title="Built-in account — cannot be deleted">
|
||||||
|
Service Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{u.is_active ? (
|
||||||
|
<span className="text-green-600">Active</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">Disabled</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
api.getUser(u.id).then(setEditing).catch((e) => setError(e.message))
|
||||||
|
}
|
||||||
|
className="text-orange-600 hover:text-orange-700"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-6 text-center text-gray-400">
|
||||||
|
No users yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<CreateUserModal
|
||||||
|
entities={entities}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setCreating(false);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<EditUserModal
|
||||||
|
user={editing}
|
||||||
|
entities={entities}
|
||||||
|
allUsers={users}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditing(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntityChecklist({
|
||||||
|
entities,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
entities: Entity[];
|
||||||
|
selected: Set<number>;
|
||||||
|
onChange: (s: Set<number>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded max-h-48 overflow-auto divide-y divide-gray-100">
|
||||||
|
{entities.map((e) => (
|
||||||
|
<label key={e.id} className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(e.id)}
|
||||||
|
onChange={(ev) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (ev.target.checked) next.add(e.id);
|
||||||
|
else next.delete(e.id);
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-900">{e.name}</span>
|
||||||
|
<span className="text-gray-400 text-xs uppercase">{e.type}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{entities.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-sm text-gray-400">No entities exist yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserModal({
|
||||||
|
entities,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
entities: Entity[];
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [role, setRole] = useState<UserRole>("investor");
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const external = EXTERNAL_ROLES.includes(role);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.createUser({
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
email: email || null,
|
||||||
|
entity_ids: external ? [...selected] : [],
|
||||||
|
});
|
||||||
|
onCreated();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Failed to create user");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="New user" onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Full name">
|
||||||
|
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Username">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={username}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Email (optional)">
|
||||||
|
<input className={inputCls} value={email} autoComplete="off" onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Temporary password">
|
||||||
|
<PasswordInput value={password} onChange={setPassword} placeholder="minimum 4 characters" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Role">
|
||||||
|
<select className={inputCls} value={role} onChange={(e) => setRole(e.target.value as UserRole)}>
|
||||||
|
{CREATABLE_ROLES.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{roleLabel(r)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{external && (
|
||||||
|
<Field label="Entity access">
|
||||||
|
<EntityChecklist entities={entities} selected={selected} onChange={setSelected} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || !name || !username || !password}
|
||||||
|
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditUserModal({
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
allUsers,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
user: UserDetail;
|
||||||
|
entities: Entity[];
|
||||||
|
allUsers: User[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set(user.entity_ids));
|
||||||
|
const [isActive, setIsActive] = useState(user.is_active);
|
||||||
|
const [username, setUsername] = useState(user.username);
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
// "" = logs in independently; a number = the primary account this name signs in under.
|
||||||
|
const [linkTo, setLinkTo] = useState<number | "">(user.primary_account_id ?? "");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const external = !isInternal(user.role);
|
||||||
|
const isInvestor = user.role === "investor";
|
||||||
|
const isPrimary = user.linked_accounts.length > 0;
|
||||||
|
const linked = linkTo !== "";
|
||||||
|
|
||||||
|
// Candidates to link under: other investors that aren't themselves linked.
|
||||||
|
const linkCandidates = allUsers.filter(
|
||||||
|
(u) => u.role === "investor" && u.id !== user.id && u.primary_account_id == null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.updateUser(user.id, {
|
||||||
|
is_active: isActive,
|
||||||
|
...(username.trim() && username.trim() !== user.username
|
||||||
|
? { username: username.trim() }
|
||||||
|
: {}),
|
||||||
|
...(external ? { entity_ids: [...selected] } : {}),
|
||||||
|
});
|
||||||
|
if (isInvestor && !isPrimary && linkTo !== (user.primary_account_id ?? "")) {
|
||||||
|
await api.linkAccount(user.id, linkTo === "" ? null : linkTo);
|
||||||
|
}
|
||||||
|
// A linked secondary doesn't sign in on its own, so skip the password.
|
||||||
|
if (newPassword && !linked) await api.resetPassword(user.id, newPassword);
|
||||||
|
onSaved();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`Manage ${user.name}`} onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-gray-500">{roleLabel(user.role)}</p>
|
||||||
|
<Field label="Username">
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={username}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} />
|
||||||
|
Account active
|
||||||
|
</label>
|
||||||
|
{external && (
|
||||||
|
<Field label="Entity access">
|
||||||
|
<EntityChecklist entities={entities} selected={selected} onChange={setSelected} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{isInvestor && (
|
||||||
|
<Field label="Login">
|
||||||
|
{isPrimary ? (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Signs in on its own. {user.linked_accounts.length} other name
|
||||||
|
{user.linked_accounts.length === 1 ? "" : "s"} sign in here:{" "}
|
||||||
|
{user.linked_accounts.map((a) => a.username).join(", ")}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={linkTo === "" ? "" : String(linkTo)}
|
||||||
|
onChange={(e) => setLinkTo(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">Logs in independently</option>
|
||||||
|
{linkCandidates.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
Linked → {u.name} ({u.username})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
Link this name to one login so that person sees every investment held under
|
||||||
|
their different names in one place.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{!linked && (
|
||||||
|
<Field label="Reset password (optional)">
|
||||||
|
<PasswordInput
|
||||||
|
value={newPassword}
|
||||||
|
onChange={setNewPassword}
|
||||||
|
placeholder="Leave blank to keep current"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Modal({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg shadow-lg w-[28rem] max-h-[90vh] overflow-auto p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type DocumentCategory,
|
||||||
|
type Entity,
|
||||||
|
type PortalDocument,
|
||||||
|
type User,
|
||||||
|
} from "../api";
|
||||||
|
import { categoryLabel, formatBytes, formatDate } from "../format";
|
||||||
|
|
||||||
|
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
|
||||||
|
|
||||||
|
export default function FundAdminHome() {
|
||||||
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.listEntities()
|
||||||
|
.then(setEntities)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <p className="text-gray-500 text-sm">Loading…</p>;
|
||||||
|
if (error) return <p className="text-red-600 text-sm">{error}</p>;
|
||||||
|
if (entities.length === 0)
|
||||||
|
return <p className="text-gray-500 text-sm">You don't have access to any entities yet.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{entities.map((e) => (
|
||||||
|
<EntityDocs key={e.id} entity={e} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntityDocs({ entity }: { entity: Entity }) {
|
||||||
|
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||||
|
const [investors, setInvestors] = useState<User[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
api.listDocuments({ entity_id: entity.id }).then(setDocs).catch((e) => setError(e.message));
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
api.investorsForEntity(entity.id).then(setInvestors).catch(() => {});
|
||||||
|
}, [entity.id]);
|
||||||
|
|
||||||
|
const investorName = (id: number | null) =>
|
||||||
|
id == null ? "Shared (all investors)" : investors.find((i) => i.id === id)?.name ?? `Investor ${id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white border border-gray-200 rounded-lg p-5">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{entity.name}</h2>
|
||||||
|
<span className="text-xs text-gray-400 uppercase">{entity.type}</span>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
|
||||||
|
|
||||||
|
<UploadForm entity={entity} investors={investors} onUploaded={load} setError={setError} />
|
||||||
|
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mt-5 mb-2">Documents</h3>
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No documents yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
||||||
|
{docs.map((d) => (
|
||||||
|
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
||||||
|
<span className="text-gray-900">{d.title}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-400">· {investorName(d.investor_user_id)}</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||||
|
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadForm({
|
||||||
|
entity,
|
||||||
|
investors,
|
||||||
|
onUploaded,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
entity: Entity;
|
||||||
|
investors: User[];
|
||||||
|
onUploaded: () => void;
|
||||||
|
setError: (s: string) => void;
|
||||||
|
}) {
|
||||||
|
const [category, setCategory] = useState<DocumentCategory>("statement");
|
||||||
|
const [investorId, setInvestorId] = useState<number | "">("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("entity_id", String(entity.id));
|
||||||
|
form.set("category", category);
|
||||||
|
if (title) form.set("title", title);
|
||||||
|
if (investorId !== "") form.set("investor_user_id", String(investorId));
|
||||||
|
form.set("file", file);
|
||||||
|
await api.uploadDocument(form);
|
||||||
|
setTitle("");
|
||||||
|
setFile(null);
|
||||||
|
setInvestorId("");
|
||||||
|
onUploaded();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 bg-gray-50 border border-gray-200 rounded p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Category</label>
|
||||||
|
<select className={inputCls} value={category} onChange={(e) => setCategory(e.target.value as DocumentCategory)}>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{categoryLabel(c)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Visibility</label>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={investorId}
|
||||||
|
onChange={(e) => setInvestorId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="">Shared — all investors</option>
|
||||||
|
{investors.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
Private — {u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Title (optional)</label>
|
||||||
|
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">File</label>
|
||||||
|
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} className="text-sm" />
|
||||||
|
{file && <span className="text-xs text-gray-400 ml-2">{formatBytes(file.size)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-2">
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || !file}
|
||||||
|
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Uploading…" : "Upload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
|
||||||
|
import InvestorPortalView from "./InvestorPortalView";
|
||||||
|
|
||||||
|
export default function InvestorHome() {
|
||||||
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
|
const [accounts, setAccounts] = useState<CapitalAccount[]>([]);
|
||||||
|
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([api.listEntities(), api.listCapitalAccounts(), api.listDocuments()])
|
||||||
|
.then(([e, a, d]) => {
|
||||||
|
setEntities(e);
|
||||||
|
setAccounts(a);
|
||||||
|
setDocs(d);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <p className="text-gray-500 text-sm">Loading…</p>;
|
||||||
|
if (error) return <p className="text-red-600 text-sm">{error}</p>;
|
||||||
|
|
||||||
|
if (entities.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
You don't have access to any funds yet. Contact Ten31 if you believe this is an error.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <InvestorPortalView entities={entities} accounts={accounts} docs={docs} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useMemo } 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";
|
||||||
|
|
||||||
|
// The investor-facing portal, rendered purely from data. Used by the investor's own home
|
||||||
|
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
|
||||||
|
export default function InvestorPortalView({
|
||||||
|
entities,
|
||||||
|
accounts,
|
||||||
|
docs,
|
||||||
|
}: {
|
||||||
|
entities: Entity[];
|
||||||
|
accounts: CapitalAccount[];
|
||||||
|
docs: PortalDocument[];
|
||||||
|
}) {
|
||||||
|
if (entities.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
No fund access yet.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When this login covers several legal names (e.g. an IRA and a trust), label each block.
|
||||||
|
const showNames = new Set(accounts.map((a) => a.investor_user_id)).size > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{entities.map((e) => (
|
||||||
|
<FundSection
|
||||||
|
key={e.id}
|
||||||
|
entity={e}
|
||||||
|
accounts={accounts.filter((a) => a.entity_id === e.id)}
|
||||||
|
docs={docs.filter((d) => d.entity_id === e.id)}
|
||||||
|
showNames={showNames}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FundSection({
|
||||||
|
entity,
|
||||||
|
accounts,
|
||||||
|
docs,
|
||||||
|
showNames,
|
||||||
|
}: {
|
||||||
|
entity: Entity;
|
||||||
|
accounts: CapitalAccount[];
|
||||||
|
docs: PortalDocument[];
|
||||||
|
showNames: boolean;
|
||||||
|
}) {
|
||||||
|
const byName = useMemo(() => {
|
||||||
|
const groups = new Map<number, CapitalAccount[]>();
|
||||||
|
for (const a of accounts) {
|
||||||
|
const g = groups.get(a.investor_user_id) ?? [];
|
||||||
|
g.push(a);
|
||||||
|
groups.set(a.investor_user_id, g);
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white border border-gray-200 rounded-lg p-5">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{entity.name}</h2>
|
||||||
|
<span className="text-xs text-gray-400 uppercase">{entity.type}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{byName.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-gray-400">No capital account statement on file yet.</p>
|
||||||
|
) : (
|
||||||
|
byName.map((group, i) => (
|
||||||
|
<CapitalBlock
|
||||||
|
key={group[0].investor_user_id}
|
||||||
|
accounts={group}
|
||||||
|
label={showNames ? group[0].investor_name : null}
|
||||||
|
divider={i > 0}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Documents</h3>
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No documents available.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
||||||
|
{docs.map((d) => (
|
||||||
|
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
||||||
|
<span className="text-gray-900">{d.title}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||||
|
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapitalBlock({
|
||||||
|
accounts,
|
||||||
|
label,
|
||||||
|
divider,
|
||||||
|
}: {
|
||||||
|
accounts: CapitalAccount[];
|
||||||
|
label: string | null;
|
||||||
|
divider: boolean;
|
||||||
|
}) {
|
||||||
|
// accounts arrive newest-first; history is oldest-first for the chart/table.
|
||||||
|
const latest = accounts[0];
|
||||||
|
const history = useMemo(
|
||||||
|
() => [...accounts].sort((a, b) => a.as_of_date.localeCompare(b.as_of_date)),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
const chartPoints: CapitalPoint[] = history.map((a) => ({
|
||||||
|
date: a.as_of_date,
|
||||||
|
value: a.ending_balance_cents,
|
||||||
|
paidIn: a.contributions_cents,
|
||||||
|
distributions: a.distributions_cents,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}>
|
||||||
|
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
|
||||||
|
<p className="text-sm text-gray-500 mt-1">As of {formatDate(latest.as_of_date)}</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
||||||
|
<Metric label="Commitment" value={formatMoneyExact(latest.commitment_cents)} />
|
||||||
|
<Metric label="Paid-in" value={formatMoneyExact(latest.contributions_cents)} />
|
||||||
|
{latest.distributions_cents > 0 && (
|
||||||
|
<Metric label="Distributions" value={formatMoneyExact(latest.distributions_cents)} />
|
||||||
|
)}
|
||||||
|
{latest.distributions_cents > 0 && latest.contributions_cents > 0 && (
|
||||||
|
<Metric
|
||||||
|
label="DPI"
|
||||||
|
value={(latest.distributions_cents / latest.contributions_cents).toFixed(2) + "x"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-4">Current capital value</p>
|
||||||
|
<p className="text-3xl font-semibold text-gray-900 mt-0.5">
|
||||||
|
{formatMoneyExact(latest.ending_balance_cents)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{history.length > 1 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Capital over time</h3>
|
||||||
|
<CapitalChart points={chartPoints} />
|
||||||
|
<table className="w-full text-sm mt-4">
|
||||||
|
<thead className="text-gray-400 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 font-medium">As of</th>
|
||||||
|
<th className="py-1 font-medium text-right">Paid-in</th>
|
||||||
|
<th className="py-1 font-medium text-right">Distributions</th>
|
||||||
|
<th className="py-1 font-medium text-right">Ending balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{history.map((a) => (
|
||||||
|
<tr key={a.id} className="border-t border-gray-100">
|
||||||
|
<td className="py-1 text-gray-600">{formatDate(a.as_of_date)}</td>
|
||||||
|
<td className="py-1 text-right text-gray-600">{formatMoneyExact(a.contributions_cents)}</td>
|
||||||
|
<td className="py-1 text-right text-gray-600">{formatMoneyExact(a.distributions_cents)}</td>
|
||||||
|
<td className="py-1 text-right text-gray-900">{formatMoneyExact(a.ending_balance_cents)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 uppercase">{label}</p>
|
||||||
|
<p className="text-lg font-medium text-gray-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { roleLabel } from "../format";
|
||||||
|
import { APP_VERSION } from "../version";
|
||||||
|
import ChangePasswordModal from "../components/ChangePasswordModal";
|
||||||
|
|
||||||
|
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [changingPw, setChangingPw] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="h-14 bg-white border-b border-gray-200 flex items-center gap-2 px-4 sm:px-6">
|
||||||
|
<img src="/ten31-logo.png" alt="" className="w-7 h-7 rounded-md" />
|
||||||
|
<h1 className="text-base sm:text-lg font-semibold text-gray-900">Ten31 Portal</h1>
|
||||||
|
<div className="ml-auto flex items-center gap-3 sm:gap-4">
|
||||||
|
<span className="hidden sm:inline text-sm text-gray-600">{user?.name}</span>
|
||||||
|
<span className="hidden sm:inline text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||||
|
{user ? roleLabel(user.role) : ""}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setChangingPw(true)} className="text-sm text-gray-500 hover:text-gray-800">
|
||||||
|
Change password
|
||||||
|
</button>
|
||||||
|
<button onClick={logout} className="text-sm text-gray-500 hover:text-gray-800">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
<span className="hidden sm:inline text-xs text-gray-300">v{APP_VERSION}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="max-w-4xl mx-auto p-4 sm:p-6">{children}</main>
|
||||||
|
{changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +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";
|
||||||
|
After Width: | Height: | Size: 11 KiB |