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 EmptyState from '../components/EmptyState.jsx'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import { useUi } from '../context/UiContext.jsx'; import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; import SoldeChart from '../components/SoldeChart.jsx'; import DistributionChart from '../components/DistributionChart.jsx'; import { fmtEUR, fmtDate, today, rangeFromDate } from '../utils/format.js'; import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx'; import SuiviObjectifs from '../components/SuiviObjectifs.jsx'; import * as XLSX from 'xlsx'; import { DEBIT_TYPES, signedMontant, MOUV_TYPE_LABELS, mouvToCSV, mouvToXLS, mouvToJSON, ExportDropdown, AddDropdown, CorrectionPickerModal, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm, timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap, } from '../components/MouvementsShared.jsx'; /* ── Indicateur de progression ── */ function TrendBadge({ current, prev, invert = false }) { // prev quasi-nul (résidu flottant, ex. 1e-12) → pourcentage non significatif (aurait // affiché des variations du type "+39628021025%"). Seuil aligné sur l'arrondi 2 décimales. if (prev == null || Math.abs(prev) < 0.005) return null; const diff = current - prev; const pct = (diff / prev) * 100; const up = diff > 0; const neutral = diff === 0; const good = neutral ? null : (invert ? !up : up); const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626'; const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)'; const arrow = neutral ? '→' : up ? '↗' : '↘'; const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`; return ( {arrow} {label} ); } function platToCSV(rows) { const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0); const BOM = ''; const sep = ';'; const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; const headers = ['Plateforme','Dépôts (€)','Retraits (€)','Diff. Dépôts vs Retraits (€)','Solde porte-monnaie (€)','Poids (%)']; const data = rows.map(p => { const poids = totalSolde !== 0 ? ((p.solde_net / totalSolde) * 100).toFixed(2) : '0.00'; return [ p.nom, String(p.depots).replace('.',','), String(p.retraits).replace('.',','), String(p.solde_net).replace('.',','), String(p.solde_portefeuille ?? 0).replace('.',','), poids.replace('.',','), ]; }); return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n'); } function platToXLS(rows) { const data = rows.map(p => ({ 'Plateforme': p.nom, 'Dépôts (€)': p.depots, 'Retraits (€)': p.retraits, 'Net (€)': p.net, })); const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Plateformes'); return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); } function platToJSON(rows) { const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0); const data = rows.map(p => ({ plateforme: p.nom, depots: p.depots, retraits: p.retraits, solde_net: p.solde_net, solde_portefeuille: p.solde_portefeuille ?? 0, poids_pct: totalSolde !== 0 ? +((p.solde_net / totalSolde) * 100).toFixed(2) : 0, })); return JSON.stringify(data, null, 2); } /* ── Composant principal ─────────────────────────────────────── */ export default function DepotsRetraits() { const { activeId, activeView, investisseurs } = useInvestisseur(); const { objectifsActifs } = useUi(); const { search } = useLocation(); const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('plateformes'); const [listFocused, setListFocused] = useState(false); const [allRows, setAllRows] = useState([]); const [plats, setPlats] = useState([]); const [dashData, setDashData] = useState(null); // Snapshots du solde porte-monnaie "à la fin de l'année N" (GET /dashboard?asOf=), // clé = 'YYYY-12-31'. Remplace un ancien recalcul client-side qui avait divergé // de la formule backend (bonus/intérêts plateforme/corrections manquants) — // cf. mémoire project_taxreport_interets_plateforme. const [dashDataAsOf, setDashDataAsOf] = useState({}); // Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements // — vide par défaut ("Toutes les années") au chargement initial de la page. const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: '', month: '' }); const [modalOpen, setModalOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyMouvementForm); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); const [selectedRow, setSelectedRow] = useState(null); const [drPlatYear, setDrPlatYear] = useState(''); // Plage du graphique de solde (1J/7J/1M/3M/YTD/1A/TOUT), partagée avec la // Distribution pour qu'elle affiche toujours la même période que le graphique. const [chartRange, setChartRange] = useState('TOUT'); const [allRemb, setAllRemb] = useState([]); const [allInv, setAllInv] = useState([]); const [allReinvests, setAllReinvests] = useState([]); const [allCorrections, setAllCorrections] = useState([]); const [deleteConfirm, setDeleteConfirm] = useState(null); /* Objectifs de versement annuel — pour enrichir le KPI "Diff. Dépôts vs Retraits" */ const [objectifsKpi, setObjectifsKpi] = useState([]); useEffect(() => { api.get('/objectifs', { type: 'versement_annuel' }) .then(r => setObjectifsKpi(Array.isArray(r) ? r : [])) .catch(() => setObjectifsKpi([])); }, []); /* Lecture des params de navigation depuis la page Plateformes */ useEffect(() => { const p = new URLSearchParams(search); if (p.get('tab') !== 'mouvements') return; setActiveTab('mouvements'); const newFilter = { type: '', plateforme_ids: [], year: '', month: '' }; if (p.get('type')) newFilter.type = p.get('type'); if (p.get('year')) newFilter.year = p.get('year'); if (p.get('month')) newFilter.month = p.get('month'); const platIds = p.get('plat_ids'); if (platIds) newFilter.plateforme_ids = platIds.split(',').filter(Boolean); setFilter(newFilter); setDrPlatYear(newFilter.year); // garde l'onglet Plateformes synchronisé avec l'année du drill-down navigate('/depots-retraits', { replace: true }); }, []); // eslint-disable-line const [openMenu, setOpenMenu] = useState(null); // { row, x, y } const [platFilterOpen, setPlatFilterOpen] = useState(false); const platFilterRef = useRef(null); useEffect(() => { if (!platFilterOpen) return; const h = e => { if (!platFilterRef.current?.contains(e.target)) setPlatFilterOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [platFilterOpen]); // État du modal de correction solde const [corrModal, setCorrModal] = useState({ open: false, plateforme_nom: '', type_operation: 'retrait', computedBalance: 0, date: '', platId: null, investisseurId: null, }); // État de la modale de sélection plateforme/date (correction de solde autonome, bouton "+") const [corrPickerOpen, setCorrPickerOpen] = useState(false); const load = async () => { if (!activeId && activeView !== 'all') return; setLoading(true); setAllRows([]); setDashData(null); const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined; try { const [r, p, d, remb, inv, corr, reinvests] = await Promise.all([ api.get('/depots-retraits', scopeParams), api.get('/plateformes'), api.get('/dashboard', scopeParams), api.get('/remboursements', scopeParams), api.get('/investissements', scopeParams), api.get('/corrections', scopeParams), // Toujours scope 'all' — filtré côté client via allInv (déjà scopé) pour respecter // le même pattern que Plateformes.jsx (route /reinvestissements ne connaît que l'utilisateur). api.get('/reinvestissements', { scope: 'all' }), ]); setAllRows(r); setPlats(p); setDashData(d); setAllRemb(remb); setAllInv(inv); setAllCorrections(corr); setAllReinvests(Array.isArray(reinvests) ? reinvests : []); } catch (e) { console.error('DepotsRetraits load error:', e); } finally { setLoading(false); } }; useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]); // Snapshots "fin d'année" du solde porte-monnaie, calculés côté backend // (GET /dashboard?asOf=YYYY-12-31) : année sélectionnée (si filtre actif) + // année précédente (toujours nécessaire pour le TrendBadge). // Extrait en fonction nommée pour pouvoir aussi être rappelée après chaque // mutation (remboursement, mouvement, correction) affectant le porte-monnaie — // `load()` seul ne suffit pas, ces snapshots vivent dans un state à part. const loadDashDataAsOf = async () => { if (!activeId && activeView !== 'all') return; const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined; const prevYear = String(Number(drPlatYear || new Date().getFullYear()) - 1); const dates = new Set([`${prevYear}-12-31`]); if (drPlatYear) dates.add(`${drPlatYear}-12-31`); try { const entries = await Promise.all([...dates].map(asOf => api.get('/dashboard', { ...scopeParams, asOf }).then(d => [asOf, d]) )); setDashDataAsOf(prev => ({ ...prev, ...Object.fromEntries(entries) })); } catch (e) { console.error('dashDataAsOf load error:', e); } }; useEffect(() => { loadDashDataAsOf(); // eslint-disable-next-line }, [drPlatYear, activeView, activeId]); // Ferme tous les menus contextuels au scroll useEffect(() => { const closeAll = () => { setOpenMenu(null); }; window.addEventListener('scroll', closeAll, true); return () => window.removeEventListener('scroll', closeAll, true); }, []); /* Filtrage */ // kpiRows : plateforme + année sélectionnée (sans filtre type — pour que les KPIs // affichent toujours dépôts ET retraits même quand un type est actif) const kpiRows = allRows.filter(r => { if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false; if (drPlatYear && r.date_operation.slice(0, 4) !== drPlatYear) return false; return true; }); // chartRows : plateforme + type + année sélectionnée (comme sur Remboursements — // choisir une année dans le sélecteur "Répartition par plateforme" recentre aussi // le graphique de solde et la Distribution sur cette année). Par défaut, l'année // vaut "Toutes les années" (drPlatYear === ''), donc "Tout" dans le graphique de // solde remonte bien jusqu'au tout premier mouvement. const chartRows = kpiRows.filter(r => { if (filter.type && r.type !== filter.type) return false; return true; }); // distributionRows : chartRows restreint à la plage du graphique de solde // (1J/7J/1M/3M/YTD/1A/TOUT) — pour que la Distribution reflète toujours la // même période que le graphique auquel elle est associée. const distributionRows = useMemo(() => { const from = rangeFromDate(chartRange); if (!from) return chartRows; return chartRows.filter(r => r.date_operation.slice(0, 10) >= from); // eslint-disable-next-line react-hooks/exhaustive-deps }, [chartRows, chartRange]); // rows : pour le tableau Mouvements (tous les filtres y compris mois) const rows = chartRows.filter(r => { const [y, m] = r.date_operation.split('-'); if (filter.year && y !== filter.year) return false; if (filter.month && m !== filter.month.padStart(2,'0')) return false; return true; }); // Corrections normalisées pour affichage dans le tableau mouvements const normalizedCorrections = useMemo(() => allCorrections.map(c => ({ id: `corr_${c.id}`, _correctionId: c.id, plateforme_id: c.plateforme_id, plateforme_nom: c.plateforme_nom, plateforme_detenteur_nom: c.investisseur_nom, date_operation: c.date, type: 'correction', montant: c.montant, libelle: c.notes || '', notes: c.notes || '', source: 'correction', investisseur_id: c.investisseur_id, })), [allCorrections]); // Corrections filtrées (plateforme + année + mois, sans filtre type) const filteredCorrections = useMemo(() => normalizedCorrections.filter(c => { if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(c.plateforme_id))) return false; const [y, m] = c.date_operation.split('-'); if (filter.year && y !== filter.year) return false; if (filter.month && m !== filter.month.padStart(2,'0')) return false; return true; }), [normalizedCorrections, filter.plateforme_ids, filter.year, filter.month]); // Investissements par id (déjà scopés) — sert à retrouver fiscalite_override et à // rattacher les réinvestissements (qui ne portent que investissement_id) à leur // plateforme/projet/détenteur. const invById = useMemo(() => new Map(allInv.map(i => [i.id, i])), [allInv]); // Remboursements affichés comme mouvements du porte-monnaie de la plateforme, décomposés // en plusieurs lignes pour rendre les prélèvements visibles : // 1. "remboursement" (crédit, montant BRUT = capital + cashback + intérêts avant tout // prélèvement, y compris avant retenue locale étrangère le cas échéant) // 2. "prelevement_local" (débit = taxe_locale) si la plateforme retient une fiscalité // locale à l'étranger — toujours réellement retenue, indépendamment du flat_tax. // 3. "prelevement_ir" et "prelevement_csg" (débits) uniquement si le PFU est réellement // retenu à la source ici (plateforme flat_tax France, non exonérée) — sinon les // prélèvements ne sont qu'indicatifs (déclaratif) et ne doivent pas apparaître comme // un mouvement réel du porte-monnaie. // La somme de ces lignes reste égale à l'ancien montant unique (net_recu / brut selon le // cas), donc le solde porte-monnaie n'est pas affecté — seule la présentation change. // Les bonus (parrainage/plateforme) créditent le cashback, sans prélèvement. // Note : les remboursements methode_remboursement='compte_courant' transitent bien par le // porte-monnaie (crédit) avant d'en être aussitôt sortis par le retrait automatique // (source='auto_remboursement', même montant net) — on affiche donc ce mouvement aussi, // même si l'effet net sur le solde est nul (cf. calcul KPI porte-monnaie ci-dessous, qui // lui continue d'ignorer ces remboursements). const normalizedRembMouvements = useMemo(() => { const out = []; for (const r of allRemb) { const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme'; const isInteretsPlateforme = r.type === 'interets_plateforme'; const plat = plats.find(p => p.id === r.plateforme_id); const base = { _rembId: r.id, _invId: r.investissement_id ?? null, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, plateforme_detenteur_nom: r.plateforme_detenteur_nom, date_operation: r.date_remb, libelle: r.nom_projet || '', source: 'remboursement', investisseur_id: r.resolved_investisseur_id ?? null, }; if (isBonus) { const montant = r.cashback || 0; if (!montant) continue; out.push({ ...base, id: `remb_${r.id}`, type: 'bonus', montant }); continue; } const inv = invById.get(r.investissement_id); const isFlatTax = plat?.fiscalite === 'flat_tax'; const isExonere = inv?.fiscalite_override === 'exonere'; // PFU réellement retenu à la source uniquement si flat_tax France non exonérée ; // sinon prélèvements indicatifs seulement (déclaratif), pas un vrai mouvement de cash. const pfuReellementRetenu = isFlatTax && !isExonere; const taxeLocale = r.taxe_locale || 0; const grossInterets = r.interets_bruts_avant_local > 0 ? r.interets_bruts_avant_local : (r.interets_bruts || 0); // Le cashback est désormais toujours sorti dans sa propre ligne "Bonus / Cashback", // même sur un remboursement de type 'normal' — le champ cashback du formulaire est // libre et saisissable sur n'importe quel type, sans lien avec bonus_parrainage/plateforme. const cashback = r.cashback || 0; const grossMontant = (r.capital || 0) + grossInterets; if (grossMontant) { out.push({ ...base, id: `remb_${r.id}`, type: isInteretsPlateforme ? 'interets_plateforme' : 'remboursement', montant: grossMontant, _isFlatTax: isFlatTax, _isAutoSweep: r.methode_remboursement === 'compte_courant', _taxeLocale: taxeLocale, _capital: r.capital || 0, _cashback: 0, _interets_bruts: r.interets_bruts || 0, _interets_nets: r.interets_nets || 0, _net_recu: r.net_recu || 0, }); } if (cashback > 0) { out.push({ ...base, id: `remb_${r.id}_cashback`, type: 'cashback', montant: cashback }); } if (taxeLocale > 0) { out.push({ ...base, id: `remb_${r.id}_local`, type: 'prelevement_local', montant: taxeLocale }); } if (pfuReellementRetenu && r.prelev_forfaitaire > 0) { out.push({ ...base, id: `remb_${r.id}_ir`, type: 'prelevement_ir', montant: r.prelev_forfaitaire }); } if (pfuReellementRetenu && r.prelev_sociaux > 0) { out.push({ ...base, id: `remb_${r.id}_csg`, type: 'prelevement_csg', montant: r.prelev_sociaux }); } } return out; }, [allRemb, plats, invById]); // Filtrés (plateforme + année + mois, sans filtre type — appliqué au merge dans tableRows) const filteredRembMouvements = useMemo(() => normalizedRembMouvements.filter(r => { if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false; const [y, m] = r.date_operation.split('-'); if (filter.year && y !== filter.year) return false; if (filter.month && m !== filter.month.padStart(2,'0')) return false; return true; }), [normalizedRembMouvements, filter.plateforme_ids, filter.year, filter.month]); // Projet lié à un remboursement (utilisé pour retrouver le nom du projet // sur les retraits AUTO, qui ne portent que remboursement_id) const rembProjetById = useMemo(() => new Map(allRemb.map(r => [r.id, r.nom_projet || ''])), [allRemb]); // Souscriptions à un investissement (débit du porte-monnaie) + réinvestissements — // les réinvestissements automatiques de capital d'intérêts sont aussi traités comme une // souscription (débit) : cohérent avec la formule du solde porte-monnaie, qui les // soustrait tous deux (capital_investi = montant_investi + Σ réinvestissements). const normalizedSouscriptionMouvements = useMemo(() => { const out = []; for (const inv of allInv) { if (!inv.montant_investi) continue; out.push({ id: `sub_${inv.id}`, _invId: inv.id, plateforme_id: inv.plateforme_id, plateforme_nom: inv.plateforme_nom, plateforme_detenteur_nom: inv.plateforme_detenteur_nom, date_operation: inv.date_souscription, type: 'souscription', montant: inv.montant_investi, libelle: inv.nom_projet || '', source: 'souscription', investisseur_id: inv.investisseur_id ?? null, _isReinvest: false, }); } for (const rv of allReinvests) { const inv = invById.get(rv.investissement_id); if (!inv || !rv.montant) continue; out.push({ id: `reinv_${rv.id}`, _invId: inv.id, plateforme_id: inv.plateforme_id, plateforme_nom: inv.plateforme_nom, plateforme_detenteur_nom: inv.plateforme_detenteur_nom, date_operation: rv.date_reinvestissement, type: 'souscription', montant: rv.montant, libelle: inv.nom_projet || '', source: 'reinvestissement', investisseur_id: inv.investisseur_id ?? null, _isReinvest: true, }); } return out; }, [allInv, allReinvests, invById]); const filteredSouscriptionMouvements = useMemo(() => normalizedSouscriptionMouvements.filter(r => { if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false; const [y, m] = r.date_operation.split('-'); if (filter.year && y !== filter.year) return false; if (filter.month && m !== filter.month.padStart(2,'0')) return false; return true; }), [normalizedSouscriptionMouvements, filter.plateforme_ids, filter.year, filter.month]); // Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique // COMPLET et NON FILTRÉ (allRows/normalizedCorrections/normalizedRembMouvements/ // normalizedSouscriptionMouvements, pas rows/filteredCorrections/...), sinon le cumul // démarrerait arbitrairement au milieu de l'historique quand un filtre année/mois/type // est actif. Purement indicatif (affichage/export) — ne remplace pas kpiSoldePortefeuille. // Voir [[project_mouvements_solde_indicatif]]. const soldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([ ...allRows, ...normalizedCorrections, ...normalizedRembMouvements, ...normalizedSouscriptionMouvements, ]), [allRows, normalizedCorrections, normalizedRembMouvements, normalizedSouscriptionMouvements]); // tableRows : dépôts/retraits + corrections + remboursements/bonus + souscriptions, triés par date desc. // _projet : nom du projet quand cela a du sens (remboursement, souscription/réinvestissement, // et retrait AUTO lié à un remboursement via remboursement_id) — vide pour dépôt/retrait // manuel, correction, bonus. const tableRows = useMemo(() => { const showDR = !filter.type || filter.type === 'depot' || filter.type === 'retrait'; const showCorr = !filter.type || filter.type === 'correction'; const drPart = showDR ? rows.map(r => ({ ...r, _projet: r.remboursement_id ? (rembProjetById.get(r.remboursement_id) || '') : '' })) : []; const corrPart = showCorr ? filteredCorrections.map(c => ({ ...c, _projet: '' })) : []; const rembPart = filteredRembMouvements .filter(r => !filter.type || r.type === filter.type) .map(r => ({ ...r, _projet: r.libelle || '' })); const subPart = filteredSouscriptionMouvements .filter(r => !filter.type || r.type === filter.type) .map(r => ({ ...r, _projet: r.libelle || '' })); return sortMouvements([...drPart, ...corrPart, ...rembPart, ...subPart]) .map(r => ({ ...r, _soldeIndicatif: soldeIndicatifByRowId.get(r.id) })); }, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById, soldeIndicatifByRowId]); /* ── Pagination mouvements ── */ const { pagedItems: pagedTableRows, page: drPage, setPage: setDrPage, pageSize: drPageSize, setPageSize: setDrPageSize, totalPages: drTotalPages, totalItems: drTotalItems, PAGE_SIZES, } = usePagination(tableRows, 'cl_pagesize_dr', [filter]); // Repli sur l'onglet Plateformes si la fixation des objectifs est désactivée // pendant qu'on est sur l'onglet Objectifs (ex. dans un autre onglet du navigateur). useEffect(() => { if (!objectifsActifs && activeTab === 'vision-annuelle') setActiveTab('plateformes'); }, [objectifsActifs, activeTab]); // Auto-sélectionne le premier mouvement quand la liste change dans l'onglet mouvements // (déclaré après `tableRows` pour éviter la temporal dead zone) useEffect(() => { if (activeTab === 'mouvements') { setSelectedRow(prev => { if (prev && tableRows.find(r => String(r.id) === String(prev.id))) return prev; return tableRows[0] || null; }); } // eslint-disable-next-line }, [tableRows, activeTab]); const years = [...new Set(allRows.map(r => r.date_operation.slice(0,4)))].sort().reverse(); /* Données tableau Plateformes — filtrées par drPlatYear si actif */ // Tri unique (alphabétique, détenteur en second critère) appliqué aux deux // branches de drPlatData — évite que l'ordre des plateformes change selon le // filtre année sélectionné. const sortPlatAlpha = (a, b) => a.nom.localeCompare(b.nom, 'fr') || (a.detenteur_nom ?? '').localeCompare(b.detenteur_nom ?? '', 'fr'); const drPlatData = useMemo(() => { if (!drPlatYear) return [...(dashData?.cashByPlatform ?? [])].sort(sortPlatAlpha); // Mouvements de l'année sélectionnée (dépôts/retraits de l'année) const src = allRows.filter(r => r.date_operation.slice(0, 4) === drPlatYear); 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, depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }; if (r.type === 'depot') map[key].depots += r.montant; else map[key].retraits += r.montant; } // Une plateforme sans dépôt/retrait cette année-là (ex. uniquement des // remboursements ou un réinvestissement) doit quand même apparaître dans le // tableau — sinon elle disparaît alors qu'elle a eu de vrais mouvements. for (const r of allRemb) { if (!r.date_remb || r.date_remb.slice(0, 4) !== drPlatYear || !r.plateforme_id) continue; 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, depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }; } for (const rv of allReinvests) { if (!rv.date_reinvestissement || rv.date_reinvestissement.slice(0, 4) !== drPlatYear) continue; const inv = allInv.find(i => i.id === rv.investissement_id); if (!inv?.plateforme_id) continue; const key = String(inv.plateforme_id); if (!map[key]) map[key] = { plateforme_id: inv.plateforme_id, nom: inv.plateforme_nom, detenteur_nom: inv.plateforme_detenteur_nom || null, depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }; } // Solde porte-monnaie = cumul jusqu'au 31/12 de l'année sélectionnée, calculé // côté backend (GET /dashboard?asOf=) pour rester la même formule que le solde // "live" — bonus, intérêts plateforme et corrections de solde inclus. const soldeMap = {}; for (const p of dashDataAsOf[`${drPlatYear}-12-31`]?.cashByPlatform ?? []) { soldeMap[String(p.plateforme_id)] = p.solde_portefeuille; } return Object.values(map).map(p => { p.solde_net = p.depots - p.retraits; p.solde_portefeuille = soldeMap[String(p.plateforme_id)] ?? 0; return p; }).sort(sortPlatAlpha); }, [allRows, allRemb, allInv, allReinvests, dashDataAsOf, drPlatYear]); const drPlatTotals = useMemo(() => drPlatData.reduce((acc, p) => { acc.depots += p.depots; acc.retraits += p.retraits; acc.solde_net += p.solde_net; acc.solde_portefeuille += p.solde_portefeuille ?? 0; return acc; }, { depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }), [drPlatData] ); // Totaux KPI depuis kpiRows (réagit à plateforme + année) const totals = kpiRows.reduce((acc, r) => { if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant; return acc; }, { depots: 0, retraits: 0 }); // Solde porte-monnaie pour le KPI : // - sans filtre année → live depuis le backend (inclut déjà les remboursements au portefeuille) // - avec filtre année → snapshot backend "fin d'année" (GET /dashboard?asOf=), même // formule que le solde live (dashDataAsOf, cf. effet plus haut) const soldeFromSnapshot = (snap) => { if (!snap) return null; if (filter.plateforme_ids.length > 0) { return (snap.cashByPlatform ?? []) .filter(c => filter.plateforme_ids.includes(String(c.plateforme_id))) .reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0); } return snap.solde_portefeuille_total ?? null; }; const kpiSoldePortefeuille = (() => { if (!drPlatYear) { if (filter.plateforme_ids.length === 1 && dashData) { const p = dashData.cashByPlatform?.find(c => c.plateforme_id === Number(filter.plateforme_ids[0])); return p?.solde_portefeuille ?? null; } return dashData?.solde_portefeuille_total ?? null; } return soldeFromSnapshot(dashDataAsOf[`${drPlatYear}-12-31`]); })(); // Totaux KPI et solde porte-monnaie pour l'année N-1 (TrendBadge) const prevYear = String(Number(drPlatYear || new Date().getFullYear()) - 1); const prevTotals = (() => { const src = allRows.filter(r => r.date_operation.slice(0, 4) === prevYear); return src.reduce((acc, r) => { if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant; return acc; }, { depots: 0, retraits: 0 }); })(); const prevKpiSoldePortefeuille = soldeFromSnapshot(dashDataAsOf[`${prevYear}-12-31`]); /* ── Enrichissement du KPI "Diff. Dépôts vs Retraits" avec l'objectif de l'année ── Toujours calculé sur le portefeuille entier (ignore le filtre plateforme), pour l'année sélectionnée (drPlatYear, ou l'année en cours si "Toutes les années"). */ const scopeInvestisseurIdsKpi = activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]; const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear()); const kpiObjectifTotal = (() => { const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id)); return list.length ? list.reduce((s, o) => s + o.montant, 0) : null; })(); const kpiDiffGlobalYear = (() => { let d = 0, r = 0; for (const row of allRows) { if (!scopeInvestisseurIdsKpi.includes(row.investisseur_id)) continue; if (row.date_operation?.slice(0, 4) !== String(kpiObjectifYear)) continue; if (row.type === 'depot') d += row.montant; else r += row.montant; } return d - r; })(); const kpiEcartObjectif = kpiObjectifTotal != null ? kpiDiffGlobalYear - kpiObjectifTotal : null; const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1; /** Investisseur par défaut selon la vue active */ const defaultInvestisseurId = () => { if (activeView === 'all') { const principal = investisseurs.find(i => i.is_principal); return String((principal || investisseurs[0])?.id || ''); } return String(activeId || ''); }; /** Retourne l'investisseur_id de la plateforme, ou le défaut si absent */ const investisseurForPlat = (platId) => { const plat = plats.find(p => String(p.id) === String(platId)); return plat?.investisseur_id ? String(plat.investisseur_id) : defaultInvestisseurId(); }; /* Ouverture auto depuis le bouton global "Nouveau dépôt/retrait", ou depuis le formulaire investissement — soit le bouton "Déposer" (ajoute ?plateforme_id=… pour présélectionner la plateforme plutôt que de retomber sur plats[0], et ?resumeInvestissement=1 pour demander un retour automatique vers l'investissement une fois le dépôt créé — cf. pendingReturnActive/maybeReturnToInvestissement plus bas), soit la proposition de dépôt après création d'un investissement dont le porte-monnaie ne couvrait pas le montant (ajoute ?montant=… pour pré-remplir le montant du dépôt, et ?autoCreate=1 pour créer le dépôt directement sans repasser par la modale — l'utilisateur a déjà validé/rectifié le montant dans la proposition affichée côté formulaire investissement). */ const pendingNew = useRef(false); const pendingNewPlatId = useRef(null); const pendingNewMontant = useRef(null); const pendingNewAutoCreate = useRef(false); const pendingReturnActive = useRef(false); useEffect(() => { const params = new URLSearchParams(search); if (params.get('new') !== '1') return; const platParam = params.get('plateforme_id'); const montantParam = params.get('montant'); const autoCreateParam = params.get('autoCreate') === '1'; if (params.get('resumeInvestissement') === '1') pendingReturnActive.current = true; navigate('/depots-retraits', { replace: true }); if (plats.length > 0) { setEditingId(null); const firstId = (platParam && plats.some(p => String(p.id) === platParam)) ? platParam : (plats[0]?.id || ''); const built = { ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId), montant: montantParam || '' }; if (autoCreateParam && montantParam) { setErr(null); doCreateDeposit(built).catch(err => { // Création auto échouée (réseau, validation serveur…) — on retombe sur la modale // pré-remplie pour que l'utilisateur voie l'erreur et puisse retenter manuellement. setForm(built); setErr(err.message); setModalOpen(true); }); } else { setForm(built); setErr(null); setModalOpen(true); } } else { pendingNew.current = true; pendingNewPlatId.current = platParam; pendingNewMontant.current = montantParam; pendingNewAutoCreate.current = autoCreateParam; } }, [search]); // eslint-disable-line /** Si le dépôt en cours de création vient du bouton "Déposer" du formulaire investissement * (cf. ci-dessus), navigue vers la page d'origine avec la saisie restaurée depuis * sessionStorage. Ne fait rien sinon (comportement normal, inchangé). À appeler après * que le dépôt est effectivement créé (pas à l'annulation) — les 3 points de sortie * possibles de la création (sans correction de solde / correction confirmée / correction * "Passer") appellent tous cette fonction. */ const maybeReturnToInvestissement = () => { if (!pendingReturnActive.current) return; pendingReturnActive.current = false; try { const raw = sessionStorage.getItem('cl_investissement_draft'); if (!raw) return; sessionStorage.removeItem('cl_investissement_draft'); const draft = JSON.parse(raw); if (!draft?.returnTo) return; const sep = draft.returnTo.includes('?') ? '&' : '?'; navigate(`${draft.returnTo}${sep}resumeInvestissement=1`); } catch { // sessionStorage/JSON invalide — pas de retour auto, on reste simplement sur cette page. } }; useEffect(() => { if (!pendingNew.current || !plats.length) return; pendingNew.current = false; setEditingId(null); const platParam = pendingNewPlatId.current; pendingNewPlatId.current = null; const montantParam = pendingNewMontant.current; pendingNewMontant.current = null; const autoCreateParam = pendingNewAutoCreate.current; pendingNewAutoCreate.current = false; const firstId = (platParam && plats.some(p => String(p.id) === platParam)) ? platParam : (plats[0]?.id || ''); const built = { ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId), montant: montantParam || '' }; if (autoCreateParam && montantParam) { setErr(null); doCreateDeposit(built).catch(err => { setForm(built); setErr(err.message); setModalOpen(true); }); } else { setForm(built); setErr(null); setModalOpen(true); } }, [plats]); // eslint-disable-line /* CRUD */ const openNew = () => { setEditingId(null); const firstId = plats[0]?.id || ''; setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); setErr(null); setModalOpen(true); }; const openEdit = (row) => { if (row.source === 'auto_remboursement') return; // retrait auto — non éditable ici setEditingId(row.id); setForm({ investisseur_id: String(row.investisseur_id || ''), plateforme_id: row.plateforme_id, date_operation: row.date_operation, type: row.type, montant: row.montant, libelle: row.libelle || '', reference: row.reference || '', notes: row.notes || '', }); setErr(null); setModalOpen(true); }; const openCorrection = async (row) => { // row = objet ligne existante, ou null pour utiliser les données du formulaire ouvert const platId = row ? row.plateforme_id : form.plateforme_id; const investId = row ? (row.investisseur_id || null) : (Number(form.investisseur_id) || null); const type = row ? row.type : form.type; const date = row ? row.date_operation : form.date_operation; const plat = plats.find(p => String(p.id) === String(platId)); // Solde calculé par le backend à la date exacte du mouvement let computedBalance = 0; try { const result = await api.get('/dashboard/solde-historique', { plateforme_id: platId, date }); computedBalance = result.solde ?? 0; } catch (e) { console.error('solde-historique error:', e); } // Ferme le modal d'édition sans sauvegarder setModalOpen(false); setEditingId(null); setCorrModal({ open: true, plateforme_nom: plat?.nom || '', type_operation: type, computedBalance, date, platId: Number(platId), investisseurId: investId ? Number(investId) : null, }); }; /* Correction de solde autonome (bouton "+" du tableau Mouvements) — pas de mouvement dépôt/retrait déclencheur, l'utilisateur choisit plateforme + date. */ const openCorrectionManual = async ({ plateforme_id, date }) => { const plat = plats.find(p => p.id === plateforme_id); const investId = investisseurForPlat(plateforme_id); let computedBalance = 0; try { const result = await api.get('/dashboard/solde-historique', { plateforme_id, date }); computedBalance = result.solde ?? 0; } catch (e) { console.error('solde-historique error:', e); } setCorrPickerOpen(false); setCorrModal({ open: true, plateforme_nom: plat?.nom || '', type_operation: null, computedBalance, date, platId: plateforme_id, investisseurId: investId ? Number(investId) : null, }); }; const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); }; /** Crée un dépôt/retrait à partir d'un objet form donné. Extrait de submit() pour être * réutilisable par la création automatique déclenchée depuis ?new=1&autoCreate=1 (cf. * effets ci-dessus) — l'utilisateur y a déjà validé/rectifié le montant en amont, donc pas * besoin de repasser par la modale. Les erreurs remontent à l'appelant (try/catch). */ const doCreateDeposit = async (f) => { const payload = { investisseur_id: Number(f.investisseur_id) || undefined, plateforme_id: Number(f.plateforme_id), date_operation: f.date_operation, type: f.type, montant: Number(f.montant), libelle: f.libelle || undefined, reference: f.reference || undefined, notes: f.notes || undefined, }; await api.post('/depots-retraits', payload); close(); // Vérifier si la plateforme est flat_tax → proposer correction de solde const plat = plats.find(p => String(p.id) === String(f.plateforme_id)); if (plat?.fiscalite === 'flat_tax') { // Solde attendu = solde actuel ± montant de l'opération (avant reload) const currentPlatData = dashData?.cashByPlatform?.find( p => String(p.plateforme_id) === String(f.plateforme_id) ); const currentBalance = currentPlatData?.solde_portefeuille ?? 0; const montant = Number(f.montant); const expectedBalance = Math.round( (f.type === 'retrait' ? currentBalance - montant : currentBalance + montant) * 100 ) / 100; setCorrModal({ open: true, plateforme_nom: plat.nom, type_operation: f.type, computedBalance: expectedBalance, date: f.date_operation, platId: Number(f.plateforme_id), investisseurId: Number(f.investisseur_id) || null, }); } else { await Promise.all([load(), loadDashDataAsOf()]); maybeReturnToInvestissement(); } }; const submit = async (e) => { e?.preventDefault?.(); setErr(null); try { if (editingId) { const payload = { investisseur_id: Number(form.investisseur_id) || undefined, plateforme_id: Number(form.plateforme_id), date_operation: form.date_operation, type: form.type, montant: Number(form.montant), libelle: form.libelle || undefined, reference: form.reference || undefined, notes: form.notes || undefined, }; await api.put(`/depots-retraits/${editingId}`, payload); close(); await Promise.all([load(), loadDashDataAsOf()]); return; } await doCreateDeposit(form); } catch (e) { setErr(e.message); } }; const handleCorrectionConfirm = async (diff) => { if (Math.abs(diff) >= 0.005) { try { await api.post('/corrections', { investisseur_id: corrModal.investisseurId, plateforme_id: corrModal.platId, date: corrModal.date, montant: diff, notes: corrModal.type_operation ? `Correction solde porte-monnaie — ${corrModal.type_operation === 'retrait' ? 'retrait' : 'dépôt'}` : 'Correction solde porte-monnaie — saisie manuelle', }); } catch (e) { console.error('Correction creation failed:', e); } } setCorrModal(m => ({ ...m, open: false })); await Promise.all([load(), loadDashDataAsOf()]); maybeReturnToInvestissement(); }; const openRowMenu = (e, row) => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setOpenMenu({ row, x: rect.right, y: rect.bottom }); }; const deleteRow = (rowId) => { setDeleteConfirm({ message: 'Supprimer définitivement ce mouvement ?', onConfirm: async () => { await api.del(`/depots-retraits/${rowId}`); setDeleteConfirm(null); setSelectedRow(null); await Promise.all([load(), loadDashDataAsOf()]); }, }); }; const deleteCorrection = (corrId) => { setDeleteConfirm({ message: 'Supprimer définitivement cette correction de solde ?', onConfirm: async () => { await api.del(`/corrections/${corrId}`); setDeleteConfirm(null); setSelectedRow(null); await Promise.all([load(), loadDashDataAsOf()]); }, }); }; const onDelete = () => { if (!editingId) return; setDeleteConfirm({ message: 'Supprimer définitivement ce mouvement ?', onConfirm: async () => { await api.del(`/depots-retraits/${editingId}`); setDeleteConfirm(null); close(); setSelectedRow(null); await Promise.all([load(), loadDashDataAsOf()]); }, }); }; if (!loading && plats.length === 0) return ( <>

Dépôts / Retraits

); if (!loading && !modalOpen && !corrModal.open && allRows.length === 0) return ( <>

Dépôts / Retraits

); return ( <>

Dépôts / Retraits

{(filter.type || filter.plateforme_ids.length > 0 || filter.month) && ( )}
{/* ── Graphiques ── */} {!listFocused &&
} {/* ── KPIs ── */} {!listFocused &&
setFilter(f => ({ ...f, type: f.type === 'depot' ? '' : 'depot' }))} title="Filtrer sur les dépôts" >
Dépôts
{fmtEUR(totals.depots)} {drPlatYear && }
{drPlatYear &&
{fmtEUR(prevTotals.depots)} en {prevYear}
}
setFilter(f => ({ ...f, type: f.type === 'retrait' ? '' : 'retrait' }))} title="Filtrer sur les retraits" >
Retraits
{fmtEUR(totals.retraits)} {drPlatYear && }
{drPlatYear &&
{fmtEUR(prevTotals.retraits)} en {prevYear}
}
0 || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`} onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }} title={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? 'Effacer les filtres' : undefined} style={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? { cursor: 'pointer' } : {}} >
Diff. Dépôts vs Retraits
{fmtEUR(totals.depots - totals.retraits)} {drPlatYear && }
{drPlatYear &&
{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}
} {objectifsActifs && kpiEcartObjectif != null && (
= 0 ? 'var(--danger)' : 'var(--warning)', }}> {Math.abs(kpiEcartObjectif) < 0.01 ? `Conforme à l'objectif ${kpiObjectifYear}` : kpiEcartObjectif >= 0 ? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}` : `${fmtEUR(-kpiEcartObjectif)} en dessous de l'objectif ${kpiObjectifYear}`}
)}
Porte-monnaie
{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'} {drPlatYear && kpiSoldePortefeuille !== null && }
{drPlatYear &&
{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}
}
} {/* ── Onglets ── */} {!listFocused &&
{objectifsActifs && ( )}
} {/* ====== ONGLET VISION ANNUELLE ====== */} {objectifsActifs && activeTab === 'vision-annuelle' && (
)} {/* ====== ONGLET PLATEFORMES ====== */} {activeTab === 'plateformes' && (

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

dlBlob(platToCSV(drPlatData), 'plateformes.csv', 'text/csv;charset=utf-8')} onXLS={() => dlBlob(platToXLS(drPlatData), 'plateformes.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} onJSON={() => dlBlob(platToJSON(drPlatData), 'plateformes.json', 'application/json')} />
{!dashData ? (
Chargement…
) : ( {multiDetenteur && } {drPlatData.length === 0 && ( )} {(() => { const totalSolde = drPlatTotals.solde_net; return drPlatData.map(p => { const isActive = filter.plateforme_ids.includes(String(p.plateforme_id)); const poids = totalSolde !== 0 ? (p.solde_net / totalSolde) * 100 : 0; return ( setFilter(f => ({ ...f, plateforme_ids: isActive ? f.plateforme_ids.filter(id => id !== String(p.plateforme_id)) : [...f.plateforme_ids, String(p.plateforme_id)] }))}> {multiDetenteur && } ); }); })()} {drPlatData.length > 1 && ( {multiDetenteur && )}
PlateformeDétenteurDépôtsRetraitsDiff. Dépôts vs RetraitsPorte-monnaiePoids
Aucune plateforme
{p.nom} {p.detenteur_nom || '—'}{fmtEUR(p.depots)} {fmtEUR(p.retraits)} {fmtEUR(p.solde_net)} {fmtEUR(p.solde_portefeuille ?? 0)}
{poids.toFixed(1)} %
{isActive ? '✕' : ''}
Total — {drPlatData.length} plateformes} {fmtEUR(drPlatTotals.depots)} {fmtEUR(drPlatTotals.retraits)} {fmtEUR(drPlatTotals.solde_net)} {fmtEUR(drPlatTotals.solde_portefeuille)} 100 %
)}
)} {/* ====== ONGLET VISION MENSUELLE ====== */} {activeTab === 'vision-mensuelle' && (
{ const next = !listFocused; setListFocused(next); setDrPageSize(next ? 25 : 15); }} > {listFocused ? ( ) : ( )} } />
)} {/* ====== ONGLET MOUVEMENTS ====== */} {activeTab === 'mouvements' && (
{/* Colonne gauche 2/3 */}

Mouvements

dlBlob(mouvToCSV(tableRows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')} onXLS={() => dlBlob(mouvToXLS(tableRows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} onJSON={() => dlBlob(mouvToJSON(tableRows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')} /> setCorrPickerOpen(true)} />
{/* Filtres */}
{platFilterOpen && (
{plats.map(p => { const sid = String(p.id); const checked = filter.plateforme_ids.includes(sid); return ( ); })}
)}
{/* Tableau */}
{multiDetenteur && } {tableRows.length === 0 && ( )} {pagedTableRows.map(r => { if (r.type === 'correction') { const isPos = r.montant >= 0; return ( setSelectedRow(String(selectedRow?.id) === String(r.id) ? null : r)} style={{ cursor: 'pointer' }}> {multiDetenteur && } ); } if (r.type === 'remboursement' || r.type === 'bonus' || r.type === 'cashback' || r.type === 'interets_plateforme') { const isBonus = r.type === 'bonus'; const isCashback = r.type === 'cashback'; const isInteretsPlateforme = r.type === 'interets_plateforme'; return ( setSelectedRow(selectedRow?.id === r.id ? null : r)} style={{ cursor: 'pointer' }}> {multiDetenteur && } ); } if (r.type === 'prelevement_ir' || r.type === 'prelevement_csg' || r.type === 'prelevement_local') { const isLocal = r.type === 'prelevement_local'; const label = isLocal ? "prélèvement étranger" : r.type === 'prelevement_ir' ? 'prélèvement IR' : 'prélèvement CSG'; const color = isLocal ? '#f97316' : 'var(--danger)'; return ( setSelectedRow(selectedRow?.id === r.id ? null : r)} style={{ cursor: 'pointer' }}> {multiDetenteur && } ); } if (r.type === 'souscription') { return ( setSelectedRow(selectedRow?.id === r.id ? null : r)} style={{ cursor: 'pointer' }}> {multiDetenteur && } ); } const isAuto = r.source === 'auto_remboursement'; return ( setSelectedRow(selectedRow?.id === r.id ? null : r)} style={{ cursor: 'pointer', opacity: isAuto ? 0.8 : 1 }}> {multiDetenteur && } ); })}
DatePlateformeDétenteurType de mouvementProjetMontantSolde indicatif
{loading ? 'Chargement…' : 'Aucun mouvement'}
{fmtDate(r.date_operation)} {r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} correction — {isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'} e.stopPropagation()}>
{fmtDate(r.date_operation)} {r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} {isBonus ? 'bonus' : isCashback ? 'cashback' : isInteretsPlateforme ? 'intérêts plateforme' : 'remboursement'} {r._projet || '—'} + {fmtEUR(r.montant)} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}
{fmtDate(r.date_operation)} {r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} {label} {r._projet || '—'} − {fmtEUR(r.montant)} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}
{fmtDate(r.date_operation)} {r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} souscription {r._isReinvest && ( RÉINVEST. )} {r._projet || '—'} − {fmtEUR(r.montant)} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}
{fmtDate(r.date_operation)} {r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} {r.type} {isAuto && ( AUTO )} {r._projet || '—'} {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'} e.stopPropagation()}> {!isAuto && ( )}
{/* Colonne droite 1/3 */} {tableRows.length > 0 && (
navigate(`/remboursements?edit-remb=${r._rembId}`)} onViewInvestissement={r => navigate(`/investissements/${r._invId}`)} />
)}
)} {/* Modal création / édition */}
{editingId && } {editingId && }
} >
{err &&
{err}
}
setForm({ ...form, date_operation: e.target.value })} />
setForm({ ...form, montant: e.target.value })} />
setForm({ ...form, libelle: e.target.value })} placeholder="Ex. Virement initial" />
setForm({ ...form, reference: e.target.value })} />
setForm({ ...form, notes: e.target.value })} />
{openMenu && ( <>
setOpenMenu(null)} />
{openMenu.row.type !== 'correction' && ( )} {openMenu.row.type !== 'correction' && openMenu.row.source !== 'auto_remboursement' && ( )}
)} { setCorrModal(m => ({ ...m, open: false })); load(); maybeReturnToInvestissement(); }} onConfirm={handleCorrectionConfirm} /> setCorrPickerOpen(false)} onContinue={openCorrectionManual} plateformes={plats} multiDetenteur={multiDetenteur} /> {deleteConfirm && ( setDeleteConfirm(null)} /> )} ); }