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>
231 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|