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 ( {arrow} {label} ); } 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 (
{open && (
)}
); } /* ── 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 ( <>

Remboursements

{filterPlatId && ( )}
{/* ── Graphiques ── */} {!listFocused &&
} {/* ── KPIs ── */} {!listFocused &&
Capital remboursé
{fmtEUR(totals.capital)}
{fmtEUR(prevTotals.capital)} en {rembPrevYear}
Cashback
{fmtEUR(totals.cashback)}
{fmtEUR(prevTotals.cashback)} en {rembPrevYear}
Intérêts — {netMode ? 'Net' : 'Brut'}
{fmtEUR(netMode ? totals.interets_nets : totals.interets)}
{fmtEUR(netMode ? prevTotals.interets_nets : prevTotals.interets)} en {rembPrevYear}
} {/* ── Onglets ── */} {!listFocused &&
} {/* ══ ONGLET PLATEFORMES ══ */} {activeTab === 'plateformes' && (

Répartition par plateforme {rembPlatYear && pour l'année {rembPlatYear}}

{filterPlatId && ( Graphiques et KPIs filtrés sur cette plateforme )} 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')} />
{byPlatform.length === 0 && ( )} {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 ( setFilterPlatId(isActive ? '' : String(p.plateforme_id))} title={isActive ? 'Cliquer pour retirer le filtre' : 'Cliquer pour filtrer sur cette plateforme'} > ); })} {byPlatform.length > 1 && ( )}
Plateforme Détenteur Capital remboursé Cashback Intérêts ({netMode ? 'Net' : 'Brut'}) Cashback + Intérêts ({netMode ? 'Net' : 'Brut'}) Échéances Poids
{loading ? 'Chargement…' : 'Aucun remboursement'}
{p.nom} {p.detenteur_nom || '—'} {fmtEUR(p.capital)} {p.cashback > 0 ? fmtEUR(p.cashback) : '—'} {fmtEUR(val)} {fmtEUR(p.cashback + val)} {p.count}
{poids.toFixed(1)} %
{isActive ? '✕' : ''}
Total — {byPlatform.length} plateformes {fmtEUR(byPlatformTotals.capital)} {byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'} {fmtEUR(netMode ? byPlatformTotals.interets_nets : byPlatformTotals.interets_bruts)} {fmtEUR(byPlatformTotals.cashback + (netMode ? byPlatformTotals.interets_nets : byPlatformTotals.interets_bruts))} {byPlatformTotals.count} 100 %
)} {/* ══ ONGLET VISION MENSUELLE ══ */} {activeTab === 'vision-mensuelle' && (
{/* Ancre fixe pour le scroll retour au tableau — toujours dans le DOM */}
{!drillCell &&
{}} onCellClick={handleCellClick} activeCell={drillCell} expandButton={ } />
} {/* ── Panneau de détail : Remboursements reçus et projection ── */}
setDrillCell(null)} pfuRates={pfuRates} activeView={activeView} activeId={activeId} onEditRecu={openEdit} onEditProjet={openFromSimul} refreshKey={drillRefreshKey} investissements={investissements} plateformes={plateformes} onBulkDone={load} />
)} {/* ══ ONGLET REMBOURSEMENTS ══ */} {activeTab === 'remboursements' && (

Remboursements

{/* Filtre porte-monnaie */} {/* Filtre compte courant */} 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')} />
{/* Filtres */}
{/* Tableau */} {tableRows.length === 0 && ( )} {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 ( { 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); } }}> ); })}
DatePlateformeDétenteurNom du projetVersement Capital RembourséCapital Restant dû Cashback Intérêts Bruts Prélèv. sociauxImpôt revenu Intérêts Nets Montant Net
{loading ? 'Chargement…' : 'Aucun remboursement'}
{fmtDate(r.date_remb)} {r.plateforme_nom} {isCorrection ? (r.investisseur_nom || '—') : (r.plateforme_detenteur_nom || '—')} {isCorrection ? {r.nom_projet} : isBonus ? {r.nom_projet} : r.nom_projet} {isCorrection ? ( Correction ) : isBonus ? ( {r.type === 'bonus_parrainage' ? 'Parrainage' : 'Bonus'} ) : r.methode_remboursement === 'compte_courant' ? ( {r.compte_nom || r.nom_compte_courant || Compte courant} ) : ( Porte-monnaie )} {(isBonus || isCorrection) ? '—' : fmtEUR(r.capital)} {(isBonus || isCorrection) ? '—' : fmtEUR(Math.max(0, r.capital_restant_du ?? 0))} {r.cashback ? fmtEUR(r.cashback) : '—'} {(isBonus || isCorrection) ? '—' : fmtEUR(r.interets_bruts)} {(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_sociaux)} {(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_forfaitaire)} {isCorrection ? = 0 ? 'var(--success)' : 'var(--danger)', fontWeight: 600 }}> {r.montant >= 0 ? '+' : ''}{fmtEUR(r.montant)} : isBonus ? '—' : fmtEUR(r.interets_nets)} {isCorrection ? = 0 ? 'var(--success)' : 'var(--danger)', fontWeight: 600 }}> {r.montant >= 0 ? '+' : ''}{fmtEUR(r.montant)} : fmtEUR(r.net_recu)} e.stopPropagation()}>
)} {/* ══ ONGLET PROJECTIONS ÉCHÉANCES ══ */} {activeTab === 'projections' && (
{simulMsg && (
{simulMsg}
)} {simulInv && (!simulInv.taux_interet || !simulInv.duree_mois) && (
Cet investissement n'a pas de taux et/ou de durée. Renseignez-les dans la fiche.
)}
{/* ── 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 = ( ); 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 = ( ); 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 = ( ); 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 = ( ); 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 (
{!isEmpty && (
Capital prévu
{fmtEUR(simulTotals.capital)}
Intérêts prévus — {netMode ? 'Net (estimé)' : 'Brut'}
{fmtEUR(netMode ? simulTotals.interets_net : simulTotals.interets)}
Total prévu
{fmtEUR(simulTotals.total)}
)} {isEmpty ? ( ) : ( echeances.map(e => { const reduction = getPfuReduction(e.date_prevue); const interets = netMode ? (e.interets_prevus || 0) * (1 - reduction) : (e.interets_prevus || 0); return ( ); }) )}
# Date prévue Capital Intérêts ({netMode ? 'Net estimé' : 'Brut'}) Total échéance
{emptyIcon} {emptyTitle} {emptyHint && {emptyHint}}
{e.numero_echeance} {fmtDate(e.date_prevue)} {fmtEUR(e.capital_prevu)} {fmtEUR(interets)} {fmtEUR(e.total_prevu)}
); })()}
)} {/* ══ 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 (
{editingId && ( !confirmingDelete ? ( ) : ( Supprimer définitivement ? ) )}
} >
{err &&
{err}
}
{/* ── Sélecteur d'investissement ── */}
{isBonus && (
)} {/* ── Champs remboursement normal ── */} {!isBonus && (<>
Remboursement
{hasLocalTax && (
setField('interets_bruts_avant_local', e.target.value)} />
)} {hasLocalTax && (
setField('taxe_locale', e.target.value)} />
)}
setField('capital', e.target.value)} />
!hasLocalTax && setField('interets_bruts', e.target.value)} />
setField('cashback', e.target.value)} />
Imposition{isIndicatif ? ' — indicatif' : ''}
!isIndicatif && setForm({ ...form, prelev_sociaux: Number(e.target.value) })} />
!isIndicatif && setForm({ ...form, prelev_forfaitaire: Number(e.target.value) })} />
Montant des intérêts après imposition {fmtEUR(interetsNets)}
Versement
setField('date_remb', e.target.value)} />
{form.methode_remboursement === 'compte_courant' && ( <> {comptesInvestisseur.length > 0 ? ( ) : (

Aucun compte défini. Créer un compte →

)} )}
{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)."}
)} {/* ── Bonus ── */} {isBonus && (
setField('date_remb', e.target.value)} />
)} {isBonus && (
setField('cashback', e.target.value)} />
)}
); })()} {openMenu && ( <>
setOpenMenu(null)} />
{!openMenu.row._is_correction && ( )}
)} {corrDeleteConfirm && ( setCorrDeleteConfirm(null)} /> )} ); }