Mise en place de la feature de gestion des Frais

This commit is contained in:
ocroguennec committed 2026-09-01 22:16:18 +02:00
1 parent 9ea078302d
commit 0334a63139
27 files changed
+2401 -117

No files matched your search

+190 -9
View File
@@ -10,6 +10,8 @@ import { useUi } from '../context/UiContext.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import InteretsChart from '../components/InteretsChart.jsx';
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx';
import RembFormModal from '../components/RembFormModal.jsx';
@@ -57,7 +59,8 @@ const empty = {
capital: 0, cashback: 0,
interets_bruts_avant_local: 0,
taxe_locale: 0,
interets_bruts: 0, prelev_sociaux: 0,
interets_bruts: 0, frais_ttc: 0, frais_mode_reglement: 'remboursement', frais_compte_id: '',
prelev_sociaux: 0,
prelev_forfaitaire: 0,
statut: 'paye', notes: '',
methode_remboursement: 'portefeuille',
@@ -181,6 +184,7 @@ export default function Remboursements() {
const [corrections, setCorrections] = useState([]);
const [investissements, setInvestissements] = useState([]);
const [plateformes, setPlateformes] = useState([]);
const [allFraisOps, setAllFraisOps] = useState([]); // frais_operations, tous investisseurs
const [platLoaded, setPlatLoaded] = useState(false);
const [pfuRates, setPfuRates] = useState([]);
const [loading, setLoading] = useState(false);
@@ -266,12 +270,16 @@ export default function Remboursements() {
setSelectedSimul(''); // reset sélection projection
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [r, inv, corr] = await Promise.all([
const [r, inv, corr, fraisOps] = await Promise.all([
api.get('/remboursements', scopeParams),
api.get('/investissements', scopeParams),
api.get('/corrections', scopeParams).catch(() => []),
// /frais-operations sans investissement_id ne connaît que l'utilisateur (tous
// investisseurs), filtré ensuite via filterPlatId/rembPlatYear comme allRows —
// même pattern que allFraisOps dans Plateformes.jsx (Étape 5).
api.get('/frais-operations').catch(() => []),
]);
setAllRows(r); setInvestissements(inv); setCorrections(corr);
setAllRows(r); setInvestissements(inv); setCorrections(corr); setAllFraisOps(fraisOps);
} finally {
setLoading(false);
setDrillRefreshKey(k => k + 1);
@@ -412,6 +420,39 @@ export default function Remboursements() {
[byPlatform]
);
/* ── Étape 5 : reporting "Frais payés" — filtré comme le reste de la page par
filterPlatId (plateforme) et rembPlatYear (année), sur date_operation cette fois. ── */
const fraisFiltered = useMemo(() =>
allFraisOps.filter(f => {
if (filterPlatId && String(f.plateforme_id) !== filterPlatId) return false;
if (rembPlatYear && (f.date_operation || '').slice(0, 4) !== rembPlatYear) return false;
return true;
}),
[allFraisOps, filterPlatId, rembPlatYear]
);
const fraisPrevFiltered = useMemo(() =>
allFraisOps.filter(f => {
if (filterPlatId && String(f.plateforme_id) !== filterPlatId) return false;
return (f.date_operation || '').slice(0, 4) === rembPrevYear;
}),
[allFraisOps, filterPlatId, rembPrevYear]
);
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
const fraisPrevTotal = useMemo(() => fraisPrevFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisPrevFiltered]);
const byCategorie = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.categorie || 'autre';
if (!map[key]) map[key] = { categorie: key, montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Tableau remboursements : filtrage client-side ── */
const tableRows = useMemo(() => {
// Remboursements filtrés
@@ -547,7 +588,12 @@ export default function Remboursements() {
capital: r.capital, cashback: r.cashback || 0,
interets_bruts_avant_local: r.interets_bruts_avant_local || 0,
taxe_locale: r.taxe_locale || 0,
interets_bruts: r.interets_bruts,
interets_bruts: r.interets_bruts, frais_ttc: r.frais_ttc || 0,
// Étape 5 bis (01/09/26) : le mode de règlement du frais n'est pas stocké sur le
// remboursement lui-même mais sur la ligne frais_operations liée — recherchée dans
// allFraisOps déjà chargé, par remboursement_id (cf. RembFormModal.jsx).
frais_mode_reglement: (allFraisOps.find(f => f.remboursement_id === r.id)?.mode_reglement) || 'remboursement',
frais_compte_id: String(allFraisOps.find(f => f.remboursement_id === r.id)?.compte_id || ''),
prelev_sociaux: r.prelev_sociaux, prelev_forfaitaire: r.prelev_forfaitaire,
statut: r.statut, notes: r.notes || '',
methode_remboursement: r.methode_remboursement || 'portefeuille',
@@ -648,8 +694,10 @@ export default function Remboursements() {
const round2 = (n) => Math.round(n * 100) / 100;
const computeInteretsNets = (f) =>
round2(Number(f.interets_bruts || 0) - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
const computeInteretsNets = (f) => {
const fraisReduit = (!f.frais_mode_reglement || f.frais_mode_reglement === 'remboursement') ? Number(f.frais_ttc || 0) : 0;
return round2(Number(f.interets_bruts || 0) - fraisReduit - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
};
const computeNet = (f) =>
round2(Number(f.capital || 0) + Number(f.cashback || 0) + computeInteretsNets(f));
@@ -674,9 +722,17 @@ export default function Remboursements() {
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
}
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale' || k === 'frais_ttc' || k === 'frais_mode_reglement') {
const dateStr = k === 'date_remb' ? v : next.date_remb;
const bruts = Number(next.interets_bruts) || 0;
// Base imposable nette de frais (Étape 4, 31/08/26) — même formule que computeChamps()
// côté backend et que POST /reprocess : les prélèvements doivent porter sur
// (interets_bruts - frais_ttc), pas sur le brut seul, sous peine de sur-taxer le coupon
// (confirmé par Olivier sur l'avis d'opéré Kyoseil-AM/Fundora du 02/07/2026). Depuis
// l'Étape 5 bis (01/09/26), cette réduction ne s'applique QUE si le frais est retenu SUR ce
// remboursement (frais_mode_reglement === 'remboursement', par défaut) — un frais réglé à
// part (portefeuille/compte courant) n'affecte pas cette base.
const fraisReduit = (!next.frais_mode_reglement || next.frais_mode_reglement === 'remboursement') ? (Number(next.frais_ttc) || 0) : 0;
const bruts = round2((Number(next.interets_bruts) || 0) - fraisReduit);
const rates = getRatesForYear(dateStr);
if (rates) {
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
@@ -743,6 +799,7 @@ export default function Remboursements() {
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
taxe_locale: Number(form.taxe_locale || 0),
interets_bruts: Number(form.interets_bruts || 0),
frais_ttc: Number(form.frais_ttc || 0),
prelev_sociaux: Number(form.prelev_sociaux || 0),
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
statut: form.statut,
@@ -758,6 +815,9 @@ export default function Remboursements() {
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
taxe_locale: Number(form.taxe_locale || 0),
interets_bruts: Number(form.interets_bruts || 0),
frais_ttc: Number(form.frais_ttc || 0),
frais_mode_reglement: form.frais_mode_reglement || 'remboursement',
frais_compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
prelev_sociaux: Number(form.prelev_sociaux || 0),
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
statut: form.statut,
@@ -881,6 +941,8 @@ export default function Remboursements() {
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
onClick={() => setActiveTab('projections')}>Projections Échéances</button>
<button className={`dr-tab${activeTab === 'frais' ? ' active' : ''}`}
onClick={() => setActiveTab('frais')}>Frais</button>
</div>}
{/* ══ ONGLET PLATEFORMES ══ */}
@@ -1265,7 +1327,35 @@ export default function Remboursements() {
{(isBonusLike || isCorrection) ? '—' : fmtEUR(Math.max(0, r.capital_restant_du ?? 0))}
</td>
<td className="num">{r.cashback ? fmtEUR(r.cashback) : '—'}</td>
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.interets_bruts)}</td>
<td className="num">
{(isBonus || isCorrection) ? '—' : (
<>
{fmtEUR(r.interets_bruts)}
{/* Icône uniquement si le frais réduit réellement ce montant (mode
'remboursement', par défaut) — un frais réglé à part (Étape 5 bis,
01/09/26) n'a aucun impact sur les intérêts bruts affichés ici. */}
{!!r.frais_ttc && (!r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement') && (
<span
className="cell-tooltip"
data-tooltip={
`Intérêts bruts avant frais : ${fmtEUR(r.interets_bruts)}\n` +
`Frais de distribution : -${fmtEUR(r.frais_ttc)}\n` +
`Base après frais (avant prélèvements) : ${fmtEUR(round2(r.interets_bruts - r.frais_ttc))}\n` +
`Prélèv. sociaux : ${fmtEUR(r.prelev_sociaux)}\n` +
`Impôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}\n` +
`Montant net reçu : ${fmtEUR(r.net_recu)}`
}
style={{ marginLeft: 5, color: 'var(--warning, #b45309)', verticalAlign: 'middle' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</span>
)}
</>
)}
</td>
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_sociaux)}</td>
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_forfaitaire)}</td>
<td className="num">
@@ -1471,6 +1561,97 @@ export default function Remboursements() {
</div>
)}
{/* ══ ONGLET FRAIS (Étape 5 — reporting consolidé) ══ */}
{activeTab === 'frais' && (
<div style={{ padding: '0 24px' }}>
{/* ── KPI ── */}
<div className="dr-kpi-row" style={{ marginBottom: 16 }}>
<div className="kpi">
<div className="label">
Frais payés{rembPlatYear && ` en ${rembPlatYear}`}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
{rembPlatYear && <TrendBadge current={fraisTotal} prev={fraisPrevTotal} invert />}
</div>
{rembPlatYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(fraisPrevTotal)} en {rembPrevYear}
</div>
)}
</div>
</div>
{/* ── Graphique ── */}
<div className="charts-row" style={{ marginBottom: 16 }}>
<FraisDistributionChart rows={fraisFiltered} />
</div>
{/* ── Tableau par catégorie ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par catégorie
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
</h3>
{filterPlatId && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontStyle: 'italic' }}>
Filtré sur cette plateforme
</span>
)}
</div>
<table>
<thead>
<tr>
<th>Catégorie</th>
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byCategorie.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byCategorie.map(c => {
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
return (
<tr key={c.categorie}>
<td>{FRAIS_CATEGORIE_LABELS[c.categorie] || c.categorie}</td>
<td className="num">{fmtEUR(c.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byCategorie.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byCategorie.length} catégories</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ MODAL ══ */}
{(() => {
// Liste d'investissements disponibles dans le select