Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+206
View File
@@ -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>
);
}