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:
Jonathan Kirkwood
2026-07-01 14:25:50 -05:00
parent 7fc78d7058
commit f0f8fd15c6
69 changed files with 5492 additions and 740 deletions
+136
View File
@@ -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>
);
}
+222
View File
@@ -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";
+265
View File
@@ -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";
+237
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+126 -30
View File
@@ -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,
+109
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+13 -14
View File
@@ -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
+432
View File
@@ -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>
);
}