1019 lines
50 KiB
React
1019 lines
50 KiB
React
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>
|
||
</>
|
||
);
|
||
}
|