import { useEffect, useState, useRef, useMemo } from 'react'; import { api } from '../api.js'; import { useInteretsChart } from '../context/InteretsChartContext.jsx'; import { fmtEUR, fmtPct } from '../utils/format.js'; const ICONS_BASE = '/api/icons-files/'; const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; function hexToRgba(hex, a) { if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`; const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); return `rgba(${r},${g},${b},${a})`; } function ChevronDown({ size = 10 }) { return ( ); } /* ── Fusionne deux maps de remboursements ou projections ── */ function mergeMaps(mapA, mapB) { const result = {}; const keys = new Set([...Object.keys(mapA || {}), ...Object.keys(mapB || {})]); for (const k of keys) { const a = mapA?.[k] || {}; const b = mapB?.[k] || {}; result[k] = { interets_bruts: (a.interets_bruts || 0) + (b.interets_bruts || 0), interets_nets: (a.interets_nets || 0) + (b.interets_nets || 0), cashback: (a.cashback || 0) + (b.cashback || 0), capital: (a.capital || 0) + (b.capital || 0), interets_prevus: (a.interets_prevus || 0) + (b.interets_prevus || 0), capital_prevu: (a.capital_prevu || 0) + (b.capital_prevu || 0), }; } return result; } export default function TableauInteretsPlateforme({ activeView, activeId, pfuRates, onCapitalMensuel, expandButton, onCellClick, activeCell }) { const { annee, setAnnee, availableYears, inclureInterets, setInclureInterets, inclureCapital, setInclureCapital, inclureCashback, setInclureCashback, netMode, showActual, toggleActual, showProjected, toggleProjected, modeGlobal, toggleModeGlobal, currentYear, currentMonth, chartInterets, chartCapital, chartCashback, } = useInteretsChart(); const [data, setData] = useState(null); const [libIcons, setLibIcons] = useState({}); const [windowStart, setWindowStart] = useState(0); const initializedRef = useRef(false); /* ── Toggle consolidation détenteurs (clé partagée avec CapitalMensuelTable) ── */ const [groupByNom, setGroupByNom] = useState(() => { try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; } }); const toggleGroupByNom = () => { setGroupByNom(v => { const next = !v; try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {} return next; }); }; /* ── Icônes bibliothèque ─────────────────────────────────────── */ useEffect(() => { api.get('/icons').then(rows => { const m = {}; rows.forEach(r => { m[r.name] = r.filename; }); setLibIcons(m); }).catch(() => {}); }, []); /* ── Fenêtre années ──────────────────────────────────────────── */ const canPrev = windowStart > 0; const canNext = windowStart + 3 < availableYears.length; const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee]; useEffect(() => { if (!availableYears.length || initializedRef.current) return; initializedRef.current = true; const idx = availableYears.indexOf(annee); const safe = idx >= 0 ? idx : availableYears.length - 1; setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1))); }, [availableYears]); /* ── Réduction PFU ───────────────────────────────────────────── */ const pfuReduction = useMemo(() => { if (!pfuRates?.length) return 0; const r = pfuRates.find(r => r.annee === annee) ?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]); return (r.prelev_sociaux + r.impot_revenu) / 100; }, [pfuRates, annee]); /* ── Fetch données ───────────────────────────────────────────── */ useEffect(() => { if (modeGlobal) { setData(null); onCapitalMensuel?.([]); return; } const params = { annee, ...(activeView === 'all' ? { scope: 'all' } : {}) }; api.get('/dashboard/interets-par-plateforme', params) .then(d => { setData(d); onCapitalMensuel?.(d.capitalMensuel ?? []); }) .catch(() => {}); }, [annee, activeView, activeId, modeGlobal]); /* ── Helpers affichage ───────────────────────────────────────── */ const AppIcon = ({ name, size = 28, active = false }) => { const filename = libIcons[name]; if (filename) return ( ); return ; }; const plateformes = data?.plateformes ?? []; const capitalMensuel = data?.capitalMensuel ?? []; // N'afficher le détenteur que s'il y en a plusieurs distincts (pattern multiDetenteur) const multiDetenteur = new Set(plateformes.map(p => p.detenteur_nom).filter(Boolean)).size > 1; /* ── Consolidation par nom si demandée ──────────────────────── */ const displayPlateformes = useMemo(() => { if (!groupByNom || !multiDetenteur) return plateformes; const byNom = {}; for (const plat of plateformes) { if (!byNom[plat.nom]) { byNom[plat.nom] = { ...plat, id: plat.nom, _ids: [plat.id], // ← tous les IDs numériques fusionnés detenteur_nom: null, rembourses: { ...plat.rembourses }, projections: { ...plat.projections }, }; } else { byNom[plat.nom]._ids.push(plat.id); byNom[plat.nom].rembourses = mergeMaps(byNom[plat.nom].rembourses, plat.rembourses); byNom[plat.nom].projections = mergeMaps(byNom[plat.nom].projections, plat.projections); } } return Object.values(byNom); }, [plateformes, groupByNom, multiDetenteur]); if (modeGlobal || !data || plateformes.length === 0) { return null; } /* ── Valeurs par plateforme/mois ──────────────────────────────── * getCellValue : pour l'affichage (interets + cashback + capital selon toggles) * getPerfValue : pour la performance (interets + cashback uniquement, jamais capital) * ─────────────────────────────────────────────────────────────── */ const buildValue = (plat, mIdx, { withCapital }) => { const m = mIdx + 1; const moisStr = `${annee}-${String(m).padStart(2, '0')}`; const isFuture = annee > currentYear || (annee === currentYear && m > currentMonth); const isCurrent = annee === currentYear && m === currentMonth; if (isFuture) { if (!showProjected) return null; const proj = plat.projections[moisStr]; if (!proj) return null; let v = 0; if (inclureInterets) v += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus; if (withCapital && inclureCapital) v += proj.capital_prevu ?? 0; return v > 0 ? { value: v, projected: true } : null; } if (isCurrent) { const remb = plat.rembourses[moisStr]; const proj = plat.projections[moisStr]; let real = 0; if (showActual && remb) { if (inclureInterets) real += netMode ? remb.interets_nets : remb.interets_bruts; if (inclureCashback) real += remb.cashback ?? 0; if (withCapital && inclureCapital) real += remb.capital ?? 0; } let projAmt = 0; // Les projections backend sont déjà filtrées NOT EXISTS par investissement → pas de double-comptage if (showProjected && proj) { if (inclureInterets) projAmt += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus; if (withCapital && inclureCapital) projAmt += proj.capital_prevu ?? 0; } const val = real + projAmt; return val > 0 ? { value: val, projected: projAmt > 0 } : null; } // Mois passé if (!showActual) return null; const remb = plat.rembourses[moisStr]; if (!remb) return null; let v = 0; if (inclureInterets) v += netMode ? remb.interets_nets : remb.interets_bruts; if (inclureCashback) v += remb.cashback ?? 0; if (withCapital && inclureCapital) v += remb.capital ?? 0; return v > 0 ? { value: v, projected: false } : null; }; const getCellValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: true }); const getPerfValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: false }); /* ── Grille ──────────────────────────────────────────────────── */ const grid = displayPlateformes.map(plat => ({ ...plat, months: Array.from({ length: 12 }, (_, i) => getCellValue(plat, i)), })); const monthTotals = Array.from({ length: 12 }, (_, i) => grid.reduce((s, row) => s + (row.months[i]?.value ?? 0), 0)); const platTotals = grid.map(row => row.months.reduce((s, v) => s + (v?.value ?? 0), 0)); const grandTotal = monthTotals.reduce((s, v) => s + v, 0); /* Totaux pour la performance : intérêts + cashback uniquement (sans capital) */ const perfMonthTotals = Array.from({ length: 12 }, (_, i) => displayPlateformes.reduce((s, plat) => s + (getPerfValue(plat, i)?.value ?? 0), 0)); const perfGrandTotal = perfMonthTotals.reduce((s, v) => s + v, 0); /* ── Capital et performances ─────────────────────────────────── */ const capitalValues = capitalMensuel.map(c => c.capital); const nonZeroCap = capitalValues.filter(v => v > 0); const avgCapital = nonZeroCap.length ? nonZeroCap.reduce((s, v) => s + v, 0) / nonZeroCap.length : 0; const lastCapital = [...capitalValues].reverse().find(v => v > 0) ?? avgCapital; const perfMensuelle = Array.from({ length: 12 }, (_, i) => capitalValues[i] > 0 ? perfMonthTotals[i] / capitalValues[i] : null); const perfAnnualisee = perfMensuelle.map(p => p !== null ? p * 12 : null); const perfAnnTotale = lastCapital > 0 ? perfGrandTotal / lastCapital : null; /* ── Label total header ──────────────────────────────────────── */ const activeTypes = [ inclureInterets && { color: chartInterets, label: netMode ? 'Intérêts nets' : 'Intérêts bruts' }, inclureCapital && { color: chartCapital, label: 'Capital' }, inclureCashback && { color: chartCashback, label: 'Cashback' }, ].filter(Boolean); /* ── Rendu ───────────────────────────────────────────────────── */ return (
{/* ── Header identique au bar chart ── */}
{inclureInterets && ( {netMode ? 'Intérêts nets' : 'Intérêts bruts'} )} {inclureCapital && ( Capital )} {inclureCashback && ( Cashback )} {!inclureInterets && !inclureCapital && !inclureCashback && ( )} · {annee}
{fmtEUR(grandTotal)}
{/* Bouton intérêts */} {/* Bouton capital */} {/* Bouton cashback */} {/* Sélecteur d'années */}
{visibleYears.map(y => ( ))} {expandButton}
{/* ── Tableau ── */}
{MOIS_LONG.map((m, i) => ( ))} {grid.map((plat, pi) => ( {plat.months.map((v, mi) => { const isCurrent = annee === currentYear && mi === currentMonth - 1; const cellKey = `${plat.id}:${annee}-${String(mi + 1).padStart(2,'0')}`; const isActive = activeCell?.key === cellKey; const clickable = !!v; return ( ); })} ))} {monthTotals.map((v, i) => ( ))} {capitalValues.map((v, i) => ( ))} {perfMensuelle.map((v, i) => ( ))} {perfAnnualisee.map((v, i) => ( ))}
{annee}
Plateforme {multiDetenteur && ( )} {m}Total Moy. mensuelle
{plat.nom} {!groupByNom && multiDetenteur && plat.detenteur_nom && ( {plat.detenteur_nom} )} clickable && onCellClick && onCellClick({ key: cellKey, platId: plat._ids ? plat._ids[0] : plat.id, // toujours numérique platIds: plat._ids ?? [plat.id], // tous les IDs (consolidé) platNom: plat.nom, annee, mois: String(mi + 1).padStart(2, '0'), moisLabel: MOIS_LONG[mi], })} > {v ? fmtEUR(v.value) : } {platTotals[pi] > 0 ? fmtEUR(platTotals[pi]) : } {platTotals[pi] > 0 ? fmtEUR(platTotals[pi] / 12) : }
Toutes les plateformes {v > 0 ? fmtEUR(v) : } {fmtEUR(grandTotal)} {grandTotal > 0 ? fmtEUR(grandTotal / 12) : }
Capital investi {v > 0 ? fmtEUR(v) : } {lastCapital > 0 ? fmtEUR(lastCapital) : }
{netMode ? "Performance nette mensuelle" : "Performance brute mensuelle"} {v !== null ? fmtPct(v * 100) : } {perfAnnTotale !== null ? fmtPct((perfAnnTotale / 12) * 100) : }
{netMode ? "Performance nette annualisée" : "Performance brute annualisée"} {v !== null ? fmtPct(v * 100) : } {perfAnnTotale !== null ? fmtPct(perfAnnTotale * 100) : }
{/* ── Sélecteur Reçu / Projeté ── */}
{[ { key:'actual', label:'Reçu', active:showActual, toggle:toggleActual }, { key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected }, ].map(btn => ( ))}
); }