Release 0.2.22: capital chart, Investor View, GP stakes, doc folders
Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.
New features:
- Investor capital-over-time chart (value, paid-in, distributions per
quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
(GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
categorized correctly.
Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
This commit is contained in:
+9
-2
@@ -2,9 +2,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/ten31-logo.png" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<meta name="theme-color" content="#16243A" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ten31 Portal" />
|
||||
<title>Ten31 Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+277
-274
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Ten31 Portal",
|
||||
"short_name": "Ten31",
|
||||
"description": "Ten31 fund portal — entities, valuations, and investor capital accounts.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#16243A",
|
||||
"theme_color": "#16243A",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Ten31 Portal service worker. Deliberately conservative so it never serves a stale app:
|
||||
// - navigations are network-first (always get the latest index.html), cache only as offline fallback
|
||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||
// - /api/* is never cached
|
||||
// Bump CACHE on each release so old entries are purged.
|
||||
const CACHE = 'ten31-portal-0.2.22'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
await self.clients.claim()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request
|
||||
if (req.method !== 'GET') return
|
||||
const url = new URL(req.url)
|
||||
if (url.origin !== self.location.origin) return
|
||||
if (url.pathname.startsWith('/api/')) return
|
||||
|
||||
if (req.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const fresh = await fetch(req)
|
||||
const cache = await caches.open(CACHE)
|
||||
cache.put('/', fresh.clone())
|
||||
return fresh
|
||||
} catch {
|
||||
const cache = await caches.open(CACHE)
|
||||
return (await cache.match('/')) || Response.error()
|
||||
}
|
||||
})(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
const hit = await cache.match(req)
|
||||
if (hit) return hit
|
||||
const res = await fetch(req)
|
||||
if (res.ok) cache.put(req, res.clone())
|
||||
return res
|
||||
})(),
|
||||
)
|
||||
}
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -8,6 +8,7 @@ import EntityOverview from "./pages/EntityOverview";
|
||||
import EntityPartners from "./pages/EntityPartners";
|
||||
import EntityDocuments from "./pages/EntityDocuments";
|
||||
import Investments from "./pages/Investments";
|
||||
import EntityAssets from "./pages/EntityAssets";
|
||||
import ValuationWorkflow from "./pages/ValuationWorkflow";
|
||||
import Import from "./pages/Import";
|
||||
import AuditLog from "./pages/AuditLog";
|
||||
@@ -15,6 +16,7 @@ import Users from "./pages/Users";
|
||||
import Documents from "./pages/Documents";
|
||||
import CapitalAccounts from "./pages/CapitalAccounts";
|
||||
import AccessGrid from "./pages/AccessGrid";
|
||||
import InvestorView from "./pages/InvestorView";
|
||||
import PortalLayout from "./portal/PortalLayout";
|
||||
import InvestorHome from "./portal/InvestorHome";
|
||||
import FundAdminHome from "./portal/FundAdminHome";
|
||||
@@ -28,10 +30,12 @@ function InternalApp() {
|
||||
<Route path="/entities/:id/partners" element={<EntityPartners />} />
|
||||
<Route path="/entities/:id/documents" element={<EntityDocuments />} />
|
||||
<Route path="/entities/:id/investments" element={<Investments />} />
|
||||
<Route path="/entities/:id/assets" element={<EntityAssets />} />
|
||||
<Route path="/entities/:id/valuation" element={<ValuationWorkflow />} />
|
||||
<Route path="/import" element={<Import />} />
|
||||
<Route path="/audit" element={<AuditLog />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/investor-view" element={<InvestorView />} />
|
||||
<Route path="/access" element={<AccessGrid />} />
|
||||
<Route path="/documents" element={<Documents />} />
|
||||
<Route path="/capital-accounts" element={<CapitalAccounts />} />
|
||||
|
||||
@@ -205,6 +205,25 @@ export interface AuditEntry {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EntityStake {
|
||||
id: number;
|
||||
holder_entity_id: number;
|
||||
fund_entity_id: number;
|
||||
fund_name: string | null;
|
||||
fund_type: EntityType | null;
|
||||
ownership_pct: number | null;
|
||||
value_cents: number | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface InvestorView {
|
||||
user: User;
|
||||
entities: Entity[];
|
||||
capital_accounts: CapitalAccount[];
|
||||
documents: PortalDocument[];
|
||||
}
|
||||
|
||||
// --- API helpers ---
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -270,6 +289,18 @@ export const api = {
|
||||
updateEntity: (id: number, data: Partial<Entity>) =>
|
||||
request<Entity>(`/api/entities/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
|
||||
// Entity stakes (a GP/mgmt entity's interest in the funds it manages)
|
||||
listStakes: (entityId: number) => request<EntityStake[]>(`/api/entities/${entityId}/stakes`),
|
||||
createStake: (
|
||||
entityId: number,
|
||||
data: { fund_entity_id: number; ownership_pct?: number | null; value_dollars?: number | null; note?: string | null },
|
||||
) => request<EntityStake>(`/api/entities/${entityId}/stakes`, { method: "POST", body: JSON.stringify(data) }),
|
||||
deleteStake: (entityId: number, stakeId: number) =>
|
||||
request<{ status: string }>(`/api/entities/${entityId}/stakes/${stakeId}`, { method: "DELETE" }),
|
||||
|
||||
// Investor View (admin read-only reconstruction of an investor's portal)
|
||||
investorView: (userId: number) => request<InvestorView>(`/api/users/${userId}/investor-view`),
|
||||
|
||||
// Holdings
|
||||
listHoldings: (entityId: number) =>
|
||||
request<Holding[]>(`/api/entities/${entityId}/holdings`),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from "react";
|
||||
import { formatMoney, formatQuarter } from "../format";
|
||||
|
||||
export interface CapitalPoint {
|
||||
date: string; // as-of date (quarter end)
|
||||
value: number; // ending balance, cents
|
||||
paidIn: number; // cumulative contributions, cents
|
||||
distributions: number; // cumulative distributions, cents
|
||||
}
|
||||
|
||||
const SERIES = [
|
||||
{ key: "value" as const, label: "Capital value", color: "#111827" },
|
||||
{ key: "paidIn" as const, label: "Paid-in", color: "#2563eb" },
|
||||
{ key: "distributions" as const, label: "Distributions", color: "#16a34a" },
|
||||
];
|
||||
|
||||
// A compact, dependency-free multi-line SVG chart of an investor's capital over time.
|
||||
export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
||||
const data = useMemo(
|
||||
() => [...points].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
[points],
|
||||
);
|
||||
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const W = 640;
|
||||
const H = 240;
|
||||
const padL = 64;
|
||||
const padR = 16;
|
||||
const padT = 16;
|
||||
const padB = 32;
|
||||
const innerW = W - padL - padR;
|
||||
const innerH = H - padT - padB;
|
||||
|
||||
const maxVal = Math.max(1, ...data.flatMap((d) => [d.value, d.paidIn, d.distributions]));
|
||||
const x = (i: number) => padL + (data.length === 1 ? innerW / 2 : (innerW * i) / (data.length - 1));
|
||||
const y = (v: number) => padT + innerH - (innerH * v) / maxVal;
|
||||
|
||||
// 4 horizontal gridlines with dollar labels.
|
||||
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(maxVal * f));
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full min-w-[28rem]" role="img" aria-label="Capital over time">
|
||||
{ticks.map((t) => (
|
||||
<g key={t}>
|
||||
<line x1={padL} y1={y(t)} x2={W - padR} y2={y(t)} stroke="#f3f4f6" strokeWidth={1} />
|
||||
<text x={padL - 8} y={y(t) + 4} textAnchor="end" fontSize={11} fill="#9ca3af">
|
||||
{formatMoney(t)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{SERIES.map((s) => (
|
||||
<polyline
|
||||
key={s.key}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
points={data.map((d, i) => `${x(i)},${y(d[s.key])}`).join(" ")}
|
||||
/>
|
||||
))}
|
||||
|
||||
{SERIES.map((s) =>
|
||||
data.map((d, i) => (
|
||||
<circle key={`${s.key}-${i}`} cx={x(i)} cy={y(d[s.key])} r={2.5} fill={s.color}>
|
||||
<title>{`${formatQuarter(d.date)} · ${s.label}: ${formatMoney(d[s.key])}`}</title>
|
||||
</circle>
|
||||
)),
|
||||
)}
|
||||
|
||||
{data.map((d, i) => (
|
||||
<text key={`xl-${i}`} x={x(i)} y={H - 10} textAnchor="middle" fontSize={11} fill="#9ca3af">
|
||||
{formatQuarter(d.date)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-2 pl-2">
|
||||
{SERIES.map((s) => (
|
||||
<span key={s.key} className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="inline-block w-3 h-0.5" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import PasswordInput from "./PasswordInput";
|
||||
|
||||
/** Self-service password change for the signed-in user. */
|
||||
export default function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
setError("");
|
||||
if (next.length < 4) return setError("New password must be at least 4 characters.");
|
||||
if (next !== confirm) return setError("New passwords don't match.");
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.changePassword(current, next);
|
||||
setDone(true);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Could not change password");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-sm p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Change password</h3>
|
||||
{done ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-green-700">Your password has been updated.</p>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={onClose} className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Current password</label>
|
||||
<PasswordInput value={current} onChange={setCurrent} autoComplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">New password</label>
|
||||
<PasswordInput value={next} onChange={setNext} placeholder="minimum 4 characters" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Confirm new password</label>
|
||||
<PasswordInput value={confirm} onChange={setConfirm} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy || !current || !next}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Saving…" : "Update password"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Entity } from "../api";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
fund: "Fund",
|
||||
spv: "SPV",
|
||||
gp: "GP",
|
||||
mgmt_co: "Mgmt Co",
|
||||
};
|
||||
|
||||
const TABS: { key: string; label: string; path: (id: number) => string }[] = [
|
||||
{ key: "overview", label: "Overview", path: (id) => `/entities/${id}` },
|
||||
{ key: "investments", label: "Investments", path: (id) => `/entities/${id}/investments` },
|
||||
{ key: "partners", label: "Partners", path: (id) => `/entities/${id}/partners` },
|
||||
{ key: "documents", label: "Documents", path: (id) => `/entities/${id}/documents` },
|
||||
];
|
||||
|
||||
// GP entities and management companies hold interests in the funds they manage; that's their
|
||||
// "Assets" tab. Funds/SPVs use the Investments tab instead.
|
||||
const ASSETS_TAB = {
|
||||
key: "assets",
|
||||
label: "Assets",
|
||||
path: (id: number) => `/entities/${id}/assets`,
|
||||
};
|
||||
|
||||
export default function EntityHeader({ entity, active }: { entity: Entity; active: string }) {
|
||||
const tabs =
|
||||
entity.type === "gp" || entity.type === "mgmt_co" ? [...TABS, ASSETS_TAB] : TABS;
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">{entity.name}</h1>
|
||||
<span className="inline-block px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
{TYPE_LABELS[entity.type] || entity.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-6 mt-4 border-b border-gray-200">
|
||||
{tabs.map((t) =>
|
||||
active === t.key ? (
|
||||
<span
|
||||
key={t.key}
|
||||
className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600"
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
key={t.key}
|
||||
to={t.path(entity.id!)}
|
||||
className="pb-2 text-sm text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { isAdmin } from "../api";
|
||||
import { roleLabel } from "../format";
|
||||
import { APP_VERSION } from "../version";
|
||||
import ChangePasswordModal from "./ChangePasswordModal";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
const TOP_NAV = [
|
||||
{ label: "Entities", path: "/" },
|
||||
{ label: "Import", path: "/import" },
|
||||
{ label: "Audit Log", path: "/audit" },
|
||||
];
|
||||
|
||||
const ADMIN_NAV = [
|
||||
{ label: "Users", path: "/users" },
|
||||
{ label: "Investor View", path: "/investor-view" },
|
||||
{ label: "Access Grid", path: "/access" },
|
||||
{ label: "Documents", path: "/documents" },
|
||||
{ label: "Capital Accounts", path: "/capital-accounts" },
|
||||
];
|
||||
|
||||
const BOTTOM_NAV = [{ label: "Audit Log", path: "/audit" }];
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [changingPw, setChangingPw] = useState(false);
|
||||
const navItems = user && isAdmin(user.role)
|
||||
? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV]
|
||||
: [...TOP_NAV, ...BOTTOM_NAV];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Left nav */}
|
||||
<nav className="w-56 bg-white border-r border-gray-200 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h1 className="text-lg font-semibold text-gray-900">Ten31Portal</h1>
|
||||
{/* Dimmed backdrop behind the mobile drawer */}
|
||||
{navOpen && (
|
||||
<div className="fixed inset-0 z-30 bg-black/30 md:hidden" onClick={() => setNavOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Left nav — static on desktop, slide-over drawer on mobile */}
|
||||
<nav
|
||||
className={`${navOpen ? "flex" : "hidden"} md:flex fixed md:static inset-y-0 left-0 z-40 w-56 bg-white border-r border-gray-200 flex-col`}
|
||||
>
|
||||
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200">
|
||||
<img src="/ten31-logo.png" alt="" className="w-7 h-7 rounded-md" />
|
||||
<h1 className="text-lg font-semibold text-gray-900">Ten31 Portal</h1>
|
||||
<button
|
||||
onClick={() => setNavOpen(false)}
|
||||
className="ml-auto md:hidden text-gray-400 hover:text-gray-700"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul className="flex-1 py-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
<ul className="flex-1 py-2 overflow-auto">
|
||||
{navItems.map((item) => {
|
||||
const active = item.path === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname.startsWith(item.path);
|
||||
@@ -27,6 +63,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
to={item.path}
|
||||
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"
|
||||
@@ -39,16 +76,37 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="px-4 py-3 border-t border-gray-200 space-y-2">
|
||||
<button
|
||||
onClick={() => { setChangingPw(true); setNavOpen(false); }}
|
||||
className="block text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
<div className="text-xs text-gray-400">v{APP_VERSION}</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* Top bar */}
|
||||
<header className="h-14 bg-white border-b border-gray-200 flex items-center justify-end px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{user?.name}</span>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{user?.role}
|
||||
<header className="h-14 bg-white border-b border-gray-200 flex items-center gap-2 px-4 sm:px-6">
|
||||
<button
|
||||
onClick={() => setNavOpen(true)}
|
||||
className="md:hidden text-gray-600 hover:text-gray-900 -ml-1"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<img src="/ten31-logo.png" alt="" className="md:hidden w-7 h-7 rounded-md" />
|
||||
<span className="md:hidden text-base font-semibold text-gray-900">Ten31 Portal</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className="hidden sm:inline text-sm text-gray-600">{user?.name}</span>
|
||||
<span className="hidden sm:inline text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{user ? roleLabel(user.role) : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
@@ -60,8 +118,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
<main className="flex-1 overflow-auto p-4 sm:p-6 min-w-0">{children}</main>
|
||||
</div>
|
||||
|
||||
{changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/** A password field with a show/hide eye toggle. */
|
||||
export default function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className = "",
|
||||
autoComplete = "new-password",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
autoComplete?: string;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
const base =
|
||||
"w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? "text" : "password"}
|
||||
value={value}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`${base} ${className}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
aria-label={show ? "Hide password" : "Show password"}
|
||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
{show ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3l18 18" />
|
||||
<path d="M10.6 10.6a2 2 0 002.8 2.8" />
|
||||
<path d="M9.36 5.18A9.3 9.3 0 0112 5c5 0 9 4 10 7a12.4 12.4 0 01-2.6 3.6M6.1 6.1A12.5 12.5 0 002 12c1 3 5 7 10 7a9.3 9.3 0 003.6-.72" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,39 @@ export function formatDate(dateStr: string | null | undefined): string {
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
/** Human label for a document category. */
|
||||
export function categoryLabel(cat: string): string {
|
||||
const map: Record<string, string> = {
|
||||
capital_account: "Capital account",
|
||||
k1: "K-1",
|
||||
statement: "Statement",
|
||||
tax: "Tax",
|
||||
other: "Other",
|
||||
};
|
||||
return map[cat] ?? cat;
|
||||
}
|
||||
|
||||
/** Human label for a user role. */
|
||||
export function roleLabel(role: string): string {
|
||||
const map: Record<string, string> = {
|
||||
approver: "Managing Partner",
|
||||
operations: "Operations",
|
||||
cfo: "CFO",
|
||||
fund_admin: "Fund Admin",
|
||||
viewer: "Viewer (internal)",
|
||||
investor: "Investor",
|
||||
fund_administrator: "Fund administrator",
|
||||
};
|
||||
return map[role] ?? role;
|
||||
}
|
||||
|
||||
/** Bytes to human size. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Format quarter-end date as "Q2 2026" */
|
||||
export function formatQuarter(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
|
||||
@@ -8,3 +8,9 @@ createRoot(document.getElementById("root")!).render(
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type AccessMatrix } from "../api";
|
||||
import { roleLabel } from "../format";
|
||||
|
||||
export default function AccessGrid() {
|
||||
const [matrix, setMatrix] = useState<AccessMatrix | null>(null);
|
||||
const [granted, setGranted] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const key = (u: number, e: number) => `${u}:${e}`;
|
||||
|
||||
const load = () => {
|
||||
api
|
||||
.accessMatrix()
|
||||
.then((m) => {
|
||||
setMatrix(m);
|
||||
setGranted(new Set(m.grants.map((g) => key(g.user_id, g.entity_id))));
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
};
|
||||
useEffect(load, []);
|
||||
|
||||
const toggle = async (userId: number, entityId: number) => {
|
||||
const k = key(userId, entityId);
|
||||
const has = granted.has(k);
|
||||
setBusy((b) => new Set(b).add(k));
|
||||
setError("");
|
||||
// optimistic update
|
||||
setGranted((g) => {
|
||||
const n = new Set(g);
|
||||
has ? n.delete(k) : n.add(k);
|
||||
return n;
|
||||
});
|
||||
try {
|
||||
if (has) await api.revokeAccess(userId, entityId);
|
||||
else await api.grantAccess(userId, entityId);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to update access");
|
||||
// revert
|
||||
setGranted((g) => {
|
||||
const n = new Set(g);
|
||||
has ? n.add(k) : n.delete(k);
|
||||
return n;
|
||||
});
|
||||
} finally {
|
||||
setBusy((b) => {
|
||||
const n = new Set(b);
|
||||
n.delete(k);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!matrix) {
|
||||
return <p className="text-gray-500 text-sm">{error || "Loading…"}</p>;
|
||||
}
|
||||
|
||||
const { users, entities } = matrix;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-1">Access grid</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Who can see what. Click a cell to grant or revoke an account's access to a fund or SPV.
|
||||
</p>
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No investor or fund-administrator accounts yet.</p>
|
||||
) : entities.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No entities yet.</p>
|
||||
) : (
|
||||
<div className="overflow-auto border border-gray-200 rounded-lg bg-white">
|
||||
<table className="text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-gray-50 text-left px-4 py-2 font-medium text-gray-500 border-b border-gray-200 min-w-48">
|
||||
Account
|
||||
</th>
|
||||
{entities.map((e) => (
|
||||
<th
|
||||
key={e.id}
|
||||
className="px-3 py-2 font-medium text-gray-600 border-b border-l border-gray-100 whitespace-nowrap align-bottom"
|
||||
title={e.name}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{e.name}</span>
|
||||
<span className="text-[10px] text-gray-400 uppercase">{e.type}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50/50">
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 border-b border-gray-100">
|
||||
<div className="font-medium text-gray-900">{u.name}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{u.username} · {roleLabel(u.role)}
|
||||
</div>
|
||||
</td>
|
||||
{entities.map((e) => {
|
||||
const k = key(u.id, e.id);
|
||||
const on = granted.has(k);
|
||||
const loading = busy.has(k);
|
||||
return (
|
||||
<td
|
||||
key={e.id}
|
||||
className="text-center border-b border-l border-gray-100 p-0"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggle(u.id, e.id)}
|
||||
disabled={loading}
|
||||
className={`w-full h-10 flex items-center justify-center transition-colors ${
|
||||
on
|
||||
? "bg-orange-50 text-orange-600 hover:bg-orange-100"
|
||||
: "text-gray-300 hover:bg-gray-100"
|
||||
} ${loading ? "opacity-50" : ""}`}
|
||||
title={on ? "Click to revoke" : "Click to grant"}
|
||||
>
|
||||
{on ? "✓" : "·"}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type CapitalAccount, type Entity, type User } from "../api";
|
||||
import { formatDate, formatMoneyExact } from "../format";
|
||||
|
||||
export default function CapitalAccounts() {
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [rows, setRows] = useState<CapitalAccount[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const investors = useMemo(() => users.filter((u) => u.role === "investor"), [users]);
|
||||
const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||
const entityById = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]);
|
||||
|
||||
const load = () => {
|
||||
api.listCapitalAccounts().then(setRows).catch((e) => setError(e.message));
|
||||
};
|
||||
useEffect(() => {
|
||||
api.listEntities().then(setEntities).catch(() => {});
|
||||
api.listUsers().then(setUsers).catch(() => {});
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Capital accounts</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Enter each investor's figures from their latest capital account statement. Investors see
|
||||
only their own.
|
||||
</p>
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
|
||||
<EntryForm
|
||||
entities={entities}
|
||||
investors={investors}
|
||||
onSaved={load}
|
||||
setError={setError}
|
||||
/>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-700 mt-8 mb-2">Statements</h3>
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Investor</th>
|
||||
<th className="px-4 py-2 font-medium">Entity</th>
|
||||
<th className="px-4 py-2 font-medium">As of</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Contributions</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Distributions</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Ending balance</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">
|
||||
{userById.get(r.investor_user_id)?.name ?? r.investor_user_id}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600">{entityById.get(r.entity_id)?.name ?? r.entity_id}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{formatDate(r.as_of_date)}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-600">{formatMoneyExact(r.contributions_cents)}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-600">{formatMoneyExact(r.distributions_cents)}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900 font-medium">
|
||||
{formatMoneyExact(r.ending_balance_cents)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Delete this statement?"))
|
||||
api.deleteCapitalAccount(r.id).then(load).catch((e) => setError(e.message));
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-6 text-center text-gray-400">
|
||||
No statements yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryForm({
|
||||
entities,
|
||||
investors,
|
||||
onSaved,
|
||||
setError,
|
||||
}: {
|
||||
entities: Entity[];
|
||||
investors: User[];
|
||||
onSaved: () => void;
|
||||
setError: (s: string) => void;
|
||||
}) {
|
||||
const [entityId, setEntityId] = useState<number | "">("");
|
||||
const [investorId, setInvestorId] = useState<number | "">("");
|
||||
const [asOf, setAsOf] = useState("");
|
||||
const [beginning, setBeginning] = useState("");
|
||||
const [contributions, setContributions] = useState("");
|
||||
const [distributions, setDistributions] = useState("");
|
||||
const [ending, setEnding] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const num = (s: string) => (s.trim() === "" ? 0 : Number(s));
|
||||
|
||||
const submit = async () => {
|
||||
if (entityId === "" || investorId === "" || !asOf) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createCapitalAccount({
|
||||
entity_id: entityId,
|
||||
investor_user_id: investorId,
|
||||
as_of_date: asOf,
|
||||
beginning_balance_dollars: num(beginning),
|
||||
contributions_dollars: num(contributions),
|
||||
distributions_dollars: num(distributions),
|
||||
ending_balance_dollars: num(ending),
|
||||
});
|
||||
setBeginning("");
|
||||
setContributions("");
|
||||
setDistributions("");
|
||||
setEnding("");
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to save");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Add a statement</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Investor</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={investorId}
|
||||
onChange={(e) => setInvestorId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{investors.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Entity</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={entityId}
|
||||
onChange={(e) => setEntityId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{entities.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">As-of date</label>
|
||||
<input type="date" className={inputCls} value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
||||
</div>
|
||||
<DollarField label="Beginning balance" value={beginning} onChange={setBeginning} />
|
||||
<DollarField label="Contributions" value={contributions} onChange={setContributions} />
|
||||
<DollarField label="Distributions" value={distributions} onChange={setDistributions} />
|
||||
<DollarField label="Ending balance" value={ending} onChange={setEnding} />
|
||||
</div>
|
||||
<div className="flex justify-end mt-3">
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || entityId === "" || investorId === "" || !asOf}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Saving…" : "Save statement"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DollarField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{label} ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className={inputCls}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type DocumentCategory,
|
||||
type Entity,
|
||||
type PortalDocument,
|
||||
type User,
|
||||
} from "../api";
|
||||
import { categoryLabel, formatBytes, formatDate } from "../format";
|
||||
|
||||
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
|
||||
|
||||
export default function Documents() {
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||
const [filterEntity, setFilterEntity] = useState<number | "">("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||
const entityById = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]);
|
||||
|
||||
const loadDocs = () => {
|
||||
api
|
||||
.listDocuments(filterEntity === "" ? undefined : { entity_id: filterEntity })
|
||||
.then(setDocs)
|
||||
.catch((e) => setError(e.message));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.listEntities().then(setEntities).catch(() => {});
|
||||
api.listUsers().then(setUsers).catch(() => {});
|
||||
}, []);
|
||||
useEffect(loadDocs, [filterEntity]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Documents</h2>
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
|
||||
<UploadForm
|
||||
entities={entities}
|
||||
onUploaded={loadDocs}
|
||||
setError={setError}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mt-8 mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-700">All documents</h3>
|
||||
<select
|
||||
className="ml-auto px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
value={filterEntity}
|
||||
onChange={(e) => setFilterEntity(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">All entities</option>
|
||||
{entities.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Title</th>
|
||||
<th className="px-4 py-2 font-medium">Entity</th>
|
||||
<th className="px-4 py-2 font-medium">Category</th>
|
||||
<th className="px-4 py-2 font-medium">Visibility</th>
|
||||
<th className="px-4 py-2 font-medium">Uploaded</th>
|
||||
<th className="px-4 py-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((d) => (
|
||||
<tr key={d.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">{d.title}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{entityById.get(d.entity_id)?.name ?? d.entity_id}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{categoryLabel(d.category)}</td>
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{d.investor_user_id == null
|
||||
? "Shared (all investors)"
|
||||
: `Private · ${userById.get(d.investor_user_id)?.name ?? d.investor_user_id}`}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
|
||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||
<a
|
||||
href={api.downloadUrl(d.id)}
|
||||
className="text-orange-600 hover:text-orange-700 mr-3"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${d.title}"?`))
|
||||
api.deleteDocument(d.id).then(loadDocs).catch((e) => setError(e.message));
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{docs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-center text-gray-400">
|
||||
No documents.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadForm({
|
||||
entities,
|
||||
onUploaded,
|
||||
setError,
|
||||
}: {
|
||||
entities: Entity[];
|
||||
onUploaded: () => void;
|
||||
setError: (s: string) => void;
|
||||
}) {
|
||||
const [entityId, setEntityId] = useState<number | "">("");
|
||||
const [category, setCategory] = useState<DocumentCategory>("statement");
|
||||
const [investorId, setInvestorId] = useState<number | "">("");
|
||||
const [entityInvestors, setEntityInvestors] = useState<User[]>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const entityName = entities.find((e) => e.id === entityId)?.name ?? "";
|
||||
|
||||
// Load only THIS entity's investors, so you can't attach a doc to someone outside the fund.
|
||||
useEffect(() => {
|
||||
setInvestorId("");
|
||||
setEntityInvestors([]);
|
||||
if (entityId === "") return;
|
||||
api
|
||||
.investorsForEntity(Number(entityId))
|
||||
.then(setEntityInvestors)
|
||||
.catch(() => setEntityInvestors([]));
|
||||
}, [entityId]);
|
||||
|
||||
const targetLabel =
|
||||
entityId === ""
|
||||
? null
|
||||
: investorId === ""
|
||||
? `everyone in ${entityName}`
|
||||
: `${entityInvestors.find((u) => u.id === investorId)?.name ?? investorId} only`;
|
||||
|
||||
const submit = async () => {
|
||||
if (entityId === "" || !file) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("entity_id", String(entityId));
|
||||
form.set("category", category);
|
||||
if (title) form.set("title", title);
|
||||
if (investorId !== "") form.set("investor_user_id", String(investorId));
|
||||
form.set("file", file);
|
||||
await api.uploadDocument(form);
|
||||
setTitle("");
|
||||
setFile(null);
|
||||
setInvestorId("");
|
||||
onUploaded();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Upload a document</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Fund / entity</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={entityId}
|
||||
onChange={(e) => setEntityId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Select fund…</option>
|
||||
{entities.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Category</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as DocumentCategory)}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{categoryLabel(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
Folder {entityId !== "" && `(investors in ${entityName})`}
|
||||
</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={investorId}
|
||||
disabled={entityId === ""}
|
||||
onChange={(e) => setInvestorId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Whole fund — all investors</option>
|
||||
{entityInvestors.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}'s folder
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Title (optional)</label>
|
||||
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-500 mb-1">File</label>
|
||||
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} className="text-sm" />
|
||||
{file && <span className="text-xs text-gray-400 ml-2">{formatBytes(file.size)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
{targetLabel ? (
|
||||
<p className="text-xs text-gray-500">
|
||||
Uploading to <span className="font-medium text-gray-700">{entityName}</span> ·{" "}
|
||||
<span className={investorId === "" ? "text-gray-700" : "text-orange-600 font-medium"}>
|
||||
{targetLabel}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Choose a fund to begin.</span>
|
||||
)}
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || entityId === "" || !file}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type Entity, type EntityStake } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatMoney } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
|
||||
// A GP / management company's assets: its ownership interest in the funds it manages.
|
||||
export default function EntityAssets() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [stakes, setStakes] = useState<EntityStake[]>([]);
|
||||
const [allEntities, setAllEntities] = useState<Entity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const entityId = id ? parseInt(id) : 0;
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
|
||||
useEffect(() => {
|
||||
if (!entityId) return;
|
||||
load();
|
||||
}, [entityId]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ent, stk, all] = await Promise.all([
|
||||
api.getEntity(entityId),
|
||||
api.listStakes(entityId),
|
||||
api.listEntities(),
|
||||
]);
|
||||
setEntity(ent);
|
||||
setStakes(stk);
|
||||
setAllEntities(all);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Funds/SPVs available to add (exclude self and ones already staked).
|
||||
const candidates = useMemo(() => {
|
||||
const staked = new Set(stakes.map((s) => s.fund_entity_id));
|
||||
return allEntities.filter(
|
||||
(e) => e.id !== entityId && !staked.has(e.id) && (e.type === "fund" || e.type === "spv"),
|
||||
);
|
||||
}, [allEntities, stakes, entityId]);
|
||||
|
||||
const totalValue = stakes.reduce((s, k) => s + (k.value_cents ?? 0), 0);
|
||||
|
||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading...</div>;
|
||||
|
||||
async function remove(stakeId: number) {
|
||||
if (!confirm("Remove this stake?")) return;
|
||||
try {
|
||||
await api.deleteStake(entityId, stakeId);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to remove");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EntityHeader entity={entity} active="assets" />
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Fund interests</h2>
|
||||
{isWriter && candidates.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||
>
|
||||
Add stake
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<AddStakeForm
|
||||
entityId={entityId}
|
||||
candidates={candidates}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Fund / SPV</th>
|
||||
<th className="px-4 py-2 font-medium">Type</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Ownership</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Value</th>
|
||||
<th className="px-4 py-2 font-medium">Note</th>
|
||||
{isWriter && <th className="px-4 py-2" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stakes.map((s) => (
|
||||
<tr key={s.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">{s.fund_name ?? s.fund_entity_id}</td>
|
||||
<td className="px-4 py-2 text-gray-600 uppercase text-xs">{s.fund_type}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-700">
|
||||
{s.ownership_pct != null ? `${s.ownership_pct}%` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">
|
||||
{s.value_cents != null ? formatMoney(s.value_cents) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{s.note || "—"}</td>
|
||||
{isWriter && (
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button onClick={() => remove(s.id)} className="text-gray-400 hover:text-red-600">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{stakes.length > 0 && (
|
||||
<tr className="bg-gray-50 font-medium">
|
||||
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(totalValue)}</td>
|
||||
<td className="px-4 py-2" colSpan={isWriter ? 2 : 1} />
|
||||
</tr>
|
||||
)}
|
||||
{stakes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={isWriter ? 6 : 5} className="px-4 py-6 text-center text-gray-400">
|
||||
No fund interests recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddStakeForm({
|
||||
entityId,
|
||||
candidates,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
entityId: number;
|
||||
candidates: Entity[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [fundId, setFundId] = useState<number | "">("");
|
||||
const [pct, setPct] = useState("");
|
||||
const [valueDollars, setValueDollars] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (fundId === "") {
|
||||
setError("Choose a fund.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.createStake(entityId, {
|
||||
fund_entity_id: Number(fundId),
|
||||
ownership_pct: pct ? parseFloat(pct) : null,
|
||||
value_dollars: valueDollars ? parseFloat(valueDollars.replace(/[,$]/g, "")) : null,
|
||||
note: note.trim() || null,
|
||||
});
|
||||
onSaved();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to add stake");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4 max-w-lg">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Add a fund interest</h3>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Fund / SPV</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={fundId}
|
||||
onChange={(e) => setFundId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-1">Ownership % (optional)</label>
|
||||
<input className={inputCls} value={pct} onChange={(e) => setPct(e.target.value)} placeholder="20" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-1">Value $ (optional)</label>
|
||||
<input className={inputCls} value={valueDollars} onChange={(e) => setValueDollars(e.target.value)} placeholder="1,000,000" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Note (optional)</label>
|
||||
<input className={inputCls} value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
||||
{saving ? "Adding…" : "Add"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, type Entity, type PortalDocument, type User } from "../api";
|
||||
import { categoryLabel, formatDate } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
|
||||
export default function EntityDocuments() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||
|
||||
const loadDocs = (eid: number) =>
|
||||
api.listDocuments({ entity_id: eid }).then(setDocs).catch((e) => setError(e.message));
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const eid = parseInt(id);
|
||||
api.getEntity(eid).then(setEntity).catch((e) => setError(e.message));
|
||||
api.listUsers().then(setUsers).catch(() => {});
|
||||
loadDocs(eid).finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
||||
const eid = entity.id!;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EntityHeader entity={entity} active="documents" />
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
All documents for this fund. As admin you see everything; each investor only sees documents
|
||||
shared to the fund or addressed to them. Upload from the Documents admin screen.
|
||||
</p>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Title</th>
|
||||
<th className="px-4 py-2 font-medium">Category</th>
|
||||
<th className="px-4 py-2 font-medium">Visibility</th>
|
||||
<th className="px-4 py-2 font-medium">Uploaded</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((d) => (
|
||||
<tr key={d.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">{d.title}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{categoryLabel(d.category)}</td>
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{d.investor_user_id == null
|
||||
? "Shared (all investors)"
|
||||
: `Private · ${userById.get(d.investor_user_id)?.name ?? d.investor_user_id}`}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
|
||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700 mr-3">
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${d.title}"?`))
|
||||
api.deleteDocument(d.id).then(() => loadDocs(eid)).catch((e) => setError(e.message));
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{docs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-6 text-center text-gray-400">
|
||||
No documents for this fund yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, type Entity, type Holding, type Position, type ValuationRound } from "../api";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type Entity, type EntityType, type Holding, type ValuationRound } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatMoney, formatDate, formatQuarter } from "../format";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
fund: "Fund",
|
||||
spv: "SPV",
|
||||
gp: "GP",
|
||||
mgmt_co: "Mgmt Co",
|
||||
};
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: "bg-gray-100 text-gray-600",
|
||||
@@ -19,12 +14,16 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
|
||||
export default function EntityOverview() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [holdings, setHoldings] = useState<Holding[]>([]);
|
||||
const [totalInvested, setTotalInvested] = useState(0);
|
||||
const [lastValue, setLastValue] = useState(0);
|
||||
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -75,29 +74,29 @@ export default function EntityOverview() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">{entity.name}</h1>
|
||||
<span className="inline-block px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
{TYPE_LABELS[entity.type] || entity.type}
|
||||
</span>
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-6 mt-4 border-b border-gray-200">
|
||||
<span className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600">
|
||||
Overview
|
||||
</span>
|
||||
<Link
|
||||
to={`/entities/${entity.id}/investments`}
|
||||
className="pb-2 text-sm text-gray-500 hover:text-gray-800"
|
||||
<EntityHeader entity={entity} active="overview" />
|
||||
|
||||
{isWriter && (
|
||||
<div className="flex justify-end mb-3">
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="px-3 py-1.5 border border-gray-300 text-sm rounded hover:bg-gray-50"
|
||||
>
|
||||
Investments
|
||||
</Link>
|
||||
<span className="pb-2 text-sm text-gray-400 cursor-not-allowed">Partners</span>
|
||||
<span className="pb-2 text-sm text-gray-400 cursor-not-allowed">Documents</span>
|
||||
Edit entity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditEntityForm
|
||||
entity={entity}
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={(updated) => {
|
||||
setEntity(updated);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
|
||||
@@ -134,6 +133,103 @@ export default function EntityOverview() {
|
||||
);
|
||||
}
|
||||
|
||||
function EditEntityForm({
|
||||
entity,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
entity: Entity;
|
||||
onClose: () => void;
|
||||
onSaved: (updated: Entity) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(entity.name);
|
||||
const [type, setType] = useState<EntityType>(entity.type);
|
||||
const [status, setStatus] = useState(entity.status);
|
||||
const [vintageYear, setVintageYear] = useState(entity.vintage_year?.toString() ?? "");
|
||||
const [fundSizeDollars, setFundSizeDollars] = useState(
|
||||
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
|
||||
);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setError("Name is required.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const data: Partial<Entity> = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
status,
|
||||
vintage_year: vintageYear ? parseInt(vintageYear) : null,
|
||||
fund_size_cents: fundSizeDollars
|
||||
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
|
||||
: null,
|
||||
};
|
||||
const updated = await api.updateEntity(entity.id, data);
|
||||
onSaved(updated);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to update entity");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6 max-w-lg">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-4">Edit entity</h3>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Name</label>
|
||||
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-700 mb-1">Type</label>
|
||||
<select className={inputCls} value={type} onChange={(e) => setType(e.target.value as EntityType)}>
|
||||
<option value="fund">Fund</option>
|
||||
<option value="spv">SPV</option>
|
||||
<option value="gp">GP</option>
|
||||
<option value="mgmt_co">Mgmt Co</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-700 mb-1">Status</label>
|
||||
<select className={inputCls} value={status} onChange={(e) => setStatus(e.target.value as Entity["status"])}>
|
||||
<option value="active">Active</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-700 mb-1">Vintage Year</label>
|
||||
<input className={inputCls} value={vintageYear} onChange={(e) => setVintageYear(e.target.value)} placeholder="2021" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-gray-700 mb-1">Fund Size ($)</label>
|
||||
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, type Entity, type Partner } from "../api";
|
||||
import { formatDate, formatMoneyExact } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
|
||||
export default function EntityPartners() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [partners, setPartners] = useState<Partner[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const eid = parseInt(id);
|
||||
Promise.all([api.getEntity(eid), api.listPartners(eid)])
|
||||
.then(([e, p]) => {
|
||||
setEntity(e);
|
||||
setPartners(p);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
||||
|
||||
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
|
||||
const totalCapital = partners.reduce((s, p) => s + (p.latest_value_cents || 0), 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EntityHeader entity={entity} active="partners" />
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
|
||||
<span className="mx-2 text-gray-300">·</span>
|
||||
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Member</th>
|
||||
<th className="px-4 py-2 font-medium">Investor ID</th>
|
||||
<th className="px-4 py-2 font-medium">Login</th>
|
||||
<th className="px-4 py-2 font-medium">As of</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Committed</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Paid-in</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Distributions</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Capital value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{partners.map((p) => (
|
||||
<tr key={p.user_id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-gray-900">{p.name}</div>
|
||||
<div className="text-xs text-gray-400">{p.username}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600">{p.external_investor_id ?? "—"}</td>
|
||||
<td className="px-4 py-2">
|
||||
{!p.is_active ? (
|
||||
<span className="text-gray-400">Disabled</span>
|
||||
) : p.login_enabled ? (
|
||||
<span className="text-green-600">Active</span>
|
||||
) : (
|
||||
<span className="text-amber-600">No login yet</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">
|
||||
{p.latest_as_of ? formatDate(p.latest_as_of) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">
|
||||
{p.latest_commitment_cents != null ? formatMoneyExact(p.latest_commitment_cents) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-600">
|
||||
{p.latest_contributions_cents != null ? formatMoneyExact(p.latest_contributions_cents) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-600">
|
||||
{p.latest_distributions_cents != null ? formatMoneyExact(p.latest_distributions_cents) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900">
|
||||
{p.latest_value_cents != null ? formatMoneyExact(p.latest_value_cents) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{partners.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-6 text-center text-gray-400">
|
||||
No members yet. Import the eNAV ALLOC SI tab from “Import Statements,” or grant
|
||||
access on the Access Grid.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type InvestorView as InvestorViewData, type User } from "../api";
|
||||
import InvestorPortalView from "../portal/InvestorPortalView";
|
||||
|
||||
// Admin-only: pick an investor and see exactly what their portal shows, read-only.
|
||||
export default function InvestorView() {
|
||||
const [investors, setInvestors] = useState<User[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | "">("");
|
||||
const [data, setData] = useState<InvestorViewData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listUsers()
|
||||
.then((us) => setInvestors(us.filter((u) => u.role === "investor")))
|
||||
.catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...investors].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[investors],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === "") {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
api
|
||||
.investorView(selectedId)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedId]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-1">Investor View</h1>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
See exactly what an investor sees in their portal. Read-only — you are not signed in as them.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm text-gray-700 mb-1">Investor</label>
|
||||
<select
|
||||
className="w-full max-w-sm px-3 py-2 border border-gray-300 rounded text-sm"
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Select an investor…</option>
|
||||
{sorted.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
{loading && <p className="text-sm text-gray-500">Loading…</p>}
|
||||
|
||||
{data && !loading && (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2 text-sm bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
||||
<span className="font-medium text-amber-800">Viewing as {data.user.name}</span>
|
||||
<span className="text-amber-700">— read-only reconstruction of their portal.</span>
|
||||
</div>
|
||||
<InvestorPortalView
|
||||
entities={data.entities}
|
||||
accounts={data.capital_accounts}
|
||||
docs={data.documents}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [handle, setHandle] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -13,7 +14,7 @@ export default function Login() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
await login(handle, password);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Login failed");
|
||||
} finally {
|
||||
@@ -24,27 +25,25 @@ export default function Login() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-96">
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-6">Ten31Portal</h1>
|
||||
<div className="flex items-center gap-2.5 mb-6">
|
||||
<img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" />
|
||||
<h1 className="text-xl font-semibold text-gray-900">Ten31 Portal</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Email</label>
|
||||
<label className="block text-sm text-gray-700 mb-1">Username or email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="text"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
EXTERNAL_ROLES,
|
||||
isInternal,
|
||||
type Entity,
|
||||
type User,
|
||||
type UserDetail,
|
||||
type UserRole,
|
||||
} from "../api";
|
||||
import { roleLabel } from "../format";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
|
||||
const CREATABLE_ROLES: UserRole[] = [
|
||||
"investor",
|
||||
"fund_administrator",
|
||||
"fund_admin",
|
||||
"operations",
|
||||
"approver",
|
||||
];
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<UserDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = () => {
|
||||
api.listUsers().then(setUsers).catch((e) => setError(e.message));
|
||||
api.listEntities().then(setEntities).catch(() => {});
|
||||
};
|
||||
useEffect(load, []);
|
||||
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Users</h2>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||
>
|
||||
New user
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Username</th>
|
||||
<th className="px-4 py-2 font-medium">Role</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">{u.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{u.username}
|
||||
{u.primary_account_id != null && (
|
||||
<span
|
||||
className="ml-2 text-xs text-gray-400"
|
||||
title="Signs in under this account"
|
||||
>
|
||||
→ {usernameById.get(u.primary_account_id) ?? "linked"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{roleLabel(u.role)}
|
||||
{u.is_service_admin && (
|
||||
<span className="ml-2 text-xs text-gray-400" title="Built-in account — cannot be deleted">
|
||||
Service Admin
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{u.is_active ? (
|
||||
<span className="text-green-600">Active</span>
|
||||
) : (
|
||||
<span className="text-gray-400">Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() =>
|
||||
api.getUser(u.id).then(setEditing).catch((e) => setError(e.message))
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-6 text-center text-gray-400">
|
||||
No users yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<CreateUserModal
|
||||
entities={entities}
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={() => {
|
||||
setCreating(false);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<EditUserModal
|
||||
user={editing}
|
||||
entities={entities}
|
||||
allUsers={users}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityChecklist({
|
||||
entities,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
entities: Entity[];
|
||||
selected: Set<number>;
|
||||
onChange: (s: Set<number>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded max-h-48 overflow-auto divide-y divide-gray-100">
|
||||
{entities.map((e) => (
|
||||
<label key={e.id} className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(e.id)}
|
||||
onChange={(ev) => {
|
||||
const next = new Set(selected);
|
||||
if (ev.target.checked) next.add(e.id);
|
||||
else next.delete(e.id);
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<span className="text-gray-900">{e.name}</span>
|
||||
<span className="text-gray-400 text-xs uppercase">{e.type}</span>
|
||||
</label>
|
||||
))}
|
||||
{entities.length === 0 && (
|
||||
<p className="px-3 py-2 text-sm text-gray-400">No entities exist yet.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserModal({
|
||||
entities,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
entities: Entity[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<UserRole>("investor");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const external = EXTERNAL_ROLES.includes(role);
|
||||
|
||||
const submit = async () => {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.createUser({
|
||||
name,
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
email: email || null,
|
||||
entity_ids: external ? [...selected] : [],
|
||||
});
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to create user");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="New user" onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<Field label="Full name">
|
||||
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Username">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={username}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email (optional)">
|
||||
<input className={inputCls} value={email} autoComplete="off" onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Temporary password">
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="minimum 4 characters" />
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
<select className={inputCls} value={role} onChange={(e) => setRole(e.target.value as UserRole)}>
|
||||
{CREATABLE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{external && (
|
||||
<Field label="Entity access">
|
||||
<EntityChecklist entities={entities} selected={selected} onChange={setSelected} />
|
||||
</Field>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !name || !username || !password}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditUserModal({
|
||||
user,
|
||||
entities,
|
||||
allUsers,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
user: UserDetail;
|
||||
entities: Entity[];
|
||||
allUsers: User[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set(user.entity_ids));
|
||||
const [isActive, setIsActive] = useState(user.is_active);
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
// "" = logs in independently; a number = the primary account this name signs in under.
|
||||
const [linkTo, setLinkTo] = useState<number | "">(user.primary_account_id ?? "");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const external = !isInternal(user.role);
|
||||
const isInvestor = user.role === "investor";
|
||||
const isPrimary = user.linked_accounts.length > 0;
|
||||
const linked = linkTo !== "";
|
||||
|
||||
// Candidates to link under: other investors that aren't themselves linked.
|
||||
const linkCandidates = allUsers.filter(
|
||||
(u) => u.role === "investor" && u.id !== user.id && u.primary_account_id == null,
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.updateUser(user.id, {
|
||||
is_active: isActive,
|
||||
...(username.trim() && username.trim() !== user.username
|
||||
? { username: username.trim() }
|
||||
: {}),
|
||||
...(external ? { entity_ids: [...selected] } : {}),
|
||||
});
|
||||
if (isInvestor && !isPrimary && linkTo !== (user.primary_account_id ?? "")) {
|
||||
await api.linkAccount(user.id, linkTo === "" ? null : linkTo);
|
||||
}
|
||||
// A linked secondary doesn't sign in on its own, so skip the password.
|
||||
if (newPassword && !linked) await api.resetPassword(user.id, newPassword);
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to save");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={`Manage ${user.name}`} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-500">{roleLabel(user.role)}</p>
|
||||
<Field label="Username">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={username}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} />
|
||||
Account active
|
||||
</label>
|
||||
{external && (
|
||||
<Field label="Entity access">
|
||||
<EntityChecklist entities={entities} selected={selected} onChange={setSelected} />
|
||||
</Field>
|
||||
)}
|
||||
{isInvestor && (
|
||||
<Field label="Login">
|
||||
{isPrimary ? (
|
||||
<p className="text-sm text-gray-600">
|
||||
Signs in on its own. {user.linked_accounts.length} other name
|
||||
{user.linked_accounts.length === 1 ? "" : "s"} sign in here:{" "}
|
||||
{user.linked_accounts.map((a) => a.username).join(", ")}.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={linkTo === "" ? "" : String(linkTo)}
|
||||
onChange={(e) => setLinkTo(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Logs in independently</option>
|
||||
{linkCandidates.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
Linked → {u.name} ({u.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Link this name to one login so that person sees every investment held under
|
||||
their different names in one place.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{!linked && (
|
||||
<Field label="Reset password (optional)">
|
||||
<PasswordInput
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-lg w-[28rem] max-h-[90vh] overflow-auto p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type DocumentCategory,
|
||||
type Entity,
|
||||
type PortalDocument,
|
||||
type User,
|
||||
} from "../api";
|
||||
import { categoryLabel, formatBytes, formatDate } from "../format";
|
||||
|
||||
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
|
||||
|
||||
export default function FundAdminHome() {
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listEntities()
|
||||
.then(setEntities)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p className="text-gray-500 text-sm">Loading…</p>;
|
||||
if (error) return <p className="text-red-600 text-sm">{error}</p>;
|
||||
if (entities.length === 0)
|
||||
return <p className="text-gray-500 text-sm">You don't have access to any entities yet.</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{entities.map((e) => (
|
||||
<EntityDocs key={e.id} entity={e} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityDocs({ entity }: { entity: Entity }) {
|
||||
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||
const [investors, setInvestors] = useState<User[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = () => {
|
||||
api.listDocuments({ entity_id: entity.id }).then(setDocs).catch((e) => setError(e.message));
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
api.investorsForEntity(entity.id).then(setInvestors).catch(() => {});
|
||||
}, [entity.id]);
|
||||
|
||||
const investorName = (id: number | null) =>
|
||||
id == null ? "Shared (all investors)" : investors.find((i) => i.id === id)?.name ?? `Investor ${id}`;
|
||||
|
||||
return (
|
||||
<section className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{entity.name}</h2>
|
||||
<span className="text-xs text-gray-400 uppercase">{entity.type}</span>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
|
||||
|
||||
<UploadForm entity={entity} investors={investors} onUploaded={load} setError={setError} />
|
||||
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase mt-5 mb-2">Documents</h3>
|
||||
{docs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No documents yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
||||
{docs.map((d) => (
|
||||
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
||||
<span className="text-gray-900">{d.title}</span>
|
||||
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||
<span className="ml-2 text-xs text-gray-400">· {investorName(d.investor_user_id)}</span>
|
||||
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadForm({
|
||||
entity,
|
||||
investors,
|
||||
onUploaded,
|
||||
setError,
|
||||
}: {
|
||||
entity: Entity;
|
||||
investors: User[];
|
||||
onUploaded: () => void;
|
||||
setError: (s: string) => void;
|
||||
}) {
|
||||
const [category, setCategory] = useState<DocumentCategory>("statement");
|
||||
const [investorId, setInvestorId] = useState<number | "">("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!file) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("entity_id", String(entity.id));
|
||||
form.set("category", category);
|
||||
if (title) form.set("title", title);
|
||||
if (investorId !== "") form.set("investor_user_id", String(investorId));
|
||||
form.set("file", file);
|
||||
await api.uploadDocument(form);
|
||||
setTitle("");
|
||||
setFile(null);
|
||||
setInvestorId("");
|
||||
onUploaded();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 bg-gray-50 border border-gray-200 rounded p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Category</label>
|
||||
<select className={inputCls} value={category} onChange={(e) => setCategory(e.target.value as DocumentCategory)}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{categoryLabel(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Visibility</label>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={investorId}
|
||||
onChange={(e) => setInvestorId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Shared — all investors</option>
|
||||
{investors.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
Private — {u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Title (optional)</label>
|
||||
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">File</label>
|
||||
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} className="text-sm" />
|
||||
{file && <span className="text-xs text-gray-400 ml-2">{formatBytes(file.size)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !file}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
|
||||
import InvestorPortalView from "./InvestorPortalView";
|
||||
|
||||
export default function InvestorHome() {
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [accounts, setAccounts] = useState<CapitalAccount[]>([]);
|
||||
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.listEntities(), api.listCapitalAccounts(), api.listDocuments()])
|
||||
.then(([e, a, d]) => {
|
||||
setEntities(e);
|
||||
setAccounts(a);
|
||||
setDocs(d);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p className="text-gray-500 text-sm">Loading…</p>;
|
||||
if (error) return <p className="text-red-600 text-sm">{error}</p>;
|
||||
|
||||
if (entities.length === 0) {
|
||||
return (
|
||||
<p className="text-gray-500 text-sm">
|
||||
You don't have access to any funds yet. Contact Ten31 if you believe this is an error.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return <InvestorPortalView entities={entities} accounts={accounts} docs={docs} />;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useMemo } from "react";
|
||||
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
|
||||
import { categoryLabel, formatDate, formatMoneyExact } from "../format";
|
||||
import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
|
||||
|
||||
// The investor-facing portal, rendered purely from data. Used by the investor's own home
|
||||
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
|
||||
export default function InvestorPortalView({
|
||||
entities,
|
||||
accounts,
|
||||
docs,
|
||||
}: {
|
||||
entities: Entity[];
|
||||
accounts: CapitalAccount[];
|
||||
docs: PortalDocument[];
|
||||
}) {
|
||||
if (entities.length === 0) {
|
||||
return (
|
||||
<p className="text-gray-500 text-sm">
|
||||
No fund access yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// When this login covers several legal names (e.g. an IRA and a trust), label each block.
|
||||
const showNames = new Set(accounts.map((a) => a.investor_user_id)).size > 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{entities.map((e) => (
|
||||
<FundSection
|
||||
key={e.id}
|
||||
entity={e}
|
||||
accounts={accounts.filter((a) => a.entity_id === e.id)}
|
||||
docs={docs.filter((d) => d.entity_id === e.id)}
|
||||
showNames={showNames}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FundSection({
|
||||
entity,
|
||||
accounts,
|
||||
docs,
|
||||
showNames,
|
||||
}: {
|
||||
entity: Entity;
|
||||
accounts: CapitalAccount[];
|
||||
docs: PortalDocument[];
|
||||
showNames: boolean;
|
||||
}) {
|
||||
const byName = useMemo(() => {
|
||||
const groups = new Map<number, CapitalAccount[]>();
|
||||
for (const a of accounts) {
|
||||
const g = groups.get(a.investor_user_id) ?? [];
|
||||
g.push(a);
|
||||
groups.set(a.investor_user_id, g);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}, [accounts]);
|
||||
|
||||
return (
|
||||
<section className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{entity.name}</h2>
|
||||
<span className="text-xs text-gray-400 uppercase">{entity.type}</span>
|
||||
</div>
|
||||
|
||||
{byName.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-gray-400">No capital account statement on file yet.</p>
|
||||
) : (
|
||||
byName.map((group, i) => (
|
||||
<CapitalBlock
|
||||
key={group[0].investor_user_id}
|
||||
accounts={group}
|
||||
label={showNames ? group[0].investor_name : null}
|
||||
divider={i > 0}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Documents</h3>
|
||||
{docs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No documents available.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
||||
{docs.map((d) => (
|
||||
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
||||
<span className="text-gray-900">{d.title}</span>
|
||||
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CapitalBlock({
|
||||
accounts,
|
||||
label,
|
||||
divider,
|
||||
}: {
|
||||
accounts: CapitalAccount[];
|
||||
label: string | null;
|
||||
divider: boolean;
|
||||
}) {
|
||||
// accounts arrive newest-first; history is oldest-first for the chart/table.
|
||||
const latest = accounts[0];
|
||||
const history = useMemo(
|
||||
() => [...accounts].sort((a, b) => a.as_of_date.localeCompare(b.as_of_date)),
|
||||
[accounts],
|
||||
);
|
||||
const chartPoints: CapitalPoint[] = history.map((a) => ({
|
||||
date: a.as_of_date,
|
||||
value: a.ending_balance_cents,
|
||||
paidIn: a.contributions_cents,
|
||||
distributions: a.distributions_cents,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}>
|
||||
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
|
||||
<p className="text-sm text-gray-500 mt-1">As of {formatDate(latest.as_of_date)}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
||||
<Metric label="Commitment" value={formatMoneyExact(latest.commitment_cents)} />
|
||||
<Metric label="Paid-in" value={formatMoneyExact(latest.contributions_cents)} />
|
||||
{latest.distributions_cents > 0 && (
|
||||
<Metric label="Distributions" value={formatMoneyExact(latest.distributions_cents)} />
|
||||
)}
|
||||
{latest.distributions_cents > 0 && latest.contributions_cents > 0 && (
|
||||
<Metric
|
||||
label="DPI"
|
||||
value={(latest.distributions_cents / latest.contributions_cents).toFixed(2) + "x"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-4">Current capital value</p>
|
||||
<p className="text-3xl font-semibold text-gray-900 mt-0.5">
|
||||
{formatMoneyExact(latest.ending_balance_cents)}
|
||||
</p>
|
||||
|
||||
{history.length > 1 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Capital over time</h3>
|
||||
<CapitalChart points={chartPoints} />
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead className="text-gray-400 text-left">
|
||||
<tr>
|
||||
<th className="py-1 font-medium">As of</th>
|
||||
<th className="py-1 font-medium text-right">Paid-in</th>
|
||||
<th className="py-1 font-medium text-right">Distributions</th>
|
||||
<th className="py-1 font-medium text-right">Ending balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((a) => (
|
||||
<tr key={a.id} className="border-t border-gray-100">
|
||||
<td className="py-1 text-gray-600">{formatDate(a.as_of_date)}</td>
|
||||
<td className="py-1 text-right text-gray-600">{formatMoneyExact(a.contributions_cents)}</td>
|
||||
<td className="py-1 text-right text-gray-600">{formatMoneyExact(a.distributions_cents)}</td>
|
||||
<td className="py-1 text-right text-gray-900">{formatMoneyExact(a.ending_balance_cents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase">{label}</p>
|
||||
<p className="text-lg font-medium text-gray-900">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { roleLabel } from "../format";
|
||||
import { APP_VERSION } from "../version";
|
||||
import ChangePasswordModal from "../components/ChangePasswordModal";
|
||||
|
||||
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [changingPw, setChangingPw] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="h-14 bg-white border-b border-gray-200 flex items-center gap-2 px-4 sm:px-6">
|
||||
<img src="/ten31-logo.png" alt="" className="w-7 h-7 rounded-md" />
|
||||
<h1 className="text-base sm:text-lg font-semibold text-gray-900">Ten31 Portal</h1>
|
||||
<div className="ml-auto flex items-center gap-3 sm:gap-4">
|
||||
<span className="hidden sm:inline text-sm text-gray-600">{user?.name}</span>
|
||||
<span className="hidden sm:inline text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{user ? roleLabel(user.role) : ""}
|
||||
</span>
|
||||
<button onClick={() => setChangingPw(true)} className="text-sm text-gray-500 hover:text-gray-800">
|
||||
Change password
|
||||
</button>
|
||||
<button onClick={logout} className="text-sm text-gray-500 hover:text-gray-800">
|
||||
Sign out
|
||||
</button>
|
||||
<span className="hidden sm:inline text-xs text-gray-300">v{APP_VERSION}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-4xl mx-auto p-4 sm:p-6">{children}</main>
|
||||
{changingPw && <ChangePasswordModal onClose={() => setChangingPw(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Bumped each release so the running build is visible in the UI.
|
||||
// If the number shown in the app doesn't match the installed s9pk version,
|
||||
// the new frontend isn't actually being served.
|
||||
export const APP_VERSION = "0.2.20";
|
||||
Reference in New Issue
Block a user