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:
Jonathan Kirkwood
2026-07-03 08:40:40 -05:00
co-authored by Claude Opus 4.8
parent 69f12b0519
commit 4215d4478f
47 changed files with 935 additions and 111 deletions
@@ -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')
+72
View File
@@ -87,6 +87,42 @@ def reset_password(args: argparse.Namespace) -> None:
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:
"""Print the randomly-generated initial admin password recorded on first boot."""
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.")
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:
"""Print every entity's id and name (so the exact name is known for reset-holdings)."""
run_migrations()
@@ -268,6 +327,11 @@ def main() -> None:
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")
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("--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()
if args.command == "create-user":
create_user(args)
@@ -291,6 +359,8 @@ def main() -> None:
reset_password(args)
elif args.command == "list-users":
list_users(args)
elif args.command == "enable-investor-logins":
enable_investor_logins(args)
elif args.command == "show-admin-password":
show_admin_password(args)
elif args.command == "delete-user":
@@ -301,6 +371,8 @@ def main() -> None:
list_funds(args)
elif args.command == "reset-holdings":
reset_holdings(args)
elif args.command == "reset-partners":
reset_partners(args)
else:
parser.print_help()
+5
View File
@@ -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).
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
# 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.
+4
View File
@@ -39,6 +39,7 @@ class EntityType(str, enum.Enum):
spv = "spv"
gp = "gp"
mgmt_co = "mgmt_co"
carry = "carry"
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)
# 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))
# 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)
+2 -2
View File
@@ -87,8 +87,8 @@ def change_password(
"""Let the signed-in user set their own password (after confirming the current one)."""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect.")
if len(body.new_password) < 4:
raise HTTPException(status_code=400, detail="New password must be at least 4 characters.")
if len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
user.password_hash = hash_password(body.new_password)
session.add(user)
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.
Commit matches existing members (by fund-admin investor ID, else name), creates new ones
(without a login unless a password is given), grants entity access, and loads each member's
capital-account statement (commitment, contributions, distributions, current value).
(on the shared default password unless one is given), grants entity access, and loads each
member's capital-account statement (commitment, contributions, distributions, current value).
"""
import io
import re
import secrets
from datetime import date, datetime
import openpyxl
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlmodel import Session, select
from ten31portal import storage
from ten31portal import config, storage
from ten31portal.audit import record_audit
from ten31portal.auth import hash_password, require_internal_admin
from ten31portal.database import get_session
@@ -38,6 +37,39 @@ MAX_ROWS = 400
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:
base = re.sub(r"[^a-z0-9]+", "", name.lower())
return base or "investor"
@@ -282,14 +314,16 @@ def commit_import(
raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.")
if inv.email and session.exec(select(User).where(User.email == inv.email)).first():
raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.")
pw = inv.password or secrets.token_urlsafe(32)
# 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(
name=inv.name,
username=inv.username,
email=inv.email or None,
password_hash=hash_password(pw),
role=UserRole.investor,
login_enabled=bool(inv.password),
login_enabled=True,
external_investor_id=inv.external_id,
)
session.add(user)
+21 -1
View File
@@ -1,5 +1,7 @@
"""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.responses import FileResponse
from sqlmodel import Session, select
@@ -58,7 +60,25 @@ def list_documents(
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]
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)
@@ -148,6 +148,26 @@ def list_partners(
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("")
def list_entities(
user: User = Depends(get_current_user),
+3 -1
View File
@@ -212,6 +212,8 @@ class DocumentResponse(BaseModel):
size_bytes: int
uploaded_by: int | None
created_at: datetime
# True for an investor when the doc arrived since their previous portal visit.
is_new: bool = False
# --- Capital account ---
@@ -310,7 +312,7 @@ class ImportCommitInvestor(BaseModel):
name: str | None = None # for action=create
username: str | None = None # for action=create
email: str | None = None
password: str | None = None # for action=create; omit to create without a login
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
+60
View File
@@ -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
+134
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "ten31portal-startos",
"version": "0.2.26",
"version": "0.2.32",
"private": true,
"scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+85
View File
@@ -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()
.addAction(createUserAction)
.addAction(resetPasswordAction)
.addAction(showAdminPasswordAction)
.addAction(listUsersAction)
.addAction(enableInvestorLoginsAction)
.addAction(deleteUserAction)
.addAction(dedupeAction)
.addAction(listFundsAction)
.addAction(resetHoldingsAction)
.addAction(resetPartnersAction)
+8 -2
View File
@@ -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_2_0 } from './v_0_2_0'
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_24 } from './v_0_2_24'
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
View File
@@ -6,7 +6,7 @@
<link rel="apple-touch-icon" href="/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#16243A" />
<meta name="theme-color" content="#0C1F33" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
+2 -2
View File
@@ -6,8 +6,8 @@
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#16243A",
"theme_color": "#16243A",
"background_color": "#0C1F33",
"theme_color": "#0C1F33",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached
// Bump CACHE on each release so old entries are purged.
const CACHE = 'ten31-portal-0.2.26'
const CACHE = 'ten31-portal-0.2.32'
self.addEventListener('install', () => self.skipWaiting())
+1 -1
View File
@@ -62,7 +62,7 @@ function OfflineNotice({ onRetry }: { onRetry: () => void }) {
</p>
<button
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
</button>
+7 -1
View File
@@ -12,7 +12,7 @@ export type UserRole =
| "viewer"
| "investor"
| "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 RoundStatus = "draft" | "submitted" | "approved" | "returned";
export type DocumentCategory =
@@ -70,6 +70,7 @@ export interface PortalDocument {
size_bytes: number;
uploaded_by: number | null;
created_at: string;
is_new?: boolean;
}
export interface Partner {
@@ -307,6 +308,11 @@ export const api = {
}[]>("/api/entities/rollup"),
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
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>) =>
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
updateEntity: (id: number, data: Partial<Entity>) =>
+8 -3
View File
@@ -23,6 +23,11 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
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 H = 240;
const padL = 64;
@@ -51,7 +56,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
</g>
))}
{SERIES.map((s) => (
{series.map((s) => (
<polyline
key={s.key}
fill="none"
@@ -61,7 +66,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
/>
))}
{SERIES.map((s) =>
{series.map((s) =>
data.map((d, i) => (
<circle key={`${s.key}-${i}`} cx={x(i)} cy={y(d[s.key])} r={2.5} fill={s.color}>
<title>{`${formatQuarter(d.date)} · ${s.label}: ${formatMoney(d[s.key])}`}</title>
@@ -77,7 +82,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
</svg>
<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 className="inline-block w-3 h-0.5" style={{ backgroundColor: s.color }} />
{s.label}
@@ -13,7 +13,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
const save = async () => {
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.");
setBusy(true);
try {
@@ -34,7 +34,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
<div className="space-y-4">
<p className="text-sm text-green-700">Your password has been updated.</p>
<div className="flex justify-end">
<button onClick={onClose} className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800">
<button onClick={onClose} className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800">
Done
</button>
</div>
@@ -47,7 +47,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
</div>
<div>
<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>
<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
onClick={save}
disabled={busy || !current || !next}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
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"}
</button>
+7 -4
View File
@@ -6,6 +6,7 @@ const TYPE_LABELS: Record<string, string> = {
spv: "SPV",
gp: "GP",
mgmt_co: "Mgmt Co",
carry: "Carry Vehicle",
};
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` },
];
// GP entities and management companies hold interests in the funds they manage; that's their
// "Assets" tab. Funds/SPVs use the Investments tab instead.
// GP entities, management companies, and carry vehicles hold interests in the funds they manage;
// that's their "Assets" tab. Funds/SPVs use the Investments tab instead.
const ASSETS_TAB = {
key: "assets",
label: "Assets",
@@ -25,7 +26,9 @@ const ASSETS_TAB = {
export default function EntityHeader({ entity, active }: { entity: Entity; active: string }) {
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 (
<div className="mb-6">
<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 ? (
<span
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}
</span>
+1 -1
View File
@@ -66,7 +66,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
onClick={() => setNavOpen(false)}
className={`block px-4 py-2 text-sm ${
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"
}`}
>
+1 -1
View File
@@ -16,7 +16,7 @@ export default function PasswordInput({
}) {
const [show, setShow] = useState(false);
const base =
"w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
"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 (
<div className="relative">
<input
+6
View File
@@ -29,6 +29,12 @@ export function formatMoneyExact(cents: number | null | undefined): string {
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 */
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
+21
View File
@@ -1 +1,22 @@
@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 */
}
+1 -1
View File
@@ -115,7 +115,7 @@ export default function AccessGrid() {
disabled={loading}
className={`w-full h-10 flex items-center justify-center transition-colors ${
on
? "bg-orange-50 text-orange-600 hover:bg-orange-100"
? "bg-accent-50 text-accent-600 hover:bg-accent-100"
: "text-gray-300 hover:bg-gray-100"
} ${loading ? "opacity-50" : ""}`}
title={on ? "Click to revoke" : "Click to grant"}
+2 -2
View File
@@ -186,7 +186,7 @@ function EntryForm({
<button
onClick={submit}
disabled={busy || entityId === "" || investorId === "" || !asOf}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
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"}
</button>
@@ -219,4 +219,4 @@ function DollarField({
}
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";
+4 -4
View File
@@ -87,7 +87,7 @@ export default function Documents() {
<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"
className="text-accent-600 hover:text-accent-700 mr-3"
>
Download
</a>
@@ -242,7 +242,7 @@ function UploadForm({
{targetLabel ? (
<p className="text-xs text-gray-500">
Uploading to <span className="font-medium text-gray-700">{entityName}</span> ·{" "}
<span className={investorId === "" ? "text-gray-700" : "text-orange-600 font-medium"}>
<span className={investorId === "" ? "text-gray-700" : "text-accent-600 font-medium"}>
{targetLabel}
</span>
</p>
@@ -252,7 +252,7 @@ function UploadForm({
<button
onClick={submit}
disabled={busy || entityId === "" || !file}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Uploading…" : "Upload"}
</button>
@@ -262,4 +262,4 @@ function UploadForm({
}
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";
+11 -7
View File
@@ -21,6 +21,7 @@ const TYPE_LABELS: Record<string, string> = {
spv: "SPV",
gp: "GP",
mgmt_co: "Mgmt Co",
carry: "Carry Vehicle",
};
export default function EntitiesList() {
@@ -62,7 +63,9 @@ export default function EntitiesList() {
}
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 (
<div className="space-y-8">
@@ -71,7 +74,7 @@ export default function EntitiesList() {
{isWriter && (
<button
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
</button>
@@ -89,7 +92,7 @@ export default function EntitiesList() {
)}
<EntityTable title="Funds and SPVs" rows={funds} />
<EntityTable title="GP Entities and Management Companies" rows={gps} />
<EntityTable title="Management Entities" rows={gps} />
</div>
);
}
@@ -155,7 +158,7 @@ function AddEntityForm({
type="text"
value={name}
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"
required
/>
@@ -171,6 +174,7 @@ function AddEntityForm({
<option value="spv">SPV</option>
<option value="gp">GP</option>
<option value="mgmt_co">Mgmt Co</option>
<option value="carry">Carry Vehicle</option>
</select>
</div>
<div className="flex gap-3">
@@ -202,7 +206,7 @@ function AddEntityForm({
<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"
className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{saving ? "Creating..." : "Create"}
</button>
@@ -261,7 +265,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
return (
<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}`}>
{label}<span className="text-orange-500">{arrow(k)}</span>
{label}<span className="text-accent-500">{arrow(k)}</span>
</button>
</th>
);
@@ -345,7 +349,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
<td className="px-4 py-3">
<Link
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}
</Link>
+2 -2
View File
@@ -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 className="mt-2 text-gray-500">
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
</Link>{" "}
(choose its investor account). Its capital-account balance in each fund will then
@@ -110,7 +110,7 @@ export default function EntityAssets() {
{rows.map((r) => (
<tr key={r.fundId} className="border-t border-gray-100">
<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)}
</Link>
</td>
+1 -1
View File
@@ -61,7 +61,7 @@ export default function EntityDocuments() {
</td>
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
<td className="px-4 py-2 text-right whitespace-nowrap">
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700 mr-3">
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700 mr-3">
Download
</a>
<button
+55 -6
View File
@@ -20,6 +20,7 @@ export default function EntityOverview() {
const [totalInvested, setTotalInvested] = useState(0);
const [lastValue, setLastValue] = useState(0);
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
const [rounds, setRounds] = useState<ValuationRound[]>([]);
const [assetBalances, setAssetBalances] = useState<AssetBalances | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
@@ -49,7 +50,10 @@ export default function EntityOverview() {
const ent = await api.getEntity(entityId);
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(() => {});
}
@@ -66,6 +70,9 @@ export default function EntityOverview() {
setTotalInvested(invested);
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");
if (approved.length > 0) {
setLatestRound(approved[0]);
@@ -141,7 +148,7 @@ export default function EntityOverview() {
{assetBalances.linked_name ? ` · ${assetBalances.linked_name}` : ""}
</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
</Link>
</div>
@@ -165,6 +172,47 @@ export default function EntityOverview() {
</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>
);
}
@@ -191,9 +239,9 @@ function EditEntityForm({
const [saving, setSaving] = useState(false);
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
// A GP / management company can be linked to its own investor account, so its Assets view
// shows its real per-fund balances.
const isGp = type === "gp" || type === "mgmt_co";
// A GP / management company / carry vehicle can be linked to its own investor account, so its
// Assets view shows its real per-fund balances.
const isGp = type === "gp" || type === "mgmt_co" || type === "carry";
useEffect(() => {
if (!isGp) return;
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="gp">GP</option>
<option value="mgmt_co">Mgmt Co</option>
<option value="carry">Carry Vehicle</option>
</select>
</div>
<div className="flex-1">
@@ -282,7 +331,7 @@ function EditEntityForm({
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2 pt-1">
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
<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"}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
+52 -6
View File
@@ -1,16 +1,22 @@
import { useEffect, useState } from "react";
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 EntityHeader from "../components/EntityHeader";
export default function EntityPartners() {
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
const [entity, setEntity] = useState<Entity | null>(null);
const [partners, setPartners] = useState<Partner[]>([]);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [clearing, setClearing] = useState(false);
const [loading, setLoading] = useState(true);
const isWriter = !!user && canEditRound(user.role);
useEffect(() => {
if (!id) return;
const eid = parseInt(id);
@@ -23,6 +29,34 @@ export default function EntityPartners() {
.finally(() => setLoading(false));
}, [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>;
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" />
{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">
<p className="text-sm text-gray-500">
{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
</p>
<p className="text-sm text-gray-700">
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
<span className="mx-2 text-gray-300">·</span>
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
</p>
<div className="flex items-center gap-4">
<p className="text-sm text-gray-700">
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
<span className="mx-2 text-gray-300">·</span>
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
</p>
{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 className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
+7 -6
View File
@@ -230,7 +230,7 @@ export default function Import() {
<li>{result.members} members ({result.created} newly created)</li>
{result.note && <li className="text-amber-700">{result.note}</li>}
</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
</button>
</div>
@@ -240,11 +240,11 @@ export default function Import() {
<label className="block text-sm text-gray-700 mb-2">Fund</label>
<div className="flex gap-4 mb-2">
<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
</label>
<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
</label>
</div>
@@ -260,6 +260,7 @@ export default function Import() {
<option value="spv">SPV</option>
<option value="gp">GP</option>
<option value="mgmt_co">Mgmt Co</option>
<option value="carry">Carry Vehicle</option>
</select>
<input className={inputCls} placeholder="Vintage year" value={vintageYear} onChange={(e) => setVintageYear(e.target.value)} />
</div>
@@ -278,7 +279,7 @@ export default function Import() {
</div>
<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"}
</button>
</div>
@@ -373,7 +374,7 @@ export default function Import() {
{decisions.filter((d) => d.action === "match").length} matched ·{" "}
{decisions.filter((d) => d.action === "skip").length} skipped
</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"}
</button>
</div>
@@ -491,7 +492,7 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
<button
onClick={run}
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"}
</button>
+8 -2
View File
@@ -37,7 +37,7 @@ export default function Login() {
autoComplete="username"
value={handle}
onChange={(e) => setHandle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
required
/>
</div>
@@ -49,11 +49,17 @@ export default function Login() {
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
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"}
</button>
</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>
);
+5 -5
View File
@@ -40,7 +40,7 @@ export default function Users() {
<h2 className="text-xl font-semibold text-gray-900">Users</h2>
<button
onClick={() => setCreating(true)}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
>
New user
</button>
@@ -94,7 +94,7 @@ export default function Users() {
onClick={() =>
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
</button>
@@ -254,7 +254,7 @@ function CreateUserModal({
<button
onClick={submit}
disabled={busy || !name || !username || !password}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
Create
</button>
@@ -387,7 +387,7 @@ function EditUserModal({
<button
onClick={save}
disabled={busy}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
Save
</button>
@@ -398,7 +398,7 @@ function EditUserModal({
}
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 }) {
return (
+4 -4
View File
@@ -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">
Investments
</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
</span>
</div>
@@ -214,7 +214,7 @@ export default function ValuationWorkflow() {
/>
<button
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
</button>
@@ -228,7 +228,7 @@ export default function ValuationWorkflow() {
<button
onClick={() => selectRound(r)}
className={`w-full text-left px-3 py-2 rounded text-sm ${
selectedRound?.id === r.id ? "bg-orange-50 text-orange-700" : "hover:bg-gray-50 text-gray-700"
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>
@@ -271,7 +271,7 @@ export default function ValuationWorkflow() {
</button>
<button
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
</button>
+3 -3
View File
@@ -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">· {investorName(d.investor_user_id)}</span>
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
Download
</a>
</li>
@@ -167,7 +167,7 @@ function UploadForm({
<button
onClick={submit}
disabled={busy || !file}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Uploading…" : "Upload"}
</button>
@@ -177,4 +177,4 @@ function UploadForm({
}
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";
+159 -29
View File
@@ -1,8 +1,10 @@
import { useMemo, useState } from "react";
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";
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
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
export default function InvestorPortalView({
@@ -27,6 +29,7 @@ export default function InvestorPortalView({
return (
<div className="space-y-8">
{entities.length > 1 && <PortfolioSummary accounts={accounts} count={entities.length} />}
{entities.map((e) => (
<FundSection
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({
entity,
accounts,
@@ -81,29 +145,64 @@ function FundSection({
))
)}
<div className="mt-5">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Documents</h3>
{docs.length === 0 ? (
<p className="text-sm text-gray-400">No documents available.</p>
) : (
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
{docs.map((d) => (
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
<span className="text-gray-900">{d.title}</span>
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
Download
</a>
</li>
))}
</ul>
)}
</div>
<DocumentList docs={docs} />
</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({
accounts,
label,
@@ -115,6 +214,17 @@ function CapitalBlock({
}) {
// accounts arrive newest-first; history is oldest-first for the chart/table.
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(
() => [...accounts].sort((a, b) => a.as_of_date.localeCompare(b.as_of_date)),
[accounts],
@@ -133,10 +243,14 @@ function CapitalBlock({
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
<p className="text-sm text-gray-500 mt-1">As of {formatDate(latest.as_of_date)}</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
<Metric label="Commitment" value={formatMoneyExact(latest.commitment_cents)} />
<Metric label="Paid-in" value={formatMoneyExact(latest.contributions_cents)} />
<Metric label="Commitment" value={formatMoneyWhole(latest.commitment_cents)} />
<Metric label="Paid-in" value={formatMoneyWhole(latest.contributions_cents)} />
{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 && (
<Metric
@@ -145,10 +259,15 @@ function CapitalBlock({
/>
)}
</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">
{formatMoneyExact(latest.ending_balance_cents)}
{formatMoneyWhole(latest.ending_balance_cents)}
</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 && (
<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"
>
<span className={`transition-transform ${showChart ? "rotate-90" : ""}`}></span>
Capital over time
History
<span className="normal-case font-normal text-gray-400">
({history.length} quarters{showChart ? "" : " — show"})
· {history.length} quarters
</span>
</button>
{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 (
<div>
<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>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI.
// If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served.
export const APP_VERSION = "0.2.26";
export const APP_VERSION = "0.2.32";