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 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 } from '../utils/format.js'; import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx'; import * as XLSX from 'xlsx'; /* ── Helpers export ──────────────────────────────────────────── */ /** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */ function timestampSuffix() { const d = new Date(); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } 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); } /* ── 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} ); } function mouvToCSV(rows) { const BOM = ''; const sep = ';'; const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; const headers = ['Date','Plateforme','Type','Montant (€)','Libellé','Référence','Source']; const data = rows.map(r => [ r.date_operation, r.plateforme_nom||'', r.type, String(r.montant).replace('.',','), r.libelle||'', r.reference||'', r.source||'', ]); return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n'); } function mouvToXLS(rows, multiDetenteur) { const data = rows.map(r => ({ 'Date': r.date_operation, 'Plateforme': r.plateforme_nom || '', ...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}), 'Type': r.type === 'depot' ? 'Dépôt' : 'Retrait', 'Montant (€)': r.montant, })); const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Mouvements'); return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); } 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 mouvToJSON(rows) { const data = rows.map(r => ({ date_operation: r.date_operation, plateforme_nom: r.plateforme_nom || '', type: r.type, montant: r.montant, libelle: r.libelle || null, reference: r.reference || null, source: r.source || null, })); return JSON.stringify(data, null, 2); } 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); } 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 && (
)}
); } /* ── Panneau de détail d'un mouvement ────────────────────────── */ function DetailPanel({ row, onEdit, onDeleteCorrection }) { if (!row) return (
Sélectionnez un mouvement
); const isCorrection = row.type === 'correction'; const isAuto = row.source === 'auto_remboursement'; const typeLabel = isCorrection ? 'Correction de solde' : row.type === 'depot' ? 'Dépôt' : 'Retrait'; const editLabel = isCorrection ? 'Modifier la correction de solde' : row.type === 'depot' ? 'Modifier le dépôt' : 'Modifier le retrait'; const isPos = row.montant >= 0; const sign = isPos ? '+' : '−'; const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`; const montantColor = undefined; const fields = [ { label: 'Plateforme', value: row.plateforme_nom }, { label: 'Type de mouvement', value: typeLabel }, { label: 'Montant', value: montantFmt, color: montantColor }, { label: 'Date', value: fmtDate(row.date_operation) }, !isCorrection && row.libelle && { label: 'Libellé', value: row.libelle }, !isCorrection && row.reference && { label: 'Référence', value: row.reference }, row.notes && { label: 'Notes', value: row.notes }, ].filter(Boolean); return (
Détails du mouvement
{isAuto && (
Retrait automatique — généré lors d'un remboursement sur compte courant. Modifiable via la page Remboursements ou depuis la fiche détail de l'investissement.
)}
{fields.map(f => (
{f.label} {f.value}
))}
{isAuto ? null : isCorrection ? ( ) : ( )}
); } /* ── Constantes ──────────────────────────────────────────────── */ const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; const empty = { investisseur_id: '', plateforme_id: '', date_operation: today(), type: 'depot', montant: '', libelle: '', reference: '', notes: '' }; /** Modal de correction de solde porte-monnaie (micro-écarts flat_tax) */ function CorrectionModal({ open, onClose, onConfirm, plateforme_nom, type_operation, computedBalance, date }) { const isRetrait = type_operation === 'retrait'; // Défaut : 0 pour un retrait (vider le PM), solde calculé pour un dépôt const [declared, setDeclared] = useState(''); useEffect(() => { if (open) setDeclared(isRetrait ? '0' : String(Math.round(computedBalance * 100) / 100)); }, [open, isRetrait, computedBalance]); const diff = Math.round((Number(declared) - computedBalance) * 100) / 100; const hasDiff = Math.abs(diff) >= 0.005; const handleConfirm = () => { onConfirm(hasDiff ? diff : 0); }; if (!open) return null; return (
Vérification du solde porte-monnaie
Après ce {isRetrait ? 'retrait' : 'dépôt'} sur {plateforme_nom}, quel est le solde indiqué sur la plateforme ? {' '}Si différent du solde calculé, une correction sera créée automatiquement.
Solde calculé {Math.round(computedBalance * 100) / 100} €
setDeclared(e.target.value)} style={{ width: '100%', fontSize: 'var(--fs-base)' }} autoFocus />
{hasDiff && (
0 ? 'color-mix(in srgb, var(--success) 12%, transparent)' : 'color-mix(in srgb, var(--danger) 12%, transparent)', border: `1px solid ${diff > 0 ? 'color-mix(in srgb, var(--success) 30%, transparent)' : 'color-mix(in srgb, var(--danger) 30%, transparent)'}`, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', }}> Une correction de 0 ? 'var(--success)' : 'var(--danger)' }}> {diff > 0 ? '+' : ''}{diff} € sera créée.
)} {!hasDiff && declared !== '' && (
Solde identique au calcul — aucune correction nécessaire.
)}
); } /* ── Composant principal ─────────────────────────────────────── */ export default function DepotsRetraits() { const { activeId, activeView, investisseurs } = useInvestisseur(); 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); // Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements const currentYear = String(new Date().getFullYear()); const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' }); const [modalOpen, setModalOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(empty); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); const [selectedRow, setSelectedRow] = useState(null); const [drPlatYear, setDrPlatYear] = useState(currentYear); const [allRemb, setAllRemb] = useState([]); const [allInv, setAllInv] = useState([]); const [allCorrections, setAllCorrections] = useState([]); const [deleteConfirm, setDeleteConfirm] = useState(null); /* 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, }); 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] = 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), ]); setAllRows(r); setPlats(p); setDashData(d); setAllRemb(remb); setAllInv(inv); setAllCorrections(corr); } catch (e) { console.error('DepotsRetraits load error:', e); } finally { setLoading(false); } }; 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); }, []); /* 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 : kpiRows + filtre type (pour les graphiques) const chartRows = kpiRows.filter(r => { if (filter.type && r.type !== filter.type) return false; return true; }); // 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]); // tableRows : dépôts/retraits + corrections, triés par date desc const tableRows = useMemo(() => { const drPart = filter.type === 'correction' ? [] : rows; const corrPart = (filter.type === 'depot' || filter.type === 'retrait') ? [] : filteredCorrections; return [...drPart, ...corrPart].sort((a, b) => b.date_operation.localeCompare(a.date_operation) || String(b.id).localeCompare(String(a.id)) ); }, [rows, filteredCorrections, filter.type]); /* ── 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]); // 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 */ const drPlatData = useMemo(() => { if (!drPlatYear) return dashData?.cashByPlatform ?? []; // 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; } // Solde porte-monnaie = cumul jusqu'au 31/12 de l'année sélectionnée // = dépôts/retraits + remboursements crédités au portefeuille // Règle fiscale : flat_tax → net_recu (PFU prélevé à la source) ; // hors France → capital + cashback + interets_bruts (pas de PFU à la source) const yearEnd = `${drPlatYear}-12-31`; const cumul = {}; // 1. Dépôts et retraits manuels (hors auto-retraits — générés par remboursements // sur compte courant, ils ne représentent pas un mouvement du porte-monnaie) for (const r of allRows) { if (r.date_operation > yearEnd) continue; if (r.source === 'auto_remboursement') continue; const key = String(r.plateforme_id); cumul[key] = (cumul[key] ?? 0) + (r.type === 'depot' ? r.montant : -r.montant); } // 2. Remboursements crédités au portefeuille // Les remboursements vers compte_courant ne crédient pas le porte-monnaie. // Pour flat_tax : net_recu (PFU déjà prélevé à la source). // Pour hors France : capital + cashback + interets_bruts (pas de PFU à la source ; // interets_bruts est déjà net de la retenue locale si applicable). for (const r of allRemb) { if (!r.date_remb || r.date_remb > yearEnd) continue; if (r.methode_remboursement !== 'portefeuille') continue; const key = String(r.plateforme_id); const plat = plats.find(p => p.id === r.plateforme_id); const credit = plat?.fiscalite === 'flat_tax' ? (r.net_recu ?? 0) : (r.capital ?? 0) + (r.cashback ?? 0) + (r.interets_bruts ?? 0); cumul[key] = (cumul[key] ?? 0) + credit; } // 3. Déduire le capital investi (capital_total = montant_investi + réinvestissements) // Le capital revient progressivement via net_recu (étape 2) au fil des remboursements for (const inv of allInv) { if (!inv.date_souscription || inv.date_souscription > yearEnd) continue; const key = String(inv.plateforme_id); cumul[key] = (cumul[key] ?? 0) - (inv.capital_total ?? inv.montant_investi); } return Object.values(map).map(p => { p.solde_net = p.depots - p.retraits; p.solde_portefeuille = cumul[String(p.plateforme_id)] ?? 0; return p; }).sort((a, b) => b.solde_net - a.solde_net); }, [allRows, allRemb, allInv, dashData, 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 → cumul jusqu'au 31/12 de l'année (dépôts/retraits + remboursements au portefeuille) 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; } const yearEnd = `${drPlatYear}-12-31`; let balance = 0; for (const r of allRows) { if (r.date_operation > yearEnd) continue; if (r.source === 'auto_remboursement') continue; if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue; balance += r.type === 'depot' ? r.montant : -r.montant; } for (const r of allRemb) { if (!r.date_remb || r.date_remb > yearEnd) continue; if (r.methode_remboursement !== 'portefeuille') continue; if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue; balance += r.net_recu ?? 0; } for (const inv of allInv) { if (!inv.date_souscription || inv.date_souscription > yearEnd) continue; if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(inv.plateforme_id))) continue; balance -= inv.capital_total ?? inv.montant_investi; } return balance; })(); // 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 = (() => { const yearEnd = `${prevYear}-12-31`; let balance = 0; for (const r of allRows) { if (r.date_operation > yearEnd) continue; if (r.source === 'auto_remboursement') continue; if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue; balance += r.type === 'depot' ? r.montant : -r.montant; } for (const r of allRemb) { if (!r.date_remb || r.date_remb > yearEnd) continue; if (r.methode_remboursement !== 'portefeuille') continue; if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue; balance += r.net_recu ?? 0; } for (const inv of allInv) { if (!inv.date_souscription || inv.date_souscription > yearEnd) continue; if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(inv.plateforme_id))) continue; balance -= inv.capital_total ?? inv.montant_investi; } return balance; })(); 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" */ const pendingNew = useRef(false); useEffect(() => { if (new URLSearchParams(search).get('new') !== '1') return; navigate('/depots-retraits', { replace: true }); if (plats.length > 0) { setEditingId(null); const firstId = plats[0]?.id || ''; setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); setErr(null); setModalOpen(true); } else { pendingNew.current = true; } }, [search]); // eslint-disable-line useEffect(() => { if (!pendingNew.current || !plats.length) return; pendingNew.current = false; setEditingId(null); const firstId = plats[0]?.id || ''; setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); setErr(null); setModalOpen(true); }, [plats]); // eslint-disable-line /* CRUD */ const openNew = () => { setEditingId(null); const firstId = plats[0]?.id || ''; setForm({ ...empty, 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, }); }; const close = () => { setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); }; const submit = async (e) => { e?.preventDefault?.(); setErr(null); try { 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, }; if (editingId) { await api.put(`/depots-retraits/${editingId}`, payload); close(); await load(); return; } // Nouvelle opération 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(form.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(form.plateforme_id) ); const currentBalance = currentPlatData?.solde_portefeuille ?? 0; const montant = Number(form.montant); const expectedBalance = Math.round( (form.type === 'retrait' ? currentBalance - montant : currentBalance + montant) * 100 ) / 100; setCorrModal({ open: true, plateforme_nom: plat.nom, type_operation: form.type, computedBalance: expectedBalance, date: form.date_operation, platId: Number(form.plateforme_id), investisseurId: Number(form.investisseur_id) || null, }); } else { await load(); } } 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: `Correction solde porte-monnaie — ${corrModal.type_operation === 'retrait' ? 'retrait' : 'dépôt'}`, }); } catch (e) { console.error('Correction creation failed:', e); } } setCorrModal(m => ({ ...m, open: false })); await load(); }; 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 load(); }, }); }; const deleteCorrection = (corrId) => { setDeleteConfirm({ message: 'Supprimer définitivement cette correction de solde ?', onConfirm: async () => { await api.del(`/corrections/${corrId}`); setDeleteConfirm(null); setSelectedRow(null); await load(); }, }); }; 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 load(); }, }); }; 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)}
{fmtEUR(prevTotals.depots)} en {prevYear}
setFilter(f => ({ ...f, type: f.type === 'retrait' ? '' : 'retrait' }))} title="Filtrer sur les retraits" >
Retraits
{fmtEUR(totals.retraits)}
{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)}
{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}
Porte-monnaie
{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'} {kpiSoldePortefeuille !== null && }
{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}
} {/* ── Onglets ── */} {!listFocused &&
} {/* ====== 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(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')} onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} onJSON={() => dlBlob(mouvToJSON(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')} />
{/* 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 && } ); } 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 mouvementMontant
{loading ? 'Chargement…' : 'Aucun mouvement'}
{fmtDate(r.date_operation)} {r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} correction {isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))} e.stopPropagation()}>
{fmtDate(r.date_operation)} {r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} {r.type} {isAuto && ( AUTO )} {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} e.stopPropagation()}> {!isAuto && ( )}
{/* Colonne droite 1/3 */} {tableRows.length > 0 && (
)}
)} {/* 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(); }} onConfirm={handleCorrectionConfirm} /> {deleteConfirm && ( setDeleteConfirm(null)} /> )} ); }