Initial commit
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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`;
|
||||
}
|
||||
|
||||
const STATUT_BG = {
|
||||
en_cours: 'var(--b-en_cours-bg)',
|
||||
rembourse: 'var(--b-rembourse-bg)',
|
||||
en_retard: 'var(--b-en_retard-bg)',
|
||||
procedure: 'var(--b-procedure-bg)',
|
||||
cloture: 'var(--surface-2)',
|
||||
};
|
||||
const STATUT_FG = {
|
||||
en_cours: 'var(--b-en_cours-fg)',
|
||||
rembourse: 'var(--b-rembourse-fg)',
|
||||
en_retard: 'var(--b-en_retard-fg)',
|
||||
procedure: 'var(--b-procedure-fg)',
|
||||
cloture: 'var(--text-muted)',
|
||||
};
|
||||
|
||||
export default function InvMensuelTable({ rows, allRembs, allReinvests, year }) {
|
||||
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 style={{
|
||||
padding: '7px 10px', background: 'var(--surface-2)', color: 'var(--text)',
|
||||
fontWeight: 600, fontSize: 'var(--fs-xs)', textAlign: 'left',
|
||||
borderRight: '1px solid var(--border)', whiteSpace: 'nowrap',
|
||||
}}>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}`)}>
|
||||
|
||||
<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 style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 4,
|
||||
fontSize: 'var(--fs-xs)', fontWeight: 600,
|
||||
background: STATUT_BG[inv.statut] || 'var(--surface-2)',
|
||||
color: STATUT_FG[inv.statut] || 'var(--text-muted)',
|
||||
}}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user