Files
crowdlending-app/frontend/src/components/CapitalMensuelTable.jsx
T
Olivier CROGUENNEC 48ed7fe65e Initial commit
2026-06-13 14:57:15 +02:00

352 lines
15 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 { 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>
);
}