import { useEffect, useMemo, useState } from 'react';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import ValorisationPeChart from '../components/ValorisationPeChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import { fmtEUR, fmtPct } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
/**
* Tableau de bord Private Equity (chantier "dashboard PE", 17/09/26) — première
* version structurelle demandée par Olivier ("un premier jet avec tout") : KPI
* de portefeuille (porte-monnaie, capital investi, NAV, TVPI/DPI/RVPI, TRI,
* frais cumulés, fraîcheur des valorisations) + graphiques (courbe de
* valorisation globale, répartitions par plateforme/stratégie, frais par
* catégorie) + classement des deals par performance. Réutilise
* ValorisationPeChart (déjà utilisé sur la page Investissements PE) pour la
* courbe, et GET /api/dashboard-pe (déjà monté côté backend, jusqu'ici
* consommé uniquement par DepotsRetraitsSimple.jsx) pour le solde du
* porte-monnaie — même snapshot que le Dashboard crowdlending. Les
* répartitions passent par un petit composant local BarBreakdown plutôt que
* FraisDistributionChart, dont l'en-tête affiche le mot "Frais" en dur —
* trompeur pour une répartition de capital investi.
*/
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
const PALETTE = [
'#1e40af', '#9333ea', '#0d9488', '#f59e0b',
'#e8547a', '#10b981', '#f97316', '#06b6d4',
];
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
/* ── Carte KPI ──────────────────────────────────────────────── */
function KpiCard({ title, value, note, color }) {
return (
{title}
{data.length === 0 && (
Aucune donnée
)}
{data.map((d, i) => (
{d.label}
{fmtEUR(d.value)} · {total > 0 ? Math.round(d.value / total * 100) : 0}%
0 ? (d.value / max * 100) : 0}%`, height: '100%',
background: PALETTE[i % PALETTE.length], borderRadius: 6,
}} />
))}
);
}
/* ── Mini-jauge TVPI (classement par deal) — échelle visuelle 0 → x3 (au-delà,
* la barre sature à 100% : l'essentiel est de voir d'un coup d'œil qui est
* au-dessus/en-dessous de x1, pas d'avoir une échelle linéaire fidèle à un
* multiple hors norme). ── */
function TvpiBar({ value }) {
if (value == null) return
—;
const pct = Math.min(value, 3) / 3 * 100;
const color = value >= 1 ? 'var(--success)' : 'var(--danger)';
return (
);
}
export default function DashboardPe() {
const { activeId: investisseurId } = useInvestisseur();
const { active } = useWorkspace() || {};
const [rows, setRows] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [fraisOps, setFraisOps] = useState([]);
const [rembs, setRembs] = useState([]);
const [wallet, setWallet] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
useEffect(() => {
if (!investisseurId) return;
setLoading(true);
setErr(null);
Promise.all([
api.get('/investissements-pe'),
api.get('/investissements-pe/valorisations'),
api.get('/frais-operations'),
api.get('/remboursements'),
api.get('/dashboard-pe'),
]).then(([inv, val, frais, remb, dash]) => {
setRows(inv);
setValorisations(val);
setFraisOps(frais);
setRembs(remb);
setWallet(dash);
}).catch(e => setErr(e.message))
.finally(() => setLoading(false));
}, [investisseurId, active?.id]);
/* ── Agrégats KPI ── */
const totals = useMemo(() => {
let investi = 0, nav = 0, distributions = 0, fraisCumules = 0, actifs = 0, soldes = 0;
const staleDeals = [];
const staleThreshold = new Date();
staleThreshold.setMonth(staleThreshold.getMonth() - 6);
const staleThresholdStr = staleThreshold.toISOString().slice(0, 10);
for (const r of rows) {
investi += r.montant_investi;
nav += (r.derniere_valorisation ?? r.montant_investi);
distributions += r.distributions_cumulees || 0;
const enCours = r.statut === 'valide' || r.statut === 'en_attente';
if (enCours) {
actifs++;
if (!r.derniere_valorisation_date || r.derniere_valorisation_date < staleThresholdStr) {
staleDeals.push(r.nom_deal);
}
} else {
soldes++;
}
}
for (const f of fraisOps) fraisCumules += f.montant || 0;
return {
investi, nav, distributions, fraisCumules, actifs, soldes,
staleCount: staleDeals.length, staleDeals,
tvpi: investi > 0 ? (nav + distributions) / investi : null,
dpi: investi > 0 ? distributions / investi : null,
rvpi: investi > 0 ? nav / investi : null,
};
}, [rows, fraisOps]);
/* ── TRI (XIRR) global du portefeuille — même méthode que le XIRR
* crowdlending (Dashboard.jsx) : flux réels datés + valeur résiduelle des
* deals encore actifs, valorisée "comme vendue aujourd'hui". ── */
const xirrData = useMemo(() => {
const cutoff = new Date().toISOString().slice(0, 10);
const flows = [];
for (const r of rows) {
if (!r.date_souscription) continue;
flows.push({ amount: -r.montant_investi, date: r.date_souscription });
}
for (const rb of rembs) {
if (rb.investissement_pe_id == null) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || d > cutoff) continue;
flows.push({ amount: rb.net_recu || 0, date: d });
}
// Frais réglés hors "source" (vraie sortie de trésorerie du détenteur) —
// même logique que le XIRR net crowdlending.
for (const fo of fraisOps) {
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
const d = fo.date_operation?.slice(0, 10);
if (!d || d > cutoff) continue;
flows.push({ amount: -(fo.montant || 0), date: d });
}
let estime = false;
for (const r of rows) {
if (r.statut !== 'valide' && r.statut !== 'en_attente') continue;
const val = r.derniere_valorisation ?? r.montant_investi;
if (val > 0.01) { estime = true; flows.push({ amount: val, date: cutoff }); }
}
flows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
return { rate: xirr(flows), estime };
}, [rows, rembs, fraisOps]);
/* ── Répartitions ── */
const capitalParPlateforme = useMemo(() => {
const map = {};
for (const r of rows) map[r.plateforme_nom] = (map[r.plateforme_nom] || 0) + r.montant_investi;
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [rows]);
const capitalParStrategie = useMemo(() => {
const map = {};
for (const r of rows) {
const k = r.strategie || 'Non renseignée';
map[k] = (map[k] || 0) + r.montant_investi;
}
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [rows]);
const fraisParCategorie = useMemo(() => {
const map = {};
for (const f of fraisOps) {
const k = FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie || 'Autre';
map[k] = (map[k] || 0) + (f.montant || 0);
}
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [fraisOps]);
/* ── Classement des deals par TVPI ── */
const ranking = useMemo(() => [...rows].sort((a, b) => {
if (a.tvpi == null && b.tvpi == null) return 0;
if (a.tvpi == null) return 1;
if (b.tvpi == null) return -1;
return b.tvpi - a.tvpi;
}), [rows]);
const xirrDisplay = xirrData.rate != null ? fmtPct(xirrData.rate * 100) : 'Non calculable';
const xirrColor = xirrData.rate != null ? (xirrData.rate >= 0 ? 'var(--success)' : 'var(--danger)') : undefined;
const xirrNote = xirrData.rate == null
? 'Pas assez de flux datés pour calculer un taux'
: (xirrData.estime ? 'Estimé — deals actifs valorisés à date du jour' : 'Taux définitif — tous les deals sont soldés');
const staleNote = totals.staleCount === 0
? 'Toutes les valorisations actives sont à jour'
: totals.staleDeals.slice(0, 3).join(', ') + (totals.staleDeals.length > 3 ? '…' : '');
return (
<>
Tableau de bord — {active?.nom || 'Private Equity'}
{loading &&
Chargement…
}
{err &&
{err}
}
{!loading && !err && rows.length === 0 && (
)}
{!loading && !err && rows.length > 0 && (
<>
1 ? 's' : ''}`} note={`${totals.soldes} soldé${totals.soldes > 1 ? 's' : ''}`} />
0 ? 'var(--warning)' : undefined}
note={staleNote}
/>
Performance par deal
| Deal | Plateforme | Statut |
Capital investi |
Distributions |
Valorisation actuelle |
TVPI |
{ranking.map(r => (
| {r.nom_deal} |
{r.plateforme_nom} |
{STATUT_LABELS[r.statut] || r.statut} |
{fmtEUR(r.montant_investi)} |
{fmtEUR(r.distributions_cumulees || 0)} |
{fmtEUR(r.derniere_valorisation ?? r.montant_investi)} |
|
))}
>
)}
>
);
}