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

182 lines
7.1 KiB
React

import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { fmtEUR, fmtStatut } from '../utils/format.js';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
function endOfMonth(Y, M) {
const d = new Date(Y, M, 0);
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`;
}
export default function InvMensuelTable({ rows, allRembs, allReinvests, year, originFrom }) {
const navigate = useNavigate();
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const displayYear = year ? Number(year) : currentYear;
/* ── Precompute rembs ── */
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]);
/* ── Capital encours d'un investissement à fin de mois M ── */
const getCapital = (inv, Y, M) => {
const endM = endOfMonth(Y, M);
if (inv.date_souscription > endM) return 0;
const startM = startOfMonth(Y, M);
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
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);
};
/* ── Grille : une ligne par investissement ── */
const grid = useMemo(() => {
if (!rows?.length) return [];
return rows
.map(inv => ({
inv,
months: Array.from({ length: 12 }, (_, i) => getCapital(inv, displayYear, i + 1)),
}))
.filter(r => r.months.some(v => v > 0))
.sort((a, b) =>
(a.inv.date_souscription || '') < (b.inv.date_souscription || '') ? -1 : 1
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, displayYear, reinvestByInv, capRembByInv, lastRembDateMap]);
const monthTotals = useMemo(() =>
Array.from({ length: 12 }, (_, i) => grid.reduce((s, r) => s + r.months[i], 0)),
[grid]
);
if (!grid.length) {
return (
<div style={{ padding: '24px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', textAlign: 'center' }}>
Aucun investissement actif pour {displayYear}.
</div>
);
}
return (
<div style={{ overflowX: 'auto', position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" style={{ minWidth: 200 }} />
<th className="tip-th-empty" style={{ minWidth: 90 }} />
<th className="tip-th-year" colSpan={12}>{displayYear}</th>
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber" style={{ minWidth: '22ch', maxWidth: '40ch' }}>Investissement</th>
<th className="tip-th-name" style={{ minWidth: 'unset', position: 'static', fontSize: 'var(--fs-xs)', textAlign: 'left' }}>Statut</th>
{MOIS_LONG.map((m, i) => (
<th key={m}
className={`tip-th-month${displayYear === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>
{m}
</th>
))}
</tr>
</thead>
<tbody>
{grid.map(({ inv, months }) => (
<tr key={inv.id} className="tip-row-plat"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/investissements/${inv.id}`, originFrom ? { state: { from: originFrom } } : undefined)}>
<td className="tip-td-name" style={{ whiteSpace: 'normal', maxWidth: '40ch', wordBreak: 'break-word' }}>
{inv.nom_projet || '—'}
</td>
<td style={{ padding: '8px 10px', whiteSpace: 'nowrap', borderRight: '1px solid var(--border)' }}>
<span className={`badge ${inv.statut}`}>{fmtStatut(inv.statut)}</span>
</td>
{months.map((v, mi) => {
const curClass = displayYear === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : '';
if (v === 0) {
// Avant la date de souscription
const subYear = Number(inv.date_souscription?.slice(0, 4));
const subMo = Number(inv.date_souscription?.slice(5, 7)) - 1;
const isBefore = inv.date_souscription && (
subYear > displayYear || (subYear === displayYear && mi < subMo)
);
// Après le dernier remboursement (prêt remboursé)
const lastDate = lastRembDateMap[inv.id];
const isAfter = inv.statut === 'rembourse' && lastDate && (() => {
const lastYear = Number(lastDate.slice(0, 4));
const lastMo = Number(lastDate.slice(5, 7)) - 1;
if (lastYear < displayYear) return true;
if (lastYear === displayYear) return mi > lastMo;
return false;
})();
if (isBefore || isAfter) {
return <td key={mi} className={`tip-td-closed${curClass}`} />;
}
}
return (
<td key={mi} className={`tip-td-num${curClass}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Total</td>
<td />
{monthTotals.map((v, i) => (
<td key={i}
className={`tip-td-num${displayYear === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash"></span>}
</td>
))}
</tr>
</tfoot>
</table>
</div>
);
}