0.2.27-0.2.32: LP portal polish, brand palette, default investor logins
Cumulative checkpoint since 0.2.26:
- 0.2.27/28: entity valuation-history table; investor gain/loss = NAV +
distributions vs paid-in
- 0.2.29: Reset Fund Partners (endpoint, Partners-tab button, CLI, action)
- 0.2.30: "Current Capital Balance" label, %-only gain/loss
- 0.2.31: Management Entities rename, Carry Vehicle type, chart
distributions-line gate
- 0.2.32: LP-facing polish pass
* Ten31 brand palette from the logo (navy/mint); orange retired
* portfolio summary card across funds; gain labeled "net of paid-in"
* whole-dollar headline figures; "History · N quarters" toggle
* documents grouped by year with a "New" badge (users.docs_seen_at)
* eNAV-created members start on default password with login enabled;
enable-investor-logins CLI + StartOS action for existing accounts
* password minimum raised to 8 chars; login help line (Portal@ten31.xyz)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
69f12b0519
commit
4215d4478f
@@ -62,7 +62,7 @@ function OfflineNotice({ onRetry }: { onRetry: () => void }) {
|
||||
</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||
className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
|
||||
+7
-1
@@ -12,7 +12,7 @@ export type UserRole =
|
||||
| "viewer"
|
||||
| "investor"
|
||||
| "fund_administrator";
|
||||
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co";
|
||||
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co" | "carry";
|
||||
export type EntityStatus = "active" | "closed";
|
||||
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
|
||||
export type DocumentCategory =
|
||||
@@ -70,6 +70,7 @@ export interface PortalDocument {
|
||||
size_bytes: number;
|
||||
uploaded_by: number | null;
|
||||
created_at: string;
|
||||
is_new?: boolean;
|
||||
}
|
||||
|
||||
export interface Partner {
|
||||
@@ -307,6 +308,11 @@ export const api = {
|
||||
}[]>("/api/entities/rollup"),
|
||||
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
|
||||
listPartners: (entityId: number) => request<Partner[]>(`/api/entities/${entityId}/partners`),
|
||||
clearPartners: (entityId: number) =>
|
||||
request<{ statements: number; access_grants: number }>(
|
||||
`/api/entities/${entityId}/partners`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
createEntity: (data: Partial<Entity>) =>
|
||||
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateEntity: (id: number, data: Partial<Entity>) =>
|
||||
|
||||
@@ -23,6 +23,11 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
||||
|
||||
if (data.length < 2) return null;
|
||||
|
||||
// Only plot the Distributions series once the LP has actually received a distribution;
|
||||
// otherwise it's a flat line pinned at zero that just clutters the chart.
|
||||
const hasDistributions = data.some((d) => d.distributions > 0);
|
||||
const series = hasDistributions ? SERIES : SERIES.filter((s) => s.key !== "distributions");
|
||||
|
||||
const W = 640;
|
||||
const H = 240;
|
||||
const padL = 64;
|
||||
@@ -51,7 +56,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
||||
</g>
|
||||
))}
|
||||
|
||||
{SERIES.map((s) => (
|
||||
{series.map((s) => (
|
||||
<polyline
|
||||
key={s.key}
|
||||
fill="none"
|
||||
@@ -61,7 +66,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
||||
/>
|
||||
))}
|
||||
|
||||
{SERIES.map((s) =>
|
||||
{series.map((s) =>
|
||||
data.map((d, i) => (
|
||||
<circle key={`${s.key}-${i}`} cx={x(i)} cy={y(d[s.key])} r={2.5} fill={s.color}>
|
||||
<title>{`${formatQuarter(d.date)} · ${s.label}: ${formatMoney(d[s.key])}`}</title>
|
||||
@@ -77,7 +82,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
|
||||
</svg>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-2 pl-2">
|
||||
{SERIES.map((s) => (
|
||||
{series.map((s) => (
|
||||
<span key={s.key} className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="inline-block w-3 h-0.5" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
|
||||
|
||||
const save = async () => {
|
||||
setError("");
|
||||
if (next.length < 4) return setError("New password must be at least 4 characters.");
|
||||
if (next.length < 8) return setError("New password must be at least 8 characters.");
|
||||
if (next !== confirm) return setError("New passwords don't match.");
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -34,7 +34,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-green-700">Your password has been updated.</p>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={onClose} className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800">
|
||||
<button onClick={onClose} className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">New password</label>
|
||||
<PasswordInput value={next} onChange={setNext} placeholder="minimum 4 characters" />
|
||||
<PasswordInput value={next} onChange={setNext} placeholder="minimum 8 characters" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Confirm new password</label>
|
||||
@@ -61,7 +61,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy || !current || !next}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Saving…" : "Update password"}
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
spv: "SPV",
|
||||
gp: "GP",
|
||||
mgmt_co: "Mgmt Co",
|
||||
carry: "Carry Vehicle",
|
||||
};
|
||||
|
||||
const TABS: { key: string; label: string; path: (id: number) => string }[] = [
|
||||
@@ -15,8 +16,8 @@ const TABS: { key: string; label: string; path: (id: number) => string }[] = [
|
||||
{ key: "documents", label: "Documents", path: (id) => `/entities/${id}/documents` },
|
||||
];
|
||||
|
||||
// GP entities and management companies hold interests in the funds they manage; that's their
|
||||
// "Assets" tab. Funds/SPVs use the Investments tab instead.
|
||||
// GP entities, management companies, and carry vehicles hold interests in the funds they manage;
|
||||
// that's their "Assets" tab. Funds/SPVs use the Investments tab instead.
|
||||
const ASSETS_TAB = {
|
||||
key: "assets",
|
||||
label: "Assets",
|
||||
@@ -25,7 +26,9 @@ const ASSETS_TAB = {
|
||||
|
||||
export default function EntityHeader({ entity, active }: { entity: Entity; active: string }) {
|
||||
const tabs =
|
||||
entity.type === "gp" || entity.type === "mgmt_co" ? [...TABS, ASSETS_TAB] : TABS;
|
||||
entity.type === "gp" || entity.type === "mgmt_co" || entity.type === "carry"
|
||||
? [...TABS, ASSETS_TAB]
|
||||
: TABS;
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
@@ -39,7 +42,7 @@ export default function EntityHeader({ entity, active }: { entity: Entity; activ
|
||||
active === t.key ? (
|
||||
<span
|
||||
key={t.key}
|
||||
className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600"
|
||||
className="pb-2 border-b-2 border-accent-500 text-sm font-medium text-accent-600"
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
onClick={() => setNavOpen(false)}
|
||||
className={`block px-4 py-2 text-sm ${
|
||||
active
|
||||
? "bg-orange-50 text-orange-600 border-r-2 border-orange-500 font-medium"
|
||||
? "bg-accent-50 text-accent-600 border-r-2 border-accent-500 font-medium"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function PasswordInput({
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
const base =
|
||||
"w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
"w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
|
||||
@@ -29,6 +29,12 @@ export function formatMoneyExact(cents: number | null | undefined): string {
|
||||
return `$${dollars.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
/** Format cents as whole dollars for headline figures — the ".00" is noise at that size. */
|
||||
export function formatMoneyWhole(cents: number | null | undefined): string {
|
||||
if (cents == null) return "—";
|
||||
return `$${Math.round(cents / 100).toLocaleString("en-US")}`;
|
||||
}
|
||||
|
||||
/** Format date string to short form: Jun 15, 2021 */
|
||||
export function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "—";
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Ten31 brand palette, sampled from the logo (frontend/public/ten31-logo.png):
|
||||
* navy #0C1F33 (tile) · cream #F4EFE3 (frame/serifs) · mint #79CDB6 (arrow).
|
||||
* brand-* = the navy, used for primary buttons and the portfolio card.
|
||||
* accent-* = the arrow's green, darkened for readable links/focus on white.
|
||||
*/
|
||||
@theme {
|
||||
--color-brand-300: #8ea4b8; /* muted text on navy */
|
||||
--color-brand-800: #16324f; /* button hover */
|
||||
--color-brand-900: #0c1f33; /* logo navy */
|
||||
|
||||
--color-cream: #f4efe3;
|
||||
|
||||
--color-accent-50: #edf7f3; /* active-nav tint */
|
||||
--color-accent-100: #d9efe6;
|
||||
--color-accent-300: #79cdb6; /* the arrow itself — on dark only */
|
||||
--color-accent-500: #2fa184; /* focus rings, sort arrows */
|
||||
--color-accent-600: #0e7a5f; /* links */
|
||||
--color-accent-700: #0b6249; /* link hover */
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function AccessGrid() {
|
||||
disabled={loading}
|
||||
className={`w-full h-10 flex items-center justify-center transition-colors ${
|
||||
on
|
||||
? "bg-orange-50 text-orange-600 hover:bg-orange-100"
|
||||
? "bg-accent-50 text-accent-600 hover:bg-accent-100"
|
||||
: "text-gray-300 hover:bg-gray-100"
|
||||
} ${loading ? "opacity-50" : ""}`}
|
||||
title={on ? "Click to revoke" : "Click to grant"}
|
||||
|
||||
@@ -186,7 +186,7 @@ function EntryForm({
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || entityId === "" || investorId === "" || !asOf}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Saving…" : "Save statement"}
|
||||
</button>
|
||||
@@ -219,4 +219,4 @@ function DollarField({
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function Documents() {
|
||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||
<a
|
||||
href={api.downloadUrl(d.id)}
|
||||
className="text-orange-600 hover:text-orange-700 mr-3"
|
||||
className="text-accent-600 hover:text-accent-700 mr-3"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
@@ -242,7 +242,7 @@ function UploadForm({
|
||||
{targetLabel ? (
|
||||
<p className="text-xs text-gray-500">
|
||||
Uploading to <span className="font-medium text-gray-700">{entityName}</span> ·{" "}
|
||||
<span className={investorId === "" ? "text-gray-700" : "text-orange-600 font-medium"}>
|
||||
<span className={investorId === "" ? "text-gray-700" : "text-accent-600 font-medium"}>
|
||||
{targetLabel}
|
||||
</span>
|
||||
</p>
|
||||
@@ -252,7 +252,7 @@ function UploadForm({
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || entityId === "" || !file}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
@@ -262,4 +262,4 @@ function UploadForm({
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||
|
||||
@@ -21,6 +21,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
spv: "SPV",
|
||||
gp: "GP",
|
||||
mgmt_co: "Mgmt Co",
|
||||
carry: "Carry Vehicle",
|
||||
};
|
||||
|
||||
export default function EntitiesList() {
|
||||
@@ -62,7 +63,9 @@ export default function EntitiesList() {
|
||||
}
|
||||
|
||||
const funds = entities.filter((e) => e.type === "fund" || e.type === "spv");
|
||||
const gps = entities.filter((e) => e.type === "gp" || e.type === "mgmt_co");
|
||||
const gps = entities.filter(
|
||||
(e) => e.type === "gp" || e.type === "mgmt_co" || e.type === "carry",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -71,7 +74,7 @@ export default function EntitiesList() {
|
||||
{isWriter && (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||
className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||
>
|
||||
Add Entity
|
||||
</button>
|
||||
@@ -89,7 +92,7 @@ export default function EntitiesList() {
|
||||
)}
|
||||
|
||||
<EntityTable title="Funds and SPVs" rows={funds} />
|
||||
<EntityTable title="GP Entities and Management Companies" rows={gps} />
|
||||
<EntityTable title="Management Entities" rows={gps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,7 +158,7 @@ function AddEntityForm({
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
placeholder="Low Time Preference Fund I, LLC"
|
||||
required
|
||||
/>
|
||||
@@ -171,6 +174,7 @@ function AddEntityForm({
|
||||
<option value="spv">SPV</option>
|
||||
<option value="gp">GP</option>
|
||||
<option value="mgmt_co">Mgmt Co</option>
|
||||
<option value="carry">Carry Vehicle</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -202,7 +206,7 @@ function AddEntityForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Creating..." : "Create"}
|
||||
</button>
|
||||
@@ -261,7 +265,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
|
||||
return (
|
||||
<th className={`px-4 py-3 font-medium text-gray-600 select-none ${a}`}>
|
||||
<button onClick={() => sortBy(k)} className={`w-full hover:text-gray-900 ${a}`}>
|
||||
{label}<span className="text-orange-500">{arrow(k)}</span>
|
||||
{label}<span className="text-accent-500">{arrow(k)}</span>
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
@@ -345,7 +349,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/entities/${row.id}`}
|
||||
className="text-gray-900 font-medium hover:text-orange-600"
|
||||
className="text-gray-900 font-medium hover:text-accent-600"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function EntityAssets() {
|
||||
<p>This entity isn't linked to an investor account yet, so there are no balances to show.</p>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Link it on the{" "}
|
||||
<Link to={`/entities/${entityId}`} className="text-orange-600 hover:text-orange-700">
|
||||
<Link to={`/entities/${entityId}`} className="text-accent-600 hover:text-accent-700">
|
||||
Overview tab → Edit entity
|
||||
</Link>{" "}
|
||||
(choose its investor account). Its capital-account balance in each fund will then
|
||||
@@ -110,7 +110,7 @@ export default function EntityAssets() {
|
||||
{rows.map((r) => (
|
||||
<tr key={r.fundId} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">
|
||||
<Link to={`/entities/${r.fundId}`} className="hover:text-orange-600">
|
||||
<Link to={`/entities/${r.fundId}`} className="hover:text-accent-600">
|
||||
{fundName(r.fundId)}
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function EntityDocuments() {
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{formatDate(d.created_at)}</td>
|
||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700 mr-3">
|
||||
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700 mr-3">
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function EntityOverview() {
|
||||
const [totalInvested, setTotalInvested] = useState(0);
|
||||
const [lastValue, setLastValue] = useState(0);
|
||||
const [latestRound, setLatestRound] = useState<ValuationRound | null>(null);
|
||||
const [rounds, setRounds] = useState<ValuationRound[]>([]);
|
||||
const [assetBalances, setAssetBalances] = useState<AssetBalances | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -49,7 +50,10 @@ export default function EntityOverview() {
|
||||
const ent = await api.getEntity(entityId);
|
||||
setEntity(ent);
|
||||
|
||||
if ((ent.type === "gp" || ent.type === "mgmt_co") && ent.linked_user_id != null) {
|
||||
if (
|
||||
(ent.type === "gp" || ent.type === "mgmt_co" || ent.type === "carry") &&
|
||||
ent.linked_user_id != null
|
||||
) {
|
||||
api.entityAssetBalances(entityId).then(setAssetBalances).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -66,6 +70,9 @@ export default function EntityOverview() {
|
||||
setTotalInvested(invested);
|
||||
|
||||
const rounds = await api.listRounds(entityId);
|
||||
// Newest quarter first, so the history table reads top-down like a statement run.
|
||||
const sorted = [...rounds].sort((a, b) => b.quarter_end.localeCompare(a.quarter_end));
|
||||
setRounds(sorted);
|
||||
const approved = rounds.filter((r) => r.status === "approved");
|
||||
if (approved.length > 0) {
|
||||
setLatestRound(approved[0]);
|
||||
@@ -141,7 +148,7 @@ export default function EntityOverview() {
|
||||
{assetBalances.linked_name ? ` · ${assetBalances.linked_name}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/entities/${entity.id}/assets`} className="text-sm text-orange-600 hover:text-orange-700">
|
||||
<Link to={`/entities/${entity.id}/assets`} className="text-sm text-accent-600 hover:text-accent-700">
|
||||
View by fund →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -165,6 +172,47 @@ export default function EntityOverview() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Valuation history — every quarter uploaded/signed for this entity */}
|
||||
{rounds.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto mt-6">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<h3 className="text-sm font-medium text-gray-700">Valuation history</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{rounds.length} quarter{rounds.length === 1 ? "" : "s"} on record
|
||||
</p>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Quarter</th>
|
||||
<th className="px-4 py-2 font-medium text-right">NAV</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Signed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rounds.map((r) => {
|
||||
const nav = r.valuations.reduce((s, v) => s + v.value_cents, 0);
|
||||
return (
|
||||
<tr key={r.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">{formatQuarter(r.quarter_end)}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900 font-medium">{formatMoney(nav)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded ${STATUS_COLORS[r.status]}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">
|
||||
{r.status === "approved" && r.approved_at ? formatDate(r.approved_at) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,9 +239,9 @@ function EditEntityForm({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||
|
||||
// A GP / management company can be linked to its own investor account, so its Assets view
|
||||
// shows its real per-fund balances.
|
||||
const isGp = type === "gp" || type === "mgmt_co";
|
||||
// A GP / management company / carry vehicle can be linked to its own investor account, so its
|
||||
// Assets view shows its real per-fund balances.
|
||||
const isGp = type === "gp" || type === "mgmt_co" || type === "carry";
|
||||
useEffect(() => {
|
||||
if (!isGp) return;
|
||||
api.listUsers().then((us) => setInvestors(us.filter((u) => u.role === "investor"))).catch(() => {});
|
||||
@@ -243,6 +291,7 @@ function EditEntityForm({
|
||||
<option value="spv">SPV</option>
|
||||
<option value="gp">GP</option>
|
||||
<option value="mgmt_co">Mgmt Co</option>
|
||||
<option value="carry">Carry Vehicle</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
@@ -282,7 +331,7 @@ function EditEntityForm({
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50">
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, type Entity, type Partner } from "../api";
|
||||
import { api, canEditRound, type Entity, type Partner } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatDate, formatMoneyExact } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
|
||||
export default function EntityPartners() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
const [entity, setEntity] = useState<Entity | null>(null);
|
||||
const [partners, setPartners] = useState<Partner[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const eid = parseInt(id);
|
||||
@@ -23,6 +29,34 @@ export default function EntityPartners() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
async function clearAllPartners() {
|
||||
if (!entity) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Remove ALL ${partners.length} partner(s) from ${entity.name}?\n\n` +
|
||||
"This deletes this fund's capital-account statements and the investors' access to " +
|
||||
"it. The investor accounts themselves are kept (they may belong to other funds), " +
|
||||
"and holdings/NAV are not affected. Re-import the correct roster afterward.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setClearing(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const res = await api.clearPartners(entity.id);
|
||||
const fresh = await api.listPartners(entity.id);
|
||||
setPartners(fresh);
|
||||
setNotice(
|
||||
`Cleared: removed ${res.statements} capital statement(s) and ${res.access_grants} access grant(s).`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to clear partners");
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
||||
|
||||
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
|
||||
@@ -33,16 +67,28 @@ export default function EntityPartners() {
|
||||
<EntityHeader entity={entity} active="partners" />
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||
{notice && <p className="text-sm text-green-600 mb-3">{notice}</p>}
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
|
||||
<span className="mx-2 text-gray-300">·</span>
|
||||
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
|
||||
<span className="mx-2 text-gray-300">·</span>
|
||||
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
|
||||
</p>
|
||||
{isWriter && partners.length > 0 && (
|
||||
<button
|
||||
onClick={clearAllPartners}
|
||||
disabled={clearing}
|
||||
className="px-3 py-1.5 border border-red-300 text-red-600 text-sm rounded hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{clearing ? "Clearing…" : "Clear all partners"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function Import() {
|
||||
<li>{result.members} members ({result.created} newly created)</li>
|
||||
{result.note && <li className="text-amber-700">{result.note}</li>}
|
||||
</ul>
|
||||
<button onClick={() => setResult(null)} className="mt-3 px-3 py-1.5 bg-gray-900 text-white rounded hover:bg-gray-800">
|
||||
<button onClick={() => setResult(null)} className="mt-3 px-3 py-1.5 bg-brand-900 text-white rounded hover:bg-brand-800">
|
||||
Import another
|
||||
</button>
|
||||
</div>
|
||||
@@ -240,11 +240,11 @@ export default function Import() {
|
||||
<label className="block text-sm text-gray-700 mb-2">Fund</label>
|
||||
<div className="flex gap-4 mb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="radio" checked={entityMode === "existing"} onChange={() => { setEntityMode("existing"); reset(); }} className="text-orange-500" />
|
||||
<input type="radio" checked={entityMode === "existing"} onChange={() => { setEntityMode("existing"); reset(); }} className="text-accent-500" />
|
||||
Use existing fund
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="radio" checked={entityMode === "from_file"} onChange={() => { setEntityMode("from_file"); reset(); }} className="text-orange-500" />
|
||||
<input type="radio" checked={entityMode === "from_file"} onChange={() => { setEntityMode("from_file"); reset(); }} className="text-accent-500" />
|
||||
Create from file
|
||||
</label>
|
||||
</div>
|
||||
@@ -260,6 +260,7 @@ export default function Import() {
|
||||
<option value="spv">SPV</option>
|
||||
<option value="gp">GP</option>
|
||||
<option value="mgmt_co">Mgmt Co</option>
|
||||
<option value="carry">Carry Vehicle</option>
|
||||
</select>
|
||||
<input className={inputCls} placeholder="Vintage year" value={vintageYear} onChange={(e) => setVintageYear(e.target.value)} />
|
||||
</div>
|
||||
@@ -278,7 +279,7 @@ export default function Import() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={readEnav} disabled={busy || !file} className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
||||
<button onClick={readEnav} disabled={busy || !file} className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50">
|
||||
{busy ? "Reading…" : "Read eNAV"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -373,7 +374,7 @@ export default function Import() {
|
||||
{decisions.filter((d) => d.action === "match").length} matched ·{" "}
|
||||
{decisions.filter((d) => d.action === "skip").length} skipped
|
||||
</span>
|
||||
<button onClick={handleConfirmClick} disabled={busy || !asOf} className="px-4 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
|
||||
<button onClick={handleConfirmClick} disabled={busy || !asOf} className="px-4 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50">
|
||||
{busy ? (step || "Importing…") : "Confirm import"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -491,7 +492,7 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={busy || !entityId || files.length === 0}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Loading history…" : "Load history"}
|
||||
</button>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function Login() {
|
||||
autoComplete="username"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -49,11 +49,17 @@ export default function Login() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-5 text-xs text-gray-400 text-center">
|
||||
Trouble signing in?{" "}
|
||||
<a href="mailto:Portal@ten31.xyz" className="text-accent-600 hover:text-accent-700">
|
||||
Contact Portal@ten31.xyz
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function Users() {
|
||||
<h2 className="text-xl font-semibold text-gray-900">Users</h2>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||
>
|
||||
New user
|
||||
</button>
|
||||
@@ -94,7 +94,7 @@ export default function Users() {
|
||||
onClick={() =>
|
||||
api.getUser(u.id).then(setEditing).catch((e) => setError(e.message))
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700"
|
||||
className="text-accent-600 hover:text-accent-700"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
@@ -254,7 +254,7 @@ function CreateUserModal({
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !name || !username || !password}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
@@ -387,7 +387,7 @@ function EditUserModal({
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@@ -398,7 +398,7 @@ function EditUserModal({
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function ValuationWorkflow() {
|
||||
<Link to={`/entities/${entity.id}/investments`} className="pb-2 text-sm text-gray-500 hover:text-gray-800">
|
||||
Investments
|
||||
</Link>
|
||||
<span className="pb-2 border-b-2 border-orange-500 text-sm font-medium text-orange-600">
|
||||
<span className="pb-2 border-b-2 border-accent-500 text-sm font-medium text-accent-600">
|
||||
Valuation
|
||||
</span>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export default function ValuationWorkflow() {
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateRound}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
@@ -228,7 +228,7 @@ export default function ValuationWorkflow() {
|
||||
<button
|
||||
onClick={() => selectRound(r)}
|
||||
className={`w-full text-left px-3 py-2 rounded text-sm ${
|
||||
selectedRound?.id === r.id ? "bg-orange-50 text-orange-700" : "hover:bg-gray-50 text-gray-700"
|
||||
selectedRound?.id === r.id ? "bg-accent-50 text-accent-700" : "hover:bg-gray-50 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{formatQuarter(r.quarter_end)}</div>
|
||||
@@ -271,7 +271,7 @@ export default function ValuationWorkflow() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||
>
|
||||
Submit for Review
|
||||
</button>
|
||||
|
||||
@@ -74,7 +74,7 @@ function EntityDocs({ entity }: { entity: Entity }) {
|
||||
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||
<span className="ml-2 text-xs text-gray-400">· {investorName(d.investor_user_id)}</span>
|
||||
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
|
||||
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
@@ -167,7 +167,7 @@ function UploadForm({
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !file}
|
||||
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
@@ -177,4 +177,4 @@ function UploadForm({
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
|
||||
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
|
||||
import { categoryLabel, formatDate, formatMoneyExact } from "../format";
|
||||
import { categoryLabel, formatDate, formatMoneyExact, formatMoneyWhole } from "../format";
|
||||
import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
|
||||
|
||||
const pct = (p: number) => `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(1)}%`;
|
||||
|
||||
// The investor-facing portal, rendered purely from data. Used by the investor's own home
|
||||
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
|
||||
export default function InvestorPortalView({
|
||||
@@ -27,6 +29,7 @@ export default function InvestorPortalView({
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{entities.length > 1 && <PortfolioSummary accounts={accounts} count={entities.length} />}
|
||||
{entities.map((e) => (
|
||||
<FundSection
|
||||
key={e.id}
|
||||
@@ -40,6 +43,67 @@ export default function InvestorPortalView({
|
||||
);
|
||||
}
|
||||
|
||||
// One quiet navy card answering the multi-fund LP's first question: the total across Ten31.
|
||||
function PortfolioSummary({ accounts, count }: { accounts: CapitalAccount[]; count: number }) {
|
||||
// Latest statement per (fund, legal name); summed across all of them.
|
||||
const totals = useMemo(() => {
|
||||
const latest = new Map<string, CapitalAccount>();
|
||||
for (const a of accounts) {
|
||||
const key = `${a.entity_id}:${a.investor_user_id}`;
|
||||
const cur = latest.get(key);
|
||||
if (!cur || a.as_of_date > cur.as_of_date) latest.set(key, a);
|
||||
}
|
||||
let commitment = 0, paidIn = 0, distributions = 0, balance = 0;
|
||||
for (const a of latest.values()) {
|
||||
commitment += a.commitment_cents;
|
||||
paidIn += a.contributions_cents;
|
||||
distributions += a.distributions_cents;
|
||||
balance += a.ending_balance_cents;
|
||||
}
|
||||
return { commitment, paidIn, distributions, balance };
|
||||
}, [accounts]);
|
||||
|
||||
if (totals.commitment === 0 && totals.balance === 0) return null;
|
||||
|
||||
const gainLossPct =
|
||||
totals.paidIn > 0
|
||||
? ((totals.balance + totals.distributions - totals.paidIn) / totals.paidIn) * 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="bg-brand-900 rounded-lg p-5 text-cream">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-brand-300">
|
||||
Across your {count} funds
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<div>
|
||||
<p className="text-xs text-brand-300 uppercase">Commitment</p>
|
||||
<p className="text-lg font-medium">{formatMoneyWhole(totals.commitment)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-brand-300 uppercase">Paid-in</p>
|
||||
<p className="text-lg font-medium">{formatMoneyWhole(totals.paidIn)}</p>
|
||||
</div>
|
||||
{totals.distributions > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-brand-300 uppercase">Distributions</p>
|
||||
<p className="text-lg font-medium">{formatMoneyWhole(totals.distributions)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-brand-300 uppercase">Current Capital Balance</p>
|
||||
<p className="text-lg font-semibold">{formatMoneyWhole(totals.balance)}</p>
|
||||
{gainLossPct != null && (
|
||||
<p className={`text-xs mt-0.5 ${gainLossPct >= 0 ? "text-accent-300" : "text-red-300"}`}>
|
||||
{pct(gainLossPct)} <span className="text-brand-300">net of paid-in</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FundSection({
|
||||
entity,
|
||||
accounts,
|
||||
@@ -81,29 +145,64 @@ function FundSection({
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Documents</h3>
|
||||
{docs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No documents available.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
||||
{docs.map((d) => (
|
||||
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
||||
<span className="text-gray-900">{d.title}</span>
|
||||
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||
<a href={api.downloadUrl(d.id)} className="text-orange-600 hover:text-orange-700">
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<DocumentList docs={docs} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentList({ docs }: { docs: PortalDocument[] }) {
|
||||
// A handful of docs stays a flat list; once tax seasons pile up, group them by year.
|
||||
const groups = useMemo(() => {
|
||||
if (docs.length <= 6) return [{ year: null as number | null, docs }];
|
||||
const byYear = new Map<number, PortalDocument[]>();
|
||||
for (const d of docs) {
|
||||
const y = new Date(d.created_at).getFullYear();
|
||||
const g = byYear.get(y) ?? [];
|
||||
g.push(d);
|
||||
byYear.set(y, g);
|
||||
}
|
||||
return [...byYear.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.map(([year, ds]) => ({ year: year as number | null, docs: ds }));
|
||||
}, [docs]);
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Documents</h3>
|
||||
{docs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No documents available.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{groups.map((g) => (
|
||||
<div key={g.year ?? "all"}>
|
||||
{g.year != null && (
|
||||
<p className="text-xs font-medium text-gray-400 mb-1">{g.year}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
||||
{g.docs.map((d) => (
|
||||
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
||||
<span className="text-gray-900">{d.title}</span>
|
||||
{d.is_new && (
|
||||
<span className="ml-2 text-[10px] font-medium uppercase text-accent-600 bg-accent-50 px-1.5 py-0.5 rounded">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
|
||||
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapitalBlock({
|
||||
accounts,
|
||||
label,
|
||||
@@ -115,6 +214,17 @@ function CapitalBlock({
|
||||
}) {
|
||||
// accounts arrive newest-first; history is oldest-first for the chart/table.
|
||||
const latest = accounts[0];
|
||||
// Simple LP performance: gain/loss = total value (current NAV + distributions received)
|
||||
// vs paid-in — so distributions taken don't read as a loss; and how much of the
|
||||
// commitment has been distributed back.
|
||||
const gainLoss =
|
||||
latest.ending_balance_cents + latest.distributions_cents - latest.contributions_cents;
|
||||
const gainLossPct =
|
||||
latest.contributions_cents > 0 ? (gainLoss / latest.contributions_cents) * 100 : null;
|
||||
const distributedPct =
|
||||
latest.commitment_cents > 0
|
||||
? (latest.distributions_cents / latest.commitment_cents) * 100
|
||||
: null;
|
||||
const history = useMemo(
|
||||
() => [...accounts].sort((a, b) => a.as_of_date.localeCompare(b.as_of_date)),
|
||||
[accounts],
|
||||
@@ -133,10 +243,14 @@ function CapitalBlock({
|
||||
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
|
||||
<p className="text-sm text-gray-500 mt-1">As of {formatDate(latest.as_of_date)}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
||||
<Metric label="Commitment" value={formatMoneyExact(latest.commitment_cents)} />
|
||||
<Metric label="Paid-in" value={formatMoneyExact(latest.contributions_cents)} />
|
||||
<Metric label="Commitment" value={formatMoneyWhole(latest.commitment_cents)} />
|
||||
<Metric label="Paid-in" value={formatMoneyWhole(latest.contributions_cents)} />
|
||||
{latest.distributions_cents > 0 && (
|
||||
<Metric label="Distributions" value={formatMoneyExact(latest.distributions_cents)} />
|
||||
<Metric
|
||||
label="Distributions"
|
||||
value={formatMoneyWhole(latest.distributions_cents)}
|
||||
sub={distributedPct != null ? `${distributedPct.toFixed(1)}% of commitment` : undefined}
|
||||
/>
|
||||
)}
|
||||
{latest.distributions_cents > 0 && latest.contributions_cents > 0 && (
|
||||
<Metric
|
||||
@@ -145,10 +259,15 @@ function CapitalBlock({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-4">Current capital value</p>
|
||||
<p className="text-sm text-gray-500 mt-4">Current Capital Balance</p>
|
||||
<p className="text-3xl font-semibold text-gray-900 mt-0.5">
|
||||
{formatMoneyExact(latest.ending_balance_cents)}
|
||||
{formatMoneyWhole(latest.ending_balance_cents)}
|
||||
</p>
|
||||
{gainLossPct != null && (
|
||||
<p className={`text-sm mt-1 ${gainLoss >= 0 ? "text-accent-600" : "text-red-600"}`}>
|
||||
{pct(gainLossPct)} <span className="text-gray-400">net of paid-in</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{history.length > 1 && (
|
||||
<div className="mt-5">
|
||||
@@ -159,9 +278,9 @@ function CapitalBlock({
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-gray-500 uppercase hover:text-gray-700"
|
||||
>
|
||||
<span className={`transition-transform ${showChart ? "rotate-90" : ""}`}>▸</span>
|
||||
Capital over time
|
||||
History
|
||||
<span className="normal-case font-normal text-gray-400">
|
||||
({history.length} quarters{showChart ? "" : " — show"})
|
||||
· {history.length} quarters
|
||||
</span>
|
||||
</button>
|
||||
{showChart && (
|
||||
@@ -195,11 +314,22 @@ function CapitalBlock({
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
color = "text-gray-900",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase">{label}</p>
|
||||
<p className="text-lg font-medium text-gray-900">{value}</p>
|
||||
<p className={`text-lg font-medium ${color}`}>{value}</p>
|
||||
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Bumped each release so the running build is visible in the UI.
|
||||
// If the number shown in the app doesn't match the installed s9pk version,
|
||||
// the new frontend isn't actually being served.
|
||||
export const APP_VERSION = "0.2.26";
|
||||
export const APP_VERSION = "0.2.32";
|
||||
|
||||
Reference in New Issue
Block a user