diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 4e5082b..2f8a9ab 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -4,7 +4,8 @@ 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 } from '../utils/format.js'; +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'; @@ -182,7 +183,7 @@ function KpiCard({ title, value, badge, refValue, note, onClick, hint }) { } /* ── KPI filtrés par année ── */ -function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements }) { +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à @@ -372,6 +373,139 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ 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; @@ -511,6 +645,76 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ badge={} 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 ( +
+
+ + XIRR Global — {netMode ? 'Net' : 'Brut'} + {xirrGlobal.estime && rendement !== null ? (estimé) : ''} + + + + + + +
+
+ = 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }} + > + {rendement !== null + ? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %` + : 'Aucun remboursement'} + +
+
+ Depuis le début +
+ {rendementAttendu !== null && ( +
+ + Attendu : {(rendementAttendu * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} % + + + + + + +
+ )} +
+ ); + })()} ); } @@ -529,6 +733,9 @@ export default function Dashboard() { 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 [drillRefreshKey, setDrillRefreshKey] = useState(0); /* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */ @@ -578,6 +785,13 @@ export default function Dashboard() { 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([])), ]); }; @@ -635,6 +849,7 @@ export default function Dashboard() { 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} />