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}
{value}
{note &&
{note}
}
); } /* ── Répartition en barres horizontales (générique, léger) ───── * items : [{ label, value }] — trié par valeur décroissante, largeur des * barres proportionnelle au maximum. Volontairement plus simple qu'un * treemap SVG (DistributionChart / FraisDistributionChart) : ce composant * reste neutre (pas de titre métier en dur) pour être réutilisé sur * plusieurs répartitions différentes sans être trompeur. */ function BarBreakdown({ title, items }) { const data = (items || []).filter(i => i.value > 0).sort((a, b) => b.value - a.value); const max = data.length ? data[0].value : 0; const total = data.reduce((s, i) => s + i.value, 0); 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 (
{fmtMultiple(value)}
); } 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

{ranking.map(r => ( ))}
DealPlateformeStatut Capital investi Distributions Valorisation actuelle TVPI
{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)}
)} ); }