Files
crowdlending-app/frontend/src/components/TableauInteretsPlateforme.jsx
T
2026-06-18 08:25:05 +02:00

503 lines
25 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
);
}
/* ── 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 (
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
);
return <span style={{ width: size, height: size, display: 'block', borderRadius: 4,
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
};
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 (
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
{/* ── Header identique au bar chart ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{ display:'flex', alignItems:'center', gap:5, flexWrap:'wrap', marginBottom:2 }}>
{inclureInterets && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartInterets,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartInterets, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartInterets, fontWeight:600 }}>
{netMode ? 'Intérêts nets' : 'Intérêts bruts'}
</span>
</span>
)}
{inclureCapital && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartCapital,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartCapital, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartCapital, fontWeight:600 }}>Capital</span>
</span>
)}
{inclureCashback && (
<span style={{ display:'inline-flex', alignItems:'center', gap:4,
background:hexToRgba(chartCashback,0.12), borderRadius:5, padding:'3px 8px' }}>
<span style={{ width:7, height:7, borderRadius:2, background:chartCashback, flexShrink:0 }}/>
<span style={{ fontSize:13, color:chartCashback, fontWeight:600 }}>Cashback</span>
</span>
)}
{!inclureInterets && !inclureCapital && !inclureCashback && (
<span style={{ fontSize:13, color:'var(--text-muted)' }}></span>
)}
<span style={{ fontSize:13, color:'var(--text-muted)' }}>· {annee}</span>
</div>
<div className="solde-chart-value">{fmtEUR(grandTotal)}</div>
</div>
<div className="solde-chart-controls">
{/* Bouton intérêts */}
<button
title={inclureInterets ? 'Intérêts inclus — cliquer pour exclure' : 'Cliquer pour inclure les intérêts'}
onClick={() => setInclureInterets(v => !v)}
style={{ background: inclureInterets ? hexToRgba(chartInterets,0.13) : 'none',
border:'1px solid '+(inclureInterets ? chartInterets : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="interets" active={inclureInterets} />
</button>
{/* Bouton capital */}
<button
title={inclureCapital ? 'Capital inclus — cliquer pour exclure' : 'Cliquer pour inclure le capital'}
onClick={() => setInclureCapital(v => !v)}
style={{ background: inclureCapital ? hexToRgba(chartCapital,0.13) : 'none',
border:'1px solid '+(inclureCapital ? chartCapital : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="capital" active={inclureCapital} />
</button>
{/* Bouton cashback */}
<button
title={inclureCashback ? 'Cashback inclus — cliquer pour exclure' : 'Cliquer pour inclure le cashback'}
onClick={() => setInclureCashback(v => !v)}
style={{ background: inclureCashback ? hexToRgba(chartCashback,0.13) : 'none',
border:'1px solid '+(inclureCashback ? chartCashback : 'transparent'),
borderRadius:8, padding:'4px 6px', cursor:'pointer', display:'flex', alignItems:'center',
transition:'background .15s,border-color .15s', marginRight:2 }}>
<AppIcon name="cashback" active={inclureCashback} />
</button>
{/* Sélecteur d'années */}
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w-1))}
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}></button>
{visibleYears.map(y => (
<button key={y} className={`solde-range-btn${annee === y ? ' active' : ''}`}
onClick={() => setAnnee(y)}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w+1))}
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}></button>
<button className={`solde-range-btn${modeGlobal ? ' active' : ''}`}
onClick={() => toggleModeGlobal()}>
TOUT
</button>
{expandButton}
</div>
</div>
</div>
{/* ── Tableau ── */}
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" />
<th className="tip-th-year" colSpan={12}>{annee}</th>
<th className="tip-th-empty" />
<th className="tip-th-empty" />
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber">
<span style={{ display:'flex', alignItems:'center', gap:6 }}>
Plateforme
{multiDetenteur && (
<button
onClick={() => toggleGroupByNom()}
title={groupByNom ? 'Vue consolidée — cliquer pour détailler par détenteur' : 'Vue détaillée — cliquer pour consolider par plateforme'}
style={{
display:'inline-flex', alignItems:'center', gap:3,
background:'rgba(255,255,255,0.15)', border:'1px solid rgba(255,255,255,0.3)',
borderRadius:4, padding:'2px 5px', cursor:'pointer',
fontSize:10, fontWeight:600, color:'#fff', letterSpacing:'.03em',
lineHeight:1.4, whiteSpace:'nowrap', transition:'background .15s',
}}>
{groupByNom ? 'Consolidé' : 'Détaillé'}
<span style={{ display:'inline-flex', transition:'transform .2s', transform: groupByNom ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<ChevronDown size={9} />
</span>
</button>
)}
</span>
</th>
{MOIS_LONG.map((m, i) => (
<th key={m} className={`tip-th-month${annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>{m}</th>
))}
<th className="tip-th-total">Total</th>
<th className="tip-th-avg">Moy. mensuelle</th>
</tr>
</thead>
<tbody>
{grid.map((plat, pi) => (
<tr key={plat.id} className="tip-row-plat">
<td className="tip-td-name">
{plat.nom}
{!groupByNom && multiDetenteur && plat.detenteur_nom && (
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
{plat.detenteur_nom}
</span>
)}
</td>
{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 (
<td key={mi}
className={`tip-td-num${v?.projected ? ' tip-projected' : ''}${isCurrent ? ' tip-col-current' : ''}${isActive ? ' tip-td-active' : ''}${clickable ? ' tip-td-clickable' : ''}`}
onClick={() => 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) : <span className="tip-dash"></span>}
</td>
);
})}
<td className="tip-td-total">
{platTotals[pi] > 0 ? fmtEUR(platTotals[pi]) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-avg">
{platTotals[pi] > 0 ? fmtEUR(platTotals[pi] / 12) : <span className="tip-dash"></span>}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Toutes les plateformes</td>
{monthTotals.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">{fmtEUR(grandTotal)}</td>
<td className="tip-td-avg">{grandTotal > 0 ? fmtEUR(grandTotal / 12) : <span className="tip-dash"></span>}</td>
</tr>
<tr className="tip-footer-capital">
<td className="tip-td-name">Capital investi</td>
{capitalValues.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">{lastCapital > 0 ? fmtEUR(lastCapital) : <span className="tip-dash"></span>}</td>
<td className="tip-td-void" />
</tr>
<tr className="tip-footer-perf">
<td className="tip-td-name">{netMode ? "Performance nette mensuelle" : "Performance brute mensuelle"}</td>
{perfMensuelle.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v !== null ? fmtPct(v * 100) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">
{perfAnnTotale !== null ? fmtPct((perfAnnTotale / 12) * 100) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-void" />
</tr>
<tr className="tip-footer-perf">
<td className="tip-td-name">{netMode ? "Performance nette annualisée" : "Performance brute annualisée"}</td>
{perfAnnualisee.map((v, i) => (
<td key={i} className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v !== null ? fmtPct(v * 100) : <span className="tip-dash"></span>}
</td>
))}
<td className="tip-td-total">
{perfAnnTotale !== null ? fmtPct(perfAnnTotale * 100) : <span className="tip-dash"></span>}
</td>
<td className="tip-td-void" />
</tr>
</tfoot>
</table>
</div>
{/* ── Sélecteur Reçu / Projeté ── */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginTop:12, gap:12, flexWrap:'wrap' }}>
<div style={{
display:'inline-flex',
background:'#f0f0f0',
borderRadius:8,
padding:3,
gap:2,
flexShrink:0,
}}>
{[
{ key:'actual', label:'Reçu', active:showActual, toggle:toggleActual },
{ key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected },
].map(btn => (
<button key={btn.key} onClick={() => btn.toggle()} style={{
border:'none', cursor:'pointer', padding:'5px 14px', borderRadius:6,
fontSize:'var(--fs-sm)',
fontWeight: btn.active ? 600 : 400,
background: btn.active ? '#ffffff' : 'transparent',
color: btn.active ? '#1a1a2e' : '#9ca3af',
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
transition: 'all .15s', lineHeight: 1.4,
}}>{btn.label}</button>
))}
</div>
</div>
</div>
);
}