352 lines
15 KiB
React
352 lines
15 KiB
React
import { useMemo, useState } from 'react';
|
||
import { fmtEUR } from '../utils/format.js';
|
||
|
||
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||
|
||
/* ── Helpers dates ───────────────────────────────────────────────── */
|
||
function endOfMonth(Y, M) {
|
||
const d = new Date(Y, M, 0); // day 0 of month M+1 = last day of month M
|
||
return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||
}
|
||
function startOfMonth(Y, M) {
|
||
return `${Y}-${String(M).padStart(2,'0')}-01`;
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
/* ── Composant ───────────────────────────────────────────────────── */
|
||
export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, plats, expandButton }) {
|
||
const currentYear = new Date().getFullYear();
|
||
const currentMonth = new Date().getMonth() + 1;
|
||
|
||
const [annee, setAnnee] = useState(currentYear);
|
||
|
||
/* ── Toggle consolidation détenteurs ── */
|
||
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;
|
||
});
|
||
};
|
||
|
||
/* ── Années disponibles ── */
|
||
const availableYears = useMemo(() => {
|
||
const set = new Set(allRows.map(r => r.date_souscription?.slice(0,4)).filter(Boolean));
|
||
return [...set].map(Number).sort((a,b) => a - b);
|
||
}, [allRows]);
|
||
|
||
/* ── Precompute : reinvests et capital_remb par investissement ── */
|
||
const reinvestByInv = useMemo(() => {
|
||
const map = {};
|
||
for (const rv of allReinvests) {
|
||
const id = rv.investissement_id;
|
||
if (!id) continue;
|
||
if (!map[id]) map[id] = [];
|
||
map[id].push({ date: rv.date_reinvestissement?.slice(0,10), montant: rv.montant || 0 });
|
||
}
|
||
return map;
|
||
}, [allReinvests]);
|
||
|
||
const capRembByInv = useMemo(() => {
|
||
const map = {};
|
||
for (const rb of allRembs) {
|
||
const id = rb.investissement_id;
|
||
if (!id || rb.type !== 'normal') continue;
|
||
if (!map[id]) map[id] = [];
|
||
map[id].push({ date: rb.date_remb?.slice(0,10), capital: rb.capital || 0 });
|
||
}
|
||
return map;
|
||
}, [allRembs]);
|
||
|
||
const lastRembDateMap = useMemo(() => {
|
||
const map = {};
|
||
for (const rb of allRembs) {
|
||
const id = rb.investissement_id;
|
||
const d = rb.date_remb?.slice(0,10);
|
||
if (!id || !d) continue;
|
||
if (!map[id] || d > map[id]) map[id] = d;
|
||
}
|
||
return map;
|
||
}, [allRembs]);
|
||
|
||
/* ── Calcul capital encours par plateforme par mois ─────────────
|
||
* Pour chaque mois M :
|
||
* - L'investissement est actif si souscrit avant fin M
|
||
* ET (statut actif aujourd'hui OU date_fin >= début M)
|
||
* - capital = montant_investi + reinvests_≤_finM − capital_remboursé_≤_finM
|
||
* ─────────────────────────────────────────────────────────────── */
|
||
const { grid, multiDetenteur } = useMemo(() => {
|
||
if (!allRows.length) return { grid: null, multiDetenteur: false };
|
||
|
||
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
|
||
|
||
// Index plateformes par id (pour nom + detenteur)
|
||
const platMap = {};
|
||
for (const p of plats) platMap[p.id] = p;
|
||
|
||
// Pour chaque investissement, capital encours au end of month M
|
||
const getCapitalAtEndOfMonth = (inv, Y, M) => {
|
||
const endM = endOfMonth(Y, M);
|
||
if (inv.date_souscription > endM) return 0;
|
||
const startM = startOfMonth(Y, M);
|
||
const isActive = ACTIVE.includes(inv.statut) ||
|
||
((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
|
||
if (!isActive) return 0;
|
||
|
||
const reinvM = (reinvestByInv[inv.id] || [])
|
||
.filter(rv => rv.date && rv.date <= endM)
|
||
.reduce((s, rv) => s + rv.montant, 0);
|
||
|
||
const capRembM = (capRembByInv[inv.id] || [])
|
||
.filter(rb => rb.date && rb.date <= endM)
|
||
.reduce((s, rb) => s + rb.capital, 0);
|
||
|
||
return Math.max(0, inv.montant_investi + reinvM - capRembM);
|
||
};
|
||
|
||
// Agréger par plateforme (id)
|
||
const byPlat = {};
|
||
for (const inv of allRows) {
|
||
const pid = inv.plateforme_id;
|
||
if (!byPlat[pid]) {
|
||
const p = platMap[pid] || {};
|
||
byPlat[pid] = {
|
||
id: pid,
|
||
nom: inv.plateforme_nom || p.nom || '—',
|
||
investisseur_id: p.investisseur_id ?? inv.investisseur_id ?? null,
|
||
detenteur_nom: inv.plateforme_detenteur_nom || null,
|
||
months: Array(12).fill(0),
|
||
};
|
||
}
|
||
const row = byPlat[pid];
|
||
for (let m = 1; m <= 12; m++) {
|
||
row.months[m-1] += getCapitalAtEndOfMonth(inv, annee, m);
|
||
}
|
||
}
|
||
|
||
const allPlats = Object.values(byPlat).filter(p => p.months.some(v => v > 0));
|
||
|
||
// Détection multi-détenteur sur données brutes
|
||
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
|
||
|
||
// Consolidation par nom si demandée
|
||
let rows;
|
||
if (groupByNom && multi) {
|
||
const byNom = {};
|
||
for (const row of allPlats) {
|
||
if (!byNom[row.nom]) {
|
||
byNom[row.nom] = { id: row.nom, nom: row.nom, investisseur_id: null, detenteur_nom: null, months: [...row.months] };
|
||
} else {
|
||
for (let i = 0; i < 12; i++) byNom[row.nom].months[i] += row.months[i];
|
||
}
|
||
}
|
||
rows = Object.values(byNom);
|
||
} else {
|
||
rows = allPlats;
|
||
}
|
||
|
||
rows = rows
|
||
.filter(p => p.months.some(v => v > 0))
|
||
.sort((a,b) => b.months.reduce((s,v) => s+v, 0) - a.months.reduce((s,v) => s+v, 0));
|
||
|
||
return { grid: rows, multiDetenteur: multi };
|
||
}, [allRows, annee, reinvestByInv, capRembByInv, lastRembDateMap, plats, groupByNom]);
|
||
|
||
/* ── Totaux et moyennes ── */
|
||
const stats = useMemo(() => {
|
||
if (!grid) return null;
|
||
|
||
const monthTotals = Array.from({ length: 12 }, (_, i) =>
|
||
grid.reduce((s, row) => s + row.months[i], 0));
|
||
|
||
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
|
||
|
||
// Moyenne : average of non-zero months per platform
|
||
const platMoyennes = grid.map(row => {
|
||
const nonZero = row.months.filter(v => v > 0);
|
||
return nonZero.length ? nonZero.reduce((s,v) => s+v, 0) / nonZero.length : 0;
|
||
});
|
||
|
||
const totalMoyenne = platMoyennes.reduce((s,v) => s+v, 0);
|
||
|
||
const platPoids = platMoyennes.map(m => totalMoyenne > 0 ? (m / totalMoyenne) * 100 : 0);
|
||
|
||
const monthMoyennes = Array.from({ length: 12 }, (_, i) =>
|
||
grid.reduce((s, row) => s + row.months[i], 0));
|
||
|
||
const nonZeroMonthTotals = monthTotals.filter(v => v > 0);
|
||
const globalMoyenne = nonZeroMonthTotals.length
|
||
? nonZeroMonthTotals.reduce((s,v) => s+v, 0) / nonZeroMonthTotals.length
|
||
: 0;
|
||
|
||
return { monthTotals, grandTotal, platMoyennes, platPoids, totalMoyenne, monthMoyennes, globalMoyenne };
|
||
}, [grid]);
|
||
|
||
/* ── Sélecteur d'années ── */
|
||
const [windowStart, setWindowStart] = useState(() => {
|
||
const idx = availableYears.indexOf(currentYear);
|
||
return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), (idx >= 0 ? idx : availableYears.length - 1) - 1));
|
||
});
|
||
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
|
||
const canPrev = windowStart > 0;
|
||
const canNext = windowStart + 3 < availableYears.length;
|
||
|
||
/* ── Rendu ─────────────────────────────────────────────────────── */
|
||
return (
|
||
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
|
||
|
||
{/* ── Header ── */}
|
||
<div className="solde-chart-header">
|
||
<div className="solde-chart-info">
|
||
<div style={{ display:'flex', alignItems:'center', gap:5, marginBottom:2 }}>
|
||
<span style={{ fontSize:13, color:'var(--text-muted)' }}>
|
||
{`Capital investi · ${annee}`}
|
||
</span>
|
||
</div>
|
||
<div className="solde-chart-value">
|
||
{stats ? fmtEUR(stats.globalMoyenne) : '—'}
|
||
<span style={{ fontSize:14, fontWeight:400, color:'var(--text-muted)', marginLeft:8 }}>moy. mensuelle</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sélecteur d'années */}
|
||
<div className="solde-chart-controls">
|
||
<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${annee === currentYear ? ' active' : ''}`}
|
||
onClick={() => setAnnee(currentYear)}>
|
||
TOUT
|
||
</button>
|
||
{expandButton}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Table ── */}
|
||
{!grid || grid.length === 0 ? (
|
||
<div style={{ marginTop: 20, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', padding: '24px 0', textAlign: 'center' }}>
|
||
Aucun capital investi pour {annee}.
|
||
</div>
|
||
) : (
|
||
<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">Moyenne</th>
|
||
<th className="tip-th-avg">Poids</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) => (
|
||
<td key={mi}
|
||
className={`tip-td-num${annee === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : ''}`}>
|
||
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
|
||
</td>
|
||
))}
|
||
<td className="tip-td-total">
|
||
{stats.platMoyennes[pi] > 0 ? fmtEUR(stats.platMoyennes[pi]) : <span className="tip-dash">—</span>}
|
||
</td>
|
||
<td className="tip-td-avg">
|
||
{stats.platPoids[pi] > 0 ? (
|
||
<div style={{ display:'flex', alignItems:'center', gap:5, justifyContent:'flex-end' }}>
|
||
<div style={{ width:36, height:4, borderRadius:2, background:'var(--surface-2)', overflow:'hidden' }}>
|
||
<div style={{ width:`${Math.min(100,stats.platPoids[pi])}%`, height:'100%', background:'var(--primary)', borderRadius:2 }} />
|
||
</div>
|
||
<span style={{ minWidth:38, textAlign:'right' }}>
|
||
{stats.platPoids[pi].toFixed(1)} %
|
||
</span>
|
||
</div>
|
||
) : <span className="tip-dash">—</span>}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
|
||
<tfoot>
|
||
<tr className="tip-footer-total">
|
||
<td className="tip-td-name">Toutes les plateformes</td>
|
||
{stats.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(stats.globalMoyenne)}</td>
|
||
<td className="tip-td-void" />
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|