Files
crowdlending-app/frontend/src/pages/Remboursements.jsx
T
Olivier CROGUENNEC 5432b0bb3c Correction bug
2026-06-13 15:23:01 +02:00

1672 lines
84 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 Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.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 Modal from '../components/Modal.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 { 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 }) {
if (prev == null || prev === 0) 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 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, 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 [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);
/* ── 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(String(new Date().getFullYear()));
/* Modal */
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
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] = await Promise.all([
api.get('/remboursements', scopeParams),
api.get('/investissements', scopeParams),
api.get('/corrections', scopeParams).catch(() => []),
]);
setAllRows(r); setInvestissements(inv); setCorrections(corr);
} finally {
setLoading(false);
setDrillRefreshKey(k => k + 1);
}
};
useEffect(() => {
api.get('/pfu').then(setPfuRates).catch(() => {});
api.get('/plateformes').then(setPlateformes).catch(() => {});
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';
if (!isBonusTypeFilter && tableFilter.investissement_id) {
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_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 sentinelle = r.type === 'bonus_parrainage' ? 'BONUS_PARRAINAGE' : r.type === 'bonus_plateforme' ? 'BONUS_PLATEFORME' : '';
setForm({
investissement_id: isBonus ? 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,
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) {
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) =>
round2(Number(f.interets_bruts || 0) - 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 inv = investissements.find(i => i.id === Number(next.investissement_id));
const plat = 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') {
const dateStr = k === 'date_remb' ? v : next.date_remb;
const bruts = Number(next.interets_bruts) || 0;
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)) {
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.type === 'compte_courant') ?? 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 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 {
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),
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 ── */
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>
<TrendBadge current={totals.capital} prev={prevTotals.capital} />
</div>
<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>
<TrendBadge current={totals.cashback} prev={prevTotals.cashback} />
</div>
<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>
<TrendBadge current={netMode ? totals.interets_nets : totals.interets} prev={netMode ? prevTotals.interets_nets : prevTotals.interets} />
</div>
<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 === '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>
<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={9} 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>
<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>
<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 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="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><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={14} 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 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>
<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>
: isBonus
? <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">{(isBonus || isCorrection) ? '' : fmtEUR(r.capital)}</td>
<td className="num" style={{ color: !isBonus && !isCorrection && r.capital_restant_du <= 0 ? 'var(--success)' : undefined }}>
{(isBonus || 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.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 || !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 ══ */}
{(() => {
const pfuYear = getRatesForYear(form.date_remb);
const fmtRate = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
const labelPS = `Prélèvements sociaux${pfuYear ? ` (${fmtRate(pfuYear.prelev_sociaux)})` : ''} (€)`;
const labelIR = `Impôt sur le revenu${pfuYear ? ` (${fmtRate(pfuYear.impot_revenu)})` : ''} (€)`;
const interetsNets = computeInteretsNets(form);
// 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');
});
// Méthode de remboursement — plateforme de l'investissement sélectionné
const currentInv = !isBonus ? investissements.find(i => i.id === Number(form.investissement_id)) : null;
const currentPlat = currentInv ? plateformes.find(p => p.id === currentInv.plateforme_id) : null;
const isChoixOuvert = currentPlat?.methode_remboursement === 'choix_investisseur';
const hasLocalTax = !isBonus && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
const isExonere = !isBonus && currentInv?.fiscalite_override === 'exonere';
const isIndicatif = isExonere || (!isBonus && !!currentPlat && currentPlat?.fiscalite !== 'flat_tax');
const netRecu = isIndicatif
? round2(Number(form.capital||0) + Number(form.cashback||0) + Number(form.interets_bruts||0))
: computeNet(form);
return (
<Modal
open={modalOpen}
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
onClose={close}
width={900}
footer={
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<div style={{ flex: 1 }}>
{editingId && (
!confirmingDelete ? (
<button className="danger" onClick={() => setConfirmingDelete(true)}>
Supprimer
</button>
) : (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
Supprimer définitivement ?
</span>
<button onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
</span>
)
)}
</div>
<button onClick={close}>Annuler</button>
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
</div>
}
>
<form onSubmit={submit}>
{err && <div className="error">{err}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px' }}>
{/* ── Sélecteur d'investissement ── */}
<div style={{ gridColumn: isBonus ? 'span 1' : 'span 3' }}>
<label>Investissement *</label>
<select required value={form.investissement_id}
onChange={e => setField('investissement_id', e.target.value)}>
<option value=""></option>
<option value="BONUS_PARRAINAGE">🎁 Bonus Parrainage</option>
<option value="BONUS_PLATEFORME">🏆 Bonus Plateforme</option>
<optgroup label="──────────────────" />
{invListForSelect.map(i =>
<option key={i.id} value={i.id}>{i.plateforme_nom} {i.nom_projet} ({fmtEUR(i.montant_investi)})</option>
)}
</select>
</div>
{isBonus && (
<div style={{ gridColumn: 'span 2' }}>
<label>Plateforme associée (bonus)</label>
<select value={form.bonus_plateforme_id} onChange={e => {
const plat = plateformes.find(p => String(p.id) === e.target.value);
setForm({ ...form, bonus_plateforme_id: e.target.value, bonus_investisseur_id: plat?.investisseur_id ? String(plat.investisseur_id) : '' });
}}>
<option value=""> Aucune </option>
{plateformes.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? `${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
)}
{/* ── Champs remboursement normal ── */}
{!isBonus && (<>
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Remboursement</span>
</div>
{hasLocalTax && (
<div style={{ gridColumn: 'span 2' }}>
<label>Intérêts bruts avant retenue locale ({currentPlat.taux_fiscalite_locale} %) ()</label>
<input type="number" step="0.01" value={form.interets_bruts_avant_local}
onChange={e => setField('interets_bruts_avant_local', e.target.value)} />
</div>
)}
{hasLocalTax && (
<div>
<label>Retenue à la source locale ()</label>
<input type="number" step="0.01" value={form.taxe_locale}
onChange={e => setField('taxe_locale', e.target.value)} />
</div>
)}
<div>
<label>Capital ()</label>
<input type="number" step="0.01" value={form.capital}
onChange={e => setField('capital', e.target.value)} />
</div>
<div>
<label>{hasLocalTax ? 'Intérêts bruts après retenue locale (€)' : 'Intérêts bruts (€)'}</label>
<input type="number" step="0.01" value={form.interets_bruts}
readOnly={!!hasLocalTax}
style={hasLocalTax ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !hasLocalTax && setField('interets_bruts', e.target.value)} />
</div>
<div>
<label>Cashback ()</label>
<input type="number" step="0.01" value={form.cashback}
onChange={e => setField('cashback', e.target.value)} />
</div>
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Imposition{isIndicatif ? ' — indicatif' : ''}</span>
</div>
<div>
<label>{labelPS}</label>
<input type="number" step="0.01" value={form.prelev_sociaux}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setForm({ ...form, prelev_sociaux: Number(e.target.value) })} />
</div>
<div>
<label>{labelIR}</label>
<input type="number" step="0.01" value={form.prelev_forfaitaire}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setForm({ ...form, prelev_forfaitaire: Number(e.target.value) })} />
</div>
<div>
<label>Total prélèvements ()</label>
<input type="number" step="0.01"
value={round2((Number(form.prelev_sociaux) || 0) + (Number(form.prelev_forfaitaire) || 0))}
readOnly
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>Montant des intérêts après imposition</span>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{fmtEUR(interetsNets)}</span>
</div>
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Versement</span>
</div>
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb}
onChange={e => setField('date_remb', e.target.value)} />
</div>
<div>
<label>Méthode de remboursement</label>
<select value={form.methode_remboursement || 'portefeuille'}
onChange={e => {
const m = e.target.value;
const def = m === 'compte_courant' ? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0]) : null;
setForm(f => ({ ...f, methode_remboursement: m, compte_id: def ? String(def.id) : '' }));
}}>
<option value="portefeuille">Porte-monnaie de la plateforme</option>
<option value="compte_courant">Compte courant de l'investisseur</option>
</select>
</div>
<div>
{form.methode_remboursement === 'compte_courant' && (
<>
<label>Compte de réception</label>
{comptesInvestisseur.length > 0 ? (
<select value={form.compte_id || ''}
onChange={e => setForm(f => ({ ...f, compte_id: e.target.value }))}>
<option value="">— Choisir un compte —</option>
{comptesInvestisseur.map(c => (
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
))}
</select>
) : (
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
Aucun compte défini. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
</p>
)}
</>
)}
<label>Montant versé (€)</label>
<input type="number" step="0.01" value={netRecu} readOnly
style={{ background: 'var(--bg-input-readonly, rgba(255,255,255,.05))', cursor: 'default' }} />
</div>
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>
{isIndicatif
? "L'imposition n'est pas déduite du montant versé (fiscalité appliquée à titre indicatif)."
: "L'imposition est déduite du montant versé (capital + cashback + intérêts nets)."}
</span>
</div>
</>)}
{/* ── Bonus ── */}
{isBonus && (
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb}
onChange={e => setField('date_remb', e.target.value)} />
</div>
)}
{isBonus && (
<div style={{ gridColumn: 'span 2' }}>
<label>Montant (€)</label>
<input type="number" step="0.01" value={form.cashback}
onChange={e => setField('cashback', e.target.value)} />
</div>
)}
</div>
</form>
</Modal>
);
})()}
{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)}
/>
)}
</>
);
}