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
+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";