Nouveau KPI
This commit is contained in:
1 parent
e401530673
commit
7519b7507f
1 file changed
+217
-2
@@ -4,7 +4,8 @@ import PageIcon from '../components/PageIcon.jsx';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||||
import { useUi } from '../context/UiContext.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 InteretsMensuelsChart from '../components/InteretsMensuelsChart.jsx';
|
||||||
import InteretsDonutChart from '../components/InteretsDonutChart.jsx';
|
import InteretsDonutChart from '../components/InteretsDonutChart.jsx';
|
||||||
import { InteretsChartProvider, useInteretsChart } from '../context/InteretsChartContext.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 ── */
|
/* ── 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,
|
// 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) —
|
// 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à
|
// 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;
|
return gainNetTotal - deltaCeMois;
|
||||||
}, [gainNetTotal, thisMonthRow]);
|
}, [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 ──
|
// ── Valeur affichée ──
|
||||||
const getValue = (field) => {
|
const getValue = (field) => {
|
||||||
if (isCurrentYear) return thisMonthRow?.[field] || 0;
|
if (isCurrentYear) return thisMonthRow?.[field] || 0;
|
||||||
@@ -511,6 +645,76 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
|||||||
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
|
badge={<TrendBadge current={gainNetTotal} prev={gainNetPrevMonthEnd} />}
|
||||||
refValue={fmtEUR(gainNetPrevMonthEnd) + ' en ' + prevMonthLabel}
|
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 (
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -529,6 +733,9 @@ export default function Dashboard() {
|
|||||||
const [plateformes, setPlateformes] = useState([]);
|
const [plateformes, setPlateformes] = useState([]);
|
||||||
const [platLoaded, setPlatLoaded] = useState(false);
|
const [platLoaded, setPlatLoaded] = useState(false);
|
||||||
const [investissements, setInvestissements] = useState([]);
|
const [investissements, setInvestissements] = useState([]);
|
||||||
|
const [allRembs, setAllRembs] = useState([]);
|
||||||
|
const [allReinvests, setAllReinvests] = useState([]);
|
||||||
|
const [allSimulRembs, setAllSimulRembs] = useState([]);
|
||||||
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
|
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
|
||||||
|
|
||||||
/* ── drillCell : cellule sélectionnée dans le TIP — par défaut mois courant toutes plateformes ── */
|
/* ── 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([
|
return Promise.all([
|
||||||
api.get('/dashboard', scopeParams).then(setData),
|
api.get('/dashboard', scopeParams).then(setData),
|
||||||
api.get('/investissements', scopeParams).then(setInvestissements).catch(() => setInvestissements([])),
|
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}
|
portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData}
|
||||||
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
|
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
|
||||||
correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
|
correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
|
||||||
|
allRembs={allRembs} allReinvests={allReinvests} allSimulRembs={allSimulRembs}
|
||||||
/>
|
/>
|
||||||
<YearSelectorKpi />
|
<YearSelectorKpi />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user