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 } from '../utils/format.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 (
setOpen(v => !v)} style={{ height: '100%', boxSizing: 'border-box', background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)', borderRadius: 10, padding: '16px 20px', boxShadow: open ? '0 6px 28px rgba(109,40,217,0.45)' : '0 4px 20px rgba(109,40,217,0.30)', cursor: 'pointer', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', userSelect: 'none', transition: 'box-shadow .15s', }} >
Période
{modeGlobal ? 'Depuis le début' : String(annee)}
{open && (
{options.map((opt, i) => { const isActive = opt.value === 'all' ? modeGlobal : (!modeGlobal && annee === opt.value); return (
handleSelect(opt.value)} style={{ padding: '10px 16px', cursor: 'pointer', background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent', color: isActive ? '#7c3aed' : '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'; }} > {opt.label} {isActive && ( )}
); })}
)}
); } /* ── Badge de tendance ── */ function TrendBadge({ current, prev, invert = false }) { if (prev == null || prev === 0) 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 ( {arrow} {label} ); } /* ── Carte KPI individuelle ── */ function KpiCard({ title, value, badge, refValue, onClick }) { return (
{ 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} >
{title}
{value} {badge}
{refValue && (
{refValue}
)}
); } /* ── KPI filtrés par année ── */ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData }) { const { annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth, setInclureInterets, setInclureCapital, setInclureCashback, } = useInteretsChart(); const isCurrentYear = !modeGlobal && Number(annee) === currentYear; const isFutureYear = !modeGlobal && Number(annee) > currentYear; // 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]); // ── 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} /> : null} refValue={perfRefLabel} /> { setInclureInterets(true); setInclureCapital(false); setInclureCashback(false); }} /> { setInclureInterets(false); setInclureCapital(true); setInclureCashback(false); }} /> { setInclureInterets(false); setInclureCapital(false); setInclureCashback(true); }} />
); } 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); /* ── 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); }); }, []); useEffect(() => { if (!activeId && activeView !== 'all') return; setData(null); setLoading(true); const params = activeView === 'all' ? { scope: 'all' } : undefined; api.get('/dashboard', params).then(setData).finally(() => setLoading(false)); }, [activeView, activeId]); const ready = activeView === 'all' ? investisseurs.length > 0 : !!activeId; if (!ready) return
Sélectionnez un compte investisseur.
; if (loading || !data) return
Chargement…
; if (platLoaded && plateformes.length === 0) return ( <>

Tableau de bord

); const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data; const viewTitle = activeView === 'all' ? 'Famille et entreprises' : memberLabel(activeViewMember); return ( <>

Tableau de bord — {viewTitle}

{/* ── KPI + sélecteur année ── */}
{/* ── Graphiques ── */}
{/* ── Tableau intérêts par plateforme ── */}
setDrillCell({ platId, platIds, platNom, annee, mois, moisLabel }) } activeCell={drillCell} />
{/* ── Panneau détail mois / plateforme (remplace les Échéances prévues) ── */} {}} 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}`); }} />
); }