0.2.27-0.2.32: LP portal polish, brand palette, default investor logins
Cumulative checkpoint since 0.2.26:
- 0.2.27/28: entity valuation-history table; investor gain/loss = NAV +
distributions vs paid-in
- 0.2.29: Reset Fund Partners (endpoint, Partners-tab button, CLI, action)
- 0.2.30: "Current Capital Balance" label, %-only gain/loss
- 0.2.31: Management Entities rename, Carry Vehicle type, chart
distributions-line gate
- 0.2.32: LP-facing polish pass
* Ten31 brand palette from the logo (navy/mint); orange retired
* portfolio summary card across funds; gain labeled "net of paid-in"
* whole-dollar headline figures; "History · N quarters" toggle
* documents grouped by year with a "New" badge (users.docs_seen_at)
* eNAV-created members start on default password with login enabled;
enable-investor-logins CLI + StartOS action for existing accounts
* password minimum raised to 8 chars; login help line (Portal@ten31.xyz)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
69f12b0519
commit
4215d4478f
@@ -0,0 +1,27 @@
|
|||||||
|
"""add users.docs_seen_at (portal "New" document badge watermark)
|
||||||
|
|
||||||
|
Revision ID: d0e1f2a3b4c5
|
||||||
|
Revises: c9d0e1f2a3b4
|
||||||
|
Create Date: 2026-07-03 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'd0e1f2a3b4c5'
|
||||||
|
down_revision: Union[str, None] = 'c9d0e1f2a3b4'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('docs_seen_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('docs_seen_at')
|
||||||
@@ -87,6 +87,42 @@ def reset_password(args: argparse.Namespace) -> None:
|
|||||||
print(f"Password reset for {user.name} ({user.username}).")
|
print(f"Password reset for {user.name} ({user.username}).")
|
||||||
|
|
||||||
|
|
||||||
|
def enable_investor_logins(args: argparse.Namespace) -> None:
|
||||||
|
"""Give every no-login investor account the shared default password and enable sign-in.
|
||||||
|
|
||||||
|
Targets investor-role accounts with login_enabled=False that log in on their own
|
||||||
|
(linked secondary names are skipped — they sign in under their primary). Accounts that
|
||||||
|
already have a working login are never touched.
|
||||||
|
"""
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
from ten31portal.models import UserRole
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
users = session.exec(
|
||||||
|
select(User).where(
|
||||||
|
User.role == UserRole.investor,
|
||||||
|
User.login_enabled == False, # noqa: E712 — SQL expression
|
||||||
|
User.primary_account_id == None, # noqa: E711
|
||||||
|
).order_by(User.name) # type: ignore[arg-type]
|
||||||
|
).all()
|
||||||
|
if not users:
|
||||||
|
print("Nothing to do — every investor account already has a login.")
|
||||||
|
return
|
||||||
|
for u in users:
|
||||||
|
u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD)
|
||||||
|
u.login_enabled = True
|
||||||
|
session.add(u)
|
||||||
|
session.commit()
|
||||||
|
for u in users:
|
||||||
|
print(f"Enabled login for {u.name} ({u.username})")
|
||||||
|
print(
|
||||||
|
f"\n{len(users)} investor account(s) set to the default password "
|
||||||
|
f"'{config.DEFAULT_INVESTOR_PASSWORD}'. Each investor should change it in the "
|
||||||
|
"portal (Change password)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def show_admin_password(args: argparse.Namespace) -> None:
|
def show_admin_password(args: argparse.Namespace) -> None:
|
||||||
"""Print the randomly-generated initial admin password recorded on first boot."""
|
"""Print the randomly-generated initial admin password recorded on first boot."""
|
||||||
path = config.ADMIN_PASSWORD_FILE
|
path = config.ADMIN_PASSWORD_FILE
|
||||||
@@ -234,6 +270,29 @@ def reset_holdings(args: argparse.Namespace) -> None:
|
|||||||
f"Re-import the fund's NAV to repopulate it.")
|
f"Re-import the fund's NAV to repopulate it.")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_partners(args: argparse.Namespace) -> None:
|
||||||
|
"""Remove all partners (capital-account statements + access grants) from one fund."""
|
||||||
|
run_migrations()
|
||||||
|
from ten31portal.models import Entity
|
||||||
|
from ten31portal.routers.capital_import_router import reset_entity_partners
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
entity = None
|
||||||
|
if args.entity_id:
|
||||||
|
entity = session.get(Entity, args.entity_id)
|
||||||
|
elif args.name:
|
||||||
|
entity = session.exec(select(Entity).where(Entity.name == args.name)).first()
|
||||||
|
if entity is None:
|
||||||
|
print(f"Error: no fund found for '{args.name or args.entity_id}'. "
|
||||||
|
f"Check the exact name with list-funds.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
res = reset_entity_partners(entity.id, session)
|
||||||
|
session.commit()
|
||||||
|
print(f"Cleared partners from {entity.name}: removed {res['statements']} capital "
|
||||||
|
f"statement(s) and {res['access_grants']} access grant(s). "
|
||||||
|
f"Investor accounts were kept. Re-import the correct roster to repopulate.")
|
||||||
|
|
||||||
|
|
||||||
def list_funds(args: argparse.Namespace) -> None:
|
def list_funds(args: argparse.Namespace) -> None:
|
||||||
"""Print every entity's id and name (so the exact name is known for reset-holdings)."""
|
"""Print every entity's id and name (so the exact name is known for reset-holdings)."""
|
||||||
run_migrations()
|
run_migrations()
|
||||||
@@ -268,6 +327,11 @@ def main() -> None:
|
|||||||
|
|
||||||
sub.add_parser("list-users", help="List all user accounts")
|
sub.add_parser("list-users", help="List all user accounts")
|
||||||
|
|
||||||
|
sub.add_parser(
|
||||||
|
"enable-investor-logins",
|
||||||
|
help="Set every no-login investor account to the default password and enable sign-in",
|
||||||
|
)
|
||||||
|
|
||||||
sub.add_parser("show-admin-password", help="Show the initial admin password from first boot")
|
sub.add_parser("show-admin-password", help="Show the initial admin password from first boot")
|
||||||
|
|
||||||
delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)")
|
delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)")
|
||||||
@@ -284,6 +348,10 @@ def main() -> None:
|
|||||||
reset.add_argument("--name", required=False, default=None, help="Exact fund name")
|
reset.add_argument("--name", required=False, default=None, help="Exact fund name")
|
||||||
reset.add_argument("--entity-id", type=int, required=False, default=None)
|
reset.add_argument("--entity-id", type=int, required=False, default=None)
|
||||||
|
|
||||||
|
rparts = sub.add_parser("reset-partners", help="Remove all partners (capital accounts + access) from a fund")
|
||||||
|
rparts.add_argument("--name", required=False, default=None, help="Exact fund name")
|
||||||
|
rparts.add_argument("--entity-id", type=int, required=False, default=None)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.command == "create-user":
|
if args.command == "create-user":
|
||||||
create_user(args)
|
create_user(args)
|
||||||
@@ -291,6 +359,8 @@ def main() -> None:
|
|||||||
reset_password(args)
|
reset_password(args)
|
||||||
elif args.command == "list-users":
|
elif args.command == "list-users":
|
||||||
list_users(args)
|
list_users(args)
|
||||||
|
elif args.command == "enable-investor-logins":
|
||||||
|
enable_investor_logins(args)
|
||||||
elif args.command == "show-admin-password":
|
elif args.command == "show-admin-password":
|
||||||
show_admin_password(args)
|
show_admin_password(args)
|
||||||
elif args.command == "delete-user":
|
elif args.command == "delete-user":
|
||||||
@@ -301,6 +371,8 @@ def main() -> None:
|
|||||||
list_funds(args)
|
list_funds(args)
|
||||||
elif args.command == "reset-holdings":
|
elif args.command == "reset-holdings":
|
||||||
reset_holdings(args)
|
reset_holdings(args)
|
||||||
|
elif args.command == "reset-partners":
|
||||||
|
reset_partners(args)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ DOCS_DIR: str = os.getenv("TEN31_DOCS_DIR", "/data/ten31portal/documents")
|
|||||||
# also bounds spreadsheet imports (which must be read fully into memory to parse).
|
# also bounds spreadsheet imports (which must be read fully into memory to parse).
|
||||||
MAX_UPLOAD_SIZE: int = int(os.getenv("TEN31_MAX_UPLOAD_SIZE", str(50 * 1024 * 1024)))
|
MAX_UPLOAD_SIZE: int = int(os.getenv("TEN31_MAX_UPLOAD_SIZE", str(50 * 1024 * 1024)))
|
||||||
|
|
||||||
|
# Investor accounts created by the eNAV import (and existing no-login accounts converted via
|
||||||
|
# the enable-investor-logins CLI/action) start with this password so the admin can hand out
|
||||||
|
# credentials easily; each investor changes it via the portal's own Change password.
|
||||||
|
DEFAULT_INVESTOR_PASSWORD: str = os.getenv("TEN31_DEFAULT_INVESTOR_PASSWORD", "Ten31Portal")
|
||||||
|
|
||||||
# Where start.sh records the randomly-generated initial admin password on first boot, so the
|
# Where start.sh records the randomly-generated initial admin password on first boot, so the
|
||||||
# operator can retrieve it once (via the "Show Initial Admin Password" service action) and then
|
# operator can retrieve it once (via the "Show Initial Admin Password" service action) and then
|
||||||
# change it. Lives next to the DB on the 0600 data volume; removed once the password is reset.
|
# change it. Lives next to the DB on the 0600 data volume; removed once the password is reset.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class EntityType(str, enum.Enum):
|
|||||||
spv = "spv"
|
spv = "spv"
|
||||||
gp = "gp"
|
gp = "gp"
|
||||||
mgmt_co = "mgmt_co"
|
mgmt_co = "mgmt_co"
|
||||||
|
carry = "carry"
|
||||||
|
|
||||||
|
|
||||||
class EntityStatus(str, enum.Enum):
|
class EntityStatus(str, enum.Enum):
|
||||||
@@ -75,6 +76,9 @@ class User(SQLModel, table=True):
|
|||||||
primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||||
# Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import.
|
# Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import.
|
||||||
external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=True))
|
external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=True))
|
||||||
|
# When this investor last loaded their documents list — docs newer than this get a "New"
|
||||||
|
# badge in the portal. Null until their first visit (nothing badged for brand-new logins).
|
||||||
|
docs_seen_at: datetime | None = Field(default=None)
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ def change_password(
|
|||||||
"""Let the signed-in user set their own password (after confirming the current one)."""
|
"""Let the signed-in user set their own password (after confirming the current one)."""
|
||||||
if not verify_password(body.current_password, user.password_hash):
|
if not verify_password(body.current_password, user.password_hash):
|
||||||
raise HTTPException(status_code=400, detail="Current password is incorrect.")
|
raise HTTPException(status_code=400, detail="Current password is incorrect.")
|
||||||
if len(body.new_password) < 4:
|
if len(body.new_password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="New password must be at least 4 characters.")
|
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
|
||||||
user.password_hash = hash_password(body.new_password)
|
user.password_hash = hash_password(body.new_password)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -6,20 +6,19 @@ Fallback: a subaccounts sheet (investor names across columns, per-vehicle value
|
|||||||
|
|
||||||
Encrypted workbooks are decrypted with the open password. Nothing is written on preview.
|
Encrypted workbooks are decrypted with the open password. Nothing is written on preview.
|
||||||
Commit matches existing members (by fund-admin investor ID, else name), creates new ones
|
Commit matches existing members (by fund-admin investor ID, else name), creates new ones
|
||||||
(without a login unless a password is given), grants entity access, and loads each member's
|
(on the shared default password unless one is given), grants entity access, and loads each
|
||||||
capital-account statement (commitment, contributions, distributions, current value).
|
member's capital-account statement (commitment, contributions, distributions, current value).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
import secrets
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from ten31portal import storage
|
from ten31portal import config, storage
|
||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import hash_password, require_internal_admin
|
from ten31portal.auth import hash_password, require_internal_admin
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
@@ -38,6 +37,39 @@ MAX_ROWS = 400
|
|||||||
MAX_COLS = 90
|
MAX_COLS = 90
|
||||||
|
|
||||||
|
|
||||||
|
def reset_entity_partners(entity_id: int, session: Session) -> dict[str, int]:
|
||||||
|
"""Remove every partner from one fund: delete its capital-account statements and the
|
||||||
|
investors' access grants to it. The investor *accounts* are kept — they usually also
|
||||||
|
belong to other funds — only their membership of THIS entity is cleared. Use to undo a
|
||||||
|
wrong members/ALLOC-SI import (e.g. Fund II's roster loaded into Fund III). Holdings/NAV
|
||||||
|
are untouched (see reset_entity_holdings for those). Caller commits.
|
||||||
|
"""
|
||||||
|
statements = 0
|
||||||
|
for s in session.exec(
|
||||||
|
select(CapitalAccountStatement).where(
|
||||||
|
CapitalAccountStatement.entity_id == entity_id
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
session.delete(s)
|
||||||
|
statements += 1
|
||||||
|
|
||||||
|
# Only drop investor memberships; a fund_administrator's access is not a "partner".
|
||||||
|
investor_ids = {
|
||||||
|
u.id for u in session.exec(
|
||||||
|
select(User).where(User.role == UserRole.investor)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
access = 0
|
||||||
|
for a in session.exec(
|
||||||
|
select(EntityAccess).where(EntityAccess.entity_id == entity_id)
|
||||||
|
).all():
|
||||||
|
if a.user_id in investor_ids:
|
||||||
|
session.delete(a)
|
||||||
|
access += 1
|
||||||
|
|
||||||
|
return {"statements": statements, "access_grants": access}
|
||||||
|
|
||||||
|
|
||||||
def _slug_username(name: str) -> str:
|
def _slug_username(name: str) -> str:
|
||||||
base = re.sub(r"[^a-z0-9]+", "", name.lower())
|
base = re.sub(r"[^a-z0-9]+", "", name.lower())
|
||||||
return base or "investor"
|
return base or "investor"
|
||||||
@@ -282,14 +314,16 @@ def commit_import(
|
|||||||
raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.")
|
raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.")
|
||||||
if inv.email and session.exec(select(User).where(User.email == inv.email)).first():
|
if inv.email and session.exec(select(User).where(User.email == inv.email)).first():
|
||||||
raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.")
|
raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.")
|
||||||
pw = inv.password or secrets.token_urlsafe(32)
|
# New members start on the shared default password (login enabled) so the admin
|
||||||
|
# can send credentials right away; each investor rotates it in the portal.
|
||||||
|
pw = inv.password or config.DEFAULT_INVESTOR_PASSWORD
|
||||||
user = User(
|
user = User(
|
||||||
name=inv.name,
|
name=inv.name,
|
||||||
username=inv.username,
|
username=inv.username,
|
||||||
email=inv.email or None,
|
email=inv.email or None,
|
||||||
password_hash=hash_password(pw),
|
password_hash=hash_password(pw),
|
||||||
role=UserRole.investor,
|
role=UserRole.investor,
|
||||||
login_enabled=bool(inv.password),
|
login_enabled=True,
|
||||||
external_investor_id=inv.external_id,
|
external_investor_id=inv.external_id,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Document upload, listing, download, and deletion with per-account access control."""
|
"""Document upload, listing, download, and deletion with per-account access control."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@@ -58,7 +60,25 @@ def list_documents(
|
|||||||
query = query.where(Document.investor_user_id == investor_user_id)
|
query = query.where(Document.investor_user_id == investor_user_id)
|
||||||
rows = session.exec(query.order_by(Document.created_at.desc())).all() # type: ignore[union-attr]
|
rows = session.exec(query.order_by(Document.created_at.desc())).all() # type: ignore[union-attr]
|
||||||
visible = [d for d in rows if _can_view(user, d, session)]
|
visible = [d for d in rows if _can_view(user, d, session)]
|
||||||
return [DocumentResponse.model_validate(d, from_attributes=True) for d in visible]
|
|
||||||
|
# Badge docs that arrived since the investor's previous visit. Badges show on the first
|
||||||
|
# page-load after new documents arrive; the watermark advances at most once per 30 minutes
|
||||||
|
# so rapid refetches don't rewrite the row. First ever visit (no watermark) badges nothing —
|
||||||
|
# everything would be "new".
|
||||||
|
seen_before = user.docs_seen_at if user.role == UserRole.investor else None
|
||||||
|
if user.role == UserRole.investor:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if user.docs_seen_at is None or (now - user.docs_seen_at) > timedelta(minutes=30):
|
||||||
|
user.docs_seen_at = now
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return [
|
||||||
|
DocumentResponse.model_validate(d, from_attributes=True).model_copy(
|
||||||
|
update={"is_new": seen_before is not None and d.created_at > seen_before}
|
||||||
|
)
|
||||||
|
for d in visible
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
|
|||||||
@@ -148,6 +148,26 @@ def list_partners(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entity_id}/partners")
|
||||||
|
def clear_partners(
|
||||||
|
entity_id: int,
|
||||||
|
user: User = Depends(require_writer),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Remove all partners from this fund — deletes its capital-account statements and the
|
||||||
|
investors' access grants, but keeps the investor accounts (they belong to other funds).
|
||||||
|
For undoing a wrong members import. Holdings/NAV are not affected."""
|
||||||
|
# Local import avoids a module-load cycle (capital_import_router imports import_router).
|
||||||
|
from ten31portal.routers.capital_import_router import reset_entity_partners
|
||||||
|
|
||||||
|
if session.get(Entity, entity_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
res = reset_entity_partners(entity_id, session)
|
||||||
|
record_audit(session, user.id, "clear_partners", "entity", entity_id, res)
|
||||||
|
session.commit()
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_entities(
|
def list_entities(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ class DocumentResponse(BaseModel):
|
|||||||
size_bytes: int
|
size_bytes: int
|
||||||
uploaded_by: int | None
|
uploaded_by: int | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
# True for an investor when the doc arrived since their previous portal visit.
|
||||||
|
is_new: bool = False
|
||||||
|
|
||||||
|
|
||||||
# --- Capital account ---
|
# --- Capital account ---
|
||||||
@@ -310,7 +312,7 @@ class ImportCommitInvestor(BaseModel):
|
|||||||
name: str | None = None # for action=create
|
name: str | None = None # for action=create
|
||||||
username: str | None = None # for action=create
|
username: str | None = None # for action=create
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
password: str | None = None # for action=create; omit to create without a login
|
password: str | None = None # for action=create; omit for the shared default password
|
||||||
external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching
|
external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Clearing a fund's partners removes only that fund's statements + access grants,
|
||||||
|
never the investor accounts (which may belong to other funds)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fund(session, name):
|
||||||
|
e = Entity(name=name, type=EntityType.fund)
|
||||||
|
session.add(e)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(e)
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def _add_partner(session, entity_id, user_id, ending):
|
||||||
|
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=entity_id, investor_user_id=user_id, as_of_date=date(2025, 12, 31),
|
||||||
|
commitment_cents=0, beginning_balance_cents=0, contributions_cents=0,
|
||||||
|
distributions_cents=0, ending_balance_cents=ending,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_partners_scoped_to_one_fund(auth_client, session):
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
fund2 = _make_fund(session, "Low Time Preference Fund II, LLC")
|
||||||
|
fund3 = _make_fund(session, "Low Time Preference Fund III, LP")
|
||||||
|
lp = make_user(session, username="lp1", role=UserRole.investor, name="LP One")
|
||||||
|
# Same investor is a partner in BOTH funds (the real-world case that caused the mixup).
|
||||||
|
_add_partner(session, fund2.id, lp.id, 1_000_00)
|
||||||
|
_add_partner(session, fund3.id, lp.id, 2_000_00)
|
||||||
|
|
||||||
|
resp = auth_client.delete(f"/api/entities/{fund3.id}/partners")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == {"statements": 1, "access_grants": 1}
|
||||||
|
|
||||||
|
# Fund III is wiped of partners...
|
||||||
|
assert session.exec(
|
||||||
|
CapitalAccountStatement.__table__.select().where(
|
||||||
|
CapitalAccountStatement.entity_id == fund3.id
|
||||||
|
)
|
||||||
|
).first() is None
|
||||||
|
assert session.exec(
|
||||||
|
EntityAccess.__table__.select().where(EntityAccess.entity_id == fund3.id)
|
||||||
|
).first() is None
|
||||||
|
assert auth_client.get(f"/api/entities/{fund3.id}/partners").json() == []
|
||||||
|
|
||||||
|
# ...but Fund II keeps its partner, and the investor account still exists.
|
||||||
|
assert len(auth_client.get(f"/api/entities/{fund2.id}/partners").json()) == 1
|
||||||
|
assert session.get(User, lp.id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_partners_missing_entity_404(auth_client):
|
||||||
|
assert auth_client.delete("/api/entities/99999/partners").status_code == 404
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""0.2.32 LP-facing behavior: 8-char password floor, default investor password,
|
||||||
|
enable-investor-logins conversion, and the documents "New" badge watermark."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ten31portal import config
|
||||||
|
from ten31portal.auth import hash_password, verify_password
|
||||||
|
from ten31portal.models import (
|
||||||
|
Document, Entity, EntityAccess, EntityType, User, UserRole,
|
||||||
|
)
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, username, password):
|
||||||
|
return client.post("/api/auth/login", json={"login": username, "password": password})
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_requires_eight_chars(auth_client):
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
json={"current_password": "password123", "new_password": "short7c"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "8 characters" in resp.json()["detail"]
|
||||||
|
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
json={"current_password": "password123", "new_password": "longenough8"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_created_member_gets_default_password(auth_client, session):
|
||||||
|
entity = Entity(name="Test Fund", type=EntityType.fund)
|
||||||
|
session.add(entity)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = auth_client.post(
|
||||||
|
"/api/import/capital-accounts/commit",
|
||||||
|
json={
|
||||||
|
"entity_id": entity.id,
|
||||||
|
"as_of_date": "2026-03-31",
|
||||||
|
"investors": [{
|
||||||
|
"action": "create",
|
||||||
|
"name": "New LP",
|
||||||
|
"username": "newlp",
|
||||||
|
"value_dollars": 100_000,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
user = session.exec(select(User).where(User.username == "newlp")).one()
|
||||||
|
assert user.login_enabled is True
|
||||||
|
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, user.password_hash)
|
||||||
|
|
||||||
|
# And the account can actually sign in with it.
|
||||||
|
resp = _login(auth_client, "newlp", config.DEFAULT_INVESTOR_PASSWORD)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_investor_logins_converts_only_no_login_accounts(session):
|
||||||
|
from ten31portal.cli import enable_investor_logins # imported here: cli pulls in argparse setup
|
||||||
|
|
||||||
|
no_login = make_user(session, username="dormant", role=UserRole.investor,
|
||||||
|
login_enabled=False, name="Dormant LP")
|
||||||
|
has_login = make_user(session, username="active-lp", role=UserRole.investor,
|
||||||
|
password="theirownpw", name="Active LP")
|
||||||
|
linked = make_user(session, username="linked", role=UserRole.investor,
|
||||||
|
login_enabled=False, primary_account_id=has_login.id, name="Linked Name")
|
||||||
|
old_active_hash = has_login.password_hash
|
||||||
|
|
||||||
|
# Run the conversion against the test engine (CLI normally uses the real one).
|
||||||
|
import ten31portal.cli as cli_mod
|
||||||
|
orig_engine, orig_migrate = cli_mod.engine, cli_mod.run_migrations
|
||||||
|
cli_mod.engine = session.get_bind()
|
||||||
|
cli_mod.run_migrations = lambda: None
|
||||||
|
try:
|
||||||
|
enable_investor_logins(None)
|
||||||
|
finally:
|
||||||
|
cli_mod.engine, cli_mod.run_migrations = orig_engine, orig_migrate
|
||||||
|
|
||||||
|
session.refresh(no_login)
|
||||||
|
session.refresh(has_login)
|
||||||
|
session.refresh(linked)
|
||||||
|
assert no_login.login_enabled is True
|
||||||
|
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, no_login.password_hash)
|
||||||
|
# Working logins and linked secondary names are untouched.
|
||||||
|
assert has_login.password_hash == old_active_hash
|
||||||
|
assert linked.login_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_documents_new_badge(client, session):
|
||||||
|
entity = Entity(name="Badge Fund", type=EntityType.fund)
|
||||||
|
session.add(entity)
|
||||||
|
session.commit()
|
||||||
|
lp = make_user(session, username="lp", role=UserRole.investor, password="password123")
|
||||||
|
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
|
||||||
|
|
||||||
|
old_doc = Document(
|
||||||
|
entity_id=entity.id, category="statement", title="Old statement",
|
||||||
|
original_filename="old.pdf", content_type="application/pdf", size_bytes=1,
|
||||||
|
storage_path="x-old", created_at=datetime.utcnow() - timedelta(days=30),
|
||||||
|
)
|
||||||
|
session.add(old_doc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert _login(client, "lp", "password123").status_code == 200
|
||||||
|
|
||||||
|
# First ever visit: nothing badged (no watermark yet), watermark gets set.
|
||||||
|
docs = client.get("/api/documents").json()
|
||||||
|
assert [d["is_new"] for d in docs] == [False]
|
||||||
|
|
||||||
|
# A doc uploaded after that visit is badged next time; pretend the visit was yesterday.
|
||||||
|
session.refresh(lp)
|
||||||
|
lp.docs_seen_at = datetime.utcnow() - timedelta(days=1)
|
||||||
|
session.add(lp)
|
||||||
|
new_doc = Document(
|
||||||
|
entity_id=entity.id, category="k1", title="Fresh K-1",
|
||||||
|
original_filename="k1.pdf", content_type="application/pdf", size_bytes=1,
|
||||||
|
storage_path="x-new",
|
||||||
|
)
|
||||||
|
session.add(new_doc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
docs = client.get("/api/documents").json()
|
||||||
|
flags = {d["title"]: d["is_new"] for d in docs}
|
||||||
|
assert flags == {"Fresh K-1": True, "Old statement": False}
|
||||||
|
|
||||||
|
# Within the same visit (watermark just advanced) the badge computation stays stable.
|
||||||
|
docs = client.get("/api/documents").json()
|
||||||
|
assert all(d["is_new"] is False for d in docs) # watermark now newer than both docs
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.2.26",
|
"version": "0.2.32",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||||
|
|||||||
@@ -414,12 +414,97 @@ const resetHoldingsAction = Action.withInput(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Reset Fund Partners
|
||||||
|
// ============================================
|
||||||
|
const resetPartnersInputSpec = InputSpec.of({
|
||||||
|
name: Value.text({
|
||||||
|
name: 'Fund Name',
|
||||||
|
description: 'Exact name of the fund whose partners to clear (see List Funds)',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Low Time Preference Fund III, LP',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPartnersAction = Action.withInput(
|
||||||
|
'reset-partners',
|
||||||
|
{
|
||||||
|
name: 'Reset Fund Partners',
|
||||||
|
description:
|
||||||
|
"Remove every partner from a fund — deletes its investor capital-account statements and their access grants to it. Use to undo a wrong members import (e.g. another fund's roster loaded into this one). Investor accounts themselves are kept, and holdings/NAV are not affected (use Reset Fund Holdings for those).",
|
||||||
|
warning:
|
||||||
|
"This permanently deletes this fund's capital-account statements and removes investors' access to it. Investor accounts are kept. Re-import the correct roster afterward to repopulate.",
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
resetPartnersInputSpec,
|
||||||
|
async () => ({ name: '' }),
|
||||||
|
async ({ input, effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['reset-partners', '--name', input.name], 'reset-partners-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to clear partners')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Partners Cleared',
|
||||||
|
message: result.stdout?.toString() || `Cleared partners from ${input.name}.`,
|
||||||
|
result: null,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to clear partners: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action: Enable Investor Logins
|
||||||
|
// ============================================
|
||||||
|
const enableInvestorLoginsAction = Action.withoutInput(
|
||||||
|
'enable-investor-logins',
|
||||||
|
{
|
||||||
|
name: 'Enable Investor Logins',
|
||||||
|
description:
|
||||||
|
'Give every investor account that has no login yet the default password (Ten31Portal) and enable sign-in. Accounts that can already sign in are not touched; investors change their own password in the portal.',
|
||||||
|
warning: 'Every converted account gets the same well-known default password until the investor changes it.',
|
||||||
|
allowedStatuses: 'only-running',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
},
|
||||||
|
async ({ effects }) => {
|
||||||
|
try {
|
||||||
|
const result = await runCli(effects, ['enable-investor-logins'], 'enable-investor-logins-task')
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return errorResult(result.stderr?.toString() || 'Failed to enable investor logins')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: '1' as const,
|
||||||
|
title: 'Investor Logins Enabled',
|
||||||
|
message: 'Send each investor their username; they sign in with the default password and change it.',
|
||||||
|
result: {
|
||||||
|
type: 'single' as const,
|
||||||
|
value: result.stdout?.toString() || 'Nothing to do.',
|
||||||
|
copyable: true,
|
||||||
|
qr: false,
|
||||||
|
masked: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResult(`Failed to enable investor logins: ${e.message || e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export const actions = sdk.Actions.of()
|
export const actions = sdk.Actions.of()
|
||||||
.addAction(createUserAction)
|
.addAction(createUserAction)
|
||||||
.addAction(resetPasswordAction)
|
.addAction(resetPasswordAction)
|
||||||
.addAction(showAdminPasswordAction)
|
.addAction(showAdminPasswordAction)
|
||||||
.addAction(listUsersAction)
|
.addAction(listUsersAction)
|
||||||
|
.addAction(enableInvestorLoginsAction)
|
||||||
.addAction(deleteUserAction)
|
.addAction(deleteUserAction)
|
||||||
.addAction(dedupeAction)
|
.addAction(dedupeAction)
|
||||||
.addAction(listFundsAction)
|
.addAction(listFundsAction)
|
||||||
.addAction(resetHoldingsAction)
|
.addAction(resetHoldingsAction)
|
||||||
|
.addAction(resetPartnersAction)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { v_0_2_26 as current } from './v_0_2_26'
|
export { v_0_2_32 as current } from './v_0_2_32'
|
||||||
import { v_0_1_0 } from './v_0_1_0'
|
import { v_0_1_0 } from './v_0_1_0'
|
||||||
import { v_0_2_0 } from './v_0_2_0'
|
import { v_0_2_0 } from './v_0_2_0'
|
||||||
import { v_0_2_1 } from './v_0_2_1'
|
import { v_0_2_1 } from './v_0_2_1'
|
||||||
@@ -25,4 +25,10 @@ import { v_0_2_22 } from './v_0_2_22'
|
|||||||
import { v_0_2_23 } from './v_0_2_23'
|
import { v_0_2_23 } from './v_0_2_23'
|
||||||
import { v_0_2_24 } from './v_0_2_24'
|
import { v_0_2_24 } from './v_0_2_24'
|
||||||
import { v_0_2_25 } from './v_0_2_25'
|
import { v_0_2_25 } from './v_0_2_25'
|
||||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25]
|
import { v_0_2_26 } from './v_0_2_26'
|
||||||
|
import { v_0_2_27 } from './v_0_2_27'
|
||||||
|
import { v_0_2_28 } from './v_0_2_28'
|
||||||
|
import { v_0_2_29 } from './v_0_2_29'
|
||||||
|
import { v_0_2_30 } from './v_0_2_30'
|
||||||
|
import { v_0_2_31 } from './v_0_2_31'
|
||||||
|
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_27 = VersionInfo.of({
|
||||||
|
version: '0.2.27:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Entity overview now shows a full Valuation history — every quarter on record with its NAV, status, and signed date — instead of just the latest quarter. Investor portal now shows, per fund/SPV, gain/loss (amount and % vs paid-in) on the capital account and the % of commitment distributed.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_28 = VersionInfo.of({
|
||||||
|
version: '0.2.28:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"Investor gain/loss now measures total value (current NAV + distributions received) against paid-in capital, so an LP who has taken distributions no longer shows a false loss.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_29 = VersionInfo.of({
|
||||||
|
version: '0.2.29:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"New 'Reset Fund Partners' service action (and a 'Clear all partners' button on a fund's Partners tab) removes all of a fund's capital-account statements and investor access grants — for undoing a wrong members import, e.g. one fund's roster loaded into another. Investor accounts and holdings/NAV are left intact. Reset Fund Holdings still only clears holdings; this covers the partner side.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_30 = VersionInfo.of({
|
||||||
|
version: '0.2.30:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"Investor portal: the capital block now reads 'Current Capital Balance' and shows only the percentage gain (green) or loss (red) beneath it — the redundant dollar 'gain/loss vs paid-in' line was removed.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_31 = VersionInfo.of({
|
||||||
|
version: '0.2.31:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"Entities view: the 'GP Entities and Management Companies' section is now 'Management Entities' and gains a new 'Carry Vehicle' entity type (with both Partners and Assets tabs) for carry vehicles like Ten31 EP LLC. Investor capital chart: the Distributions line only appears once distributions have actually been made.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_32 = VersionInfo.of({
|
||||||
|
version: '0.2.32:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
"Investor portal polish: a portfolio summary card totals commitment, paid-in, distributions and current balance across funds; gain/loss is labeled 'net of paid-in'; headline figures show whole dollars; documents group by year with a 'New' badge since the investor's last visit. Ten31 brand palette (navy/mint from the logo) replaces the orange accents. New members from the eNAV import now start with the default password 'Ten31Portal' (login enabled), and a new 'Enable Investor Logins' action converts existing no-login accounts. Password changes now require at least 8 characters. Login page shows a Portal@ten31.xyz contact line.",
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#16243A" />
|
<meta name="theme-color" content="#0C1F33" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"background_color": "#16243A",
|
"background_color": "#0C1F33",
|
||||||
"theme_color": "#16243A",
|
"theme_color": "#0C1F33",
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||||
// - /api/* is never cached
|
// - /api/* is never cached
|
||||||
// Bump CACHE on each release so old entries are purged.
|
// Bump CACHE on each release so old entries are purged.
|
||||||
const CACHE = 'ten31-portal-0.2.26'
|
const CACHE = 'ten31-portal-0.2.32'
|
||||||
|
|
||||||
self.addEventListener('install', () => self.skipWaiting())
|
self.addEventListener('install', () => self.skipWaiting())
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function OfflineNotice({ onRetry }: { onRetry: () => void }) {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+7
-1
@@ -12,7 +12,7 @@ export type UserRole =
|
|||||||
| "viewer"
|
| "viewer"
|
||||||
| "investor"
|
| "investor"
|
||||||
| "fund_administrator";
|
| "fund_administrator";
|
||||||
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co";
|
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co" | "carry";
|
||||||
export type EntityStatus = "active" | "closed";
|
export type EntityStatus = "active" | "closed";
|
||||||
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
|
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
|
||||||
export type DocumentCategory =
|
export type DocumentCategory =
|
||||||
@@ -70,6 +70,7 @@ export interface PortalDocument {
|
|||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
uploaded_by: number | null;
|
uploaded_by: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
is_new?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Partner {
|
export interface Partner {
|
||||||
@@ -307,6 +308,11 @@ export const api = {
|
|||||||
}[]>("/api/entities/rollup"),
|
}[]>("/api/entities/rollup"),
|
||||||
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
|
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
|
||||||
listPartners: (entityId: number) => request<Partner[]>(`/api/entities/${entityId}/partners`),
|
listPartners: (entityId: number) => request<Partner[]>(`/api/entities/${entityId}/partners`),
|
||||||
|
clearPartners: (entityId: number) =>
|
||||||
|
request<{ statements: number; access_grants: number }>(
|
||||||
|
`/api/entities/${entityId}/partners`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
createEntity: (data: Partial<Entity>) =>
|
createEntity: (data: Partial<Entity>) =>
|
||||||
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
|
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
|
||||||
updateEntity: (id: number, data: Partial<Entity>) =>
|
updateEntity: (id: number, data: Partial<Entity>) =>
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
|||||||
|
|
||||||
if (data.length < 2) return null;
|
if (data.length < 2) return null;
|
||||||
|
|
||||||
|
// Only plot the Distributions series once the LP has actually received a distribution;
|
||||||
|
// otherwise it's a flat line pinned at zero that just clutters the chart.
|
||||||
|
const hasDistributions = data.some((d) => d.distributions > 0);
|
||||||
|
const series = hasDistributions ? SERIES : SERIES.filter((s) => s.key !== "distributions");
|
||||||
|
|
||||||
const W = 640;
|
const W = 640;
|
||||||
const H = 240;
|
const H = 240;
|
||||||
const padL = 64;
|
const padL = 64;
|
||||||
@@ -51,7 +56,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
|||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{SERIES.map((s) => (
|
{series.map((s) => (
|
||||||
<polyline
|
<polyline
|
||||||
key={s.key}
|
key={s.key}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -61,7 +66,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{SERIES.map((s) =>
|
{series.map((s) =>
|
||||||
data.map((d, i) => (
|
data.map((d, i) => (
|
||||||
<circle key={`${s.key}-${i}`} cx={x(i)} cy={y(d[s.key])} r={2.5} fill={s.color}>
|
<circle key={`${s.key}-${i}`} cx={x(i)} cy={y(d[s.key])} r={2.5} fill={s.color}>
|
||||||
<title>{`${formatQuarter(d.date)} · ${s.label}: ${formatMoney(d[s.key])}`}</title>
|
<title>{`${formatQuarter(d.date)} · ${s.label}: ${formatMoney(d[s.key])}`}</title>
|
||||||
@@ -77,7 +82,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 mt-2 pl-2">
|
<div className="flex flex-wrap gap-4 mt-2 pl-2">
|
||||||
{SERIES.map((s) => (
|
{series.map((s) => (
|
||||||
<span key={s.key} className="flex items-center gap-1.5 text-xs text-gray-500">
|
<span key={s.key} className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
<span className="inline-block w-3 h-0.5" style={{ backgroundColor: s.color }} />
|
<span className="inline-block w-3 h-0.5" style={{ backgroundColor: s.color }} />
|
||||||
{s.label}
|
{s.label}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
|
|||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setError("");
|
setError("");
|
||||||
if (next.length < 4) return setError("New password must be at least 4 characters.");
|
if (next.length < 8) return setError("New password must be at least 8 characters.");
|
||||||
if (next !== confirm) return setError("New passwords don't match.");
|
if (next !== confirm) return setError("New passwords don't match.");
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -34,7 +34,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-green-700">Your password has been updated.</p>
|
<p className="text-sm text-green-700">Your password has been updated.</p>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={onClose} className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800">
|
<button onClick={onClose} className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800">
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-700 mb-1">New password</label>
|
<label className="block text-sm text-gray-700 mb-1">New password</label>
|
||||||
<PasswordInput value={next} onChange={setNext} placeholder="minimum 4 characters" />
|
<PasswordInput value={next} onChange={setNext} placeholder="minimum 8 characters" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-700 mb-1">Confirm new password</label>
|
<label className="block text-sm text-gray-700 mb-1">Confirm new password</label>
|
||||||
@@ -61,7 +61,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
|
|||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={busy || !current || !next}
|
disabled={busy || !current || !next}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? "Saving…" : "Update password"}
|
{busy ? "Saving…" : "Update password"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
spv: "SPV",
|
spv: "SPV",
|
||||||
gp: "GP",
|
gp: "GP",
|
||||||
mgmt_co: "Mgmt Co",
|
mgmt_co: "Mgmt Co",
|
||||||
|
carry: "Carry Vehicle",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS: { key: string; label: string; path: (id: number) => string }[] = [
|
const TABS: { key: string; label: string; path: (id: number) => string }[] = [
|
||||||
@@ -15,8 +16,8 @@ const TABS: { key: string; label: string; path: (id: number) => string }[] = [
|
|||||||
{ key: "documents", label: "Documents", path: (id) => `/entities/${id}/documents` },
|
{ key: "documents", label: "Documents", path: (id) => `/entities/${id}/documents` },
|
||||||
];
|
];
|
||||||
|
|
||||||
// GP entities and management companies hold interests in the funds they manage; that's their
|
// GP entities, management companies, and carry vehicles hold interests in the funds they manage;
|
||||||
// "Assets" tab. Funds/SPVs use the Investments tab instead.
|
// that's their "Assets" tab. Funds/SPVs use the Investments tab instead.
|
||||||
const ASSETS_TAB = {
|
const ASSETS_TAB = {
|
||||||
key: "assets",
|
key: "assets",
|
||||||
label: "Assets",
|
label: "Assets",
|
||||||
@@ -25,7 +26,9 @@ const ASSETS_TAB = {
|
|||||||
|
|
||||||
export default function EntityHeader({ entity, active }: { entity: Entity; active: string }) {
|
export default function EntityHeader({ entity, active }: { entity: Entity; active: string }) {
|
||||||
const tabs =
|
const tabs =
|
||||||
entity.type === "gp" || entity.type === "mgmt_co" ? [...TABS, ASSETS_TAB] : TABS;
|
entity.type === "gp" || entity.type === "mgmt_co" || entity.type === "carry"
|
||||||
|
? [...TABS, ASSETS_TAB]
|
||||||
|
: TABS;
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
@@ -39,7 +42,7 @@ export default function EntityHeader({ entity, active }: { entity: Entity; activ
|
|||||||
active === t.key ? (
|
active === t.key ? (
|
||||||
<span
|
<span
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600"
|
className="pb-2 border-b-2 border-accent-500 text-sm font-medium text-accent-600"
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
onClick={() => setNavOpen(false)}
|
onClick={() => setNavOpen(false)}
|
||||||
className={`block px-4 py-2 text-sm ${
|
className={`block px-4 py-2 text-sm ${
|
||||||
active
|
active
|
||||||
? "bg-orange-50 text-orange-600 border-r-2 border-orange-500 font-medium"
|
? "bg-accent-50 text-accent-600 border-r-2 border-accent-500 font-medium"
|
||||||
: "text-gray-700 hover:bg-gray-50"
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function PasswordInput({
|
|||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const base =
|
const base =
|
||||||
"w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
"w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export function formatMoneyExact(cents: number | null | undefined): string {
|
|||||||
return `$${dollars.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return `$${dollars.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format cents as whole dollars for headline figures — the ".00" is noise at that size. */
|
||||||
|
export function formatMoneyWhole(cents: number | null | undefined): string {
|
||||||
|
if (cents == null) return "—";
|
||||||
|
return `$${Math.round(cents / 100).toLocaleString("en-US")}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Format date string to short form: Jun 15, 2021 */
|
/** Format date string to short form: Jun 15, 2021 */
|
||||||
export function formatDate(dateStr: string | null | undefined): string {
|
export function formatDate(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
|
|||||||
@@ -1 +1,22 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ten31 brand palette, sampled from the logo (frontend/public/ten31-logo.png):
|
||||||
|
* navy #0C1F33 (tile) · cream #F4EFE3 (frame/serifs) · mint #79CDB6 (arrow).
|
||||||
|
* brand-* = the navy, used for primary buttons and the portfolio card.
|
||||||
|
* accent-* = the arrow's green, darkened for readable links/focus on white.
|
||||||
|
*/
|
||||||
|
@theme {
|
||||||
|
--color-brand-300: #8ea4b8; /* muted text on navy */
|
||||||
|
--color-brand-800: #16324f; /* button hover */
|
||||||
|
--color-brand-900: #0c1f33; /* logo navy */
|
||||||
|
|
||||||
|
--color-cream: #f4efe3;
|
||||||
|
|
||||||
|
--color-accent-50: #edf7f3; /* active-nav tint */
|
||||||
|
--color-accent-100: #d9efe6;
|
||||||
|
--color-accent-300: #79cdb6; /* the arrow itself — on dark only */
|
||||||
|
--color-accent-500: #2fa184; /* focus rings, sort arrows */
|
||||||
|
--color-accent-600: #0e7a5f; /* links */
|
||||||
|
--color-accent-700: #0b6249; /* link hover */
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export default function AccessGrid() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={`w-full h-10 flex items-center justify-center transition-colors ${
|
className={`w-full h-10 flex items-center justify-center transition-colors ${
|
||||||
on
|
on
|
||||||
? "bg-orange-50 text-orange-600 hover:bg-orange-100"
|
? "bg-accent-50 text-accent-600 hover:bg-accent-100"
|
||||||
: "text-gray-300 hover:bg-gray-100"
|
: "text-gray-300 hover:bg-gray-100"
|
||||||
} ${loading ? "opacity-50" : ""}`}
|
} ${loading ? "opacity-50" : ""}`}
|
||||||
title={on ? "Click to revoke" : "Click to grant"}
|
title={on ? "Click to revoke" : "Click to grant"}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ function EntryForm({
|
|||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy || entityId === "" || investorId === "" || !asOf}
|
disabled={busy || entityId === "" || investorId === "" || !asOf}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? "Saving…" : "Save statement"}
|
{busy ? "Saving…" : "Save statement"}
|
||||||
</button>
|
</button>
|
||||||
@@ -219,4 +219,4 @@ function DollarField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function Documents() {
|
|||||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||||
<a
|
<a
|
||||||
href={api.downloadUrl(d.id)}
|
href={api.downloadUrl(d.id)}
|
||||||
className="text-orange-600 hover:text-orange-700 mr-3"
|
className="text-accent-600 hover:text-accent-700 mr-3"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
@@ -242,7 +242,7 @@ function UploadForm({
|
|||||||
{targetLabel ? (
|
{targetLabel ? (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Uploading to <span className="font-medium text-gray-700">{entityName}</span> ·{" "}
|
Uploading to <span className="font-medium text-gray-700">{entityName}</span> ·{" "}
|
||||||
<span className={investorId === "" ? "text-gray-700" : "text-orange-600 font-medium"}>
|
<span className={investorId === "" ? "text-gray-700" : "text-accent-600 font-medium"}>
|
||||||
{targetLabel}
|
{targetLabel}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -252,7 +252,7 @@ function UploadForm({
|
|||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy || entityId === "" || !file}
|
disabled={busy || entityId === "" || !file}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? "Uploading…" : "Upload"}
|
{busy ? "Uploading…" : "Upload"}
|
||||||
</button>
|
</button>
|
||||||
@@ -262,4 +262,4 @@ function UploadForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
spv: "SPV",
|
spv: "SPV",
|
||||||
gp: "GP",
|
gp: "GP",
|
||||||
mgmt_co: "Mgmt Co",
|
mgmt_co: "Mgmt Co",
|
||||||
|
carry: "Carry Vehicle",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EntitiesList() {
|
export default function EntitiesList() {
|
||||||
@@ -62,7 +63,9 @@ export default function EntitiesList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const funds = entities.filter((e) => e.type === "fund" || e.type === "spv");
|
const funds = entities.filter((e) => e.type === "fund" || e.type === "spv");
|
||||||
const gps = entities.filter((e) => e.type === "gp" || e.type === "mgmt_co");
|
const gps = entities.filter(
|
||||||
|
(e) => e.type === "gp" || e.type === "mgmt_co" || e.type === "carry",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -71,7 +74,7 @@ export default function EntitiesList() {
|
|||||||
{isWriter && (
|
{isWriter && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||||
>
|
>
|
||||||
Add Entity
|
Add Entity
|
||||||
</button>
|
</button>
|
||||||
@@ -89,7 +92,7 @@ export default function EntitiesList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<EntityTable title="Funds and SPVs" rows={funds} />
|
<EntityTable title="Funds and SPVs" rows={funds} />
|
||||||
<EntityTable title="GP Entities and Management Companies" rows={gps} />
|
<EntityTable title="Management Entities" rows={gps} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,7 +158,7 @@ function AddEntityForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||||
placeholder="Low Time Preference Fund I, LLC"
|
placeholder="Low Time Preference Fund I, LLC"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -171,6 +174,7 @@ function AddEntityForm({
|
|||||||
<option value="spv">SPV</option>
|
<option value="spv">SPV</option>
|
||||||
<option value="gp">GP</option>
|
<option value="gp">GP</option>
|
||||||
<option value="mgmt_co">Mgmt Co</option>
|
<option value="mgmt_co">Mgmt Co</option>
|
||||||
|
<option value="carry">Carry Vehicle</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -202,7 +206,7 @@ function AddEntityForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "Creating..." : "Create"}
|
{saving ? "Creating..." : "Create"}
|
||||||
</button>
|
</button>
|
||||||
@@ -261,7 +265,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
|
|||||||
return (
|
return (
|
||||||
<th className={`px-4 py-3 font-medium text-gray-600 select-none ${a}`}>
|
<th className={`px-4 py-3 font-medium text-gray-600 select-none ${a}`}>
|
||||||
<button onClick={() => sortBy(k)} className={`w-full hover:text-gray-900 ${a}`}>
|
<button onClick={() => sortBy(k)} className={`w-full hover:text-gray-900 ${a}`}>
|
||||||
{label}<span className="text-orange-500">{arrow(k)}</span>
|
{label}<span className="text-accent-500">{arrow(k)}</span>
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
@@ -345,7 +349,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link
|
<Link
|
||||||
to={`/entities/${row.id}`}
|
to={`/entities/${row.id}`}
|
||||||
className="text-gray-900 font-medium hover:text-orange-600"
|
className="text-gray-900 font-medium hover:text-accent-600"
|
||||||
>
|
>
|
||||||
{row.name}
|
{row.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function EntityAssets() {
|
|||||||
<p>This entity isn't linked to an investor account yet, so there are no balances to show.</p>
|
<p>This entity isn't linked to an investor account yet, so there are no balances to show.</p>
|
||||||
<p className="mt-2 text-gray-500">
|
<p className="mt-2 text-gray-500">
|
||||||
Link it on the{" "}
|
Link it on the{" "}
|
||||||
<Link to={`/entities/${entityId}`} className="text-orange-600 hover:text-orange-700">
|
<Link to={`/entities/${entityId}`} className="text-accent-600 hover:text-accent-700">
|
||||||
Overview tab → Edit entity
|
Overview tab → Edit entity
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
(choose its investor account). Its capital-account balance in each fund will then
|
(choose its investor account). Its capital-account balance in each fund will then
|
||||||
@@ -110,7 +110,7 @@ export default function EntityAssets() {
|
|||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<tr key={r.fundId} className="border-t border-gray-100">
|
<tr key={r.fundId} className="border-t border-gray-100">
|
||||||
<td className="px-4 py-2 text-gray-900">
|
<td className="px-4 py-2 text-gray-900">
|
||||||
<Link to={`/entities/${r.fundId}`} className="hover:text-orange-600">
|
<Link to={`/entities/${r.fundId}`} className="hover:text-accent-600">
|
||||||
{fundName(r.fundId)}
|
{fundName(r.fundId)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function EntityDocuments() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
|
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
|
||||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700 mr-3">
|
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700 mr-3">
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function EntityOverview() {
|
|||||||
const [totalInvested, setTotalInvested] = useState(0);
|
const [totalInvested, setTotalInvested] = useState(0);
|
||||||
const [lastValue, setLastValue] = useState(0);
|
const [lastValue, setLastValue] = useState(0);
|
||||||
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
|
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
|
||||||
|
const [rounds, setRounds] = useState<ValuationRound[]>([]);
|
||||||
const [assetBalances, setAssetBalances] = useState<AssetBalances | null>(null);
|
const [assetBalances, setAssetBalances] = useState<AssetBalances | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -49,7 +50,10 @@ export default function EntityOverview() {
|
|||||||
const ent = await api.getEntity(entityId);
|
const ent = await api.getEntity(entityId);
|
||||||
setEntity(ent);
|
setEntity(ent);
|
||||||
|
|
||||||
if ((ent.type === "gp" || ent.type === "mgmt_co") && ent.linked_user_id != null) {
|
if (
|
||||||
|
(ent.type === "gp" || ent.type === "mgmt_co" || ent.type === "carry") &&
|
||||||
|
ent.linked_user_id != null
|
||||||
|
) {
|
||||||
api.entityAssetBalances(entityId).then(setAssetBalances).catch(() => {});
|
api.entityAssetBalances(entityId).then(setAssetBalances).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +70,9 @@ export default function EntityOverview() {
|
|||||||
setTotalInvested(invested);
|
setTotalInvested(invested);
|
||||||
|
|
||||||
const rounds = await api.listRounds(entityId);
|
const rounds = await api.listRounds(entityId);
|
||||||
|
// Newest quarter first, so the history table reads top-down like a statement run.
|
||||||
|
const sorted = [...rounds].sort((a, b) => b.quarter_end.localeCompare(a.quarter_end));
|
||||||
|
setRounds(sorted);
|
||||||
const approved = rounds.filter((r) => r.status === "approved");
|
const approved = rounds.filter((r) => r.status === "approved");
|
||||||
if (approved.length > 0) {
|
if (approved.length > 0) {
|
||||||
setLatestRound(approved[0]);
|
setLatestRound(approved[0]);
|
||||||
@@ -141,7 +148,7 @@ export default function EntityOverview() {
|
|||||||
{assetBalances.linked_name ? ` · ${assetBalances.linked_name}` : ""}
|
{assetBalances.linked_name ? ` · ${assetBalances.linked_name}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to={`/entities/${entity.id}/assets`} className="text-sm text-orange-600 hover:text-orange-700">
|
<Link to={`/entities/${entity.id}/assets`} className="text-sm text-accent-600 hover:text-accent-700">
|
||||||
View by fund →
|
View by fund →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,6 +172,47 @@ export default function EntityOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Valuation history — every quarter uploaded/signed for this entity */}
|
||||||
|
{rounds.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto mt-6">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">Valuation history</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{rounds.length} quarter{rounds.length === 1 ? "" : "s"} on record
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Quarter</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">NAV</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Signed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rounds.map((r) => {
|
||||||
|
const nav = r.valuations.reduce((s, v) => s + v.value_cents, 0);
|
||||||
|
return (
|
||||||
|
<tr key={r.id} className="border-t border-gray-100">
|
||||||
|
<td className="px-4 py-2 text-gray-900">{formatQuarter(r.quarter_end)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900 font-medium">{formatMoney(nav)}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={`inline-block px-2 py-0.5 text-xs rounded ${STATUS_COLORS[r.status]}`}>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">
|
||||||
|
{r.status === "approved" && r.approved_at ? formatDate(r.approved_at) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,9 +239,9 @@ function EditEntityForm({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||||
|
|
||||||
// A GP / management company can be linked to its own investor account, so its Assets view
|
// A GP / management company / carry vehicle can be linked to its own investor account, so its
|
||||||
// shows its real per-fund balances.
|
// Assets view shows its real per-fund balances.
|
||||||
const isGp = type === "gp" || type === "mgmt_co";
|
const isGp = type === "gp" || type === "mgmt_co" || type === "carry";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isGp) return;
|
if (!isGp) return;
|
||||||
api.listUsers().then((us) => setInvestors(us.filter((u) => u.role === "investor"))).catch(() => {});
|
api.listUsers().then((us) => setInvestors(us.filter((u) => u.role === "investor"))).catch(() => {});
|
||||||
@@ -243,6 +291,7 @@ function EditEntityForm({
|
|||||||
<option value="spv">SPV</option>
|
<option value="spv">SPV</option>
|
||||||
<option value="gp">GP</option>
|
<option value="gp">GP</option>
|
||||||
<option value="mgmt_co">Mgmt Co</option>
|
<option value="mgmt_co">Mgmt Co</option>
|
||||||
|
<option value="carry">Carry Vehicle</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -282,7 +331,7 @@ function EditEntityForm({
|
|||||||
)}
|
)}
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
<button type="submit" disabled={saving} className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50">
|
||||||
{saving ? "Saving…" : "Save"}
|
{saving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
|
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { api, type Entity, type Partner } from "../api";
|
import { api, canEditRound, type Entity, type Partner } from "../api";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { formatDate, formatMoneyExact } from "../format";
|
import { formatDate, formatMoneyExact } from "../format";
|
||||||
import EntityHeader from "../components/EntityHeader";
|
import EntityHeader from "../components/EntityHeader";
|
||||||
|
|
||||||
export default function EntityPartners() {
|
export default function EntityPartners() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { user } = useAuth();
|
||||||
const [entity, setEntity] = useState<Entity | null>(null);
|
const [entity, setEntity] = useState<Entity | null>(null);
|
||||||
const [partners, setPartners] = useState<Partner[]>([]);
|
const [partners, setPartners] = useState<Partner[]>([]);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const isWriter = !!user && canEditRound(user.role);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const eid = parseInt(id);
|
const eid = parseInt(id);
|
||||||
@@ -23,6 +29,34 @@ export default function EntityPartners() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
async function clearAllPartners() {
|
||||||
|
if (!entity) return;
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Remove ALL ${partners.length} partner(s) from ${entity.name}?\n\n` +
|
||||||
|
"This deletes this fund's capital-account statements and the investors' access to " +
|
||||||
|
"it. The investor accounts themselves are kept (they may belong to other funds), " +
|
||||||
|
"and holdings/NAV are not affected. Re-import the correct roster afterward.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setClearing(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const res = await api.clearPartners(entity.id);
|
||||||
|
const fresh = await api.listPartners(entity.id);
|
||||||
|
setPartners(fresh);
|
||||||
|
setNotice(
|
||||||
|
`Cleared: removed ${res.statements} capital statement(s) and ${res.access_grants} access grant(s).`,
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to clear partners");
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
||||||
|
|
||||||
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
|
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
|
||||||
@@ -33,16 +67,28 @@ export default function EntityPartners() {
|
|||||||
<EntityHeader entity={entity} active="partners" />
|
<EntityHeader entity={entity} active="partners" />
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
{notice && <p className="text-sm text-green-600 mb-3">{notice}</p>}
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
|
{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-700">
|
<div className="flex items-center gap-4">
|
||||||
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
|
<p className="text-sm text-gray-700">
|
||||||
<span className="mx-2 text-gray-300">·</span>
|
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
|
||||||
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
|
<span className="mx-2 text-gray-300">·</span>
|
||||||
</p>
|
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
|
||||||
|
</p>
|
||||||
|
{isWriter && partners.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearAllPartners}
|
||||||
|
disabled={clearing}
|
||||||
|
className="px-3 py-1.5 border border-red-300 text-red-600 text-sm rounded hover:bg-red-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{clearing ? "Clearing…" : "Clear all partners"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export default function Import() {
|
|||||||
<li>{result.members} members ({result.created} newly created)</li>
|
<li>{result.members} members ({result.created} newly created)</li>
|
||||||
{result.note && <li className="text-amber-700">{result.note}</li>}
|
{result.note && <li className="text-amber-700">{result.note}</li>}
|
||||||
</ul>
|
</ul>
|
||||||
<button onClick={() => setResult(null)} className="mt-3 px-3 py-1.5 bg-gray-900 text-white rounded hover:bg-gray-800">
|
<button onClick={() => setResult(null)} className="mt-3 px-3 py-1.5 bg-brand-900 text-white rounded hover:bg-brand-800">
|
||||||
Import another
|
Import another
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,11 +240,11 @@ export default function Import() {
|
|||||||
<label className="block text-sm text-gray-700 mb-2">Fund</label>
|
<label className="block text-sm text-gray-700 mb-2">Fund</label>
|
||||||
<div className="flex gap-4 mb-2">
|
<div className="flex gap-4 mb-2">
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||||
<input type="radio" checked={entityMode === "existing"} onChange={() => { setEntityMode("existing"); reset(); }} className="text-orange-500" />
|
<input type="radio" checked={entityMode === "existing"} onChange={() => { setEntityMode("existing"); reset(); }} className="text-accent-500" />
|
||||||
Use existing fund
|
Use existing fund
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||||
<input type="radio" checked={entityMode === "from_file"} onChange={() => { setEntityMode("from_file"); reset(); }} className="text-orange-500" />
|
<input type="radio" checked={entityMode === "from_file"} onChange={() => { setEntityMode("from_file"); reset(); }} className="text-accent-500" />
|
||||||
Create from file
|
Create from file
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,6 +260,7 @@ export default function Import() {
|
|||||||
<option value="spv">SPV</option>
|
<option value="spv">SPV</option>
|
||||||
<option value="gp">GP</option>
|
<option value="gp">GP</option>
|
||||||
<option value="mgmt_co">Mgmt Co</option>
|
<option value="mgmt_co">Mgmt Co</option>
|
||||||
|
<option value="carry">Carry Vehicle</option>
|
||||||
</select>
|
</select>
|
||||||
<input className={inputCls} placeholder="Vintage year" value={vintageYear} onChange={(e) => setVintageYear(e.target.value)} />
|
<input className={inputCls} placeholder="Vintage year" value={vintageYear} onChange={(e) => setVintageYear(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +279,7 @@ export default function Import() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={readEnav} disabled={busy || !file} className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
<button onClick={readEnav} disabled={busy || !file} className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50">
|
||||||
{busy ? "Reading…" : "Read eNAV"}
|
{busy ? "Reading…" : "Read eNAV"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,7 +374,7 @@ export default function Import() {
|
|||||||
{decisions.filter((d) => d.action === "match").length} matched ·{" "}
|
{decisions.filter((d) => d.action === "match").length} matched ·{" "}
|
||||||
{decisions.filter((d) => d.action === "skip").length} skipped
|
{decisions.filter((d) => d.action === "skip").length} skipped
|
||||||
</span>
|
</span>
|
||||||
<button onClick={handleConfirmClick} disabled={busy || !asOf} className="px-4 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
<button onClick={handleConfirmClick} disabled={busy || !asOf} className="px-4 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50">
|
||||||
{busy ? (step || "Importing…") : "Confirm import"}
|
{busy ? (step || "Importing…") : "Confirm import"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,7 +492,7 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
|
|||||||
<button
|
<button
|
||||||
onClick={run}
|
onClick={run}
|
||||||
disabled={busy || !entityId || files.length === 0}
|
disabled={busy || !entityId || files.length === 0}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? "Loading history…" : "Load history"}
|
{busy ? "Loading history…" : "Load history"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function Login() {
|
|||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
value={handle}
|
value={handle}
|
||||||
onChange={(e) => setHandle(e.target.value)}
|
onChange={(e) => setHandle(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,11 +49,17 @@ export default function Login() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? "Signing in..." : "Sign in"}
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p className="mt-5 text-xs text-gray-400 text-center">
|
||||||
|
Trouble signing in?{" "}
|
||||||
|
<a href="mailto:Portal@ten31.xyz" className="text-accent-600 hover:text-accent-700">
|
||||||
|
Contact Portal@ten31.xyz
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function Users() {
|
|||||||
<h2 className="text-xl font-semibold text-gray-900">Users</h2>
|
<h2 className="text-xl font-semibold text-gray-900">Users</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCreating(true)}
|
onClick={() => setCreating(true)}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||||
>
|
>
|
||||||
New user
|
New user
|
||||||
</button>
|
</button>
|
||||||
@@ -94,7 +94,7 @@ export default function Users() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
api.getUser(u.id).then(setEditing).catch((e) => setError(e.message))
|
api.getUser(u.id).then(setEditing).catch((e) => setError(e.message))
|
||||||
}
|
}
|
||||||
className="text-orange-600 hover:text-orange-700"
|
className="text-accent-600 hover:text-accent-700"
|
||||||
>
|
>
|
||||||
Manage
|
Manage
|
||||||
</button>
|
</button>
|
||||||
@@ -254,7 +254,7 @@ function CreateUserModal({
|
|||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy || !name || !username || !password}
|
disabled={busy || !name || !username || !password}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
@@ -387,7 +387,7 @@ function EditUserModal({
|
|||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -398,7 +398,7 @@ function EditUserModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export default function ValuationWorkflow() {
|
|||||||
<Link to={`/entities/${entity.id}/investments`} className="pb-2 text-sm text-gray-500 hover:text-gray-800">
|
<Link to={`/entities/${entity.id}/investments`} className="pb-2 text-sm text-gray-500 hover:text-gray-800">
|
||||||
Investments
|
Investments
|
||||||
</Link>
|
</Link>
|
||||||
<span className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600">
|
<span className="pb-2 border-b-2 border-accent-500 text-sm font-medium text-accent-600">
|
||||||
Valuation
|
Valuation
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +214,7 @@ export default function ValuationWorkflow() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateRound}
|
onClick={handleCreateRound}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
@@ -228,7 +228,7 @@ export default function ValuationWorkflow() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => selectRound(r)}
|
onClick={() => selectRound(r)}
|
||||||
className={`w-full text-left px-3 py-2 rounded text-sm ${
|
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"
|
selectedRound?.id === r.id ? "bg-accent-50 text-accent-700" : "hover:bg-gray-50 text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{formatQuarter(r.quarter_end)}</div>
|
<div className="font-medium">{formatQuarter(r.quarter_end)}</div>
|
||||||
@@ -271,7 +271,7 @@ export default function ValuationWorkflow() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||||
>
|
>
|
||||||
Submit for Review
|
Submit for Review
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function EntityDocs({ entity }: { entity: Entity }) {
|
|||||||
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||||
<span className="ml-2 text-xs text-gray-400">· {investorName(d.investor_user_id)}</span>
|
<span className="ml-2 text-xs text-gray-400">· {investorName(d.investor_user_id)}</span>
|
||||||
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
|
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -167,7 +167,7 @@ function UploadForm({
|
|||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy || !file}
|
disabled={busy || !file}
|
||||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? "Uploading…" : "Upload"}
|
{busy ? "Uploading…" : "Upload"}
|
||||||
</button>
|
</button>
|
||||||
@@ -177,4 +177,4 @@ function UploadForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
|
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
|
||||||
import { categoryLabel, formatDate, formatMoneyExact } from "../format";
|
import { categoryLabel, formatDate, formatMoneyExact, formatMoneyWhole } from "../format";
|
||||||
import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
|
import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
|
||||||
|
|
||||||
|
const pct = (p: number) => `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(1)}%`;
|
||||||
|
|
||||||
// The investor-facing portal, rendered purely from data. Used by the investor's own home
|
// The investor-facing portal, rendered purely from data. Used by the investor's own home
|
||||||
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
|
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
|
||||||
export default function InvestorPortalView({
|
export default function InvestorPortalView({
|
||||||
@@ -27,6 +29,7 @@ export default function InvestorPortalView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
{entities.length > 1 && <PortfolioSummary accounts={accounts} count={entities.length} />}
|
||||||
{entities.map((e) => (
|
{entities.map((e) => (
|
||||||
<FundSection
|
<FundSection
|
||||||
key={e.id}
|
key={e.id}
|
||||||
@@ -40,6 +43,67 @@ export default function InvestorPortalView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One quiet navy card answering the multi-fund LP's first question: the total across Ten31.
|
||||||
|
function PortfolioSummary({ accounts, count }: { accounts: CapitalAccount[]; count: number }) {
|
||||||
|
// Latest statement per (fund, legal name); summed across all of them.
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const latest = new Map<string, CapitalAccount>();
|
||||||
|
for (const a of accounts) {
|
||||||
|
const key = `${a.entity_id}:${a.investor_user_id}`;
|
||||||
|
const cur = latest.get(key);
|
||||||
|
if (!cur || a.as_of_date > cur.as_of_date) latest.set(key, a);
|
||||||
|
}
|
||||||
|
let commitment = 0, paidIn = 0, distributions = 0, balance = 0;
|
||||||
|
for (const a of latest.values()) {
|
||||||
|
commitment += a.commitment_cents;
|
||||||
|
paidIn += a.contributions_cents;
|
||||||
|
distributions += a.distributions_cents;
|
||||||
|
balance += a.ending_balance_cents;
|
||||||
|
}
|
||||||
|
return { commitment, paidIn, distributions, balance };
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
if (totals.commitment === 0 && totals.balance === 0) return null;
|
||||||
|
|
||||||
|
const gainLossPct =
|
||||||
|
totals.paidIn > 0
|
||||||
|
? ((totals.balance + totals.distributions - totals.paidIn) / totals.paidIn) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-brand-900 rounded-lg p-5 text-cream">
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wide text-brand-300">
|
||||||
|
Across your {count} funds
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-brand-300 uppercase">Commitment</p>
|
||||||
|
<p className="text-lg font-medium">{formatMoneyWhole(totals.commitment)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-brand-300 uppercase">Paid-in</p>
|
||||||
|
<p className="text-lg font-medium">{formatMoneyWhole(totals.paidIn)}</p>
|
||||||
|
</div>
|
||||||
|
{totals.distributions > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-brand-300 uppercase">Distributions</p>
|
||||||
|
<p className="text-lg font-medium">{formatMoneyWhole(totals.distributions)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-brand-300 uppercase">Current Capital Balance</p>
|
||||||
|
<p className="text-lg font-semibold">{formatMoneyWhole(totals.balance)}</p>
|
||||||
|
{gainLossPct != null && (
|
||||||
|
<p className={`text-xs mt-0.5 ${gainLossPct >= 0 ? "text-accent-300" : "text-red-300"}`}>
|
||||||
|
{pct(gainLossPct)} <span className="text-brand-300">net of paid-in</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FundSection({
|
function FundSection({
|
||||||
entity,
|
entity,
|
||||||
accounts,
|
accounts,
|
||||||
@@ -81,29 +145,64 @@ function FundSection({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-5">
|
<DocumentList docs={docs} />
|
||||||
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Documents</h3>
|
|
||||||
{docs.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-400">No documents available.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
|
||||||
{docs.map((d) => (
|
|
||||||
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
|
||||||
<span className="text-gray-900">{d.title}</span>
|
|
||||||
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
|
||||||
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
|
||||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DocumentList({ docs }: { docs: PortalDocument[] }) {
|
||||||
|
// A handful of docs stays a flat list; once tax seasons pile up, group them by year.
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (docs.length <= 6) return [{ year: null as number | null, docs }];
|
||||||
|
const byYear = new Map<number, PortalDocument[]>();
|
||||||
|
for (const d of docs) {
|
||||||
|
const y = new Date(d.created_at).getFullYear();
|
||||||
|
const g = byYear.get(y) ?? [];
|
||||||
|
g.push(d);
|
||||||
|
byYear.set(y, g);
|
||||||
|
}
|
||||||
|
return [...byYear.entries()]
|
||||||
|
.sort((a, b) => b[0] - a[0])
|
||||||
|
.map(([year, ds]) => ({ year: year as number | null, docs: ds }));
|
||||||
|
}, [docs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Documents</h3>
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No documents available.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g.year ?? "all"}>
|
||||||
|
{g.year != null && (
|
||||||
|
<p className="text-xs font-medium text-gray-400 mb-1">{g.year}</p>
|
||||||
|
)}
|
||||||
|
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
||||||
|
{g.docs.map((d) => (
|
||||||
|
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
||||||
|
<span className="text-gray-900">{d.title}</span>
|
||||||
|
{d.is_new && (
|
||||||
|
<span className="ml-2 text-[10px] font-medium uppercase text-accent-600 bg-accent-50 px-1.5 py-0.5 rounded">
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||||
|
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CapitalBlock({
|
function CapitalBlock({
|
||||||
accounts,
|
accounts,
|
||||||
label,
|
label,
|
||||||
@@ -115,6 +214,17 @@ function CapitalBlock({
|
|||||||
}) {
|
}) {
|
||||||
// accounts arrive newest-first; history is oldest-first for the chart/table.
|
// accounts arrive newest-first; history is oldest-first for the chart/table.
|
||||||
const latest = accounts[0];
|
const latest = accounts[0];
|
||||||
|
// Simple LP performance: gain/loss = total value (current NAV + distributions received)
|
||||||
|
// vs paid-in — so distributions taken don't read as a loss; and how much of the
|
||||||
|
// commitment has been distributed back.
|
||||||
|
const gainLoss =
|
||||||
|
latest.ending_balance_cents + latest.distributions_cents - latest.contributions_cents;
|
||||||
|
const gainLossPct =
|
||||||
|
latest.contributions_cents > 0 ? (gainLoss / latest.contributions_cents) * 100 : null;
|
||||||
|
const distributedPct =
|
||||||
|
latest.commitment_cents > 0
|
||||||
|
? (latest.distributions_cents / latest.commitment_cents) * 100
|
||||||
|
: null;
|
||||||
const history = useMemo(
|
const history = useMemo(
|
||||||
() => [...accounts].sort((a, b) => a.as_of_date.localeCompare(b.as_of_date)),
|
() => [...accounts].sort((a, b) => a.as_of_date.localeCompare(b.as_of_date)),
|
||||||
[accounts],
|
[accounts],
|
||||||
@@ -133,10 +243,14 @@ function CapitalBlock({
|
|||||||
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
|
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
|
||||||
<p className="text-sm text-gray-500 mt-1">As of {formatDate(latest.as_of_date)}</p>
|
<p className="text-sm text-gray-500 mt-1">As of {formatDate(latest.as_of_date)}</p>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
||||||
<Metric label="Commitment" value={formatMoneyExact(latest.commitment_cents)} />
|
<Metric label="Commitment" value={formatMoneyWhole(latest.commitment_cents)} />
|
||||||
<Metric label="Paid-in" value={formatMoneyExact(latest.contributions_cents)} />
|
<Metric label="Paid-in" value={formatMoneyWhole(latest.contributions_cents)} />
|
||||||
{latest.distributions_cents > 0 && (
|
{latest.distributions_cents > 0 && (
|
||||||
<Metric label="Distributions" value={formatMoneyExact(latest.distributions_cents)} />
|
<Metric
|
||||||
|
label="Distributions"
|
||||||
|
value={formatMoneyWhole(latest.distributions_cents)}
|
||||||
|
sub={distributedPct != null ? `${distributedPct.toFixed(1)}% of commitment` : undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{latest.distributions_cents > 0 && latest.contributions_cents > 0 && (
|
{latest.distributions_cents > 0 && latest.contributions_cents > 0 && (
|
||||||
<Metric
|
<Metric
|
||||||
@@ -145,10 +259,15 @@ function CapitalBlock({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 mt-4">Current capital value</p>
|
<p className="text-sm text-gray-500 mt-4">Current Capital Balance</p>
|
||||||
<p className="text-3xl font-semibold text-gray-900 mt-0.5">
|
<p className="text-3xl font-semibold text-gray-900 mt-0.5">
|
||||||
{formatMoneyExact(latest.ending_balance_cents)}
|
{formatMoneyWhole(latest.ending_balance_cents)}
|
||||||
</p>
|
</p>
|
||||||
|
{gainLossPct != null && (
|
||||||
|
<p className={`text-sm mt-1 ${gainLoss >= 0 ? "text-accent-600" : "text-red-600"}`}>
|
||||||
|
{pct(gainLossPct)} <span className="text-gray-400">net of paid-in</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{history.length > 1 && (
|
{history.length > 1 && (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
@@ -159,9 +278,9 @@ function CapitalBlock({
|
|||||||
className="flex items-center gap-1.5 text-xs font-medium text-gray-500 uppercase hover:text-gray-700"
|
className="flex items-center gap-1.5 text-xs font-medium text-gray-500 uppercase hover:text-gray-700"
|
||||||
>
|
>
|
||||||
<span className={`transition-transform ${showChart ? "rotate-90" : ""}`}>▸</span>
|
<span className={`transition-transform ${showChart ? "rotate-90" : ""}`}>▸</span>
|
||||||
Capital over time
|
History
|
||||||
<span className="normal-case font-normal text-gray-400">
|
<span className="normal-case font-normal text-gray-400">
|
||||||
({history.length} quarters{showChart ? "" : " — show"})
|
· {history.length} quarters
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{showChart && (
|
{showChart && (
|
||||||
@@ -195,11 +314,22 @@ function CapitalBlock({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: string }) {
|
function Metric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
color = "text-gray-900",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-400 uppercase">{label}</p>
|
<p className="text-xs text-gray-400 uppercase">{label}</p>
|
||||||
<p className="text-lg font-medium text-gray-900">{value}</p>
|
<p className={`text-lg font-medium ${color}`}>{value}</p>
|
||||||
|
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Bumped each release so the running build is visible in the UI.
|
// Bumped each release so the running build is visible in the UI.
|
||||||
// If the number shown in the app doesn't match the installed s9pk version,
|
// If the number shown in the app doesn't match the installed s9pk version,
|
||||||
// the new frontend isn't actually being served.
|
// the new frontend isn't actually being served.
|
||||||
export const APP_VERSION = "0.2.26";
|
export const APP_VERSION = "0.2.32";
|
||||||
|
|||||||
Reference in New Issue
Block a user