182 lines
7.1 KiB
React
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>
|
|
);
|
|
}
|