Issue 1: repo scaffold and project structure

This commit is contained in:
Johnny 5
2026-06-07 19:20:02 +00:00
parent e0b31009b7
commit a70bdeaa5e
52 changed files with 6750 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# Python
__pycache__/
*.pyc
*.pyo
.venv/
*.egg-info/
dist/
# Node
node_modules/
frontend/dist/
# DB
*.db
# IDE
.vscode/
.idea/
+48
View File
@@ -0,0 +1,48 @@
# Ten31Portal
Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off.
## Prerequisites
- Python 3.11+
- Node.js 20+
## Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -e .
uvicorn ten31portal.main:app --reload --port 8000
```
Health check: `GET http://localhost:8000/api/health`
## Frontend
```bash
cd frontend
npm install
npm run dev
```
Opens at `http://localhost:5173`. Proxies `/api` to the backend on port 8000.
## Project structure
```
ten31portal/
backend/
ten31portal/ # FastAPI application
main.py # App object and health endpoint
config.py # Env-based configuration
pyproject.toml
frontend/
src/
App.tsx # Main component
main.tsx # Entry point
vite.config.ts
deploy/ # StartOS packaging (Issue 17)
SPEC.md # v1 issue specs
```
+25
View File
@@ -0,0 +1,25 @@
# Ten31Portal — v1 Issue Specs
Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off. Replaces Carta for internal use. Self-hosted on StartOS.
## Stack (baked in, override if you disagree)
- Backend: FastAPI, SQLModel, SQLite, Alembic migrations
- Frontend: React, Vite, Tailwind
- Auth: session cookies, server-side sessions, bcrypt or argon2 password hashing
- Money: stored as integer cents. Never float in the DB. Format to dollars only at the view layer.
- Dates: `investment_date` is a date. Valuation quarter is stored as a quarter-end date (e.g. 2026-06-30).
- Packaging: StartOS 0.4.0 service, single data volume, platform backups cover the DB.
## Conventions (every issue)
- One concern per commit. YAGNI. No speculative abstraction.
- Type hints everywhere. Pydantic/SQLModel for all shapes crossing a boundary.
- No PRs. Build directly.
- House style in any user-facing copy: short sentences, no hype, no em-dashes.
## Scope guard for all of v1
In: entities, holdings, positions, valuation rounds, sign-off workflow, audit log, four roles, CSV import, three read views, one workflow view, StartOS packaging.
Out (phase 2, do not build): LP access and per-LP isolation, the partner / capital-account side (capital calls, allocations, K-1s), IRR / TVPI / DPI / RVPI computation, BTC or sats denomination, charts.
+119
View File
@@ -0,0 +1,119 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+59
View File
@@ -0,0 +1,59 @@
"""Alembic environment configuration."""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
from ten31portal.config import DB_PATH
# Import all models so metadata is populated
from ten31portal.models import ( # noqa: F401
User, Entity, Holding, Position,
ValuationRound, Valuation, AuditLog,
)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
url = f"sqlite:///{DB_PATH}"
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
cfg = config.get_section(config.config_ini_section, {})
cfg["sqlalchemy.url"] = f"sqlite:///{DB_PATH}"
connectable = engine_from_config(
cfg,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+29
View File
@@ -0,0 +1,29 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,118 @@
"""initial schema
Revision ID: 2792c4ff4612
Revises:
Create Date: 2026-06-07 19:03:02.057772
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '2792c4ff4612'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('entities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('type', sa.Enum('fund', 'spv', 'gp', 'mgmt_co', name='entitytype'), nullable=False),
sa.Column('vintage_year', sa.Integer(), nullable=True),
sa.Column('fund_size_cents', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('active', 'closed', name='entitystatus'), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('role', sa.Enum('approver', 'cfo', 'fund_admin', 'viewer', name='userrole'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('audit_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('actor_user_id', sa.Integer(), nullable=True),
sa.Column('action', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('object_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('object_id', sa.Integer(), nullable=True),
sa.Column('detail', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('holdings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('company_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('valuation_rounds',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('quarter_end', sa.Date(), nullable=False),
sa.Column('status', sa.Enum('draft', 'submitted', 'approved', 'returned', name='roundstatus'), nullable=False),
sa.Column('submitted_by', sa.Integer(), nullable=True),
sa.Column('submitted_at', sa.DateTime(), nullable=True),
sa.Column('approved_by', sa.Integer(), nullable=True),
sa.Column('approved_at', sa.DateTime(), nullable=True),
sa.Column('return_note', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_seed', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['approved_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
sa.ForeignKeyConstraint(['submitted_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('entity_id', 'quarter_end', name='uq_round_entity_quarter')
)
op.create_table('positions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('holding_id', sa.Integer(), nullable=False),
sa.Column('security_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('investment_date', sa.Date(), nullable=False),
sa.Column('shares', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('cost_cents', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['holding_id'], ['holdings.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('valuations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('round_id', sa.Integer(), nullable=False),
sa.Column('position_id', sa.Integer(), nullable=False),
sa.Column('value_cents', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ),
sa.ForeignKeyConstraint(['round_id'], ['valuation_rounds.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('round_id', 'position_id', name='uq_valuation_round_position')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('valuations')
op.drop_table('positions')
op.drop_table('valuation_rounds')
op.drop_table('holdings')
op.drop_table('audit_log')
op.drop_table('users')
op.drop_table('entities')
# ### end Alembic commands ###
+24
View File
@@ -0,0 +1,24 @@
[project]
name = "ten31portal"
version = "0.1.0"
description = "Internal system of record for Ten31 entities, holdings, and valuations."
requires-python = ">=3.11"
dependencies = [
"fastapi==0.115.12",
"uvicorn[standard]==0.34.3",
"sqlmodel==0.0.24",
"alembic==1.15.2",
"argon2-cffi==23.1.0",
"python-multipart==0.0.20",
"itsdangerous==2.2.0",
"aiosqlite==0.21.0",
"starlette-session==0.4.3",
]
[project.scripts]
ten31portal = "ten31portal.main:cli"
ten31portal-cli = "ten31portal.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+1
View File
@@ -0,0 +1 @@
# Ten31Portal backend
+29
View File
@@ -0,0 +1,29 @@
"""Audit log helper. Every state-changing endpoint must call record_audit."""
import json
from typing import Any
from sqlmodel import Session
from ten31portal.models import AuditLog
def record_audit(
session: Session,
actor_user_id: int | None,
action: str,
object_type: str,
object_id: int | None = None,
detail: Any = None,
) -> AuditLog:
"""Write one audit log entry and flush it."""
entry = AuditLog(
actor_user_id=actor_user_id,
action=action,
object_type=object_type,
object_id=object_id,
detail=detail,
)
session.add(entry)
session.flush()
return entry
+51
View File
@@ -0,0 +1,51 @@
"""Authentication, session management, and role enforcement."""
from typing import Annotated
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, HTTPException, Request
from sqlmodel import Session, select
from ten31portal.database import get_session
from ten31portal.models import User, UserRole
ph = PasswordHasher()
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return ph.verify(password_hash, password)
except VerifyMismatchError:
return False
def get_current_user(request: Request, session: Session = Depends(get_session)) -> User:
"""FastAPI dependency: extract user from session cookie."""
user_id = request.session.get("user_id")
if user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
user = session.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def require_role(*roles: UserRole):
"""Return a dependency that enforces one of the given roles."""
def checker(user: User = Depends(get_current_user)) -> User:
if user.role not in roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
return checker
# Convenience aliases
require_user = get_current_user
require_writer = require_role(UserRole.fund_admin, UserRole.cfo, UserRole.approver)
require_approver = require_role(UserRole.approver)
require_audit_reader = require_role(UserRole.approver, UserRole.cfo)
+59
View File
@@ -0,0 +1,59 @@
"""CLI commands for Ten31Portal."""
import argparse
import sys
from sqlmodel import Session, select
from ten31portal.auth import hash_password
from ten31portal.database import engine
from ten31portal.db_init import run_migrations
from ten31portal.models import User, UserRole
def create_user(args: argparse.Namespace) -> None:
"""Create a new user account."""
run_migrations()
with Session(engine) as session:
existing = session.exec(select(User).where(User.email == args.email)).first()
if existing:
print(f"Error: user with email {args.email} already exists.", file=sys.stderr)
sys.exit(1)
try:
role = UserRole(args.role)
except ValueError:
print(f"Error: invalid role '{args.role}'. Must be one of: {', '.join(r.value for r in UserRole)}", file=sys.stderr)
sys.exit(1)
user = User(
name=args.name,
email=args.email,
password_hash=hash_password(args.password),
role=role,
)
session.add(user)
session.commit()
print(f"Created user: {user.name} ({user.email}) with role {user.role.value}")
def main() -> None:
parser = argparse.ArgumentParser(prog="ten31portal-cli")
sub = parser.add_subparsers(dest="command")
create = sub.add_parser("create-user", help="Provision a new user")
create.add_argument("--name", required=True)
create.add_argument("--email", required=True)
create.add_argument("--role", required=True, choices=[r.value for r in UserRole])
create.add_argument("--password", required=True)
args = parser.parse_args()
if args.command == "create-user":
create_user(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
"""Application configuration via environment variables."""
import os
DB_PATH: str = os.getenv("TEN31_DB_PATH", "/data/ten31portal/portal.db")
SESSION_SECRET: str = os.getenv("TEN31_SESSION_SECRET", "change-me-in-production")
+13
View File
@@ -0,0 +1,13 @@
"""Database engine and session management."""
from sqlmodel import Session, create_engine
from ten31portal.config import DB_PATH
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False)
def get_session():
"""FastAPI dependency that yields a database session."""
with Session(engine) as session:
yield session
+15
View File
@@ -0,0 +1,15 @@
"""Run Alembic migrations to head on startup."""
import os
from pathlib import Path
from alembic import command
from alembic.config import Config
def run_migrations() -> None:
"""Apply all pending Alembic migrations."""
backend_dir = Path(__file__).resolve().parent.parent
alembic_cfg = Config(str(backend_dir / "alembic.ini"))
alembic_cfg.set_main_option("script_location", str(backend_dir / "alembic"))
command.upgrade(alembic_cfg, "head")
+47
View File
@@ -0,0 +1,47 @@
"""Ten31Portal FastAPI application."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from ten31portal.config import SESSION_SECRET
from ten31portal.db_init import run_migrations
from ten31portal.routers.auth_router import router as auth_router
from ten31portal.routers.audit_router import router as audit_router
from ten31portal.routers.entity_router import router as entity_router
from ten31portal.routers.holding_router import router as holding_router
from ten31portal.routers.position_router import router as position_router
from ten31portal.routers.round_router import router as round_router
from ten31portal.routers.import_router import router as import_router
@asynccontextmanager
async def lifespan(app: FastAPI):
run_migrations()
yield
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
app.include_router(auth_router)
app.include_router(audit_router)
app.include_router(entity_router)
app.include_router(holding_router)
app.include_router(position_router)
app.include_router(round_router)
app.include_router(import_router)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
def cli() -> None:
import uvicorn
uvicorn.run("ten31portal.main:app", host="0.0.0.0", port=8000, reload=True)
if __name__ == "__main__":
cli()
+127
View File
@@ -0,0 +1,127 @@
"""SQLModel table definitions for Ten31Portal."""
import enum
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from sqlmodel import Field, SQLModel, Column, String, JSON, UniqueConstraint
# --- Enums ---
class UserRole(str, enum.Enum):
approver = "approver"
cfo = "cfo"
fund_admin = "fund_admin"
viewer = "viewer"
class EntityType(str, enum.Enum):
fund = "fund"
spv = "spv"
gp = "gp"
mgmt_co = "mgmt_co"
class EntityStatus(str, enum.Enum):
active = "active"
closed = "closed"
class RoundStatus(str, enum.Enum):
draft = "draft"
submitted = "submitted"
approved = "approved"
returned = "returned"
# --- Tables ---
class User(SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str
email: str = Field(sa_column=Column(String, unique=True, nullable=False))
password_hash: str
role: UserRole
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Entity(SQLModel, table=True):
__tablename__ = "entities"
id: int | None = Field(default=None, primary_key=True)
name: str
type: EntityType
vintage_year: int | None = None
fund_size_cents: int | None = None
status: EntityStatus = Field(default=EntityStatus.active)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Holding(SQLModel, table=True):
__tablename__ = "holdings"
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id")
company_name: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class Position(SQLModel, table=True):
__tablename__ = "positions"
id: int | None = Field(default=None, primary_key=True)
holding_id: int = Field(foreign_key="holdings.id")
security_name: str
investment_date: date
shares: str | None = None # Decimal stored as string
cost_cents: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class ValuationRound(SQLModel, table=True):
__tablename__ = "valuation_rounds"
__table_args__ = (
UniqueConstraint("entity_id", "quarter_end", name="uq_round_entity_quarter"),
)
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id")
quarter_end: date
status: RoundStatus = Field(default=RoundStatus.draft)
submitted_by: int | None = Field(default=None, foreign_key="users.id")
submitted_at: datetime | None = None
approved_by: int | None = Field(default=None, foreign_key="users.id")
approved_at: datetime | None = None
return_note: str | None = None
is_seed: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Valuation(SQLModel, table=True):
__tablename__ = "valuations"
__table_args__ = (
UniqueConstraint("round_id", "position_id", name="uq_valuation_round_position"),
)
id: int | None = Field(default=None, primary_key=True)
round_id: int = Field(foreign_key="valuation_rounds.id")
position_id: int = Field(foreign_key="positions.id")
value_cents: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class AuditLog(SQLModel, table=True):
__tablename__ = "audit_log"
id: int | None = Field(default=None, primary_key=True)
actor_user_id: int | None = Field(default=None, foreign_key="users.id")
action: str
object_type: str
object_id: int | None = None
detail: str | None = Field(default=None, sa_column=Column(JSON, nullable=True))
created_at: datetime = Field(default_factory=datetime.utcnow)
+1
View File
@@ -0,0 +1 @@
# Router package
@@ -0,0 +1,31 @@
"""Audit log endpoint."""
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select, col
from ten31portal.auth import require_audit_reader
from ten31portal.database import get_session
from ten31portal.models import AuditLog, User
from ten31portal.schemas import AuditLogResponse
router = APIRouter(prefix="/api/audit", tags=["audit"])
@router.get("")
def list_audit(
object_type: str | None = None,
object_id: int | None = None,
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200),
user: User = Depends(require_audit_reader),
session: Session = Depends(get_session),
) -> list[AuditLogResponse]:
stmt = select(AuditLog)
if object_type is not None:
stmt = stmt.where(AuditLog.object_type == object_type)
if object_id is not None:
stmt = stmt.where(AuditLog.object_id == object_id)
stmt = stmt.order_by(col(AuditLog.id).desc())
stmt = stmt.offset((page - 1) * per_page).limit(per_page)
rows = session.exec(stmt).all()
return [AuditLogResponse.model_validate(r, from_attributes=True) for r in rows]
@@ -0,0 +1,37 @@
"""Authentication endpoints."""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from ten31portal.auth import get_current_user, hash_password, verify_password
from ten31portal.database import get_session
from ten31portal.models import User
from ten31portal.schemas import LoginRequest, UserResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login")
def login(
body: LoginRequest,
request: Request,
session: Session = Depends(get_session),
) -> UserResponse:
user = session.exec(select(User).where(User.email == body.email)).first()
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid email or password")
if not user.is_active:
raise HTTPException(status_code=401, detail="Account disabled")
request.session["user_id"] = user.id
return UserResponse.model_validate(user, from_attributes=True)
@router.post("/logout")
def logout(request: Request) -> dict[str, str]:
request.session.clear()
return {"status": "ok"}
@router.get("/me")
def me(user: User = Depends(get_current_user)) -> UserResponse:
return UserResponse.model_validate(user, from_attributes=True)
@@ -0,0 +1,69 @@
"""Entity CRUD endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import get_current_user, require_writer
from ten31portal.database import get_session
from ten31portal.models import Entity, EntityStatus, User
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
router = APIRouter(prefix="/api/entities", tags=["entities"])
@router.get("")
def list_entities(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityResponse]:
rows = session.exec(select(Entity)).all()
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{entity_id}")
def get_entity(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> EntityResponse:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
return EntityResponse.model_validate(entity, from_attributes=True)
@router.post("", status_code=201)
def create_entity(
body: EntityCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
entity = Entity(**body.model_dump())
session.add(entity)
session.flush()
record_audit(session, user.id, "create", "entity", entity.id, body.model_dump())
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)
@router.patch("/{entity_id}")
def update_entity(
entity_id: int,
body: EntityUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
changes = body.model_dump(exclude_unset=True)
for key, val in changes.items():
setattr(entity, key, val)
session.add(entity)
session.flush()
record_audit(session, user.id, "update", "entity", entity.id, changes)
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)
@@ -0,0 +1,83 @@
"""Holding CRUD endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import get_current_user, require_writer
from ten31portal.database import get_session
from ten31portal.models import Entity, Holding, Position, User
from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate
router = APIRouter(tags=["holdings"])
@router.get("/api/entities/{entity_id}/holdings")
def list_holdings(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[HoldingResponse]:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
rows = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all()
return [HoldingResponse.model_validate(r, from_attributes=True) for r in rows]
@router.post("/api/entities/{entity_id}/holdings", status_code=201)
def create_holding(
entity_id: int,
body: HoldingCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> HoldingResponse:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
holding = Holding(entity_id=entity_id, company_name=body.company_name)
session.add(holding)
session.flush()
record_audit(session, user.id, "create", "holding", holding.id, {"entity_id": entity_id, **body.model_dump()})
session.commit()
session.refresh(holding)
return HoldingResponse.model_validate(holding, from_attributes=True)
@router.patch("/api/holdings/{holding_id}")
def update_holding(
holding_id: int,
body: HoldingUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> HoldingResponse:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
changes = body.model_dump(exclude_unset=True)
for key, val in changes.items():
setattr(holding, key, val)
session.add(holding)
session.flush()
record_audit(session, user.id, "update", "holding", holding.id, changes)
session.commit()
session.refresh(holding)
return HoldingResponse.model_validate(holding, from_attributes=True)
@router.delete("/api/holdings/{holding_id}")
def delete_holding(
holding_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> dict[str, str]:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
positions = session.exec(select(Position).where(Position.holding_id == holding_id)).all()
if positions:
raise HTTPException(status_code=409, detail="Cannot delete holding with existing positions. Remove positions first.")
record_audit(session, user.id, "delete", "holding", holding.id, {"company_name": holding.company_name})
session.delete(holding)
session.commit()
return {"status": "deleted"}
@@ -0,0 +1,437 @@
"""CSV import endpoints for entities and schedule of investments."""
import csv
import io
import re
from datetime import date, datetime
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import require_role
from ten31portal.database import get_session
from ten31portal.models import (
Entity, EntityStatus, EntityType, Holding, Position,
User, UserRole, Valuation, ValuationRound, RoundStatus,
)
router = APIRouter(prefix="/api/import", tags=["import"])
# --- Column maps ---
# These must be confirmed against real Carta CSV exports.
# Mark as UNCONFIRMED until a sample file is validated.
ENTITY_COLUMN_MAP: dict[str, str] = {
# Carta export header -> model field
# UNCONFIRMED: update these 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_COLUMN_MAP: dict[str, str] = {
# UNCONFIRMED: update after inspecting a real Carta schedule-of-investments export
"Company": "company_name",
"Security": "security_name",
"Investment Date": "investment_date",
"Shares": "shares",
"Cost": "cost_cents",
"Value": "value_cents",
}
def _parse_money(raw: str) -> int | None:
"""Parse dollar strings like '$3.3M', '$61.9M', '$1,234,567', '3300000' to cents."""
if not raw or not raw.strip():
return None
s = raw.strip().replace(",", "").replace("$", "")
multiplier = 1
if s.upper().endswith("M"):
multiplier = 1_000_000
s = s[:-1]
elif s.upper().endswith("K"):
multiplier = 1_000
s = s[:-1]
elif s.upper().endswith("B"):
multiplier = 1_000_000_000
s = s[:-1]
try:
return round(float(s) * multiplier * 100)
except ValueError:
return None
def _parse_date(raw: str) -> date | None:
"""Try common date formats."""
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y", "%Y/%m/%d"):
try:
return datetime.strptime(raw.strip(), fmt).date()
except ValueError:
continue
return None
def _parse_shares(raw: str) -> str | None:
"""Parse share count, return as string (Decimal stored as string)."""
if not raw or not raw.strip():
return None
s = raw.strip().replace(",", "")
try:
float(s) # Validate it's numeric
return s
except ValueError:
return None
# --- Entity import ---
@router.post("/entities")
def import_entities(
file: UploadFile = File(...),
commit: bool = Query(default=True),
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
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): # row 1 is header
mapped: dict[str, Any] = {}
unmapped_cols: list[str] = []
for csv_col, model_field in ENTITY_COLUMN_MAP.items():
val = row.get(csv_col)
if val is None:
# Try case-insensitive match
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()
else:
unmapped_cols.append(csv_col)
# Parse fields
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:
parsed["vintage_year"] = int(vy)
except ValueError:
row_errors.append(f"Invalid vintage year: {vy}")
fs = mapped.get("fund_size_cents")
if fs:
cents = _parse_money(fs)
if cents is None:
row_errors.append(f"Cannot parse fund size: {fs}")
else:
parsed["fund_size_cents"] = cents
if row_errors:
errors.append({"row": i, "errors": row_errors, "raw": dict(row)})
continue
if not parsed.get("name"):
continue
# Check existing
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 ---
@router.post("/schedule")
def import_schedule(
file: UploadFile = File(...),
entity_id: int = Query(...),
as_of: date = Query(...),
commit: bool = Query(default=True),
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
session: Session = Depends(get_session),
) -> dict[str, Any]:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
# Check for existing non-seed round at this quarter
existing_round = session.exec(
select(ValuationRound).where(
ValuationRound.entity_id == entity_id,
ValuationRound.quarter_end == as_of,
ValuationRound.is_seed == False,
)
).first()
if existing_round:
raise HTTPException(
status_code=409,
detail="A non-seed valuation round already exists for this entity and quarter.",
)
content = file.file.read().decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(content))
holdings_preview: list[dict] = []
positions_preview: list[dict] = []
errors: list[dict] = []
current_company: str | None = None
for i, row in enumerate(reader, start=2):
mapped: dict[str, Any] = {}
for csv_col, model_field in SCHEDULE_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()
company = mapped.get("company_name", "").strip()
security = mapped.get("security_name", "").strip()
# Grouped layout: company row has company name but may lack security detail
if company and not security:
current_company = company
holdings_preview.append({"row": i, "company_name": company})
continue
# Position row: may inherit company from grouping
if not company and current_company:
company = current_company
elif company:
# Both company and security on same row
if company not in [h["company_name"] for h in holdings_preview]:
holdings_preview.append({"row": i, "company_name": company})
current_company = company
if not security:
continue # Skip empty/subtotal rows
row_errors: list[str] = []
inv_date = None
if mapped.get("investment_date"):
inv_date = _parse_date(mapped["investment_date"])
if inv_date is None:
row_errors.append(f"Cannot parse date: {mapped['investment_date']}")
shares = _parse_shares(mapped.get("shares", ""))
cost_cents = None
if mapped.get("cost_cents"):
cost_cents = _parse_money(mapped["cost_cents"])
if cost_cents is None:
row_errors.append(f"Cannot parse cost: {mapped['cost_cents']}")
value_cents = None
if mapped.get("value_cents"):
value_cents = _parse_money(mapped["value_cents"])
if value_cents is None:
row_errors.append(f"Cannot parse value: {mapped['value_cents']}")
if row_errors:
errors.append({"row": i, "errors": row_errors, "raw": dict(row)})
continue
positions_preview.append({
"row": i,
"company_name": company,
"security_name": security,
"investment_date": str(inv_date) if inv_date else None,
"shares": shares,
"cost_cents": cost_cents,
"value_cents": value_cents,
})
if not commit:
return {
"committed": False,
"holdings": holdings_preview,
"positions": positions_preview,
"errors": errors,
"seed_round": {"entity_id": entity_id, "quarter_end": str(as_of)},
}
# Commit: create holdings, positions, seed round
holding_map: dict[str, Holding] = {}
for hp in holdings_preview:
name = hp["company_name"]
existing = session.exec(
select(Holding).where(
Holding.entity_id == entity_id,
Holding.company_name == name,
)
).first()
if existing:
holding_map[name] = existing
else:
h = Holding(entity_id=entity_id, company_name=name)
session.add(h)
session.flush()
holding_map[name] = h
# Create seed round
seed_round = ValuationRound(
entity_id=entity_id,
quarter_end=as_of,
status=RoundStatus.approved,
is_seed=True,
approved_by=user.id,
approved_at=datetime.utcnow(),
)
session.add(seed_round)
session.flush()
positions_created = 0
for pp in positions_preview:
company = pp["company_name"]
holding = holding_map.get(company)
if holding is None:
# Create holding on the fly for positions with inline company
existing = session.exec(
select(Holding).where(
Holding.entity_id == entity_id,
Holding.company_name == company,
)
).first()
if existing:
holding = existing
else:
holding = Holding(entity_id=entity_id, company_name=company)
session.add(holding)
session.flush()
holding_map[company] = holding
# Upsert position by (holding, security_name)
pos = session.exec(
select(Position).where(
Position.holding_id == holding.id,
Position.security_name == pp["security_name"],
)
).first()
if pos is None:
pos = Position(
holding_id=holding.id,
security_name=pp["security_name"],
investment_date=_parse_date(pp["investment_date"]) if pp["investment_date"] else as_of,
shares=pp["shares"],
cost_cents=pp["cost_cents"] or 0,
)
session.add(pos)
session.flush()
positions_created += 1
else:
if pp["investment_date"]:
pos.investment_date = _parse_date(pp["investment_date"])
if pp["shares"]:
pos.shares = pp["shares"]
if pp["cost_cents"] is not None:
pos.cost_cents = pp["cost_cents"]
session.add(pos)
session.flush()
# Attach valuation to seed round
val = Valuation(
round_id=seed_round.id,
position_id=pos.id,
value_cents=pp["value_cents"] or 0,
)
session.add(val)
record_audit(session, user.id, "import_schedule", "entity", entity_id, {
"holdings": len(holding_map),
"positions": positions_created,
"seed_quarter": str(as_of),
})
session.commit()
return {
"committed": True,
"holdings_count": len(holding_map),
"positions_count": len(positions_preview),
"seed_round_id": seed_round.id,
"errors": errors,
}
@@ -0,0 +1,117 @@
"""Position CRUD endpoints."""
import math
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import get_current_user, require_writer
from ten31portal.database import get_session
from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User
from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate
router = APIRouter(tags=["positions"])
def _dollars_to_cents(dollars: float) -> int:
"""Convert dollar amount to integer cents, rounding to nearest cent."""
return round(dollars * 100)
@router.get("/api/holdings/{holding_id}/positions")
def list_positions(
holding_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[PositionResponse]:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
rows = session.exec(select(Position).where(Position.holding_id == holding_id)).all()
return [PositionResponse.model_validate(r, from_attributes=True) for r in rows]
@router.post("/api/holdings/{holding_id}/positions", status_code=201)
def create_position(
holding_id: int,
body: PositionCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> PositionResponse:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
position = Position(
holding_id=holding_id,
security_name=body.security_name,
investment_date=body.investment_date,
shares=body.shares,
cost_cents=_dollars_to_cents(body.cost_dollars),
)
session.add(position)
session.flush()
record_audit(session, user.id, "create", "position", position.id, {
"holding_id": holding_id,
"security_name": body.security_name,
"cost_dollars": body.cost_dollars,
})
session.commit()
session.refresh(position)
return PositionResponse.model_validate(position, from_attributes=True)
@router.patch("/api/positions/{position_id}")
def update_position(
position_id: int,
body: PositionUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> PositionResponse:
position = session.get(Position, position_id)
if position is None:
raise HTTPException(status_code=404, detail="Position not found")
changes = body.model_dump(exclude_unset=True)
if "cost_dollars" in changes:
position.cost_cents = _dollars_to_cents(changes.pop("cost_dollars"))
for key, val in changes.items():
setattr(position, key, val)
session.add(position)
session.flush()
record_audit(session, user.id, "update", "position", position.id, body.model_dump(exclude_unset=True))
session.commit()
session.refresh(position)
return PositionResponse.model_validate(position, from_attributes=True)
@router.delete("/api/positions/{position_id}")
def delete_position(
position_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> dict[str, str]:
position = session.get(Position, position_id)
if position is None:
raise HTTPException(status_code=404, detail="Position not found")
# Refuse if any approved valuation references this position
approved_vals = session.exec(
select(Valuation)
.join(ValuationRound)
.where(
Valuation.position_id == position_id,
ValuationRound.status == RoundStatus.approved,
)
).all()
if approved_vals:
raise HTTPException(
status_code=409,
detail="Cannot delete position referenced by an approved valuation round.",
)
record_audit(session, user.id, "delete", "position", position.id, {
"security_name": position.security_name,
})
session.delete(position)
session.commit()
return {"status": "deleted"}
+221
View File
@@ -0,0 +1,221 @@
"""Valuation round workflow endpoints."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, col
from ten31portal.audit import record_audit
from ten31portal.auth import get_current_user, require_writer, require_approver
from ten31portal.database import get_session
from ten31portal.models import (
Entity, Holding, Position, Valuation, ValuationRound,
RoundStatus, User,
)
from ten31portal.schemas import (
RoundCreate, RoundResponse, ValuationBulkUpdate, ValuationResponse, ReturnNote,
)
router = APIRouter(tags=["rounds"])
def _round_response(round: ValuationRound, session: Session) -> RoundResponse:
vals = session.exec(select(Valuation).where(Valuation.round_id == round.id)).all()
return RoundResponse(
**round.model_dump(),
valuations=[ValuationResponse.model_validate(v, from_attributes=True) for v in vals],
)
@router.get("/api/entities/{entity_id}/rounds")
def list_rounds(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[RoundResponse]:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
rounds = session.exec(
select(ValuationRound)
.where(ValuationRound.entity_id == entity_id)
.order_by(col(ValuationRound.quarter_end).desc())
).all()
return [_round_response(r, session) for r in rounds]
@router.get("/api/rounds/{round_id}")
def get_round(
round_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
return _round_response(round, session)
@router.post("/api/entities/{entity_id}/rounds", status_code=201)
def create_round(
entity_id: int,
body: RoundCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> RoundResponse:
"""Create a draft round. Pre-populate valuations with last approved marks (carry forward)."""
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
# Check for existing non-returned round at this quarter
existing = session.exec(
select(ValuationRound).where(
ValuationRound.entity_id == entity_id,
ValuationRound.quarter_end == body.quarter_end,
ValuationRound.status != RoundStatus.returned,
)
).first()
if existing:
raise HTTPException(status_code=409, detail="A round already exists for this entity and quarter.")
round = ValuationRound(entity_id=entity_id, quarter_end=body.quarter_end)
session.add(round)
session.flush()
# Get all positions for this entity
holdings = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all()
for holding in holdings:
positions = session.exec(select(Position).where(Position.holding_id == holding.id)).all()
for pos in positions:
# Find most recent approved value for this position
last_val = session.exec(
select(Valuation)
.join(ValuationRound)
.where(
Valuation.position_id == pos.id,
ValuationRound.status == RoundStatus.approved,
)
.order_by(col(ValuationRound.quarter_end).desc())
).first()
val = Valuation(
round_id=round.id,
position_id=pos.id,
value_cents=last_val.value_cents if last_val else 0,
)
session.add(val)
record_audit(session, user.id, "create", "round", round.id, {
"entity_id": entity_id,
"quarter_end": str(body.quarter_end),
})
session.commit()
session.refresh(round)
return _round_response(round, session)
@router.patch("/api/rounds/{round_id}/valuations")
def bulk_update_valuations(
round_id: int,
body: ValuationBulkUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> RoundResponse:
"""Bulk set value_cents for positions in a draft or returned round."""
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
if round.status not in (RoundStatus.draft, RoundStatus.returned):
raise HTTPException(status_code=409, detail="Can only edit valuations in draft or returned rounds.")
for item in body.valuations:
val = session.exec(
select(Valuation).where(
Valuation.round_id == round_id,
Valuation.position_id == item.position_id,
)
).first()
if val is None:
raise HTTPException(status_code=404, detail=f"Valuation not found for position {item.position_id}")
val.value_cents = item.value_cents
session.add(val)
record_audit(session, user.id, "update_valuations", "round", round.id, {
"count": len(body.valuations),
})
session.commit()
session.refresh(round)
return _round_response(round, session)
@router.post("/api/rounds/{round_id}/submit")
def submit_round(
round_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
if round.status not in (RoundStatus.draft, RoundStatus.returned):
raise HTTPException(status_code=409, detail="Only draft or returned rounds can be submitted.")
round.status = RoundStatus.submitted
round.submitted_by = user.id
round.submitted_at = datetime.utcnow()
session.add(round)
record_audit(session, user.id, "submit", "round", round.id, {
"quarter_end": str(round.quarter_end),
})
session.commit()
session.refresh(round)
return _round_response(round, session)
@router.post("/api/rounds/{round_id}/approve")
def approve_round(
round_id: int,
user: User = Depends(require_approver),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
if round.status != RoundStatus.submitted:
raise HTTPException(status_code=409, detail="Only submitted rounds can be approved.")
round.status = RoundStatus.approved
round.approved_by = user.id
round.approved_at = datetime.utcnow()
session.add(round)
# Record both roles if self-approving
detail = {"quarter_end": str(round.quarter_end)}
if round.submitted_by == user.id:
detail["note"] = "Self-approved (submitter and approver are the same user)"
record_audit(session, user.id, "approve", "round", round.id, detail)
session.commit()
session.refresh(round)
return _round_response(round, session)
@router.post("/api/rounds/{round_id}/return")
def return_round(
round_id: int,
body: ReturnNote,
user: User = Depends(require_approver),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
if round.status != RoundStatus.submitted:
raise HTTPException(status_code=409, detail="Only submitted rounds can be returned.")
round.status = RoundStatus.returned
round.return_note = body.note
session.add(round)
record_audit(session, user.id, "return", "round", round.id, {
"quarter_end": str(round.quarter_end),
"note": body.note,
})
session.commit()
session.refresh(round)
return _round_response(round, session)
+148
View File
@@ -0,0 +1,148 @@
"""Pydantic schemas for API request/response shapes."""
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel
from ten31portal.models import UserRole, EntityType, EntityStatus, RoundStatus
# --- Auth ---
class LoginRequest(BaseModel):
email: str
password: str
class UserResponse(BaseModel):
id: int
name: str
email: str
role: UserRole
is_active: bool
created_at: datetime
# --- Entity ---
class EntityCreate(BaseModel):
name: str
type: EntityType
vintage_year: int | None = None
fund_size_cents: int | None = None
class EntityUpdate(BaseModel):
name: str | None = None
type: EntityType | None = None
vintage_year: int | None = None
fund_size_cents: int | None = None
status: EntityStatus | None = None
class EntityResponse(BaseModel):
id: int
name: str
type: EntityType
vintage_year: int | None
fund_size_cents: int | None
status: EntityStatus
created_at: datetime
# --- Holding ---
class HoldingCreate(BaseModel):
company_name: str
class HoldingUpdate(BaseModel):
company_name: str | None = None
class HoldingResponse(BaseModel):
id: int
entity_id: int
company_name: str
created_at: datetime
# --- Position ---
class PositionCreate(BaseModel):
security_name: str
investment_date: date
shares: str | None = None
cost_dollars: float # Accept dollars at API boundary, store as cents
class PositionUpdate(BaseModel):
security_name: str | None = None
investment_date: date | None = None
shares: str | None = None
cost_dollars: float | None = None
class PositionResponse(BaseModel):
id: int
holding_id: int
security_name: str
investment_date: date
shares: str | None
cost_cents: int
created_at: datetime
# --- Valuation Round ---
class RoundCreate(BaseModel):
quarter_end: date
class ValuationBulkItem(BaseModel):
position_id: int
value_cents: int
class ValuationBulkUpdate(BaseModel):
valuations: list[ValuationBulkItem]
class ReturnNote(BaseModel):
note: str
class ValuationResponse(BaseModel):
id: int
round_id: int
position_id: int
value_cents: int
created_at: datetime
class RoundResponse(BaseModel):
id: int
entity_id: int
quarter_end: date
status: RoundStatus
submitted_by: int | None
submitted_at: datetime | None
approved_by: int | None
approved_at: datetime | None
return_note: str | None
is_seed: bool
created_at: datetime
valuations: list[ValuationResponse] = []
# --- Audit ---
class AuditLogResponse(BaseModel):
id: int
actor_user_id: int | None
action: str
object_type: str
object_id: int | None
detail: dict | list | str | None
created_at: datetime
+3
View File
@@ -0,0 +1,3 @@
# StartOS packaging
StartOS 0.4.0 service packaging goes here (Issue 17).
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3132
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+46
View File
@@ -0,0 +1,46 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
import Layout from "./components/Layout";
import Login from "./pages/Login";
import EntitiesList from "./pages/EntitiesList";
import EntityOverview from "./pages/EntityOverview";
import Investments from "./pages/Investments";
import ValuationWorkflow from "./pages/ValuationWorkflow";
function ProtectedRoutes() {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-500 text-sm">
Loading...
</div>
);
}
if (!user) {
return <Login />;
}
return (
<Layout>
<Routes>
<Route path="/" element={<EntitiesList />} />
<Route path="/entities/:id" element={<EntityOverview />} />
<Route path="/entities/:id/investments" element={<Investments />} />
<Route path="/entities/:id/valuation" element={<ValuationWorkflow />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
);
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<ProtectedRoutes />
</AuthProvider>
</BrowserRouter>
);
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Typed API client for Ten31Portal backend.
*/
// --- Types ---
export type UserRole = "approver" | "cfo" | "fund_admin" | "viewer";
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co";
export type EntityStatus = "active" | "closed";
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
export interface User {
id: number;
name: string;
email: string;
role: UserRole;
is_active: boolean;
created_at: string;
}
export interface Entity {
id: number;
name: string;
type: EntityType;
vintage_year: number | null;
fund_size_cents: number | null;
status: EntityStatus;
created_at: string;
}
export interface Holding {
id: number;
entity_id: number;
company_name: string;
created_at: string;
}
export interface Position {
id: number;
holding_id: number;
security_name: string;
investment_date: string;
shares: string | null;
cost_cents: number;
created_at: string;
}
export interface Valuation {
id: number;
round_id: number;
position_id: number;
value_cents: number;
created_at: string;
}
export interface ValuationRound {
id: number;
entity_id: number;
quarter_end: string;
status: RoundStatus;
submitted_by: number | null;
submitted_at: string | null;
approved_by: number | null;
approved_at: string | null;
return_note: string | null;
is_seed: boolean;
created_at: string;
valuations: Valuation[];
}
export interface AuditEntry {
id: number;
actor_user_id: number | null;
action: string;
object_type: string;
object_id: number | null;
detail: unknown;
created_at: string;
}
// --- API helpers ---
class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }));
throw new ApiError(res.status, body.detail || res.statusText);
}
return res.json();
}
// --- Auth ---
export const api = {
login: (email: string, password: string) =>
request<User>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }),
me: () => request<User>("/api/auth/me"),
// Entities
listEntities: () => request<Entity[]>("/api/entities"),
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
createEntity: (data: Partial<Entity>) =>
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
updateEntity: (id: number, data: Partial<Entity>) =>
request<Entity>(`/api/entities/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
// Holdings
listHoldings: (entityId: number) =>
request<Holding[]>(`/api/entities/${entityId}/holdings`),
createHolding: (entityId: number, data: { company_name: string }) =>
request<Holding>(`/api/entities/${entityId}/holdings`, {
method: "POST",
body: JSON.stringify(data),
}),
// Positions
listPositions: (holdingId: number) =>
request<Position[]>(`/api/holdings/${holdingId}/positions`),
// Rounds
listRounds: (entityId: number) =>
request<ValuationRound[]>(`/api/entities/${entityId}/rounds`),
getRound: (roundId: number) => request<ValuationRound>(`/api/rounds/${roundId}`),
createRound: (entityId: number, quarterEnd: string) =>
request<ValuationRound>(`/api/entities/${entityId}/rounds`, {
method: "POST",
body: JSON.stringify({ quarter_end: quarterEnd }),
}),
updateValuations: (roundId: number, valuations: { position_id: number; value_cents: number }[]) =>
request<ValuationRound>(`/api/rounds/${roundId}/valuations`, {
method: "PATCH",
body: JSON.stringify({ valuations }),
}),
submitRound: (roundId: number) =>
request<ValuationRound>(`/api/rounds/${roundId}/submit`, { method: "POST" }),
approveRound: (roundId: number) =>
request<ValuationRound>(`/api/rounds/${roundId}/approve`, { method: "POST" }),
returnRound: (roundId: number, note: string) =>
request<ValuationRound>(`/api/rounds/${roundId}/return`, {
method: "POST",
body: JSON.stringify({ note }),
}),
// Audit
listAudit: (params?: { object_type?: string; object_id?: number; page?: number }) => {
const q = new URLSearchParams();
if (params?.object_type) q.set("object_type", params.object_type);
if (params?.object_id) q.set("object_id", String(params.object_id));
if (params?.page) q.set("page", String(params.page));
return request<AuditEntry[]>(`/api/audit?${q}`);
},
};
+73
View File
@@ -0,0 +1,73 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
const NAV_ITEMS = [
{ label: "Entities", path: "/" },
{ label: "Import", path: "/import", stub: true },
{ label: "Audit Log", path: "/audit", stub: true },
];
export default function Layout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const location = useLocation();
return (
<div className="flex h-screen bg-gray-50">
{/* Left nav */}
<nav className="w-56 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-200">
<h1 className="text-lg font-semibold text-gray-900">Ten31Portal</h1>
</div>
<ul className="flex-1 py-2">
{NAV_ITEMS.map((item) => {
const active = item.path === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.path);
return (
<li key={item.path}>
{item.stub ? (
<span className="block px-4 py-2 text-sm text-gray-400 cursor-not-allowed">
{item.label}
</span>
) : (
<Link
to={item.path}
className={`block px-4 py-2 text-sm ${
active
? "bg-orange-50 text-orange-600 border-r-2 border-orange-500 font-medium"
: "text-gray-700 hover:bg-gray-50"
}`}
>
{item.label}
</Link>
)}
</li>
);
})}
</ul>
</nav>
{/* Main area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top bar */}
<header className="h-14 bg-white border-b border-gray-200 flex items-center justify-end px-6">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user?.name}</span>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
{user?.role}
</span>
<button
onClick={logout}
className="text-sm text-gray-500 hover:text-gray-800"
>
Sign out
</button>
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { api, type User } from "../api";
interface AuthState {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.me().then(setUser).catch(() => setUser(null)).finally(() => setLoading(false));
}, []);
const login = async (email: string, password: string) => {
const u = await api.login(email, password);
setUser(u);
};
const logout = async () => {
await api.logout();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Money and date formatting helpers.
* Money: cents -> display string. Never float in the DB.
*/
/** Format cents as dollars: $1.2M, $12,345, etc. */
export function formatMoney(cents: number | null | undefined): string {
if (cents == null) return "—";
const dollars = cents / 100;
const abs = Math.abs(dollars);
const sign = dollars < 0 ? "-" : "";
if (abs >= 1_000_000_000) {
return `${sign}$${(abs / 1_000_000_000).toFixed(1)}B`;
}
if (abs >= 1_000_000) {
return `${sign}$${(abs / 1_000_000).toFixed(1)}M`;
}
if (abs >= 1_000) {
return `${sign}$${Math.round(abs).toLocaleString("en-US")}`;
}
return `${sign}$${abs.toFixed(2)}`;
}
/** Format cents as compact dollars for tables (no rounding for small values). */
export function formatMoneyExact(cents: number | null | undefined): string {
if (cents == null) return "—";
const dollars = cents / 100;
return `$${dollars.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
/** Format date string to short form: Jun 15, 2021 */
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
const d = new Date(dateStr + (dateStr.includes("T") ? "" : "T00:00:00"));
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
/** Format quarter-end date as "Q2 2026" */
export function formatQuarter(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
const month = d.getMonth(); // 0-11
const q = Math.floor(month / 3) + 1;
return `Q${q} ${d.getFullYear()}`;
}
/** Gain/loss as formatted string with sign */
export function formatGainLoss(valueCents: number, costCents: number): {
text: string;
positive: boolean;
cents: number;
} {
const diff = valueCents - costCents;
return {
text: formatMoney(Math.abs(diff)),
positive: diff >= 0,
cents: diff,
};
}
/** Per-share calculation: cents / shares string. Returns null if shares is null/zero. */
export function perShare(cents: number, shares: string | null): number | null {
if (!shares) return null;
const s = parseFloat(shares);
if (!s || s === 0) return null;
return cents / s;
}
/** Format per-share value */
export function formatPerShare(cents: number, shares: string | null): string {
const val = perShare(cents, shares);
if (val == null) return "—";
return formatMoneyExact(Math.round(val));
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api, type Entity, type Holding, type Position, type ValuationRound } from "../api";
import { formatMoney, formatGainLoss } from "../format";
interface EntityRow extends Entity {
investedCents: number;
lastValueCents: number;
}
export default function EntitiesList() {
const [entities, setEntities] = useState<EntityRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
async function loadData() {
setLoading(true);
try {
const ents = await api.listEntities();
const enriched: EntityRow[] = [];
for (const ent of ents) {
const holdings = await api.listHoldings(ent.id);
let investedCents = 0;
let lastValueCents = 0;
// Get all positions for cost
for (const h of holdings) {
const positions = await api.listPositions(h.id);
for (const p of positions) {
investedCents += p.cost_cents;
}
}
// Get latest approved round for value
const rounds = await api.listRounds(ent.id);
const approved = rounds.filter((r) => r.status === "approved");
if (approved.length > 0) {
const latest = approved[0]; // Already sorted desc by quarter_end
lastValueCents = latest.valuations.reduce((sum, v) => sum + v.value_cents, 0);
}
enriched.push({ ...ent, investedCents, lastValueCents });
}
setEntities(enriched);
} finally {
setLoading(false);
}
}
if (loading) {
return <div className="text-gray-500 text-sm">Loading...</div>;
}
const funds = entities.filter((e) => e.type === "fund" || e.type === "spv");
const gps = entities.filter((e) => e.type === "gp" || e.type === "mgmt_co");
return (
<div className="space-y-8">
<EntityTable title="Funds and SPVs" rows={funds} />
<EntityTable title="GP Entities and Management Companies" rows={gps} />
</div>
);
}
function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
const totalInvested = rows.reduce((s, r) => s + r.investedCents, 0);
const totalValue = rows.reduce((s, r) => s + r.lastValueCents, 0);
const totalGain = totalValue - totalInvested;
const TYPE_LABELS: Record<string, string> = {
fund: "Fund",
spv: "SPV",
gp: "GP",
mgmt_co: "Mgmt Co",
};
return (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">{title}</h2>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-4 py-3 font-medium text-gray-600">Name</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Type</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Vintage</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Invested</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Last Signed Value</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Gain/Loss</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const gl = formatGainLoss(row.lastValueCents, row.investedCents);
return (
<tr key={row.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3">
<Link
to={`/entities/${row.id}`}
className="text-gray-900 font-medium hover:text-orange-600"
>
{row.name}
</Link>
</td>
<td className="px-4 py-3">
<span className="inline-block px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
{TYPE_LABELS[row.type] || row.type}
</span>
</td>
<td className="px-4 py-3 text-gray-600">{row.vintage_year || "—"}</td>
<td className="px-4 py-3 text-right text-gray-900">{formatMoney(row.investedCents)}</td>
<td className="px-4 py-3 text-right text-gray-900">{formatMoney(row.lastValueCents)}</td>
<td className={`px-4 py-3 text-right font-medium ${gl.positive ? "text-green-600" : "text-red-600"}`}>
{gl.positive ? "+" : "-"}{gl.text}
</td>
</tr>
);
})}
{rows.length > 0 && (
<tr className="bg-gray-50 font-medium">
<td className="px-4 py-3 text-gray-900" colSpan={3}>
Total
</td>
<td className="px-4 py-3 text-right text-gray-900">{formatMoney(totalInvested)}</td>
<td className="px-4 py-3 text-right text-gray-900">{formatMoney(totalValue)}</td>
<td className={`px-4 py-3 text-right ${totalGain >= 0 ? "text-green-600" : "text-red-600"}`}>
{totalGain >= 0 ? "+" : "-"}{formatMoney(Math.abs(totalGain))}
</td>
</tr>
)}
{rows.length === 0 && (
<tr>
<td className="px-4 py-6 text-center text-gray-400" colSpan={6}>
No entities yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api, type Entity, type Holding, type Position, type ValuationRound } from "../api";
import { formatMoney, formatDate, formatQuarter } from "../format";
const TYPE_LABELS: Record<string, string> = {
fund: "Fund",
spv: "SPV",
gp: "GP",
mgmt_co: "Mgmt Co",
};
const STATUS_COLORS: Record<string, string> = {
draft: "bg-gray-100 text-gray-600",
submitted: "bg-yellow-100 text-yellow-700",
approved: "bg-green-100 text-green-700",
returned: "bg-red-100 text-red-600",
};
export default function EntityOverview() {
const { id } = useParams<{ id: string }>();
const [entity, setEntity] = useState<Entity | null>(null);
const [holdings, setHoldings] = useState<Holding[]>([]);
const [totalInvested, setTotalInvested] = useState(0);
const [lastValue, setLastValue] = useState(0);
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
loadData(parseInt(id));
}, [id]);
async function loadData(entityId: number) {
setLoading(true);
try {
const ent = await api.getEntity(entityId);
setEntity(ent);
const holds = await api.listHoldings(entityId);
setHoldings(holds);
let invested = 0;
for (const h of holds) {
const positions = await api.listPositions(h.id);
for (const p of positions) {
invested += p.cost_cents;
}
}
setTotalInvested(invested);
const rounds = await api.listRounds(entityId);
const approved = rounds.filter((r) => r.status === "approved");
if (approved.length > 0) {
setLatestRound(approved[0]);
setLastValue(approved[0].valuations.reduce((s, v) => s + v.value_cents, 0));
}
// Also check for most recent round of any status
if (rounds.length > 0) {
setLatestRound(rounds[0]);
if (rounds[0].status === "approved") {
setLastValue(rounds[0].valuations.reduce((s, v) => s + v.value_cents, 0));
}
}
} finally {
setLoading(false);
}
}
if (loading || !entity) {
return <div className="text-gray-500 text-sm">Loading...</div>;
}
const gainLoss = lastValue - totalInvested;
return (
<div>
{/* Header */}
<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>
{/* 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
</Link>
<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>
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
<SummaryCard label="Vintage" value={entity.vintage_year?.toString() || "—"} />
<SummaryCard label="Fund Size" value={formatMoney(entity.fund_size_cents)} />
<SummaryCard label="Total Invested" value={formatMoney(totalInvested)} />
<SummaryCard label="Holdings" value={holdings.length.toString()} />
<SummaryCard label="Last Signed Value" value={formatMoney(lastValue)} />
<SummaryCard
label="Gain/Loss"
value={`${gainLoss >= 0 ? "+" : "-"}${formatMoney(Math.abs(gainLoss))}`}
color={gainLoss >= 0 ? "text-green-600" : "text-red-600"}
/>
</div>
{/* Current quarter status */}
{latestRound && (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-2">Current Quarter Status</h3>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-900">{formatQuarter(latestRound.quarter_end)}</span>
<span className={`inline-block px-2 py-0.5 text-xs rounded ${STATUS_COLORS[latestRound.status]}`}>
{latestRound.status}
</span>
{latestRound.status === "approved" && latestRound.approved_at && (
<span className="text-sm text-gray-500">
Signed {formatDate(latestRound.approved_at)}
</span>
)}
</div>
</div>
)}
</div>
);
}
function SummaryCard({
label,
value,
color = "text-gray-900",
}: {
label: string;
value: string;
color?: string;
}) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className={`text-lg font-semibold ${color}`}>{value}</div>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api, type Entity, type Holding, type Position, type ValuationRound, type Valuation } from "../api";
import { formatMoney, formatMoneyExact, formatDate, formatQuarter, formatPerShare } from "../format";
const TYPE_LABELS: Record<string, string> = {
fund: "Fund",
spv: "SPV",
gp: "GP",
mgmt_co: "Mgmt Co",
};
interface PositionWithValuation extends Position {
lastValueCents: number | null;
valuationDate: string | null;
valuationQuarter: string | null;
}
interface HoldingGroup {
holding: Holding;
positions: PositionWithValuation[];
totalCost: number;
totalValue: number;
}
export default function Investments() {
const { id } = useParams<{ id: string }>();
const [entity, setEntity] = useState<Entity | null>(null);
const [groups, setGroups] = useState<HoldingGroup[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
loadData(parseInt(id));
}, [id]);
async function loadData(entityId: number) {
setLoading(true);
try {
const ent = await api.getEntity(entityId);
setEntity(ent);
const holdings = await api.listHoldings(entityId);
const rounds = await api.listRounds(entityId);
// Find latest approved round
const approvedRounds = rounds
.filter((r) => r.status === "approved")
.sort((a, b) => b.quarter_end.localeCompare(a.quarter_end));
const latestApproved = approvedRounds[0] || null;
// Build valuation lookup: position_id -> {value_cents, quarter_end, approved_at}
const valMap = new Map<number, { value_cents: number; quarter_end: string; approved_at: string | null }>();
if (latestApproved) {
for (const v of latestApproved.valuations) {
valMap.set(v.position_id, {
value_cents: v.value_cents,
quarter_end: latestApproved.quarter_end,
approved_at: latestApproved.approved_at,
});
}
}
const result: HoldingGroup[] = [];
for (const h of holdings) {
const positions = await api.listPositions(h.id);
const enriched: PositionWithValuation[] = positions.map((p) => {
const val = valMap.get(p.id);
return {
...p,
lastValueCents: val?.value_cents ?? null,
valuationDate: val?.approved_at ?? null,
valuationQuarter: val?.quarter_end ?? null,
};
});
const totalCost = enriched.reduce((s, p) => s + p.cost_cents, 0);
const totalValue = enriched.reduce((s, p) => s + (p.lastValueCents ?? 0), 0);
result.push({ holding: h, positions: enriched, totalCost, totalValue });
}
setGroups(result);
} finally {
setLoading(false);
}
}
if (loading || !entity) {
return <div className="text-gray-500 text-sm">Loading...</div>;
}
const totalCost = groups.reduce((s, g) => s + g.totalCost, 0);
const totalValue = groups.reduce((s, g) => s + g.totalValue, 0);
const totalGain = totalValue - totalCost;
const positionCount = groups.reduce((s, g) => s + g.positions.length, 0);
return (
<div>
{/* Header */}
<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">
<Link
to={`/entities/${entity.id}`}
className="pb-2 text-sm text-gray-500 hover:text-gray-800"
>
Overview
</Link>
<span className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600">
Investments
</span>
<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>
{/* Header band */}
<div className="flex gap-6 mb-6 text-sm">
<div>
<span className="text-gray-500">Active investments:</span>{" "}
<span className="font-medium text-gray-900">{positionCount}</span>
</div>
<div>
<span className="text-gray-500">Total cost:</span>{" "}
<span className="font-medium text-gray-900">{formatMoney(totalCost)}</span>
</div>
<div>
<span className="text-gray-500">Total value:</span>{" "}
<span className="font-medium text-gray-900">{formatMoney(totalValue)}</span>
</div>
<div>
<span className="text-gray-500">Unrealized gain/loss:</span>{" "}
<span className={`font-medium ${totalGain >= 0 ? "text-green-600" : "text-red-600"}`}>
{totalGain >= 0 ? "+" : "-"}{formatMoney(Math.abs(totalGain))}
</span>
</div>
</div>
{/* Grouped table */}
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-4 py-3 font-medium text-gray-600">Security</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Date</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Shares</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Cost</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Cost/Share</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Signed Value</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Value/Share</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Signed</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Gain/Loss</th>
</tr>
</thead>
<tbody>
{groups.map((group) => {
const holdingGain = group.totalValue - group.totalCost;
return (
<HoldingGroupRows key={group.holding.id} group={group} holdingGain={holdingGain} />
);
})}
</tbody>
</table>
</div>
</div>
);
}
function HoldingGroupRows({ group, holdingGain }: { group: HoldingGroup; holdingGain: number }) {
return (
<>
{/* Holding header row */}
<tr className="bg-gray-50 border-t border-gray-200">
<td className="px-4 py-2 font-semibold text-gray-900" colSpan={3}>
{group.holding.company_name}
</td>
<td className="px-4 py-2 text-right font-medium text-gray-900">
{formatMoney(group.totalCost)}
</td>
<td className="px-4 py-2" />
<td className="px-4 py-2 text-right font-medium text-gray-900">
{formatMoney(group.totalValue)}
</td>
<td className="px-4 py-2" />
<td className="px-4 py-2" />
<td className={`px-4 py-2 text-right font-medium ${holdingGain >= 0 ? "text-green-600" : "text-red-600"}`}>
{holdingGain >= 0 ? "+" : "-"}{formatMoney(Math.abs(holdingGain))}
</td>
</tr>
{/* Position rows */}
{group.positions.map((pos) => {
const gain = (pos.lastValueCents ?? 0) - pos.cost_cents;
return (
<tr key={pos.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-2 pl-8 text-gray-700">{pos.security_name}</td>
<td className="px-4 py-2 text-gray-600">{formatDate(pos.investment_date)}</td>
<td className="px-4 py-2 text-right text-gray-600">
{pos.shares ? parseFloat(pos.shares).toLocaleString() : "—"}
</td>
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(pos.cost_cents)}</td>
<td className="px-4 py-2 text-right text-gray-600">
{formatPerShare(pos.cost_cents, pos.shares)}
</td>
<td className="px-4 py-2 text-right text-gray-900">
{pos.lastValueCents != null ? formatMoney(pos.lastValueCents) : "—"}
</td>
<td className="px-4 py-2 text-right text-gray-600">
{pos.lastValueCents != null ? formatPerShare(pos.lastValueCents, pos.shares) : "—"}
</td>
<td className="px-4 py-2 text-gray-600">
{pos.valuationQuarter
? `${formatQuarter(pos.valuationQuarter)} ${pos.valuationDate ? formatDate(pos.valuationDate) : ""}`
: "—"}
</td>
<td className={`px-4 py-2 text-right font-medium ${gain >= 0 ? "text-green-600" : "text-red-600"}`}>
{pos.lastValueCents != null ? (
<>
{gain >= 0 ? "▲" : "▼"} {formatMoney(Math.abs(gain))}
</>
) : (
"—"
)}
</td>
</tr>
);
})}
</>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, type FormEvent } from "react";
import { useAuth } from "../context/AuthContext";
export default function Login() {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setLoading(false);
}
};
return (
<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">
<h1 className="text-xl font-semibold text-gray-900 mb-6">Ten31Portal</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(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>
<label className="block text-sm text-gray-700 mb-1">Password</label>
<input
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>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
</div>
</div>
);
}
+411
View File
@@ -0,0 +1,411 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import {
api,
type Entity,
type Holding,
type Position,
type ValuationRound,
type Valuation,
} from "../api";
import { useAuth } from "../context/AuthContext";
import { formatMoney, formatMoneyExact, formatQuarter, formatDate } from "../format";
const STATUS_COLORS: Record<string, string> = {
draft: "bg-gray-100 text-gray-600",
submitted: "bg-yellow-100 text-yellow-700",
approved: "bg-green-100 text-green-700",
returned: "bg-red-100 text-red-600",
};
interface PosInfo {
position: Position;
holdingName: string;
}
export default function ValuationWorkflow() {
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
const [entity, setEntity] = useState<Entity | null>(null);
const [rounds, setRounds] = useState<ValuationRound[]>([]);
const [selectedRound, setSelectedRound] = useState<ValuationRound | null>(null);
const [posMap, setPosMap] = useState<Map<number, PosInfo>>(new Map());
const [editValues, setEditValues] = useState<Map<number, string>>(new Map());
const [priorValues, setPriorValues] = useState<Map<number, number>>(new Map());
const [returnNote, setReturnNote] = useState("");
const [newQuarter, setNewQuarter] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const entityId = id ? parseInt(id) : 0;
const isApprover = user?.role === "approver";
const canEdit =
selectedRound &&
(selectedRound.status === "draft" || selectedRound.status === "returned") &&
user &&
["fund_admin", "cfo", "approver"].includes(user.role);
const canSubmit = canEdit;
const canApprove =
selectedRound?.status === "submitted" && isApprover;
const canReturn =
selectedRound?.status === "submitted" && isApprover;
useEffect(() => {
if (!entityId) return;
loadData();
}, [entityId]);
async function loadData() {
setLoading(true);
try {
const ent = await api.getEntity(entityId);
setEntity(ent);
const rds = await api.listRounds(entityId);
setRounds(rds);
if (rds.length > 0) selectRound(rds[0], entityId);
// Build position map
const holdings = await api.listHoldings(entityId);
const pm = new Map<number, PosInfo>();
for (const h of holdings) {
const positions = await api.listPositions(h.id);
for (const p of positions) {
pm.set(p.id, { position: p, holdingName: h.company_name });
}
}
setPosMap(pm);
// Build prior approved values map
const approvedRounds = rds.filter((r) => r.status === "approved");
if (approvedRounds.length > 0) {
const latest = approvedRounds[0];
const pv = new Map<number, number>();
for (const v of latest.valuations) {
pv.set(v.position_id, v.value_cents);
}
setPriorValues(pv);
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
function selectRound(round: ValuationRound, _entityId?: number) {
setSelectedRound(round);
const ev = new Map<number, string>();
for (const v of round.valuations) {
ev.set(v.position_id, (v.value_cents / 100).toString());
}
setEditValues(ev);
setError("");
}
async function handleCreateRound() {
if (!newQuarter) return;
setError("");
try {
const round = await api.createRound(entityId, newQuarter);
setRounds((prev) => [round, ...prev]);
selectRound(round);
setNewQuarter("");
} catch (err: any) {
setError(err.message);
}
}
async function handleSave() {
if (!selectedRound) return;
setSaving(true);
setError("");
try {
const valuations = Array.from(editValues.entries()).map(([posId, dollars]) => ({
position_id: posId,
value_cents: Math.round(parseFloat(dollars) * 100),
}));
const updated = await api.updateValuations(selectedRound.id, valuations);
setSelectedRound(updated);
setRounds((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
async function handleSubmit() {
if (!selectedRound) return;
await handleSave();
try {
const updated = await api.submitRound(selectedRound.id);
setSelectedRound(updated);
setRounds((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
} catch (err: any) {
setError(err.message);
}
}
async function handleApprove() {
if (!selectedRound) return;
try {
const updated = await api.approveRound(selectedRound.id);
setSelectedRound(updated);
setRounds((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
} catch (err: any) {
setError(err.message);
}
}
async function handleReturn() {
if (!selectedRound || !returnNote.trim()) return;
try {
const updated = await api.returnRound(selectedRound.id, returnNote);
setSelectedRound(updated);
setRounds((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
setReturnNote("");
} catch (err: any) {
setError(err.message);
}
}
if (loading || !entity) {
return <div className="text-gray-500 text-sm">Loading...</div>;
}
return (
<div>
<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>
</div>
<div className="flex gap-6 mt-4 border-b border-gray-200">
<Link to={`/entities/${entity.id}`} className="pb-2 text-sm text-gray-500 hover:text-gray-800">
Overview
</Link>
<Link to={`/entities/${entity.id}/investments`} className="pb-2 text-sm text-gray-500 hover:text-gray-800">
Investments
</Link>
<span className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600">
Valuation
</span>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{error}
</div>
)}
<div className="flex gap-6">
{/* Round list sidebar */}
<div className="w-64 shrink-0">
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-700 mb-2">New Round</h3>
<div className="flex gap-2">
<input
type="date"
value={newQuarter}
onChange={(e) => setNewQuarter(e.target.value)}
className="flex-1 px-2 py-1.5 border border-gray-300 rounded text-sm"
placeholder="Quarter end"
/>
<button
onClick={handleCreateRound}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
>
Create
</button>
</div>
</div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Rounds</h3>
<ul className="space-y-1">
{rounds.map((r) => (
<li key={r.id}>
<button
onClick={() => selectRound(r)}
className={`w-full text-left px-3 py-2 rounded text-sm ${
selectedRound?.id === r.id ? "bg-orange-50 text-orange-700" : "hover:bg-gray-50 text-gray-700"
}`}
>
<div className="font-medium">{formatQuarter(r.quarter_end)}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`inline-block px-1.5 py-0.5 text-xs rounded ${STATUS_COLORS[r.status]}`}>
{r.status}
</span>
{r.is_seed && <span className="text-xs text-gray-400">seed</span>}
</div>
</button>
</li>
))}
{rounds.length === 0 && (
<li className="text-sm text-gray-400 px-3">No rounds yet.</li>
)}
</ul>
</div>
{/* Round detail */}
{selectedRound ? (
<div className="flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900">
{formatQuarter(selectedRound.quarter_end)}
</h2>
<span className={`inline-block px-2 py-0.5 text-xs rounded ${STATUS_COLORS[selectedRound.status]}`}>
{selectedRound.status}
</span>
</div>
<div className="flex gap-2">
{canEdit && (
<>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 border border-gray-300 text-sm rounded hover:bg-gray-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={handleSubmit}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
>
Submit for Review
</button>
</>
)}
{canApprove && (
<button
onClick={handleApprove}
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700"
>
Approve
</button>
)}
</div>
</div>
{selectedRound.return_note && selectedRound.status === "returned" && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800">
Return note: {selectedRound.return_note}
</div>
)}
{selectedRound.status === "approved" && selectedRound.approved_at && (
<div className="mb-4 text-sm text-gray-500">
Approved by user #{selectedRound.approved_by} on {formatDate(selectedRound.approved_at)}
</div>
)}
{/* Valuations table */}
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-4 py-3 font-medium text-gray-600">Company</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Security</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Cost</th>
{canApprove && (
<th className="text-right px-4 py-3 font-medium text-gray-600">Prior Value</th>
)}
<th className="text-right px-4 py-3 font-medium text-gray-600">
{canEdit ? "Value (edit)" : "Value"}
</th>
{canApprove && (
<th className="text-right px-4 py-3 font-medium text-gray-600">Delta</th>
)}
</tr>
</thead>
<tbody>
{selectedRound.valuations.map((v) => {
const info = posMap.get(v.position_id);
const prior = priorValues.get(v.position_id) ?? 0;
const currentCents = canEdit
? Math.round(parseFloat(editValues.get(v.position_id) || "0") * 100)
: v.value_cents;
const delta = currentCents - prior;
return (
<tr key={v.id} className="border-b border-gray-100">
<td className="px-4 py-2 text-gray-700">{info?.holdingName || "—"}</td>
<td className="px-4 py-2 text-gray-700">{info?.position.security_name || "—"}</td>
<td className="px-4 py-2 text-right text-gray-600">
{info ? formatMoney(info.position.cost_cents) : "—"}
</td>
{canApprove && (
<td className="px-4 py-2 text-right text-gray-600">
{formatMoney(prior)}
</td>
)}
<td className="px-4 py-2 text-right">
{canEdit ? (
<input
type="number"
step="0.01"
value={editValues.get(v.position_id) || "0"}
onChange={(e) => {
const next = new Map(editValues);
next.set(v.position_id, e.target.value);
setEditValues(next);
}}
className="w-32 px-2 py-1 border border-gray-300 rounded text-right text-sm"
/>
) : (
<span className="text-gray-900">{formatMoney(v.value_cents)}</span>
)}
</td>
{canApprove && (
<td className={`px-4 py-2 text-right font-medium ${delta >= 0 ? "text-green-600" : "text-red-600"}`}>
{delta >= 0 ? "+" : "-"}{formatMoney(Math.abs(delta))}
</td>
)}
</tr>
);
})}
{selectedRound.valuations.length === 0 && (
<tr>
<td className="px-4 py-6 text-center text-gray-400" colSpan={6}>
No positions in this round.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Return form for approvers */}
{canReturn && (
<div className="mt-4 p-4 bg-gray-50 border border-gray-200 rounded">
<h3 className="text-sm font-medium text-gray-700 mb-2">Return with note</h3>
<div className="flex gap-2">
<input
type="text"
value={returnNote}
onChange={(e) => setReturnNote(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm"
placeholder="Reason for returning..."
/>
<button
onClick={handleReturn}
disabled={!returnNote.trim()}
className="px-4 py-2 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50"
>
Return
</button>
</div>
</div>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
Select or create a round.
</div>
)}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
});