Files
crowdlending-app/frontend/src/pages/Remboursements.jsx
T

1623 lines
81 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
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 TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx';
import RembFormModal from '../components/RembFormModal.jsx';
import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtDate, today, memberLabel } from '../utils/format.js';
import * as XLSX from 'xlsx';
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
/* ── Indicateur de progression ── */
function TrendBadge({ current, prev, invert = false }) {
// prev quasi-nul (résidu flottant) → pourcentage non significatif ; seuil aligné sur
// l'arrondi 2 décimales pour éviter des variations aberrantes type "+39628021025%".
if (prev == null || Math.abs(prev) < 0.005) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
const BONUS_VALUES = ['BONUS_PARRAINAGE', 'BONUS_PLATEFORME'];
const INTERETS_PLATEFORME_VALUE = 'INTERETS_PLATEFORME';
const ICONS_BASE = '/api/icons-files/';
const BONUS_TYPE_MAP = { BONUS_PARRAINAGE: 'bonus_parrainage', BONUS_PLATEFORME: 'bonus_plateforme' };
const empty = {
investissement_id: '', date_remb: today(),
capital: 0, cashback: 0,
interets_bruts_avant_local: 0,
taxe_locale: 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',
compte_id: '',
// champs bonus
bonus_plateforme_id: '',
bonus_investisseur_id: '',
};
/* ── Export helpers ─────────────────────────────────────────── */
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function rembToCSV(rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Date','Plateforme','Projet','Capital (€)','Cashback (€)','Intérêts bruts (€)','Prélèv. sociaux (€)','Impôt revenu (€)','Intérêts nets (€)','Net reçu (€)'];
const data = rows.map(r => [
r.date_remb, r.plateforme_nom||'', r.nom_projet||'',
String(r.capital||0).replace('.',','), String(r.cashback||0).replace('.',','),
String(r.interets_bruts||0).replace('.',','), String(r.prelev_sociaux||0).replace('.',','),
String(r.prelev_forfaitaire||0).replace('.',','), String(r.interets_nets||0).replace('.',','),
String(r.net_recu||0).replace('.',','),
]);
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function rembToXLS(rows) {
const data = rows.map(r => ({
'Date': r.date_remb,
'Plateforme': r.plateforme_nom || '',
'Projet': r.nom_projet || '',
'Capital (€)': r.capital || 0,
'Cashback (€)': r.cashback || 0,
'Intérêts bruts (€)': r.interets_bruts || 0,
'Prélèv. sociaux (€)': r.prelev_sociaux || 0,
'Impôt revenu (€)': r.prelev_forfaitaire || 0,
'Intérêts nets (€)': r.interets_nets || 0,
'Net reçu (€)': r.net_recu || 0,
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Remboursements');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function rembToJSON(rows) {
return JSON.stringify(rows.map(r => ({
date_remb: r.date_remb, plateforme: r.plateforme_nom||'', projet: r.nom_projet||'',
capital: r.capital||0, cashback: r.cashback||0,
interets_bruts: r.interets_bruts||0, prelev_sociaux: r.prelev_sociaux||0,
prelev_forfaitaire: r.prelev_forfaitaire||0, interets_nets: r.interets_nets||0,
net_recu: r.net_recu||0,
})), null, 2);
}
/* ── ExportDropdown ─────────────────────────────────────────── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title="Exporter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
</svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function Remboursements() {
const { activeId, activeView, investisseurs } = useInvestisseur();
const { displayMode } = useUi();
const { search } = useLocation();
const navigate = useNavigate();
/* netMode dérivé du contexte global */
const netMode = displayMode === 'net';
/* ── State ── */
const [allRows, setAllRows] = useState([]);
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);
/* Charts / filtres visuels */
const [filterPlatId, setFilterPlatId] = useState(''); // filtre depuis onglet Plateformes
/* Onglets */
const [activeTab, setActiveTab] = useState('plateformes');
const [listFocused, setListFocused] = useState(false);
/* ── Drill-down cellule vision mensuelle ── */
const [drillCell, setDrillCell] = useState(null);
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
const drillPanelRef = useRef(null);
const tipTableRef = useRef(null);
/* ── Cellule "mois en cours" (onglet dédié, toutes plateformes) ── */
const moisEnCoursCell = useMemo(() => {
const now = new Date();
return {
platId: null,
platNom: null,
annee: now.getFullYear(),
mois: now.getMonth() + 1,
moisLabel: MOIS_FR[now.getMonth()],
};
}, []);
/* ── Navigation retour Dashboard ── */
const autoOpenRef = useRef(false); // garantit l'ouverture auto une seule fois
const backToDashboardRef = useRef(null); // URL de retour quand from=dashboard
const handleCellClick = (cellInfo) => {
setDrillCell(prev => (prev?.key === cellInfo.key ? null : cellInfo));
};
/* Scroll automatique à l'ouverture/fermeture du panneau */
useEffect(() => {
if (drillCell) {
// Petit délai pour laisser React rendre le panneau avant de scroller
setTimeout(() => {
drillPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 50);
} else {
setTimeout(() => {
tipTableRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 50);
}
}, [drillCell]);
/* Filtre du tableau Remboursements */
const [tableFilter, setTableFilter] = useState({ plateforme_id: '', investissement_id: '', year: '', month: '', type: '' });
/* Filtre année du tableau Plateformes */
const [rembPlatYear, setRembPlatYear] = useState(''); // 'Toutes les années' par défaut
/* Modal */
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [corrDeleteConfirm, setCorrDeleteConfirm] = useState(null);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
const [libIcons, setLibIcons] = useState({});
const [filterPortefeuille, setFilterPortefeuille] = useState(true);
const [filterCompteCourant, setFilterCompteCourant] = useState(true);
const [openMenu, setOpenMenu] = useState(null);
/* Projections échéances */
const [selectedSimul, setSelectedSimul] = useState('');
const [echeances, setEcheances] = useState([]);
const [simulBusy, setSimulBusy] = useState(false);
const [simulMsg, setSimulMsg] = useState(null);
/* ── Chargement ── */
const load = async () => {
if (!activeId && activeView !== 'all') return;
setLoading(true);
setAllRows([]); // vide les données résiduelles
setInvestissements([]); // idem pour la liste projections
setSelectedSimul(''); // reset sélection projection
try {
const scopeParams = activeView === 'all' ? { scope: '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); setAllFraisOps(fraisOps);
} finally {
setLoading(false);
setDrillRefreshKey(k => k + 1);
}
};
useEffect(() => {
api.get('/pfu').then(setPfuRates).catch(() => {});
api.get('/plateformes').then(d => { setPlateformes(d); setPlatLoaded(true); }).catch(() => { setPlatLoaded(true); });
api.get('/icons').then(rows => { const m = {}; rows.forEach(r => { m[r.name] = r.filename; }); setLibIcons(m); }).catch(() => {});
}, []);
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
// Ferme tous les menus contextuels au scroll
useEffect(() => {
const closeAll = () => {
setOpenMenu(null);
};
window.addEventListener('scroll', closeAll, true);
return () => window.removeEventListener('scroll', closeAll, true);
}, []);
/* Auto-sélection du premier investissement dès le chargement */
useEffect(() => {
if (selectedSimul || !investissements.length) return;
setSelectedSimul(String(investissements[0].id));
}, [investissements]); /* eslint-disable-next-line */
useEffect(() => {
if (!selectedSimul) { setEcheances([]); return; }
api.get('/simul', { investissement_id: selectedSimul }).then(setEcheances).catch(() => {});
}, [selectedSimul]);
const generateSimul = async () => {
if (!selectedSimul) return;
setSimulBusy(true); setSimulMsg(null);
try {
const r = await api.post('/simul/generate', { investissement_id: Number(selectedSimul), replace: true });
setSimulMsg(`✔ ${r.inserted} échéances générées.`);
const e = await api.get('/simul', { investissement_id: selectedSimul });
setEcheances(e);
} catch (e) { setSimulMsg(`✗ ${e.message}`); }
finally { setSimulBusy(false); }
};
/* ── Données pour graphiques et KPI : filtrées par plateforme ET année ── */
const chartRows = useMemo(() =>
allRows.filter(r => {
if (filterPlatId && String(r.plateforme_id) !== filterPlatId) return false;
if (rembPlatYear && r.date_remb.slice(0, 4) !== rembPlatYear) return false;
return true;
}),
[allRows, filterPlatId, rembPlatYear]
);
/* ── Corrections filtrées (même scope plateforme + année) ── */
const filteredCorrections = useMemo(() =>
corrections.filter(c => {
if (filterPlatId && String(c.plateforme_id) !== filterPlatId) return false;
if (rembPlatYear && c.date.slice(0, 4) !== rembPlatYear) return false;
return true;
}),
[corrections, filterPlatId, rembPlatYear]
);
/* ── KPIs (calculés sur chartRows + corrections) ── */
const totals = useMemo(() => {
const base = chartRows.reduce((acc, r) => {
acc.capital += r.capital || 0;
acc.cashback += r.cashback || 0;
acc.interets += r.interets_bruts || 0;
acc.interets_nets += r.interets_nets || 0;
return acc;
}, { capital: 0, cashback: 0, interets: 0, interets_nets: 0 });
// Les corrections s'ajoutent uniquement au net (brut = 0)
base.interets_nets += filteredCorrections.reduce((s, c) => s + c.montant, 0);
return base;
}, [chartRows, filteredCorrections]);
/* ── Totaux KPI année N-1 (pour TrendBadge) ── */
const rembPrevYear = String(Number(rembPlatYear || new Date().getFullYear()) - 1);
const prevTotals = useMemo(() => {
const prevRows = allRows.filter(r => {
if (filterPlatId && String(r.plateforme_id) !== filterPlatId) return false;
return r.date_remb.slice(0, 4) === rembPrevYear;
});
const base = prevRows.reduce((acc, r) => {
acc.capital += r.capital || 0;
acc.cashback += r.cashback || 0;
acc.interets += r.interets_bruts || 0;
acc.interets_nets += r.interets_nets || 0;
return acc;
}, { capital: 0, cashback: 0, interets: 0, interets_nets: 0 });
const prevCorr = corrections.filter(c => {
if (filterPlatId && String(c.plateforme_id) !== filterPlatId) return false;
return c.date.slice(0, 4) === rembPrevYear;
});
base.interets_nets += prevCorr.reduce((s, c) => s + c.montant, 0);
return base;
}, [allRows, corrections, filterPlatId, rembPrevYear]);
/* ── Données par plateforme (onglet Plateformes) ── */
const byPlatform = useMemo(() => {
const src = rembPlatYear
? allRows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear)
: allRows;
const map = {};
for (const r of src) {
const key = String(r.plateforme_id);
if (!map[key]) map[key] = {
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
detenteur_nom: r.plateforme_detenteur_nom || null,
capital: 0, cashback: 0, interets_bruts: 0, interets_nets: 0, net_recu: 0, count: 0,
};
map[key].capital += r.capital || 0;
map[key].cashback += r.cashback || 0;
map[key].interets_bruts += r.interets_bruts || 0;
map[key].interets_nets += r.interets_nets || 0;
map[key].net_recu += r.net_recu || 0;
map[key].count++;
}
return Object.values(map).sort((a, b) => b.interets_nets - a.interets_nets);
}, [allRows, rembPlatYear]);
const totalInterets = byPlatform.reduce((s, p) => s + (netMode ? p.interets_nets : p.interets_bruts), 0);
const byPlatformTotals = useMemo(() =>
byPlatform.reduce((acc, p) => {
acc.capital += p.capital;
acc.cashback += p.cashback;
acc.interets_bruts += p.interets_bruts;
acc.interets_nets += p.interets_nets;
acc.net_recu += p.net_recu;
acc.count += p.count;
return acc;
}, { capital: 0, cashback: 0, interets_bruts: 0, interets_nets: 0, net_recu: 0, count: 0 }),
[byPlatform]
);
/* ── Tableau remboursements : filtrage client-side ── */
const tableRows = useMemo(() => {
// Remboursements filtrés
const remb = allRows.filter(r => {
if (tableFilter.plateforme_id && String(r.plateforme_id) !== String(tableFilter.plateforme_id)) return false;
// Filtre porte-monnaie / compte-courant
if (!filterPortefeuille && !filterCompteCourant) return false;
if (!filterPortefeuille && (r.methode_remboursement === 'portefeuille' || !r.methode_remboursement)) return false;
if (!filterCompteCourant && r.methode_remboursement === 'compte_courant') return false;
// Filtre type : les corrections n'ont pas de type remboursement
if (tableFilter.type && tableFilter.type !== 'correction_solde' && r.type !== tableFilter.type) return false;
if (tableFilter.type === 'correction_solde') return false; // géré séparément
// Filtre projet
const isBonusTypeFilter = tableFilter.type === 'bonus_parrainage' || tableFilter.type === 'bonus_plateforme' || tableFilter.type === 'interets_plateforme';
if (!isBonusTypeFilter && tableFilter.investissement_id) {
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme' || r.type === 'interets_plateforme';
if (isBonus) return false;
if (String(r.investissement_id) !== String(tableFilter.investissement_id)) return false;
}
const [y, m] = r.date_remb.split('-');
if (tableFilter.year && y !== tableFilter.year) return false;
if (tableFilter.month && m !== tableFilter.month.padStart(2, '0')) return false;
return true;
});
// Corrections filtrées (normalisées pour l'affichage)
const showCorrections = filterPortefeuille && (!tableFilter.type || tableFilter.type === 'correction_solde');
const corr = showCorrections ? corrections.filter(c => {
if (tableFilter.plateforme_id && String(c.plateforme_id) !== String(tableFilter.plateforme_id)) return false;
const [y, m] = c.date.split('-');
if (tableFilter.year && y !== tableFilter.year) return false;
if (tableFilter.month && m !== tableFilter.month.padStart(2, '0')) return false;
return true;
}).map(c => ({
...c,
_is_correction: true,
date_remb: c.date,
type: 'correction_solde',
nom_projet: c.notes || 'Correction de solde',
capital: 0, cashback: 0, interets_bruts: 0, prelev_sociaux: 0,
prelev_forfaitaire: 0, interets_nets: c.montant, net_recu: c.montant,
statut: 'paye',
})) : [];
return [...remb, ...corr].sort((a, b) =>
(b.date_remb || '').localeCompare(a.date_remb || '')
);
}, [allRows, corrections, tableFilter, filterPortefeuille, filterCompteCourant]);
/* ── Pagination remboursements ── */
const {
pagedItems: pagedTableRows, page: rembPage, setPage: setRembPage,
pageSize: rembPageSize, setPageSize: setRembPageSize,
totalPages: rembTotalPages, totalItems: rembTotalItems, PAGE_SIZES,
} = usePagination(tableRows, 'cl_pagesize_remb', [tableFilter]);
const years = useMemo(() =>
[...new Set(allRows.map(r => r.date_remb.slice(0, 4)))].sort().reverse(),
[allRows]
);
/* ── Investissements éligibles ── */
const investissementsActifs = investissements.filter(i => i.statut !== 'rembourse');
/* ── Ouverture auto depuis le bouton global "Nouveau remboursement" ── */
useEffect(() => {
if (new URLSearchParams(search).get('new') !== '1') return;
navigate('/remboursements', { replace: true });
setEditingId(null);
setForm({ ...empty, investissement_id: '' });
setErr(null);
setModalOpen(true);
}, [search]); /* eslint-disable-next-line */
/* ── Ouverture auto depuis le Dashboard (clic sur ligne DrillCellPanel) ── */
useEffect(() => {
if (autoOpenRef.current) return;
const sp = new URLSearchParams(search);
const editRembId = sp.get('edit-remb');
const openSimulInvId = sp.get('open-simul');
if (!editRembId && !openSimulInvId) return;
/* Mémorise l'URL de retour Dashboard (une seule fois) */
if (sp.get('from') === 'dashboard' && !backToDashboardRef.current) {
const q = new URLSearchParams({ 'drill-annee': sp.get('drill-annee'), 'drill-mois': sp.get('drill-mois') });
if (sp.get('drill-plat')) q.set('drill-plat', sp.get('drill-plat'));
backToDashboardRef.current = `/?${q}`;
}
if (editRembId && allRows.length) {
const remb = allRows.find(r => r.id === Number(editRembId));
if (remb) {
autoOpenRef.current = true;
navigate('/remboursements', { replace: true });
openEdit(remb);
}
}
if (openSimulInvId && investissements.length && plateformes.length) {
const invId = Number(openSimulInvId);
const inv = investissements.find(i => i.id === invId);
if (inv) {
autoOpenRef.current = true;
navigate('/remboursements', { replace: true });
openFromSimul({
investissement_id: invId,
date_prevue: sp.get('simul-date') || '',
capital_prevu: Number(sp.get('simul-capital') || 0),
interets_prevus: Number(sp.get('simul-interets') || 0),
});
}
}
}, [allRows.length, investissements.length, plateformes.length]); /* eslint-disable-next-line */
/* ── Modal helpers ── */
const openNew = () => {
setEditingId(null);
setForm({ ...empty, investissement_id: '' });
setErr(null); setModalOpen(true);
};
const openEdit = (r) => {
setEditingId(r.id);
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme';
const isInteretsPlateforme = r.type === 'interets_plateforme';
const sentinelle = r.type === 'bonus_parrainage' ? 'BONUS_PARRAINAGE'
: r.type === 'bonus_plateforme' ? 'BONUS_PLATEFORME'
: isInteretsPlateforme ? INTERETS_PLATEFORME_VALUE
: '';
setForm({
investissement_id: (isBonus || isInteretsPlateforme) ? sentinelle : r.investissement_id,
date_remb: r.date_remb,
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, 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',
compte_id: r.compte_id || '',
bonus_plateforme_id: r.bonus_plateforme_id || '',
bonus_investisseur_id: r.bonus_investisseur_id || '',
});
setErr(null); setModalOpen(true);
// Charge les comptes du détenteur pour le dropdown Compte de réception
if (!isBonus && !isInteretsPlateforme) {
const invForEdit = investissements.find(i => i.id === r.investissement_id);
if (invForEdit?.investisseur_id) {
api.get(`/investissements/comptes-par-investisseur/${invForEdit.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
} else {
setComptesInvestisseur([]);
}
}
};
/* Ouvre le formulaire remboursement pré-rempli depuis une ligne de projection */
const openFromSimul = (s) => {
const inv = investissements.find(i => i.id === s.investissement_id);
const plat = inv ? plateformes.find(p => p.id === inv.plateforme_id) : null;
const bruts = s.interets_prevus || 0;
const rates = getRatesForYear(s.date_prevue);
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
? plat.methode_remboursement
: (inv?.methode_remboursement || 'portefeuille');
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0;
const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts;
setEditingId(null);
setForm({
...empty,
investissement_id: s.investissement_id,
date_remb: s.date_prevue,
capital: s.capital_prevu || 0,
cashback: 0,
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
taxe_locale,
interets_bruts: brutsApresLocal,
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
statut: 'paye',
notes: '',
methode_remboursement: methode,
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
});
setErr(null);
setModalOpen(true);
// Charge les comptes du détenteur pour le dropdown Compte de réception
if (inv?.investisseur_id) {
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
} else {
setComptesInvestisseur([]);
}
};
const openRowMenu = (e, row) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ row, x: rect.right, y: rect.bottom });
};
const deleteRemb = (id) => {
setCorrDeleteConfirm({
message: 'Supprimer définitivement ce remboursement ?',
onConfirm: async () => {
await api.del(`/remboursements/${id}`);
setCorrDeleteConfirm(null);
load();
},
});
};
const close = () => {
setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); setConfirmingDelete(false);
if (backToDashboardRef.current) {
const url = backToDashboardRef.current;
backToDashboardRef.current = null;
navigate(url);
}
};
/* ── PFU rates ── */
const getLastKnownRates = () =>
pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
const getRatesForYear = (dateStr) => {
if (!dateStr || !pfuRates.length) return null;
const year = parseInt(dateStr.slice(0, 4), 10);
return pfuRates.find(r => r.annee === year) ?? getLastKnownRates();
};
const round2 = (n) => Math.round(n * 100) / 100;
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));
const setField = (k, v) => {
const next = { ...form, [k]: v };
// Fiscalité locale : calcule taxe_locale et interets_bruts à partir du brut avant retenue
if (k === 'interets_bruts_avant_local') {
const plat = next.investissement_id === INTERETS_PLATEFORME_VALUE
? plateformes.find(p => String(p.id) === String(next.bonus_plateforme_id))
: (() => {
const inv = investissements.find(i => i.id === Number(next.investissement_id));
return plateformes.find(p => p.id === inv?.plateforme_id);
})();
const taux = plat?.fiscalite === 'avec_fiscalite_locale' ? (plat?.taux_fiscalite_locale || 0) : 0;
next.taxe_locale = round2(Number(v) * taux / 100);
next.interets_bruts = round2(Number(v) - next.taxe_locale);
}
// Correction manuelle de la taxe locale : recalcule interets_bruts
if (k === 'taxe_locale') {
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' || k === 'frais_ttc' || k === 'frais_mode_reglement') {
const dateStr = k === 'date_remb' ? v : next.date_remb;
// 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);
next.prelev_forfaitaire = round2(bruts * rates.impot_revenu / 100);
}
}
// Quand l'investissement change, on hérite silencieusement de la méthode de la plateforme
// (si fermée) ou on remet le défaut (si ouverte, l'utilisateur choisira dans le formulaire)
if (k === 'investissement_id' && !BONUS_VALUES.includes(v) && v !== INTERETS_PLATEFORME_VALUE) {
const inv = investissements.find(i => i.id === Number(v));
const plat = plateformes.find(p => p.id === inv?.plateforme_id);
if (plat && plat.methode_remboursement !== 'choix_investisseur') {
next.methode_remboursement = plat.methode_remboursement;
} else {
next.methode_remboursement = inv?.methode_remboursement || 'portefeuille';
}
next.compte_id = '';
// Réinitialise le brut avant fiscalité locale lors d'un changement d'investissement
next.interets_bruts_avant_local = 0;
// Charge les comptes du détenteur et auto-sélectionne si compte_courant
if (inv?.investisseur_id) {
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
.then(comptes => {
setComptesInvestisseur(comptes);
if (next.methode_remboursement === 'compte_courant') {
// Priorité : compte lié à l'investissement, sinon premier compte_courant
const inherited = inv.compte_id ? comptes.find(c => c.id === inv.compte_id) : null;
const def = inherited ?? comptes.find(c => c.compte_reglement_par_defaut) ?? comptes[0];
if (def) setForm(f => ({ ...f, compte_id: String(def.id) }));
}
})
.catch(() => setComptesInvestisseur([]));
} else {
setComptesInvestisseur([]);
}
}
setForm(next);
};
const isBonus = BONUS_VALUES.includes(form.investissement_id);
const isInteretsPlateforme = form.investissement_id === INTERETS_PLATEFORME_VALUE;
const submit = async (e) => {
e?.preventDefault?.();
setErr(null);
try {
let payload;
if (isBonus) {
payload = {
type: BONUS_TYPE_MAP[form.investissement_id],
bonus_plateforme_id: Number(form.bonus_plateforme_id),
bonus_investisseur_id: form.bonus_investisseur_id ? Number(form.bonus_investisseur_id) : undefined,
date_remb: form.date_remb,
cashback: Number(form.cashback || 0),
statut: form.statut,
notes: form.notes || undefined,
};
} else if (isInteretsPlateforme) {
payload = {
type: 'interets_plateforme',
bonus_plateforme_id: Number(form.bonus_plateforme_id),
bonus_investisseur_id: form.bonus_investisseur_id ? Number(form.bonus_investisseur_id) : undefined,
date_remb: form.date_remb,
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,
notes: form.notes || undefined,
};
} else {
payload = {
type: 'normal',
investissement_id: Number(form.investissement_id),
date_remb: form.date_remb,
capital: Number(form.capital || 0),
cashback: Number(form.cashback || 0),
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,
notes: form.notes || undefined,
methode_remboursement: form.methode_remboursement || 'portefeuille',
compte_id: form.methode_remboursement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
};
}
if (editingId) await api.put(`/remboursements/${editingId}`, payload);
else await api.post('/remboursements', payload);
close(); await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
await api.del(`/remboursements/${editingId}`);
close(); load();
};
/* ── Projections : helper taux PFU ── */
const getPfuReduction = (dateStr) => {
if (!dateStr || !pfuRates.length) return 0;
const year = parseInt(dateStr.slice(0, 4), 10);
const rates = pfuRates.find(r => r.annee === year) ?? getLastKnownRates();
return (rates.prelev_sociaux + rates.impot_revenu) / 100;
};
/* ── Projections : totaux ── */
const simulInv = investissements.find(i => i.id === Number(selectedSimul));
const simulTotals = echeances.reduce((acc, e) => {
const brut = e.interets_prevus || 0;
const reduction = getPfuReduction(e.date_prevue);
acc.capital += e.capital_prevu || 0;
acc.interets += brut;
acc.interets_net += brut * (1 - reduction);
acc.total += e.total_prevu || 0;
return acc;
}, { capital: 0, interets: 0, interets_net: 0, total: 0 });
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
/* ── Rendu ── */
if (platLoaded && !loading && plateformes.length === 0) return (
<>
<div className="topbar"><h2>Remboursements</h2></div>
<EmptyState />
</>
);
if (platLoaded && !loading && !modalOpen && allRows.length === 0) return (
<>
<div className="topbar"><h2>Remboursements</h2></div>
<EmptyState
icon="📋"
title="Aucun remboursement pour le moment"
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré. Ajoutez un remboursement pour voir apparaître vos données."
cta="Ajouter un remboursement"
to="/remboursements?new=1"
/>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="remboursement" />Remboursements</h2>
{filterPlatId && (
<button className="dr-clear-btn" onClick={() => setFilterPlatId('')}>
✕ Effacer le filtre plateforme
</button>
)}
</div>
{/* ── Graphiques ── */}
{!listFocused && <div className="charts-row">
<InteretsChart
rows={chartRows}
netMode={netMode}
/>
<InteretsDistributionChart
rows={chartRows}
netMode={netMode}
/>
</div>}
{/* ── KPIs ── */}
{!listFocused && <div className="dr-kpi-row">
<div className="kpi">
<div className="label">Capital remboursé</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.capital)}</span>
{rembPlatYear && <TrendBadge current={totals.capital} prev={prevTotals.capital} />}
</div>
{rembPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.capital)} en {rembPrevYear}</div>}
</div>
<div className="kpi">
<div className="label">Cashback</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cashback)}</span>
{rembPlatYear && <TrendBadge current={totals.cashback} prev={prevTotals.cashback} />}
</div>
{rembPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.cashback)} en {rembPrevYear}</div>}
</div>
<div className="kpi">
<div className="label">Intérêts — {netMode ? 'Net' : 'Brut'}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(netMode ? totals.interets_nets : totals.interets)}</span>
{rembPlatYear && <TrendBadge current={netMode ? totals.interets_nets : totals.interets} prev={netMode ? prevTotals.interets_nets : prevTotals.interets} />}
</div>
{rembPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(netMode ? prevTotals.interets_nets : prevTotals.interets)} en {rembPrevYear}</div>}
</div>
</div>}
{/* ── Onglets ── */}
{!listFocused && <div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'mois-en-cours' ? ' active' : ''}`}
onClick={() => setActiveTab('mois-en-cours')}>Mois en cours</button>
<button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`}
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
onClick={() => setActiveTab('projections')}>Projections Échéances</button>
</div>}
{/* ══ ONGLET PLATEFORMES ══ */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par plateforme
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select value={rembPlatYear} onChange={e => setRembPlatYear(e.target.value)}
style={{
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-muted)',
cursor: 'pointer', outline: 'none',
}}>
<option value="">Toutes les années</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
{filterPlatId && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontStyle: 'italic' }}>
Graphiques et KPIs filtrés sur cette plateforme
</span>
)}
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
onClick={() => { const next = !listFocused; setListFocused(next); setRembPageSize(next ? 25 : 15); }}
>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
<ExportDropdown
disabled={byPlatform.length === 0}
onCSV={() => dlBlob(rembToCSV(allRows), 'remboursements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(rembToXLS(allRows), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(rembToJSON(allRows), 'remboursements.json', 'application/json')}
/>
</div>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Capital remboursé</th>
<th className="num">Cashback</th>
<th className="num">Intérêts ({netMode ? 'Net' : 'Brut'})</th>
<th className="num">Cashback + Intérêts ({netMode ? 'Net' : 'Brut'})</th>
<th className="num">Échéances</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
<th style={{ width: 28 }}></th>
</tr>
</thead>
<tbody>
{byPlatform.length === 0 && (
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun remboursement'}
</td></tr>
)}
{byPlatform.map(p => {
const isActive = String(filterPlatId) === String(p.plateforme_id);
const val = netMode ? p.interets_nets : p.interets_bruts;
const poids = totalInterets !== 0 ? (val / totalInterets) * 100 : 0;
return (
<tr key={p.plateforme_id}
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setFilterPlatId(isActive ? '' : String(p.plateforme_id))}
title={isActive ? 'Cliquer pour retirer le filtre' : 'Cliquer pour filtrer sur cette plateforme'}
>
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{fmtEUR(p.capital)}</td>
<td className="num">{p.cashback > 0 ? fmtEUR(p.cashback) : '—'}</td>
<td className="num">{fmtEUR(val)}</td>
<td className="num">{fmtEUR(p.cashback + val)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.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>
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>
{isActive ? '✕' : ''}
</td>
</tr>
);
})}
</tbody>
{byPlatform.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byPlatform.length} plateformes</td>
{multiDetenteur && <td />}
<td className="num">{fmtEUR(byPlatformTotals.capital)}</td>
<td className="num">{byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'}</td>
<td className="num">{fmtEUR(netMode ? byPlatformTotals.interets_nets : byPlatformTotals.interets_bruts)}</td>
<td className="num">{fmtEUR(byPlatformTotals.cashback + (netMode ? byPlatformTotals.interets_nets : byPlatformTotals.interets_bruts))}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{byPlatformTotals.count}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
<td />
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET VISION MENSUELLE ══ */}
{activeTab === 'vision-mensuelle' && (
<div style={{ padding: '0 24px' }}>
{/* Ancre fixe pour le scroll retour au tableau — toujours dans le DOM */}
<div ref={tipTableRef} />
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
{!drillCell && <div><TableauInteretsPlateforme
activeView={activeView}
activeId={activeId}
pfuRates={pfuRates}
onCapitalMensuel={() => {}}
onCellClick={handleCellClick}
activeCell={drillCell}
expandButton={
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
style={{ marginLeft: 4 }}
onClick={() => { const next = !listFocused; setListFocused(next); setRembPageSize(next ? 25 : 15); }}
>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
}
/></div>}
{/* ── Panneau de détail : Remboursements reçus et projection ── */}
<div ref={drillPanelRef}>
<DrillCellPanel
cell={drillCell}
onClose={() => setDrillCell(null)}
pfuRates={pfuRates}
activeView={activeView}
activeId={activeId}
onEditRecu={openEdit}
onEditProjet={openFromSimul}
refreshKey={drillRefreshKey}
investissements={investissements}
plateformes={plateformes}
onBulkDone={load}
/>
</div>
</InteretsChartProvider>
</div>
)}
{/* ══ ONGLET MOIS EN COURS ══ */}
{activeTab === 'mois-en-cours' && (
<div style={{ padding: '0 24px' }}>
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
<DrillCellPanel
cell={moisEnCoursCell}
alwaysOpen
pfuRates={pfuRates}
activeView={activeView}
activeId={activeId}
onEditRecu={openEdit}
onEditProjet={openFromSimul}
refreshKey={drillRefreshKey}
investissements={investissements}
plateformes={plateformes}
onBulkDone={load}
/>
</InteretsChartProvider>
</div>
)}
{/* ══ ONGLET REMBOURSEMENTS ══ */}
{activeTab === 'remboursements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Remboursements</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Filtre porte-monnaie */}
<button
type="button"
className="icon-btn"
title={filterPortefeuille ? 'Masquer porte-monnaie' : 'Afficher porte-monnaie'}
onClick={() => setFilterPortefeuille(v => !v)}
style={{ opacity: filterPortefeuille ? 1 : 0.35, padding: 3 }}
>
{libIcons['porte-monnaie']
? <img src={ICONS_BASE + libIcons['porte-monnaie']} width={20} height={20} style={{ display: 'block', filter: 'var(--icon-filter, none)' }} />
: <span style={{ width: 20, height: 20, display: 'block', borderRadius: 3, background: 'var(--text-muted)' }} />}
</button>
{/* Filtre compte courant */}
<button
type="button"
className="icon-btn"
title={filterCompteCourant ? 'Masquer compte courant' : 'Afficher compte courant'}
onClick={() => setFilterCompteCourant(v => !v)}
style={{ opacity: filterCompteCourant ? 1 : 0.35, padding: 3 }}
>
{libIcons['compte-courant']
? <img src={ICONS_BASE + libIcons['compte-courant']} width={20} height={20} style={{ display: 'block', filter: 'var(--icon-filter, none)' }} />
: <span style={{ width: 20, height: 20, display: 'block', borderRadius: 3, background: 'var(--text-muted)' }} />}
</button>
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
onClick={() => { const next = !listFocused; setListFocused(next); setRembPageSize(next ? 25 : 15); }}
>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
<ExportDropdown
disabled={tableRows.length === 0}
onCSV={() => dlBlob(rembToCSV(tableRows), 'remboursements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(rembToXLS(tableRows), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(rembToJSON(tableRows), 'remboursements.json', 'application/json')}
/>
</div>
</div>
{/* Filtres */}
<div className="row" style={{ marginBottom: 12 }}>
<div>
<label>Plateforme</label>
<select value={tableFilter.plateforme_id} onChange={e => setTableFilter({ ...tableFilter, plateforme_id: e.target.value })}>
<option value="">Toutes</option>
{plateformes.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
<div>
<label>Type</label>
<select value={tableFilter.type} onChange={e => setTableFilter({ ...tableFilter, type: e.target.value, investissement_id: '' })}>
<option value="">Tous</option>
<option value="normal">Remboursements</option>
<option value="bonus_parrainage">Bonus Parrainage</option>
<option value="bonus_plateforme">Bonus Plateforme</option>
<option value="interets_plateforme">Intérêts plateforme</option>
<option value="correction_solde">Corrections de solde</option>
</select>
</div>
<div>
<label>Projet</label>
<select value={tableFilter.investissement_id}
onChange={e => setTableFilter({ ...tableFilter, investissement_id: e.target.value, type: e.target.value ? 'normal' : tableFilter.type })}
disabled={tableFilter.type && tableFilter.type !== 'normal'}>
<option value="">Tous</option>
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
</select>
</div>
<div>
<label>Année</label>
<select value={tableFilter.year} onChange={e => setTableFilter({ ...tableFilter, year: e.target.value })}>
<option value="">Toutes</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div>
<label>Mois</label>
<select value={tableFilter.month} onChange={e => setTableFilter({ ...tableFilter, month: e.target.value })}>
<option value="">Tous</option>
{MOIS_FR.map((m, i) => <option key={i+1} value={String(i+1).padStart(2,'0')}>{m}</option>)}
</select>
</div>
</div>
{/* Tableau */}
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Nom du projet</th><th>Versement</th>
<th className="num">Capital Remboursé</th><th className="num">Capital Restant dû</th>
<th className="num">Cashback</th>
<th className="num">Intérêts Bruts</th>
<th className="num">Prélèv. sociaux</th><th className="num">Impôt revenu</th>
<th className="num">Intérêts Nets</th>
<th className="num">Montant Net</th>
<th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{tableRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 14 : 13} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun remboursement'}
</td></tr>
)}
{pagedTableRows.map(r => {
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme';
const isInteretsPlateforme = r.type === 'interets_plateforme';
const isBonusLike = isBonus || isInteretsPlateforme;
const isCorrection = r._is_correction === true;
const rowKey = isCorrection ? `corr-${r.id}` : `remb-${r.id}`;
return (
<tr key={rowKey} style={{ cursor: 'pointer' }}
title={isCorrection ? 'Cliquer pour supprimer cette correction' : 'Modifier ce remboursement'}
onClick={() => {
if (isCorrection) {
setCorrDeleteConfirm({
message: `Supprimer la correction de ${r.montant > 0 ? '+' : ''}${r.montant} € du ${fmtDate(r.date_remb)} ?`,
onConfirm: () => {
api.del(`/corrections/${r.id}`).then(() => load()).catch(console.error);
setCorrDeleteConfirm(null);
},
});
} else {
openEdit(r);
}
}}>
<td>{fmtDate(r.date_remb)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && (
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>
{isCorrection ? (r.investisseur_nom || '—') : (r.plateforme_detenteur_nom || '—')}
</td>
)}
<td>
{isCorrection
? <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>{r.nom_projet}</span>
: isBonusLike
? <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>{r.nom_projet}</span>
: r.nom_projet}
</td>
<td style={{ fontSize: 'var(--fs-sm)' }}>
{isCorrection ? (
<span className="text-muted" style={{ fontStyle: 'italic' }}>Correction</span>
) : isBonus ? (
<span className="text-muted" style={{ fontStyle: 'italic' }}>
{r.type === 'bonus_parrainage' ? 'Parrainage' : 'Bonus'}
</span>
) : r.methode_remboursement === 'compte_courant' ? (
<span title={r.compte_nom || r.nom_compte_courant || ''}>
{r.compte_nom || r.nom_compte_courant || <span className="text-muted">Compte courant</span>}
</span>
) : (
<span className="text-muted">Porte-monnaie</span>
)}
</td>
<td className="num">{(isBonusLike || isCorrection) ? '—' : fmtEUR(r.capital)}</td>
<td className="num" style={{ color: !isBonusLike && !isCorrection && r.capital_restant_du <= 0 ? 'var(--success)' : undefined }}>
{(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)}
{/* 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">
{isCorrection
? <span style={{ color: r.montant >= 0 ? 'var(--success)' : 'var(--danger)', fontWeight: 600 }}>
{r.montant >= 0 ? '+' : ''}{fmtEUR(r.montant)}
</span>
: isBonus ? '—' : fmtEUR(r.interets_nets)}
</td>
<td className="num">
{isCorrection
? <span style={{ color: r.montant >= 0 ? 'var(--success)' : 'var(--danger)', fontWeight: 600 }}>
{r.montant >= 0 ? '+' : ''}{fmtEUR(r.montant)}
</span>
: fmtEUR(r.net_recu)}
</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => openRowMenu(e, r)}>⋮</button>
</td>
</tr>
);
})}
</tbody>
</table>
<Pagination
page={rembPage} setPage={setRembPage}
pageSize={rembPageSize} setPageSize={setRembPageSize}
totalPages={rembTotalPages} totalItems={rembTotalItems}
PAGE_SIZES={PAGE_SIZES}
/>
</div>
</div>
)}
{/* ══ ONGLET PROJECTIONS ÉCHÉANCES ══ */}
{activeTab === 'projections' && (
<div style={{ padding: '0 24px' }}>
<div className="card" style={{ marginBottom: 16 }}>
<div className="row">
<div style={{ flex: 2 }}>
<label>Investissement</label>
<select value={selectedSimul} onChange={e => setSelectedSimul(e.target.value)}>
<option value="">— Choisir —</option>
{investissements.map(i => (
<option key={i.id} value={i.id}>
{i.nom_projet} ({fmtEUR(i.montant_investi)} – {i.taux_interet ?? '?'} % – {i.duree_mois ?? '?'} m – {i.type_remb || '?'})
</option>
))}
</select>
</div>
<div style={{ flex: 1 }}>
<button className="primary" onClick={generateSimul}
disabled={!selectedSimul || simulBusy} style={{ width: '100%' }}>
{simulBusy ? '…' : 'Générer / Régénérer'}
</button>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 2 }}>
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
onClick={() => { const next = !listFocused; setListFocused(next); setRembPageSize(next ? 25 : 15); }}
>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
</div>
</div>
{simulMsg && (
<div className={simulMsg.startsWith('✔') ? 'success-msg' : 'error'} style={{ marginTop: 12 }}>
{simulMsg}
</div>
)}
{simulInv && (simulInv.taux_interet == null || !simulInv.duree_mois) && (
<div className="error" style={{ marginTop: 12 }}>
Cet investissement n'a pas de <strong>taux</strong> et/ou de <strong>durée</strong>. Renseignez-les dans la fiche.
</div>
)}
</div>
{/* ── Tableau / état vide ── */}
{(() => {
/* Détermine le message d'état vide selon le contexte */
let emptyIcon = null;
let emptyTitle = null;
let emptyHint = null;
if (loading) {
emptyTitle = 'Chargement…';
} else if (investissements.length === 0) {
emptyIcon = (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.3, marginBottom: 10 }}>
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
);
emptyTitle = 'Aucun investissement enregistré';
emptyHint = 'Créez votre premier investissement pour visualiser les projections d\'échéances.';
} else if (investissements.every(i => i.statut === 'rembourse')) {
emptyIcon = (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.3, marginBottom: 10 }}>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
</svg>
);
emptyTitle = 'Tous les investissements ont été remboursés';
emptyHint = 'Il n\'y a plus de projection à afficher. Les projections concernent les projets encore en cours.';
} else if (!selectedSimul) {
emptyIcon = (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.3, marginBottom: 10 }}>
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
);
emptyTitle = 'Aucun projet sélectionné';
emptyHint = 'Sélectionnez un projet dans la liste ci-dessus pour visualiser son échéancier prévisionnel.';
} else if (selectedSimul && echeances.length === 0 && !simulBusy) {
emptyIcon = (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.3, marginBottom: 10 }}>
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
<path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/>
</svg>
);
emptyTitle = 'Échéancier non généré';
emptyHint = 'Cliquez sur « Générer / Régénérer » ci-dessus pour créer la projection de ce projet.';
}
const isEmpty = !!(emptyTitle && echeances.length === 0);
return (
<div className="card">
{!isEmpty && (
<div className="kpi-grid" style={{ marginBottom: 12 }}>
<div className="kpi">
<div className="label">Capital prévu</div>
<div className="value">{fmtEUR(simulTotals.capital)}</div>
</div>
<div className="kpi">
<div className="label">
Intérêts prévus — {netMode ? 'Net (estimé)' : 'Brut'}
</div>
<div className="value success">
{fmtEUR(netMode ? simulTotals.interets_net : simulTotals.interets)}
</div>
</div>
<div className="kpi">
<div className="label">Total prévu</div>
<div className="value">{fmtEUR(simulTotals.total)}</div>
</div>
</div>
)}
<table>
<thead>
<tr>
<th>#</th>
<th>Date prévue</th>
<th className="num">Capital</th>
<th className="num">Intérêts ({netMode ? 'Net estimé' : 'Brut'})</th>
<th className="num">Total échéance</th>
</tr>
</thead>
<tbody>
{isEmpty ? (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '48px 24px', height: 220 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, color: 'var(--text-muted)' }}>
{emptyIcon}
<span style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--text)' }}>{emptyTitle}</span>
{emptyHint && <span style={{ fontSize: '0.85rem', maxWidth: 360, textAlign: 'center', lineHeight: 1.5 }}>{emptyHint}</span>}
</div>
</td>
</tr>
) : (
echeances.map(e => {
const reduction = getPfuReduction(e.date_prevue);
const interets = netMode
? (e.interets_prevus || 0) * (1 - reduction)
: (e.interets_prevus || 0);
return (
<tr key={e.id}>
<td>{e.numero_echeance}</td>
<td>{fmtDate(e.date_prevue)}</td>
<td className="num">{fmtEUR(e.capital_prevu)}</td>
<td className="num">{fmtEUR(interets)}</td>
<td className="num">{fmtEUR(e.total_prevu)}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
);
})()}
</div>
)}
{/* ══ MODAL ══ */}
{(() => {
// Liste d'investissements disponibles dans le select
const invListForSelect = (editingId
? investissements.filter(i => i.statut !== 'rembourse' || i.id === Number(form.investissement_id))
: investissementsActifs
).slice().sort((a, b) => {
const pa = (a.plateforme_nom || '').toLowerCase();
const pb = (b.plateforme_nom || '').toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return (a.nom_projet || '').localeCompare(b.nom_projet || '', 'fr');
});
return (
<RembFormModal
open={modalOpen}
editingId={editingId}
onClose={close}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
err={err}
form={form}
onFieldChange={setField}
onFormPatch={patch => setForm(f => ({ ...f, ...patch }))}
pfuRates={pfuRates}
comptesInvestisseur={comptesInvestisseur}
investissements={investissements}
plateformes={plateformes}
investissementOptions={invListForSelect}
multiDetenteur={multiDetenteur}
/>
);
})()}
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{
position: 'fixed', left: openMenu.x, top: openMenu.y,
transform: 'translateX(-100%) translateY(4px)',
zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: '4px 0', minWidth: 140,
}}>
{!openMenu.row._is_correction && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={() => { setOpenMenu(null); openEdit(openMenu.row); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Modifier
</button>
)}
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => {
const row = openMenu.row;
setOpenMenu(null);
if (row._is_correction) {
setCorrDeleteConfirm({
message: 'Supprimer définitivement cette correction ?',
onConfirm: async () => {
await api.del(`/corrections/${row.id}`);
setCorrDeleteConfirm(null);
load();
},
});
} else {
deleteRemb(row.id);
}
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Supprimer
</button>
</div>
</>
)}
{corrDeleteConfirm && (
<ConfirmModal
message={corrDeleteConfirm.message}
onConfirm={corrDeleteConfirm.onConfirm}
onCancel={() => setCorrDeleteConfirm(null)}
/>
)}
</>
);
}