Files
Ten31-Portal/frontend/src/pages/EntityPartners.tsx
T
Jonathan KirkwoodandClaude Opus 4.8 a639ba14cf 0.2.33: exited positions (secondary-sale edge case)
A member who sold/transferred their stake showed a phantom -100% loss
($0 ending balance, no distribution through the fund). Now:
- entity_access.exited_on (migration e1f2a3b4c5d6), set/cleared from the
  Partners tab (writer-only, inline date picker, audited)
- LP portal card shows a quiet "Exited <date>" badge, keeps documents,
  hides balance/gain
- portfolio summary excludes exited positions ("Excludes N exited")
- fund committed totals (rollup + Partners tab) skip exited members so
  seller + buyer are not double-counted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:54:19 -05:00

231 lines
9.8 KiB
TypeScript

import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
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);
// Row currently getting an exit date (inline date picker), and the chosen date.
const [exitingId, setExitingId] = useState<number | null>(null);
const [exitDate, setExitDate] = useState(() => new Date().toISOString().slice(0, 10));
const isWriter = !!user && canEditRound(user.role);
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]);
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);
}
}
async function saveExit(userId: number, exitedOn: string | null) {
if (!entity) return;
setError("");
try {
const updated = await api.setPartnerExited(entity.id, userId, exitedOn);
setPartners((prev) => prev.map((p) => (p.user_id === userId ? updated : p)));
setExitingId(null);
} catch (err: any) {
setError(err.message || "Failed to update exit status");
}
}
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading</div>;
// Exited members (stake sold/transferred) stay on the roster but out of the totals —
// otherwise a seller and their buyer would both be counted.
const active = partners.filter((p) => !p.exited_on);
const exitedCount = partners.length - active.length;
const totalCommitted = active.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
const totalCapital = active.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>}
{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>
<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>
{exitedCount > 0 && (
<span className="ml-2 text-xs text-gray-400">
(excludes {exitedCount} exited)
</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">
<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">Status</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 ${p.exited_on ? "text-gray-400" : ""}`}>
<td className="px-4 py-2">
<div className={p.exited_on ? "text-gray-500" : "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 whitespace-nowrap">
{p.exited_on ? (
<span>
<span className="text-xs font-medium uppercase text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
Exited {formatDate(p.exited_on)}
</span>
{isWriter && (
<button
onClick={() => saveExit(p.user_id, null)}
className="ml-2 text-xs text-accent-600 hover:text-accent-700"
>
Undo
</button>
)}
</span>
) : exitingId === p.user_id ? (
<span className="flex items-center gap-1.5">
<input
type="date"
value={exitDate}
onChange={(e) => setExitDate(e.target.value)}
className="px-1.5 py-0.5 border border-gray-300 rounded text-xs"
/>
<button
onClick={() => exitDate && saveExit(p.user_id, exitDate)}
className="text-xs text-accent-600 hover:text-accent-700 font-medium"
>
Save
</button>
<button
onClick={() => setExitingId(null)}
className="text-xs text-gray-400 hover:text-gray-600"
>
Cancel
</button>
</span>
) : (
<span>
<span className="text-gray-600">Member</span>
{isWriter && (
<button
onClick={() => setExitingId(p.user_id)}
className="ml-2 text-xs text-gray-400 hover:text-gray-600"
>
Mark exited
</button>
)}
</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={9} 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>
);
}