);
}
/* ── KPI filtrés par année ── */
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements, allRembs, allReinvests, allSimulRembs }) {
// 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.
const gainNetTotal = 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 });
return totals.interets_nets + totals.cashback + (correctionsTotal || 0);
}, [rawDataGlobal, correctionsTotal]);
// 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(() => {
const deltaCeMois = (thisMonthRow?.interets_nets || 0) + (thisMonthRow?.cashback || 0);
return gainNetTotal - deltaCeMois;
}, [gainNetTotal, thisMonthRow]);
// ── 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 });
}
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, 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 });
}
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, 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 ;
}
if (!prevAnnualData) return null;
return ;
};
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 (
0
?
: null}
refValue={isCurrentYear && capitalPrev != null
? fmtEUR(capitalPrev) + ' en ' + prevMonthLabel
: null}
/>
0
?
: 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}
/>
: null}
refValue={soldePortefeuillePrev != null
? fmtEUR(soldePortefeuillePrev) + ' en ' + soldePortefeuillePrevLabel
: null}
/>
: null}
refValue={perfRefLabel}
/>
{ setInclureInterets(true); setInclureCapital(false); setInclureCashback(false); }}
/>
{ setInclureInterets(false); setInclureCapital(true); setInclureCashback(false); }}
/>
{ setInclureInterets(false); setInclureCapital(false); setInclureCashback(true); }}
/>
}
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel}
/>
{(() => {
const rendement = netMode ? xirrGlobal.net : xirrGlobal.brut;
const rendementAttendu = netMode ? xirrGlobalAttendu.net : xirrGlobalAttendu.brut;
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.\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.\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 (