Files
crowdlending-app/frontend/src/pages/Dashboard.jsx
T
2026-09-06 18:59:09 +02:00

1019 lines
50 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageIcon from '../components/PageIcon.jsx';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
import InteretsMensuelsChart from '../components/InteretsMensuelsChart.jsx';
import InteretsDonutChart from '../components/InteretsDonutChart.jsx';
import { InteretsChartProvider, useInteretsChart } from '../context/InteretsChartContext.jsx';
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx';
import EmptyState from '../components/EmptyState.jsx';
/* ── Sélecteur d'année — doit être enfant de InteretsChartProvider ── */
function YearSelectorKpi() {
const { annee, setAnnee, availableYears, modeGlobal, toggleModeGlobal } = useInteretsChart();
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleSelect = (value) => {
if (value === 'all') {
if (!modeGlobal) toggleModeGlobal();
} else {
if (modeGlobal) toggleModeGlobal();
setAnnee(value);
}
setOpen(false);
};
// Années récentes en premier (desc), puis "Depuis le début" en tête
const options = [
{ value: 'all', label: 'Depuis le début' },
...availableYears.map(y => ({ value: y, label: String(y) })),
];
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 200 }}>
<div
onClick={() => setOpen(v => !v)}
style={{
height: '100%', boxSizing: 'border-box',
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
borderRadius: 10,
padding: '16px 20px',
boxShadow: open
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
cursor: 'pointer',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
userSelect: 'none',
transition: 'box-shadow .15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
}}>
Période
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{
color: '#fff',
fontSize: modeGlobal ? '1.1rem' : '2rem',
fontWeight: 700,
lineHeight: 1.1,
marginTop: 8,
}}>
{modeGlobal ? 'Depuis le début' : String(annee)}
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
minWidth: 200, overflow: 'hidden',
}}>
{options.map((opt, i) => {
const isActive = opt.value === 'all' ? modeGlobal : (!modeGlobal && annee === opt.value);
return (
<div
key={opt.value}
onClick={() => handleSelect(opt.value)}
style={{
padding: '10px 16px',
cursor: 'pointer',
background: isActive ? 'color-mix(in srgb, var(--primary) 8%, transparent)' : 'transparent',
color: isActive ? 'var(--primary)' : 'var(--text)',
fontWeight: isActive ? 700 : 400,
fontSize: 'var(--fs-sm)',
borderBottom: i < options.length - 1 ? '1px solid var(--border)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
transition: 'background .1s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<span>{opt.label}</span>
{isActive && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── Badge de tendance ── */
function TrendBadge({ current, prev, invert = false }) {
// prev quasi-nul (résidu flottant) → pourcentage non significatif ; seuil aligné sur
// l'arrondi 2 décimales pour éviter des variations aberrantes type "+39628021025%".
if (prev == null || Math.abs(prev) < 0.005) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
// invert=true : une hausse est mauvaise (ex. capital en risque)
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
/* ── Carte KPI individuelle ── */
function KpiCard({ title, value, badge, refValue, note, onClick, hint, infoTooltip }) {
return (
<div
className="kpi"
title={infoTooltip ? undefined : hint}
onClick={onClick}
style={onClick ? { cursor: 'pointer', transition: 'box-shadow 0.15s, opacity 0.15s' } : undefined}
onMouseEnter={onClick ? (e) => { e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'; e.currentTarget.style.opacity = '0.88'; } : undefined}
onMouseLeave={onClick ? (e) => { e.currentTarget.style.boxShadow = ''; e.currentTarget.style.opacity = ''; } : undefined}
>
<div className="label" style={infoTooltip ? { display: 'flex', alignItems: 'center', gap: 4 } : undefined}>
<span>{title}</span>
{infoTooltip && (
<span
className="cell-tooltip tooltip-down"
data-tooltip={infoTooltip}
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
onClick={(e) => e.stopPropagation()}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{value}</span>
{badge}
</div>
{refValue && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{refValue}</div>
)}
{note && (
<div style={{ fontSize: '0.8em', color: '#7c2d12', marginTop: 4, fontWeight: 600 }}>{note}</div>
)}
</div>
);
}
/* ── KPI filtrés par année ── */
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements, allRembs, allReinvests, allSimulRembs, allFraisOps }) {
// Montant total déjà déclaré en perte définitive (tous statuts confondus,
// seuls les dossiers passés en perte_definitive ont un capital_perdu > 0) —
// affiché en complément du KPI "Capital en risque", qui les exclut déjà
// (ce ne sont plus des dossiers "à risque" mais des pertes actées).
const capitalPertesDefinitives = (investissements || []).reduce((s, r) => s + (r.capital_perdu || 0), 0);
const {
annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth,
setInclureInterets, setInclureCapital, setInclureCashback,
} = useInteretsChart();
const isCurrentYear = !modeGlobal && Number(annee) === currentYear;
const isFutureYear = !modeGlobal && Number(annee) > currentYear;
// ── Porte-monnaie — même snapshot backend que DepotsRetraits/Plateformes
// (GET /dashboard[?asOf=]), formule unique cf. mémoire
// project_taxreport_interets_plateforme. "Depuis le début" → valeur live
// déjà chargée par le parent ; année précise → snapshot fin d'année.
//
// Comparaison : en vue live (mode global ou année courante), on compare au
// solde de fin du mois calendaire précédent (comme le KPI "Gain net depuis
// le début"). En vue d'une année passée, on garde la comparaison fin
// d'année vs fin d'année précédente (seule comparaison qui a du sens pour
// un snapshot historique figé au 31/12).
const prevMonthDatePM = new Date(currentYear, currentMonth - 2, 1);
const prevMonthEndDatePM = new Date(prevMonthDatePM.getFullYear(), prevMonthDatePM.getMonth() + 1, 0);
const walletPrevMonthKey = `${prevMonthEndDatePM.getFullYear()}-${String(prevMonthEndDatePM.getMonth() + 1).padStart(2, '0')}-${String(prevMonthEndDatePM.getDate()).padStart(2, '0')}`;
const walletPrevMonthLabel = `${['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'][prevMonthDatePM.getMonth()]} ${prevMonthDatePM.getFullYear()}`;
const [walletAsOf, setWalletAsOf] = useState({});
useEffect(() => {
if (!activeId && activeView !== 'all') return;
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
const keys = [];
if (modeGlobal || isCurrentYear) {
keys.push(walletPrevMonthKey);
}
if (!modeGlobal && !isCurrentYear) {
const prevYear = Number(annee) - 1;
keys.push(`${annee}-12-31`, `${prevYear}-12-31`);
}
if (!keys.length) return;
Promise.all(keys.map(asOf =>
api.get('/dashboard', { ...scopeParams, asOf }).then(d => [asOf, d])
)).then(entries => {
setWalletAsOf(prev => ({ ...prev, ...Object.fromEntries(entries) }));
}).catch(e => console.error('walletAsOf load error:', e));
}, [annee, modeGlobal, isCurrentYear, walletPrevMonthKey, activeId, activeView]); // eslint-disable-line
const soldePortefeuilleValue = (modeGlobal || isCurrentYear)
? soldePortefeuilleLive
: (walletAsOf[`${annee}-12-31`]?.solde_portefeuille_total ?? null);
const soldePortefeuillePrev = (modeGlobal || isCurrentYear)
? (walletAsOf[walletPrevMonthKey]?.solde_portefeuille_total ?? null)
: (walletAsOf[`${Number(annee) - 1}-12-31`]?.solde_portefeuille_total ?? null);
const soldePortefeuillePrevLabel = (modeGlobal || isCurrentYear)
? walletPrevMonthLabel
: String(Number(annee) - 1);
// Capital de référence pour l'année sélectionnée
// Pour les années passées : dernier mois de capitalMensuel (= capital déployé fin décembre)
// Pour l'année courante / mode global : capital actuellement déployé (portfolio)
const capitalAnnee = useMemo(() => {
if (modeGlobal || isCurrentYear) return portfolio.encours + portfolio.en_defaut;
// Année passée : prendre la valeur de fin décembre depuis capitalMensuel
const anneeStr = String(annee);
const moisAnnee = (capitalMensuelData ?? []).filter(c => c.mois?.startsWith(anneeStr));
if (moisAnnee.length > 0) {
// Prendre le dernier mois disponible (normalement décembre)
const last = moisAnnee[moisAnnee.length - 1];
return last.capital ?? 0;
}
return portfolio.encours + portfolio.en_defaut;
}, [modeGlobal, isCurrentYear, annee, capitalMensuelData, portfolio]);
// Capital souscrit par année (pour référence performance N-1, données manquantes, etc.)
const capitalParAnneeMap = useMemo(() => {
const list = rawDataGlobal.capitalParAnnee ?? [];
return Object.fromEntries(list.map(r => [r.annee, r.capital_souscrit]));
}, [rawDataGlobal]);
// ── Estimation réduction PFU pour une année donnée ──
const getPfuReduction = (yr) => {
if (!pfuRates.length) return 0;
const rate = pfuRates.find(r => r.annee === yr)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (rate.prelev_sociaux + rate.impot_revenu) / 100;
};
// ── Données consolidées pour une année (actuel + projeté selon le cas) ──
const getYearData = (yr) => {
const remRow = (rawDataGlobal.rembourses ?? []).find(r => Number(r.annee) === yr);
const projRow = (rawDataGlobal.projections ?? []).find(r => Number(r.annee) === yr);
if (yr > currentYear) {
// Année future : projections uniquement
const bruts = projRow?.interets_prevus || 0;
const red = getPfuReduction(yr);
return { interets_bruts: bruts, interets_nets: bruts * (1 - red), capital: projRow?.capital_prevu || 0, cashback: 0 };
}
if (yr === currentYear) {
// Année en cours : actuel reçu + reste projeté
const bruts_act = remRow?.interets_bruts || 0;
const bruts_proj = projRow?.interets_prevus || 0;
const red = getPfuReduction(yr);
return {
interets_bruts: bruts_act + bruts_proj,
interets_nets: (remRow?.interets_nets || 0) + bruts_proj * (1 - red),
capital: (remRow?.capital || 0) + (projRow?.capital_prevu || 0),
cashback: remRow?.cashback || 0,
};
}
// Année passée : actuel uniquement
return {
interets_bruts: remRow?.interets_bruts || 0,
interets_nets: remRow?.interets_nets || 0,
capital: remRow?.capital || 0,
cashback: remRow?.cashback || 0,
};
};
// ── Données mensuelles (mois en cours + M-1) — uniquement si année courante ──
const { thisMonthRow, prevMonthRow, prevMonthLabel, capitalCurrent, capitalPrev, enDefautCurrent, enDefautPrev } = useMemo(() => {
const MOIS_FR = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
const rows = rawData.rembourses ?? [];
const thisStr = String(currentYear) + '-' + String(currentMonth).padStart(2, '0');
const curr = rows.find(r => r.mois === thisStr) ?? null;
const prevDate = new Date(currentYear, currentMonth - 2, 1);
const prevStr = String(prevDate.getFullYear()) + '-' + String(prevDate.getMonth() + 1).padStart(2, '0');
const prev = rows.find(r => r.mois === prevStr) ?? null;
const label = MOIS_FR[prevDate.getMonth()] + ' ' + prevDate.getFullYear();
// Capital investi et en risque M vs M-1 depuis capitalMensuelData
const capRows = capitalMensuelData ?? [];
const capCurr = capRows.find(c => c.mois === thisStr);
const capPrev = capRows.find(c => c.mois === prevStr);
return {
thisMonthRow: curr, prevMonthRow: prev, prevMonthLabel: label,
capitalCurrent: capCurr?.capital ?? null,
capitalPrev: capPrev?.capital ?? null,
enDefautCurrent: capCurr?.en_defaut ?? null,
enDefautPrev: capPrev?.en_defaut ?? null,
};
}, [rawData, currentYear, currentMonth, capitalMensuelData]);
// ── Données annuelles (année sélectionnée + N-1) ──
const { annualData, prevAnnualData } = useMemo(() => {
if (modeGlobal) {
const total = (rawDataGlobal.rembourses ?? []).reduce((acc, r) => ({
interets_bruts: acc.interets_bruts + (r.interets_bruts || 0),
interets_nets: acc.interets_nets + (r.interets_nets || 0),
capital: acc.capital + (r.capital || 0),
cashback: acc.cashback + (r.cashback || 0),
}), { interets_bruts: 0, interets_nets: 0, capital: 0, cashback: 0 });
return { annualData: total, prevAnnualData: null };
}
return {
annualData: getYearData(Number(annee)),
prevAnnualData: getYearData(Number(annee) - 1),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawDataGlobal, annee, modeGlobal, pfuRates]);
// ── Gain net depuis le début ──────────────────────────────────
// Toujours net (indépendant du toggle Brut/Net), toujours cumulé sur tout
// l'historique (indépendant du sélecteur d'année) : intérêts nets + cashback
// (investissement + bonus parrainage/plateforme, déjà inclus dans le champ
// "cashback" de interets-annuels) + corrections de solde. Volontairement
// indépendant du capital encore investi et du solde porte-monnaie courant :
// reste juste même si tout l'argent est retiré des plateformes.
// Voir mémoire project_gain_net_depuis_debut pour la dérivation complète.
// gainNetDetail : mêmes totaux que gainNetTotal ci-dessous, mais conservés en détail
// (pas seulement le résultat final) pour alimenter le popup ⓘ de la carte KPI — demande
// Olivier du 02/09/26, juste après le correctif de la règle de déduction des frais.
const gainNetDetail = useMemo(() => {
const totals = (rawDataGlobal.rembourses ?? []).reduce((acc, r) => ({
interets_nets: acc.interets_nets + (r.interets_nets || 0),
cashback: acc.cashback + (r.cashback || 0),
}), { interets_nets: 0, cashback: 0 });
// Tous les frais réels réglés par l'investisseur SAUF ceux retenus directement sur un
// remboursement (mode_reglement='remboursement') — ceux-là sont déjà déduits de
// interets_nets/net_recu au moment du passage brut → net par la plateforme (ex.
// Fundora) : les soustraire ici referait une 2ᵉ diminution du gain, ce qui ne
// correspond à aucune sortie d'argent réelle supplémentaire (piège signalé par
// Olivier le 02/09/26). En revanche mode 'source' (déduit du versement à la
// souscription — déjà netté dans montant_investi, une donnée de CAPITAL que ce
// total n'utilise jamais) et 'portefeuille'/'compte_courant' (Baltis, réglés à part)
// sont chacun une vraie sortie d'argent jamais reflétée ailleurs dans cette somme
// (qui ne fait intervenir que des flux de revenu : intérêts, cashback, corrections)
// — les trois doivent donc être déduits. Ne PAS réutiliser tel quel le filtre
// ('portefeuille','compte_courant') de xirrGlobal (Étape 5) : le XIRR exclut
// 'source' pour une raison différente et spécifique au XIRR (déjà représenté par un
// flux de capital investi plus petit à t0), qui ne s'applique pas ici puisque ce
// total ne comporte aucun flux de capital.
const idsGainNet = new Set((investissements || []).map(r => r.id));
const fraisHorsRembTotal = (allFraisOps || []).reduce((sum, fo) => {
if (!idsGainNet.has(fo.investissement_id)) return sum;
if (fo.mode_reglement === 'remboursement') return sum;
return sum + (fo.montant || 0);
}, 0);
const corrections = correctionsTotal || 0;
const avantFrais = totals.interets_nets + totals.cashback + corrections;
return {
interetsNets: totals.interets_nets,
cashback: totals.cashback,
corrections,
avantFrais,
frais: fraisHorsRembTotal,
total: avantFrais - fraisHorsRembTotal,
};
}, [rawDataGlobal, correctionsTotal, investissements, allFraisOps]);
const gainNetTotal = gainNetDetail.total;
const gainNetInfoTooltip =
`Intérêts nets + cashback + bonus parrainage/plateforme + corrections de solde, moins ` +
`tous les frais réellement payés hors du passage brut → net d'un remboursement — cumulé ` +
`depuis le début, indépendant du capital encore investi ou du solde actuel.\n\n` +
`Détail du calcul :\n\n` +
`Intérêts nets perçus : ${fmtEUR(gainNetDetail.interetsNets)}\n` +
`+ Cashback et bonus (parrainage/plateforme) : ${fmtEUR(gainNetDetail.cashback)}\n` +
`+ Corrections de solde : ${fmtEUR(gainNetDetail.corrections)}\n` +
`= Gain avant frais : ${fmtEUR(gainNetDetail.avantFrais)}\n\n` +
`− Frais pris en compte (hors frais déjà déduits d'un remboursement) : ${fmtEUR(gainNetDetail.frais)}\n\n` +
`= Gain net depuis le début : ${fmtEUR(gainNetDetail.total)}`;
// Valeur "Gain net" telle qu'elle aurait été affichée fin du mois précédent —
// on retire du cumul total la part perçue ce mois-ci (intérêts nets + cashback,
// via thisMonthRow, déjà chargé pour l'année courante). Les corrections de solde
// n'ont pas de granularité mensuelle côté backend ; elles restent donc dans le
// cumul "avant ce mois", ce qui est sans incidence pratique (montants marginaux).
const gainNetPrevMonthEnd = useMemo(() => {
// Même granularité mensuelle que interets_nets/cashback pour les frais hors
// remboursement (portefeuille/compte_courant) : contrairement aux corrections de
// solde (jamais datées finement), frais_operations porte une vraie date_operation —
// on peut donc isoler la part du mois en cours plutôt que de la laisser polluer tout
// le delta M vs M-1 (cf. gainNetTotal ci-dessus).
const thisMonthStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}`;
const idsGainNet = new Set((investissements || []).map(r => r.id));
const fraisCeMois = (allFraisOps || []).reduce((sum, fo) => {
if (!idsGainNet.has(fo.investissement_id)) return sum;
if (fo.mode_reglement === 'remboursement') return sum;
if (!fo.date_operation?.startsWith(thisMonthStr)) return sum;
return sum + (fo.montant || 0);
}, 0);
const deltaCeMois = (thisMonthRow?.interets_nets || 0) + (thisMonthRow?.cashback || 0) - fraisCeMois;
return gainNetTotal - deltaCeMois;
}, [gainNetTotal, thisMonthRow, currentYear, currentMonth, investissements, allFraisOps]);
// ── XIRR Global (réel + attendu) — depuis le début, indépendant du sélecteur
// d'année (même logique que "Gain net depuis le début" ci-dessus). Reprend
// exactement la méthode plateformeXirr / plateformeXirrAttendu de
// Plateformes.jsx, appliquée ici à la totalité du portefeuille dans le
// scope courant (activeId / activeView, déjà appliqué côté API sur
// investissements / allRembs / allSimulRembs ; allReinvests est chargé en
// scope 'all' comme dans Plateformes.jsx, filtré ci-dessous via `ids`).
// Voir mémoire project_xirr_attendu_theorique.
const getRatesForYearKpi = (dateStr) => {
if (!dateStr || !pfuRates.length) return null;
const year = parseInt(dateStr.slice(0, 4), 10);
return pfuRates.find(r => r.annee === year)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
};
const capRembParInvGlobal = useMemo(() => {
const map = {};
for (const rb of (allRembs || [])) {
if (!rb.investissement_id) continue;
map[rb.investissement_id] = (map[rb.investissement_id] || 0) + (rb.capital || 0);
}
return map;
}, [allRembs]);
const reinvestCumulParInvGlobal = useMemo(() => {
const map = {};
for (const rv of (allReinvests || [])) {
map[rv.investissement_id] = (map[rv.investissement_id] || 0) + (rv.montant || 0);
}
return map;
}, [allReinvests]);
const xirrGlobal = useMemo(() => {
const cutoff = today();
const invs = investissements || [];
const ids = new Set(invs.map(r => r.id));
const flowsBrut = [];
const flowsNet = [];
for (const r of invs) {
if (!r.date_souscription) continue;
flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription });
flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription });
}
for (const rv of (allReinvests || [])) {
if (!ids.has(rv.investissement_id)) continue;
const d = rv.date_reinvestissement?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsBrut.push({ amount: -(rv.montant || 0), date: d });
flowsNet.push({ amount: -(rv.montant || 0), date: d });
}
for (const rb of (allRembs || [])) {
if (!ids.has(rb.investissement_id)) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
flowsNet.push({ amount: rb.net_recu || 0, date: d });
}
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
// ou 'compte_courant' — vraie sortie d'argent, comptée uniquement dans le XIRR NET.
for (const fo of (allFraisOps || [])) {
if (!ids.has(fo.investissement_id)) continue;
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
const d = fo.date_operation?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsNet.push({ amount: -(fo.montant || 0), date: d });
}
let estime = false;
for (const r of invs) {
const capInv = r.montant_investi + (reinvestCumulParInvGlobal[r.id] || 0);
const capRemb = capRembParInvGlobal[r.id] || 0;
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
if (capRestant > 0.01) {
estime = true;
flowsBrut.push({ amount: capRestant, date: cutoff });
flowsNet.push({ amount: capRestant, date: cutoff });
}
}
const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
flowsBrut.sort(byDate);
flowsNet.sort(byDate);
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
}, [investissements, allRembs, allReinvests, allFraisOps, reinvestCumulParInvGlobal, capRembParInvGlobal]);
const xirrGlobalAttendu = useMemo(() => {
const cutoff = today();
const invs = investissements || [];
const ids = new Set(invs.map(r => r.id));
const flowsBrut = [];
const flowsNet = [];
for (const r of invs) {
if (!r.date_souscription) continue;
flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription });
flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription });
}
for (const rv of (allReinvests || [])) {
if (!ids.has(rv.investissement_id)) continue;
const d = rv.date_reinvestissement?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsBrut.push({ amount: -(rv.montant || 0), date: d });
flowsNet.push({ amount: -(rv.montant || 0), date: d });
}
for (const rb of (allRembs || [])) {
if (!ids.has(rb.investissement_id)) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
flowsNet.push({ amount: rb.net_recu || 0, date: d });
}
// Frais réglés en dehors des montants déjà nettés (source à la souscription,
// et frais liés à un remboursement déjà déduits de net_recu) : mode 'portefeuille'
// ou 'compte_courant' — vraie sortie d'argent, comptée uniquement dans le XIRR NET.
for (const fo of (allFraisOps || [])) {
if (!ids.has(fo.investissement_id)) continue;
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
const d = fo.date_operation?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsNet.push({ amount: -(fo.montant || 0), date: d });
}
const futureSimulParInv = {};
for (const sr of (allSimulRembs || [])) {
if (!ids.has(sr.investissement_id)) continue;
const d = sr.date_prevue?.slice(0, 10);
if (!d || d <= cutoff) continue;
if (!futureSimulParInv[sr.investissement_id]) futureSimulParInv[sr.investissement_id] = [];
futureSimulParInv[sr.investissement_id].push({ date: d, capital: sr.capital_prevu || 0, interets: sr.interets_prevus || 0 });
}
for (const r of invs) {
const capInv = r.montant_investi + (reinvestCumulParInvGlobal[r.id] || 0);
const capRemb = capRembParInvGlobal[r.id] || 0;
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
if (capRestant <= 0.01) continue;
const echeances = futureSimulParInv[r.id];
if (echeances && echeances.length) {
for (const e of echeances) {
if (e.capital <= 0 && e.interets <= 0) continue;
const rate = getRatesForYearKpi(e.date);
const pfu = rate ? (rate.prelev_sociaux + rate.impot_revenu) / 100 : 0.3;
flowsBrut.push({ amount: e.capital + e.interets, date: e.date });
flowsNet.push({ amount: e.capital + e.interets * (1 - pfu), date: e.date });
}
} else {
flowsBrut.push({ amount: capRestant, date: cutoff });
flowsNet.push({ amount: capRestant, date: cutoff });
}
}
const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
flowsBrut.sort(byDate);
flowsNet.sort(byDate);
return { brut: xirr(flowsBrut), net: xirr(flowsNet) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investissements, allRembs, allReinvests, allFraisOps, allSimulRembs, reinvestCumulParInvGlobal, capRembParInvGlobal, pfuRates]);
// ── Valeur affichée ──
const getValue = (field) => {
if (isCurrentYear) return thisMonthRow?.[field] || 0;
return annualData[field] || 0;
};
// ── Badge et référence ──
const mkBadge = (field) => {
if (modeGlobal) return null;
if (isCurrentYear) {
if (!thisMonthRow || !prevMonthRow) return null;
return <TrendBadge current={thisMonthRow[field] || 0} prev={prevMonthRow[field] || 0} />;
}
if (!prevAnnualData) return null;
return <TrendBadge current={annualData[field] || 0} prev={prevAnnualData[field] || 0} />;
};
const mkRef = (field) => {
if (modeGlobal) return null;
if (isCurrentYear) {
if (!prevMonthRow) return null;
return fmtEUR(prevMonthRow[field] || 0) + ' en ' + prevMonthLabel;
}
if (!prevAnnualData) return null;
const prevYr = Number(annee) - 1;
const suffix = prevYr >= currentYear ? ' (proj.)' : '';
return fmtEUR(prevAnnualData[field] || 0) + ' en ' + prevYr + suffix;
};
const interetsField = netMode ? 'interets_nets' : 'interets_bruts';
// ── Performance annualisée ──────────────────────────────────────
const calcPerfRatio = (interetsVal, capital) =>
capital > 0 && interetsVal != null ? interetsVal / capital : null;
// Mensuelle annualisée (mode année courante)
const capitalDeploye = portfolio.encours + portfolio.en_defaut;
const perfCurrent = isCurrentYear && thisMonthRow && capitalDeploye > 0
? calcPerfRatio(thisMonthRow[interetsField] || 0, capitalDeploye) * 12
: null;
const perfPrev = isCurrentYear && prevMonthRow && capitalDeploye > 0
? calcPerfRatio(prevMonthRow[interetsField] || 0, capitalDeploye) * 12
: null;
// Annuelle (mode année passée/future) — même formule que le tableau :
// (interets + cashback) / capital souscrit cette année-là
const perfInteretsAnnee = (annualData[interetsField] || 0) + (annualData.cashback || 0);
const perfAnnual = !modeGlobal && !isCurrentYear && capitalAnnee > 0
? calcPerfRatio(perfInteretsAnnee, capitalAnnee)
: null;
const prevCapitalAnnee = modeGlobal || isCurrentYear
? capitalDeploye
: (capitalParAnneeMap[Number(annee) - 1] ?? capitalDeploye);
const perfInteretsPrevAnnee = ((prevAnnualData?.[interetsField] || 0) + (prevAnnualData?.cashback || 0));
const perfAnnualPrev = !modeGlobal && !isCurrentYear && prevAnnualData && prevCapitalAnnee > 0
? calcPerfRatio(perfInteretsPrevAnnee, prevCapitalAnnee)
: null;
const perfValue = modeGlobal ? null : (isCurrentYear ? perfCurrent : perfAnnual);
const perfPrevVal = modeGlobal ? null : (isCurrentYear ? perfPrev : perfAnnualPrev);
const perfLabel = netMode ? 'Performance nette annualisée' : 'Performance brute annualisée';
const perfRefLabel = isCurrentYear
? (perfPrevVal != null ? fmtPct(perfPrevVal * 100) + ' en ' + prevMonthLabel : null)
: (perfAnnualPrev != null ? fmtPct(perfAnnualPrev * 100) + ' en ' + (Number(annee) - 1) : null);
return (
<div className="kpi-grid" style={{ flex: 1, marginBottom: 0 }}>
<KpiCard
title="Capital investi"
value={fmtEUR(capitalAnnee)}
badge={isCurrentYear && capitalCurrent != null && capitalPrev != null && capitalPrev > 0
? <TrendBadge current={capitalCurrent} prev={capitalPrev} />
: null}
refValue={isCurrentYear && capitalPrev != null
? fmtEUR(capitalPrev) + ' en ' + prevMonthLabel
: null}
/>
<KpiCard
title="Capital en risque"
value={fmtEUR(portfolio.en_defaut)}
badge={isCurrentYear && enDefautCurrent != null && enDefautPrev != null && enDefautPrev > 0
? <TrendBadge current={enDefautCurrent} prev={enDefautPrev} invert={true} />
: null}
refValue={isCurrentYear && enDefautPrev != null && enDefautPrev > 0
? fmtEUR(enDefautPrev) + ' en ' + prevMonthLabel
: null}
note={capitalPertesDefinitives > 0
? `dont ${fmtEUR(capitalPertesDefinitives)} déjà déclarés en perte définitive (non compris ci-dessus)`
: null}
/>
<KpiCard
title="Porte-monnaie"
hint="Cash disponible sur les porte-monnaie des plateformes (hors compte courant)"
value={soldePortefeuilleValue != null ? fmtEUR(soldePortefeuilleValue) : '…'}
badge={soldePortefeuilleValue != null && soldePortefeuillePrev != null
? <TrendBadge current={soldePortefeuilleValue} prev={soldePortefeuillePrev} />
: null}
refValue={soldePortefeuillePrev != null
? fmtEUR(soldePortefeuillePrev) + ' en ' + soldePortefeuillePrevLabel
: null}
/>
<KpiCard
title={perfLabel + (isFutureYear ? ' (proj.)' : '')}
value={perfValue != null ? fmtPct(perfValue * 100) : '—'}
badge={perfValue != null && perfPrevVal != null
? <TrendBadge current={perfValue} prev={perfPrevVal} />
: null}
refValue={perfRefLabel}
/>
<KpiCard
title={(netMode ? 'Intérêts nets' : 'Intérêts bruts') + (isFutureYear ? ' (proj.)' : '')}
value={fmtEUR(getValue(interetsField))}
badge={mkBadge(interetsField)}
refValue={mkRef(interetsField)}
onClick={() => { setInclureInterets(true); setInclureCapital(false); setInclureCashback(false); }}
/>
<KpiCard
title={'Capital remboursé' + (isFutureYear ? ' (proj.)' : '')}
value={fmtEUR(getValue('capital'))}
badge={mkBadge('capital')}
refValue={mkRef('capital')}
onClick={() => { setInclureInterets(false); setInclureCapital(true); setInclureCashback(false); }}
/>
<KpiCard
title="Cashback reçu"
value={fmtEUR(getValue('cashback'))}
badge={mkBadge('cashback')}
refValue={mkRef('cashback')}
onClick={() => { setInclureInterets(false); setInclureCapital(false); setInclureCashback(true); }}
/>
<KpiCard
title="Gain net depuis le début"
infoTooltip={gainNetInfoTooltip}
value={fmtEUR(gainNetTotal)}
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel}
/>
{(() => {
const rendement = netMode ? xirrGlobal.net : xirrGlobal.brut;
const rendementAttendu = netMode ? xirrGlobalAttendu.net : xirrGlobalAttendu.brut;
const fraisExplication = ` Les frais réglés hors remboursement (via le porte-monnaie ou un compte courant) sont eux aussi comptés comme une sortie d'argent dans ce calcul net — en plus des frais déjà déduits à la source ou d'un remboursement, toujours pris en compte.`;
const xirrExplication =
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
`des prêts du portefeuille (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}), ` +
`depuis le début. Chaque flux est pondéré par sa date exacte.` + (netMode ? fraisExplication : '') + `\n\n` +
(xirrGlobal.estime
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé à aujourd'hui — c'est donc une estimation, pas un taux définitif.`
: 'Tous les prêts du portefeuille sont soldés : taux définitif.');
const xirrAttenduExplication =
`XIRR attendu (théorique) : comme le taux de rendement interne affiché par les plateformes de crowdlending ` +
`(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` +
`que toutes les échéances prévues (capital + intérêts) des prêts encore en cours seront honorées exactement à la ` +
`date prévue par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû ` +
`"comme racheté aujourd'hui", sans compter les intérêts futurs.` + (netMode ? fraisExplication : '') + `\n\n` +
`C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement ` +
`(voir le KPI "Capital en risque" pour ce risque). Si un prêt n'a pas d'échéancier prévisionnel en base, il est ` +
`valorisé de la même façon que dans le XIRR réel.`;
return (
<div className="kpi">
<div className="label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span>
XIRR Global — {netMode ? 'Net' : 'Brut'}
{xirrGlobal.estime && rendement !== null ? <span className="text-muted"> (estimé)</span> : ''}
</span>
<span
className="cell-tooltip tooltip-down"
data-tooltip={xirrExplication}
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span
style={{ fontSize: '1.35rem', fontWeight: 700, color: rendement !== null ? (rendement >= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}
>
{rendement !== null
? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %`
: 'Aucun remboursement'}
</span>
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
Depuis le début
</div>
{rendementAttendu !== null && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 6 }}>
<span style={{
fontSize: '0.78em', fontWeight: 600, padding: '2px 8px', borderRadius: 20,
background: 'rgba(124,58,237,0.10)', color: '#7c3aed', whiteSpace: 'nowrap',
}}>
Attendu : {(rendementAttendu * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %
</span>
<span
className="cell-tooltip tooltip-down"
data-tooltip={xirrAttenduExplication}
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</span>
</div>
)}
</div>
);
})()}
</div>
);
}
export default function Dashboard() {
const { activeId, activeView, activeViewMember, investisseurs } = useInvestisseur();
const { displayMode } = useUi();
const netMode = displayMode === 'net';
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [data, setData] = useState(null);
const [pfuRates, setPfuRates] = useState([]);
const [loading, setLoading] = useState(false);
const [capitalMensuelData, setCapitalMensuelData] = useState([]);
const [plateformes, setPlateformes] = useState([]);
const [platLoaded, setPlatLoaded] = useState(false);
const [investissements, setInvestissements] = useState([]);
const [allRembs, setAllRembs] = useState([]);
const [allReinvests, setAllReinvests] = useState([]);
const [allSimulRembs, setAllSimulRembs] = useState([]);
const [allFraisOps, setAllFraisOps] = useState([]);
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
const _now = new Date();
const _moisLabels = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
/* Restaure le drillCell depuis les params URL (retour depuis Remboursements) */
const _initDrillCell = () => {
const ba = searchParams.get('drill-annee');
const bm = searchParams.get('drill-mois');
const bp = searchParams.get('drill-plat');
if (ba && bm) {
const annee = Number(ba), mois = Number(bm);
return {
platId: bp ? Number(bp) : null,
platNom: null,
annee,
mois,
moisLabel: _moisLabels[mois - 1],
};
}
return {
platId: null,
platNom: null,
annee: _now.getFullYear(),
mois: _now.getMonth() + 1,
moisLabel: _moisLabels[_now.getMonth()],
};
};
const [drillCell, setDrillCell] = useState(_initDrillCell);
/* Nettoie les params URL de retour dès le premier rendu */
useEffect(() => {
if (searchParams.get('drill-annee')) {
setSearchParams({}, { replace: true });
}
}, []); /* eslint-disable-next-line */
useEffect(() => {
api.get('/pfu').then(setPfuRates).catch(() => {});
api.get('/plateformes').then(d => { setPlateformes(d); setPlatLoaded(true); }).catch(() => { setPlatLoaded(true); });
}, []);
const loadDashboardData = () => {
if (!activeId && activeView !== 'all') return Promise.resolve();
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
return Promise.all([
api.get('/dashboard', scopeParams).then(setData),
api.get('/investissements', scopeParams).then(setInvestissements).catch(() => setInvestissements([])),
// Nécessaires au calcul du KPI "XIRR Global" (réel + attendu), même
// méthode que plateformeXirr / plateformeXirrAttendu dans Plateformes.jsx.
// allReinvests en scope 'all' (comme dans Plateformes.jsx) : filtré par
// investissement via `ids` au moment du calcul XIRR.
api.get('/remboursements', scopeParams).then(setAllRembs).catch(() => setAllRembs([])),
api.get('/reinvestissements', { scope: 'all' }).then(setAllReinvests).catch(() => setAllReinvests([])),
api.get('/simul/all', scopeParams).then(setAllSimulRembs).catch(() => setAllSimulRembs([])),
// Nécessaire au XIRR net (frais réglés par portefeuille/compte courant, cf. Étape 5) —
// sans investissement_id, ne connaît que l'utilisateur (tous investisseurs), filtré
// ensuite via `ids` au moment du calcul, même pattern que allFraisOps dans Plateformes.jsx.
api.get('/frais-operations').then(setAllFraisOps).catch(() => setAllFraisOps([])),
]);
};
useEffect(() => {
setData(null);
setLoading(true);
loadDashboardData().finally(() => setLoading(false));
/* eslint-disable-next-line */
}, [activeView, activeId]);
/* Rafraîchit le tableau de bord + le panneau de détail après une validation en masse
de projections (bulk) depuis le DrillCellPanel — sans quoi l'écran restait figé sur
les anciennes projections malgré des remboursements bien créés en base. */
const onDrillBulkDone = () => {
loadDashboardData();
setDrillRefreshKey(k => k + 1);
};
const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId;
if (!ready) return <div className="card text-muted">Sélectionnez un compte investisseur.</div>;
if (loading || !data) return <div className="card text-muted">Chargement…</div>;
if (platLoaded && plateformes.length === 0) return (
<>
<div className="topbar"><h2>Tableau de bord</h2></div>
<EmptyState />
</>
);
if (platLoaded && data.portfolio.nb_projets === 0) return (
<>
<div className="topbar"><h2>Tableau de bord</h2></div>
<EmptyState
icon="📊"
title="Aucun investissement pour le moment"
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez un investissement pour voir apparaître les données de votre tableau de bord."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
</>
);
const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data;
const viewTitle = activeView === 'all'
? 'Famille et entreprises'
: memberLabel(activeViewMember);
return (
<>
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {viewTitle}</h2></div>
<InteretsChartProvider key={drillRefreshKey} netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
{/* ── KPI + sélecteur année ── */}
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch', marginBottom: 16 }}>
<DashboardKpis
portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData}
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
allRembs={allRembs} allReinvests={allReinvests} allSimulRembs={allSimulRembs} allFraisOps={allFraisOps}
/>
<YearSelectorKpi />
</div>
{/* ── Graphiques ── */}
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch', marginTop: 8, marginBottom: 24 }}>
<div style={{ flex: 2, minWidth: 0 }}>
<InteretsMensuelsChart />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<InteretsDonutChart />
</div>
</div>
{/* ── Tableau intérêts par plateforme ── */}
<div style={{ marginBottom: 0 }}>
<TableauInteretsPlateforme
activeView={activeView}
activeId={activeId}
pfuRates={pfuRates}
onCapitalMensuel={setCapitalMensuelData}
onCellClick={({ platId, platIds, platNom, annee, mois, moisLabel }) =>
setDrillCell({ platId, platIds, platNom, annee, mois, moisLabel })
}
activeCell={drillCell}
/>
</div>
{/* ── Panneau détail mois / plateforme (remplace les Échéances prévues) ── */}
<DrillCellPanel
cell={drillCell}
alwaysOpen={true}
pfuRates={pfuRates}
activeView={activeView}
activeId={activeId}
plateformes={plateformes}
investissements={investissements}
refreshKey={drillRefreshKey}
onBulkDone={onDrillBulkDone}
onEditRecu={(r) => {
const q = new URLSearchParams({
'edit-remb': r.id,
from: 'dashboard',
'drill-annee': drillCell.annee,
'drill-mois': drillCell.mois,
...(drillCell.platId ? { 'drill-plat': drillCell.platId } : {}),
});
navigate(`/remboursements?${q}`);
}}
onEditProjet={(p) => {
const q = new URLSearchParams({
'open-simul': p.investissement_id,
'simul-date': p.date_prevue,
'simul-capital': p.capital_prevu ?? 0,
'simul-interets': p.interets_prevus ?? 0,
from: 'dashboard',
'drill-annee': drillCell.annee,
'drill-mois': drillCell.mois,
...(drillCell.platId ? { 'drill-plat': drillCell.platId } : {}),
});
navigate(`/remboursements?${q}`);
}}
/>
</InteretsChartProvider>
</>
);
}