Mise en place de la feature de gestion des Frais
This commit is contained in:
1 parent
9ea078302d
commit
0334a63139
27 files changed
+2401
-117
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user