Files
crowdlending-app/frontend/src/pages/DashboardPe.jsx
T

332 lines
15 KiB
React

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 (
<div className="kpi">
<div className="label">{title}</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4, color: color || 'var(--text)' }}>{value}</div>
{note && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{note}</div>}
</div>
);
}
/* ── 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 (
<div className="card">
<h3 style={{ margin: '0 0 14px' }}>{title}</h3>
{data.length === 0 && (
<div className="text-muted" style={{ textAlign: 'center', padding: '20px 0' }}>Aucune donnée</div>
)}
{data.map((d, i) => (
<div key={d.label} style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85em', marginBottom: 3 }}>
<span>{d.label}</span>
<span className="text-muted">{fmtEUR(d.value)} · {total > 0 ? Math.round(d.value / total * 100) : 0}%</span>
</div>
<div style={{ background: 'var(--surface-2)', borderRadius: 6, height: 8, overflow: 'hidden' }}>
<div style={{
width: `${max > 0 ? (d.value / max * 100) : 0}%`, height: '100%',
background: PALETTE[i % PALETTE.length], borderRadius: 6,
}} />
</div>
</div>
))}
</div>
);
}
/* ── 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 <span className="text-muted">—</span>;
const pct = Math.min(value, 3) / 3 * 100;
const color = value >= 1 ? 'var(--success)' : 'var(--danger)';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ position: 'relative', width: 90, height: 8, background: 'var(--surface-2)', borderRadius: 4, flexShrink: 0 }}>
<div style={{ position: 'absolute', inset: 0, width: `${pct}%`, background: color, borderRadius: 4 }} />
<div title="x1 (seuil de rentabilité)" style={{ position: 'absolute', left: `${(1 / 3) * 100}%`, top: -2, width: 1, height: 12, background: 'var(--text-muted)' }} />
</div>
<span style={{ fontWeight: 600, color }}>{fmtMultiple(value)}</span>
</div>
);
}
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 (
<>
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {active?.nom || 'Private Equity'}</h2></div>
{loading && <div className="text-muted" style={{ padding: '24px 0' }}>Chargement…</div>}
{err && <div className="error">{err}</div>}
{!loading && !err && rows.length === 0 && (
<EmptyState
icon="📈"
title="Aucun investissement enregistré"
message="Ajoutez votre premier investissement Private Equity pour voir apparaître les indicateurs et graphiques du portefeuille."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
)}
{!loading && !err && rows.length > 0 && (
<>
<div className="kpi-grid">
<KpiCard title="Capital investi total" value={fmtEUR(totals.investi)} />
<KpiCard
title="Porte-monnaie"
value={wallet ? fmtEUR(wallet.solde_portefeuille_total) : '…'}
note="Cash disponible sur les plateformes, hors compte courant"
/>
<KpiCard title="Valorisation nette (NAV)" value={fmtEUR(totals.nav)} />
<KpiCard title="Distributions cumulées" value={fmtEUR(totals.distributions)} />
<KpiCard title="TVPI portefeuille" value={fmtMultiple(totals.tvpi)} note="Valeur totale / capital investi" />
<KpiCard title="DPI (réalisé)" value={fmtMultiple(totals.dpi)} note="Distributions / capital investi" />
<KpiCard title="RVPI (non réalisé)" value={fmtMultiple(totals.rvpi)} note="Valorisation résiduelle / capital investi" />
<KpiCard title="TRI (XIRR) global" value={xirrDisplay} color={xirrColor} note={xirrNote} />
<KpiCard title="Frais cumulés" value={fmtEUR(totals.fraisCumules)} />
<KpiCard title="Deals" value={`${totals.actifs} actif${totals.actifs > 1 ? 's' : ''}`} note={`${totals.soldes} soldé${totals.soldes > 1 ? 's' : ''}`} />
<KpiCard
title="Valorisations à rafraîchir"
value={String(totals.staleCount)}
color={totals.staleCount > 0 ? 'var(--warning)' : undefined}
note={staleNote}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '5fr 3fr', gap: 16, marginTop: 8, marginBottom: 16 }}>
<ValorisationPeChart rows={rows} valorisations={valorisations} platYear={null} />
<BarBreakdown title="Capital investi par plateforme" items={capitalParPlateforme} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<BarBreakdown title="Capital investi par stratégie" items={capitalParStrategie} />
<BarBreakdown title="Frais cumulés par catégorie" items={fraisParCategorie} />
</div>
<div className="card">
<h3 style={{ margin: '0 0 14px' }}>Performance par deal</h3>
<table>
<thead>
<tr>
<th>Deal</th><th>Plateforme</th><th>Statut</th>
<th className="num">Capital investi</th>
<th className="num">Distributions</th>
<th className="num">Valorisation actuelle</th>
<th>TVPI</th>
</tr>
</thead>
<tbody>
{ranking.map(r => (
<tr key={r.id}>
<td>{r.nom_deal}</td>
<td className="text-muted">{r.plateforme_nom}</td>
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
<td className="num">{fmtEUR(r.montant_investi)}</td>
<td className="num">{fmtEUR(r.distributions_cumulees || 0)}</td>
<td className="num">{fmtEUR(r.derniere_valorisation ?? r.montant_investi)}</td>
<td><TvpiBar value={r.tvpi} /></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
);
}