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.
This commit is contained in:
Jonathan Kirkwood
2026-07-01 14:25:50 -05:00
parent 7fc78d7058
commit f0f8fd15c6
69 changed files with 5492 additions and 740 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "frontend",
"port": 5173
}
]
}
+13
View File
@@ -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
+25
View File
@@ -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')
+48 -5
View File
@@ -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)
+34 -2
View File
@@ -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:
+20
View File
@@ -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)
+31 -3
View File
@@ -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,
}
+173 -5
View File
@@ -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)
+328 -290
View File
@@ -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)
+3 -3
View File
@@ -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)
+375
View File
@@ -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"}
+214 -3
View File
@@ -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):
+40
View File
@@ -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
+41
View File
@@ -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() == []
+6 -2
View File
@@ -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
+21
View File
@@ -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.
View File
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 23 KiB

+39
View File
@@ -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.
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+310 -53
View File
@@ -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)
+3 -2
View File
@@ -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 }) => {},
},
})
+1
View File
@@ -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',
}, },
}, },
+9 -2
View File
@@ -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>
+277 -274
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+16
View File
@@ -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" }
]
}
+56
View File
@@ -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
})(),
)
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+4
View File
@@ -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 />} />
+31
View File
@@ -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`),
+89
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+75 -15
View File
@@ -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)} />
<h1 className="text-lg font-semibold text-gray-900">Ten31Portal</h1> )}
{/* 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>
<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>
); );
} }
+51
View File
@@ -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>
);
}
+33
View File
@@ -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");
+6
View File
@@ -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(() => {});
});
}
+136
View File
@@ -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>
);
}
+222
View File
@@ -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";
+265
View File
@@ -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";
+237
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+126 -30
View File
@@ -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,
+109
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+13 -14
View File
@@ -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
+432
View File
@@ -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>
);
}
+180
View File
@@ -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";
+35
View File
@@ -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} />;
}
+188
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+4
View File
@@ -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";
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB