import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { api } from '../api.js'; import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import { useUi } from '../context/UiContext.jsx'; import PageIcon from '../components/PageIcon.jsx'; import EmptyState from '../components/EmptyState.jsx'; import InvChart from '../components/InvChart.jsx'; import InvMensuelTable from '../components/InvMensuelTable.jsx'; import SuiviObjectifsCapital from '../components/SuiviObjectifsCapital.jsx'; import DrillCellPanel from '../components/DrillCellPanel.jsx'; import RembFormModal from '../components/RembFormModal.jsx'; import { InteretsChartProvider } from '../context/InteretsChartContext.jsx'; import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js'; import { xirr } from '../utils/xirr.js'; import { usePagination } from '../hooks/usePagination.js'; import Pagination from '../components/Pagination.jsx'; import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; import { mouvToCSV, mouvToXLS, mouvToJSON, ExportDropdown, AddDropdown, CorrectionPickerModal, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm, timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap, } from '../components/MouvementsShared.jsx'; const LOGOS_BASE = '/api/logos/'; /* ── TrendBadge ── */ function TrendBadge({ current, prev, invert = false }) { // prev quasi-nul (résidu flottant) → pourcentage non significatif ; seuil aligné sur // l'arrondi 2 décimales pour éviter des variations aberrantes type "+39628021025%". if (prev == null || Math.abs(prev) < 0.005) return null; const diff = current - prev; const pct = (diff / prev) * 100; const up = diff > 0; const neutral = diff === 0; const good = neutral ? null : (invert ? !up : up); const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626'; const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)'; const arrow = neutral ? '→' : up ? '↗' : '↘'; const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`; return ( {arrow} {label} ); } /* ── Sélecteur Plateforme ── */ function PlatSelector({ platOptions, selectedPlatName, onSelect }) { 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 selected = platOptions.find(p => p.nom === selectedPlatName) || platOptions[0]; return (
setOpen(v => !v)} style={{ background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)', borderRadius: 10, padding: '12px 16px', boxShadow: open ? '0 6px 28px rgba(109,40,217,0.45)' : '0 4px 20px rgba(109,40,217,0.30)', cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s', }} >
Plateforme
{selected?.icon_filename && ( )} {selected?.nom || '—'}
{open && (
{platOptions.map(p => (
{ onSelect(p.nom); setOpen(false); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', cursor: 'pointer', background: p.nom === selectedPlatName ? 'var(--surface-2)' : 'transparent', fontWeight: p.nom === selectedPlatName ? 700 : 400, fontSize: 'var(--fs-sm)', borderBottom: '1px solid var(--border)', transition: 'background .1s', }} onMouseEnter={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'var(--surface-2)'; }} onMouseLeave={e => { if (p.nom !== selectedPlatName) e.currentTarget.style.background = 'transparent'; }} > {p.icon_filename ? ( ) : ( )} {p.nom}
))}
)}
); } /* ── Sélecteur Détenteur ── */ function DetenteurSelector({ detenteurOptions, selectedId, onSelect }) { 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 selected = detenteurOptions.find(d => d.id === selectedId) || detenteurOptions[0]; return (
setOpen(v => !v)} style={{ background: 'linear-gradient(135deg, #0d9488 0%, #0891b2 100%)', borderRadius: 10, padding: '12px 16px', boxShadow: open ? '0 6px 22px rgba(13,148,136,0.45)' : '0 4px 16px rgba(13,148,136,0.3)', cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s', }} >
Détenteur
{selected?.label || '—'}
{open && (
{detenteurOptions.map(d => (
{ onSelect(d.id); setOpen(false); }} style={{ padding: '10px 14px', cursor: 'pointer', background: d.id === selectedId ? 'var(--surface-2)' : 'transparent', fontWeight: d.id === selectedId ? 700 : 400, fontSize: 'var(--fs-sm)', borderBottom: '1px solid var(--border)', transition: 'background .1s', }} onMouseEnter={e => { if (d.id !== selectedId) e.currentTarget.style.background = 'var(--surface-2)'; }} onMouseLeave={e => { if (d.id !== selectedId) e.currentTarget.style.background = 'transparent'; }} > {d.label}
))}
)}
); } /* ── Sélecteur Année ── */ function AnneeSelector({ availableYears, selectedYear, onSelect }) { 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 options = [ { value: '', label: 'Depuis le début' }, ...[...availableYears].sort((a, b) => b - a).map(y => ({ value: String(y), label: String(y) })), ]; const displayLabel = selectedYear || 'Depuis le début'; const isGlobal = !selectedYear; return (
setOpen(v => !v)} style={{ background: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)', borderRadius: 10, padding: '12px 16px', boxShadow: open ? '0 6px 28px rgba(109,40,217,0.45)' : '0 4px 20px rgba(109,40,217,0.30)', cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s', }} >
Période
{displayLabel}
{open && (
{options.map(opt => (
{ onSelect(opt.value); setOpen(false); }} style={{ padding: '10px 14px', cursor: 'pointer', background: opt.value === selectedYear ? 'var(--surface-2)' : 'transparent', fontWeight: opt.value === selectedYear ? 700 : 400, fontSize: 'var(--fs-sm)', borderBottom: '1px solid var(--border)', transition: 'background .1s', }} onMouseEnter={e => { if (opt.value !== selectedYear) e.currentTarget.style.background = 'var(--surface-2)'; }} onMouseLeave={e => { if (opt.value !== selectedYear) e.currentTarget.style.background = 'transparent'; }} > {opt.label}
))}
)}
); } /* ═══════════════════════════════════════════════════════════════ Page principale ═══════════════════════════════════════════════════════════════ */ export default function Plateformes() { const { activeId, activeView, investisseurs } = useInvestisseur(); const { displayMode, chartInterets, chartCapital, chartCashback, objectifsActifs } = useUi(); const navigate = useNavigate(); const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); /* ── État données ── */ const [allRows, setAllRows] = useState([]); const [allRembs, setAllRembs] = useState([]); const [allReinvests, setAllReinvests] = useState([]); const [plats, setPlats] = useState([]); const [allDepots, setAllDepots] = useState([]); const [allCorrections, setAllCorrections] = useState([]); const [loading, setLoading] = useState(false); // Snapshots backend du solde porte-monnaie par plateforme, clé 'live' ou 'YYYY-12-31' // (GET /dashboard[?asOf=]) — remplace le recalcul client historique de // computeSoldePortefeuille, cf. dashDataAsOf dans DepotsRetraits.jsx et mémoire // project_taxreport_interets_plateforme (source unique de vérité). const [dashDataAsOf, setDashDataAsOf] = useState({}); /* ── État sélecteurs ── */ /* selectedPlatName et activeTab sont dans l'URL pour survivre au retour arrière */ const selectedPlatName = searchParams.get('plat') || null; const setSelectedPlatName = (nom) => { try { localStorage.setItem('cl_plat_selected', nom); } catch {} setSearchParams(p => { const n = new URLSearchParams(p); n.set('plat', nom); return n; }, { replace: true }); }; const [selectedDetenteurId, setSelectedDetenteurId] = useState(() => { try { return localStorage.getItem('cl_plat_detenteur') || 'all'; } catch { return 'all'; } }); const [selectedYear, setSelectedYear] = useState(() => { try { return localStorage.getItem('cl_plat_year') || String(new Date().getFullYear()); } catch { return String(new Date().getFullYear()); } }); const [windowStart, setWindowStart] = useState(0); const [drDetaille, setDrDetaille] = useState(() => { try { return localStorage.getItem('cl_dr_plat_detaille') === 'true'; } catch { return false; } }); const toggleDrDetaille = () => setDrDetaille(v => { const next = !v; try { localStorage.setItem('cl_dr_plat_detaille', String(next)); } catch {} return next; }); const [rembDetaille, setRembDetaille] = useState(() => { try { return localStorage.getItem('cl_remb_plat_detaille') === 'true'; } catch { return false; } }); const toggleRembDetaille = () => setRembDetaille(v => { const next = !v; try { localStorage.setItem('cl_remb_plat_detaille', String(next)); } catch {} return next; }); const [rembInclureInterets, setRembInclureInterets] = useState(() => { try { return localStorage.getItem('cl_remb_plat_interets') !== 'false'; } catch { return true; } }); const [rembInclureCapital, setRembInclureCapital] = useState(() => { try { return localStorage.getItem('cl_remb_plat_capital') === 'true'; } catch { return false; } }); const [rembInclureCashback, setRembInclureCashback] = useState(() => { try { return localStorage.getItem('cl_remb_plat_cashback') === 'true'; } catch { return false; } }); const [rembShowActual, setRembShowActual] = useState(true); const [rembShowProjected, setRembShowProjected] = useState(true); const [libIcons, setLibIcons] = useState({}); const [allSimulRembs, setAllSimulRembs] = useState([]); const [pfuRates, setPfuRates] = useState([]); /* ── État UI ── */ const activeTab = searchParams.get('tab') || (() => { try { return localStorage.getItem('cl_plat_tab') || 'remboursements'; } catch { return 'remboursements'; } })(); const setActiveTab = (tab) => { try { localStorage.setItem('cl_plat_tab', tab); } catch {} setSearchParams(p => { const n = new URLSearchParams(p); n.set('tab', tab); return n; }, { replace: true }); }; const [listFocused, setListFocused] = useState(false); // Repli sur l'onglet Remboursements si la fixation des objectifs est // désactivée pendant qu'on est sur l'onglet Objectifs (ex. tab restauré // depuis l'URL ou le localStorage alors que le paramètre a été désactivé). useEffect(() => { if (!objectifsActifs && activeTab === 'objectifs') setActiveTab('remboursements'); // eslint-disable-next-line }, [objectifsActifs, activeTab]); /* ── Chargement ── */ const load = async () => { if (!activeId && activeView !== 'all') return; setLoading(true); try { const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; const [rows, p, rembs, reinvests, depots, corrections, simulRembs, pfu] = await Promise.all([ api.get('/investissements', scopeParams), api.get('/plateformes'), api.get('/remboursements', scopeParams), api.get('/reinvestissements', { scope: 'all' }), api.get('/depots-retraits', scopeParams), api.get('/corrections', scopeParams), api.get('/simul/all', scopeParams), api.get('/pfu'), ]); setAllRows(rows); setPlats(p); setAllRembs(rembs); setAllReinvests(reinvests); setAllDepots(depots); setAllCorrections(corrections); setAllSimulRembs(Array.isArray(simulRembs) ? simulRembs : []); setPfuRates(Array.isArray(pfu) ? pfu : []); } finally { setLoading(false); setDrillRefreshKey(k => k + 1); } }; useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]); // Snapshots backend du solde porte-monnaie : 'live' (aucune coupure) + l'année // sélectionnée (si filtre actif) + l'année précédente (toujours nécessaire pour // le TrendBadge). Même endpoint que DepotsRetraits.jsx (GET /dashboard[?asOf=]). // 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 = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1; const keys = new Set(['live', `${prevYear}-12-31`]); if (selectedYear) keys.add(`${selectedYear}-12-31`); try { const entries = await Promise.all([...keys].map(k => api.get('/dashboard', { ...scopeParams, ...(k === 'live' ? {} : { asOf: k }) }).then(d => [k, d]) )); setDashDataAsOf(prev => ({ ...prev, ...Object.fromEntries(entries) })); } catch (e) { console.error('dashDataAsOf load error (Plateformes):', e); } }; useEffect(() => { loadDashDataAsOf(); // eslint-disable-next-line }, [selectedYear, activeView, activeId]); /* ── Section "Mois en cours" (DrillCellPanel) — saisie/édition de remboursements ── */ const [drillRefreshKey, setDrillRefreshKey] = useState(0); const [rembModalOpen, setRembModalOpen] = useState(false); const [rembEditingId, setRembEditingId] = useState(null); const [rembInv, setRembInv] = useState(null); // investissement lié au remb en cours d'édition const [rembForm, setRembForm] = useState({}); const [rembErr, setRembErr] = useState(null); const [rembConfirmingDelete, setRembConfirmingDelete] = useState(false); const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]); const round2 = (n) => Math.round(n * 100) / 100; const getRatesForYear = (dateStr) => { if (!dateStr || !pfuRates.length) return null; const year = parseInt(dateStr.slice(0, 4), 10); return pfuRates.find(r => r.annee === year) ?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]); }; const loadComptesRemb = (investisseurId) => { if (!investisseurId) { setComptesRembInvestisseur([]); return; } api.get(`/investissements/comptes-par-investisseur/${investisseurId}`) .then(setComptesRembInvestisseur) .catch(() => setComptesRembInvestisseur([])); }; const setRembField = (k, v) => { setRembForm(prev => { const next = { ...prev, [k]: v }; const plat = plats.find(p => p.id === rembInv?.plateforme_id); const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale; if (k === 'interets_bruts_avant_local') { const taux = hasLocalTax ? (plat.taux_fiscalite_locale || 0) : 0; next.taxe_locale = round2(Number(v) * taux / 100); next.interets_bruts = round2(Number(v) - next.taxe_locale); } 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); } } return next; }); }; const openEditRecu = (r) => { const inv = allRows.find(i => i.id === r.investissement_id); setRembInv(inv || null); setRembEditingId(r.id); setRembForm({ date_remb: r.date_remb, capital: r.capital ?? 0, 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 ?? 0, prelev_sociaux: r.prelev_sociaux ?? 0, prelev_forfaitaire: r.prelev_forfaitaire ?? 0, statut: r.statut, notes: r.notes || '', methode_remboursement: r.methode_remboursement || 'portefeuille', compte_id: r.compte_id || '', }); setRembErr(null); setRembConfirmingDelete(false); setRembModalOpen(true); loadComptesRemb(inv?.investisseur_id); }; const openFromProjet = (p) => { const inv = allRows.find(i => i.id === p.investissement_id); const plat = inv ? plats.find(pl => pl.id === inv.plateforme_id) : null; const bruts = p.interets_prevus || 0; const rates = getRatesForYear(p.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; setRembInv(inv || null); setRembEditingId(null); setRembForm({ date_remb: p.date_prevue, capital: p.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 || '') : '', }); setRembErr(null); setRembConfirmingDelete(false); setRembModalOpen(true); loadComptesRemb(inv?.investisseur_id); }; const closeRembModal = () => { setRembModalOpen(false); setRembEditingId(null); setRembInv(null); setRembForm({}); setRembErr(null); setRembConfirmingDelete(false); }; const submitRemb = async (e) => { e?.preventDefault?.(); setRembErr(null); try { const payload = { type: 'normal', investissement_id: rembInv?.id, date_remb: rembForm.date_remb, capital: Number(rembForm.capital || 0), cashback: Number(rembForm.cashback || 0), interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0), taxe_locale: Number(rembForm.taxe_locale || 0), interets_bruts: Number(rembForm.interets_bruts || 0), prelev_sociaux: Number(rembForm.prelev_sociaux || 0), prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0), statut: rembForm.statut, notes: rembForm.notes || undefined, methode_remboursement: rembForm.methode_remboursement || 'portefeuille', compte_id: rembForm.methode_remboursement === 'compte_courant' && rembForm.compte_id ? Number(rembForm.compte_id) : null, }; if (rembEditingId) await api.put(`/remboursements/${rembEditingId}`, payload); else await api.post('/remboursements', payload); closeRembModal(); await Promise.all([load(), loadDashDataAsOf()]); } catch (e) { setRembErr(e.message); } }; const onDeleteRemb = async () => { try { await api.del(`/remboursements/${rembEditingId}`); closeRembModal(); await Promise.all([load(), loadDashDataAsOf()]); } catch (e) { setRembErr(e.message); } }; /* ── Icônes bibliothèque ── */ useEffect(() => { api.get('/icons').then(rows => { const m = {}; rows.forEach(r => { m[r.name] = r.filename; }); setLibIcons(m); }).catch(() => {}); }, []); /* ── Options plateformes : dédupliquées par nom ── */ const platOptions = useMemo(() => { const map = {}; for (const r of allRows) { const nom = r.plateforme_nom || '—'; if (!map[nom]) { const p = plats.find(pl => pl.id === r.plateforme_id); map[nom] = { nom, icon_filename: p?.icone_filename || p?.logo_filename || null, investi: 0, }; } if (!map[nom].icon_filename) { const p = plats.find(pl => pl.id === r.plateforme_id); map[nom].icon_filename = p?.icone_filename || p?.logo_filename || null; } map[nom].investi += r.montant_investi || 0; } return Object.values(map).sort((a, b) => b.investi - a.investi); }, [allRows, plats]); /* ── Init sélecteur plateforme — préfère localStorage ── */ useEffect(() => { if (!selectedPlatName && platOptions.length > 0) { const saved = (() => { try { return localStorage.getItem('cl_plat_selected'); } catch { return null; } })(); const target = platOptions.find(p => p.nom === saved) ? saved : platOptions[0].nom; setSelectedPlatName(target); } }, [platOptions, selectedPlatName]); /* ── Investissements pour la plateforme sélectionnée ── */ const platRows = useMemo(() => allRows.filter(r => selectedPlatName && r.plateforme_nom === selectedPlatName), [allRows, selectedPlatName] ); /* ── Options détenteur (visible seulement si plusieurs détenteurs distincts) ── */ const detenteurOptions = useMemo(() => { const ids = [...new Set(platRows.map(r => r.investisseur_id).filter(v => v != null))]; if (ids.length <= 1) return null; return [ { id: 'all', label: 'Multi-détenteur' }, ...ids.map(id => { const inv = investisseurs.find(i => i.id === id || String(i.id) === String(id)); return { id: String(id), label: inv ? memberLabel(inv) : `Détenteur ${id}` }; }), ]; }, [platRows, investisseurs]); /* ── Vrai/faux multi-détenteur global (indépendant de la plateforme sélectionnée) ── */ const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1; /* ── Vision "Multi-détenteur" active sur la plateforme sélectionnée — alimente la ventilation par détenteur du tableau Objectifs (cf. detenteurOptions). ── */ const showMultiDetenteurBreakdown = !!detenteurOptions && selectedDetenteurId === 'all'; /* ── Reset détenteur quand plateforme change (skip restauration initiale) ── */ const isFirstPlatChange = useRef(true); useEffect(() => { if (isFirstPlatChange.current) { isFirstPlatChange.current = false; return; } setSelectedDetenteurId('all'); try { localStorage.setItem('cl_plat_detenteur', 'all'); } catch {} }, [selectedPlatName]); /* ── Persistance selectedYear et selectedDetenteurId ── */ useEffect(() => { try { localStorage.setItem('cl_plat_year', selectedYear); } catch {} }, [selectedYear]); useEffect(() => { try { localStorage.setItem('cl_plat_detenteur', selectedDetenteurId); } catch {} }, [selectedDetenteurId]); /* ── IDs des plateformes correspondant à la sélection nom + détenteur ── */ const filteredPlatIds = useMemo(() => { if (!selectedPlatName) return new Set(); const matching = plats.filter(p => p.nom === selectedPlatName); if (selectedDetenteurId === 'all') return new Set(matching.map(p => p.id)); return new Set(matching.filter(p => String(p.investisseur_id) === String(selectedDetenteurId)).map(p => p.id)); }, [plats, selectedPlatName, selectedDetenteurId]); /* ── IDs des plateformes du nom sélectionné, regroupés par détenteur — alimente la ventilation par détenteur du tableau Objectifs quand la plateforme est détenue par plusieurs personnes (cf. detenteurOptions). ── */ const platIdsByDetenteur = useMemo(() => { if (!selectedPlatName) return {}; const map = {}; for (const p of plats) { if (p.nom !== selectedPlatName) continue; const key = String(p.investisseur_id); (map[key] ??= new Set()).add(p.id); } return map; }, [plats, selectedPlatName]); /* ── Investissements filtrés sur la plateforme sélectionnée — alimente la colonne "Capital investi" (encours à fin d'année) de SuiviObjectifs, cf. onglet Dépôts/Retraits plus bas. remboursements/réinvestissements ne sont pas filtrés ici : le rattachement se fait par investissement_id, déjà scopé via cette liste. ── */ const investissementsForObjectifs = useMemo( () => allRows.filter(inv => filteredPlatIds.size === 0 || filteredPlatIds.has(inv.plateforme_id)), [allRows, filteredPlatIds] ); const remboursementsCapitalForObjectifs = useMemo( () => allRembs.filter(r => r.type === 'normal'), [allRembs] ); /* Objectifs de capital investi — pour enrichir le KPI "Capital investi" (écart vs objectif de l'année affichée, sur le même scope plateforme/détenteur que la page). Chargés une fois, indépendamment du filtre en cours. */ const [objectifsCapitalKpi, setObjectifsCapitalKpi] = useState([]); useEffect(() => { api.get('/objectifs', { type: 'capital_investi' }) .then(rows => setObjectifsCapitalKpi(Array.isArray(rows) ? rows : [])) .catch(() => setObjectifsCapitalKpi([])); }, []); /* ── Cellule "mois en cours" pour la section DrillCellPanel ── */ const MOIS_LONG_CELL = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; const moisEnCoursCell = useMemo(() => { const ids = [...filteredPlatIds]; if (!selectedPlatName || ids.length === 0) return null; const now = new Date(); return { platId: ids.length === 1 ? ids[0] : null, platIds: ids, platNom: selectedPlatName, annee: now.getFullYear(), mois: now.getMonth() + 1, moisLabel: MOIS_LONG_CELL[now.getMonth()], }; }, [filteredPlatIds, selectedPlatName]); /* ── Lignes filtrées (plateforme + détenteur) ── */ const filteredRows = useMemo(() => { if (selectedDetenteurId === 'all' || !detenteurOptions) return platRows; return platRows.filter(r => String(r.investisseur_id) === String(selectedDetenteurId)); }, [platRows, selectedDetenteurId, detenteurOptions]); /* ── Années disponibles (asc pour la barre de boutons, desc pour le dropdown) ── */ const availableYearsAsc = useMemo(() => { const set = new Set(filteredRows.map(r => r.date_souscription?.slice(0,4)).filter(Boolean)); return [...set].map(Number).sort((a, b) => a - b); }, [filteredRows]); const availableYears = useMemo(() => [...availableYearsAsc].reverse(), [availableYearsAsc]); /* Repositionne la fenêtre sur l'année courante à chaque changement de plateforme ── */ useEffect(() => { if (!availableYearsAsc.length) return; const currentYear = new Date().getFullYear(); const idx = availableYearsAsc.indexOf(currentYear); const pos = idx >= 0 ? idx : availableYearsAsc.length - 1; setWindowStart(Math.max(0, Math.min(Math.max(0, availableYearsAsc.length - 3), pos - 1))); }, [selectedPlatName]); // eslint-disable-line react-hooks/exhaustive-deps /* ── lastRembDateMap ── */ const lastRembDateMap = useMemo(() => { const map = {}; for (const rb of allRembs) { const id = rb.investissement_id; const d = rb.date_remb?.slice(0,10); if (!id || !d) continue; if (!map[id] || d > map[id]) map[id] = d; } return map; }, [allRembs]); const isActiveAtEndOfYear = (r, yr) => { if (!yr) return true; const cutoff = `${yr}-12-31`; const decStart = `${yr}-12-01`; if (r.date_souscription > cutoff) return false; const ACTIVE = ['en_cours', 'en_retard', 'procedure']; if (ACTIVE.includes(r.statut)) return true; const fin = r.date_cible ?? lastRembDateMap[r.id] ?? null; return !!fin && fin >= decStart; }; /* ── Lignes pour les graphiques (filtrées par année) ── */ const chartRows = useMemo(() => { if (!selectedYear) return filteredRows; return filteredRows.filter(r => isActiveAtEndOfYear(r, selectedYear)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filteredRows, selectedYear, lastRembDateMap]); /* ── Agrégats remboursements (filtrés par coupure année) ── */ const rembParInv = useMemo(() => { const cutoff = selectedYear ? `${selectedYear}-12-31` : null; const map = {}; for (const rb of allRembs) { if (!rb.investissement_id) continue; const d = rb.date_remb?.slice(0,10); if (!d || (cutoff && d > cutoff)) continue; const id = rb.investissement_id; if (!map[id]) map[id] = { capital: 0, interets_bruts: 0, interets_nets: 0 }; map[id].capital += rb.capital || 0; map[id].interets_bruts += rb.interets_bruts || 0; map[id].interets_nets += rb.interets_nets || 0; } return map; }, [allRembs, selectedYear]); const capRembParInv = useMemo(() => Object.fromEntries(Object.entries(rembParInv).map(([id, v]) => [id, v.capital])), [rembParInv] ); const reinvestCumulParInv = useMemo(() => { const cutoff = selectedYear ? `${selectedYear}-12-31` : null; const map = {}; for (const rv of allReinvests) { const d = rv.date_reinvestissement?.slice(0,10); if (!d || (cutoff && d > cutoff)) continue; map[rv.investissement_id] = (map[rv.investissement_id] || 0) + (rv.montant || 0); } return map; }, [allReinvests, selectedYear]); /* ── KPI rows (= chartRows : déjà filtrées par plateforme + détenteur + année) ── */ const totals = useMemo(() => chartRows.reduce((acc, r) => { const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); const capRemb = capRembParInv[r.id] || 0; acc.investi += capInv; acc.cap_remb += capRemb; acc.int_perc += rembParInv[r.id]?.interets_bruts || 0; acc.int_perc_net += rembParInv[r.id]?.interets_nets || 0; const capRestant = Math.max(0, capInv - capRemb); acc.encours += capRestant; if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant; return acc; }, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0 }), [chartRows, capRembParInv, rembParInv, reinvestCumulParInv]); /* ── Pagination onglet Investissements ── */ const sortedChartRows = useMemo(() => chartRows.slice().sort((a, b) => (a.date_souscription || '') < (b.date_souscription || '') ? -1 : 1), [chartRows] ); const { pagedItems: pagedChartRows, page: platInvPage, setPage: setPlatInvPage, pageSize: platInvPageSize, setPageSize: setPlatInvPageSize, totalPages: platInvTotalPages, totalItems: platInvTotalItems, PAGE_SIZES: platInvPageSizes, } = usePagination(sortedChartRows, 'cl_pagesize_plat_inv', [selectedPlatName, selectedYear]); /* ── KPI N-1 (pour TrendBadge) ── */ const prevTotals = useMemo(() => { const effectiveYear = selectedYear || String(new Date().getFullYear()); const prevYear = String(Number(effectiveYear) - 1); const cutoff = `${prevYear}-12-31`; const prevRembParInv = {}; for (const rb of allRembs) { if (!rb.investissement_id) continue; const d = rb.date_remb?.slice(0,10); if (!d || d > cutoff) continue; const id = rb.investissement_id; if (!prevRembParInv[id]) prevRembParInv[id] = { capital: 0, interets_bruts: 0, interets_nets: 0 }; prevRembParInv[id].capital += rb.capital || 0; prevRembParInv[id].interets_bruts += rb.interets_bruts || 0; prevRembParInv[id].interets_nets += rb.interets_nets || 0; } const prevReinvest = {}; for (const rv of allReinvests) { const d = rv.date_reinvestissement?.slice(0,10); if (!d || d > cutoff) continue; prevReinvest[rv.investissement_id] = (prevReinvest[rv.investissement_id] || 0) + (rv.montant || 0); } const prevRows = filteredRows.filter(r => isActiveAtEndOfYear(r, prevYear)); return prevRows.reduce((acc, r) => { const capInv = r.montant_investi + (prevReinvest[r.id] || 0); const capRemb = prevRembParInv[r.id]?.capital || 0; acc.investi += capInv; acc.cap_remb += capRemb; acc.int_perc += prevRembParInv[r.id]?.interets_bruts || 0; acc.int_perc_net += prevRembParInv[r.id]?.interets_nets || 0; const capRestant = Math.max(0, capInv - capRemb); acc.encours += capRestant; if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant; return acc; }, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0 }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedYear, filteredRows, allRembs, allReinvests, lastRembDateMap]); /* ── XIRR agrégé (flux datés : investis + réinvestissements + remboursements + valorisation du capital restant dû pour les prêts non soldés, à la date de coupure) ── */ const plateformeXirr = useMemo(() => { const cutoff = selectedYear ? `${selectedYear}-12-31` : today(); const ids = new Set(chartRows.map(r => r.id)); const flowsBrut = []; const flowsNet = []; for (const r of chartRows) { if (!r.date_souscription) continue; flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription }); flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription }); } for (const rv of allReinvests) { if (!ids.has(rv.investissement_id)) continue; const d = rv.date_reinvestissement?.slice(0, 10); if (!d || d > cutoff) continue; flowsBrut.push({ amount: -(rv.montant || 0), date: d }); flowsNet.push({ amount: -(rv.montant || 0), date: d }); } for (const rb of allRembs) { if (!ids.has(rb.investissement_id)) continue; const d = rb.date_remb?.slice(0, 10); if (!d || d > cutoff) continue; flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d }); flowsNet.push({ amount: rb.net_recu || 0, date: d }); } let estime = false; for (const r of chartRows) { const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); const capRemb = capRembParInv[r.id] || 0; const capRestant = Math.max(0, capInv - capRemb); if (capRestant > 0.01) { estime = true; flowsBrut.push({ amount: capRestant, date: cutoff }); flowsNet.push({ amount: capRestant, date: cutoff }); } } const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0); flowsBrut.sort(byDate); flowsNet.sort(byDate); return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff }; }, [chartRows, allRembs, allReinvests, selectedYear, reinvestCumulParInv, capRembParInv]); /* ── XIRR "attendu" (théorique) — mêmes flux réels que ci-dessus, mais les prêts non soldés sont valorisés via leur échéancier prévisionnel (simul_remboursements) projeté jusqu'à leur terme, plutôt qu'un unique flux "capital restant racheté aujourd'hui". Reproduit la logique des taux affichés par les plateformes elles- mêmes (ex. "Mon taux de rendement interne" chez Bienprêter, "X %/an" chez La Première Brique) : optimiste, suppose que toutes les échéances prévues seront honorées à la date prévue — y compris pour les prêts en_retard/procédure (le KPI "Capital en risque" signale déjà ce risque séparément). Fallback : si un prêt non soldé n'a aucune échéance future en base (schéma manquant), on retombe sur la valorisation "capital restant au cutoff" comme pour le XIRR réel, pour ne jamais sous-évaluer un prêt faute de données. ── */ const plateformeXirrAttendu = useMemo(() => { const cutoff = selectedYear ? `${selectedYear}-12-31` : today(); const ids = new Set(chartRows.map(r => r.id)); const flowsBrut = []; const flowsNet = []; for (const r of chartRows) { if (!r.date_souscription) continue; flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription }); flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription }); } for (const rv of allReinvests) { if (!ids.has(rv.investissement_id)) continue; const d = rv.date_reinvestissement?.slice(0, 10); if (!d || d > cutoff) continue; flowsBrut.push({ amount: -(rv.montant || 0), date: d }); flowsNet.push({ amount: -(rv.montant || 0), date: d }); } for (const rb of allRembs) { if (!ids.has(rb.investissement_id)) continue; const d = rb.date_remb?.slice(0, 10); if (!d || d > cutoff) continue; flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d }); flowsNet.push({ amount: rb.net_recu || 0, date: d }); } // Échéances futures prévues, groupées par investissement const futureSimulParInv = {}; for (const sr of allSimulRembs) { if (!ids.has(sr.investissement_id)) continue; const d = sr.date_prevue?.slice(0, 10); if (!d || d <= cutoff) continue; if (!futureSimulParInv[sr.investissement_id]) futureSimulParInv[sr.investissement_id] = []; futureSimulParInv[sr.investissement_id].push({ date: d, capital: sr.capital_prevu || 0, interets: sr.interets_prevus || 0 }); } for (const r of chartRows) { const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); const capRemb = capRembParInv[r.id] || 0; const capRestant = Math.max(0, capInv - capRemb); if (capRestant <= 0.01) continue; const echeances = futureSimulParInv[r.id]; if (echeances && echeances.length) { for (const e of echeances) { if (e.capital <= 0 && e.interets <= 0) continue; const rate = getRatesForYear(e.date); const pfu = rate ? (rate.prelev_sociaux + rate.impot_revenu) / 100 : 0.3; flowsBrut.push({ amount: e.capital + e.interets, date: e.date }); flowsNet.push({ amount: e.capital + e.interets * (1 - pfu), date: e.date }); } } else { // Pas d'échéancier prévisionnel disponible : fallback identique au XIRR réel flowsBrut.push({ amount: capRestant, date: cutoff }); flowsNet.push({ amount: capRestant, date: cutoff }); } } const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0); flowsBrut.sort(byDate); flowsNet.sort(byDate); return { brut: xirr(flowsBrut), net: xirr(flowsNet) }; }, [chartRows, allRembs, allReinvests, allSimulRembs, selectedYear, reinvestCumulParInv, capRembParInv, pfuRates]); const netMode = displayMode === 'net'; const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1; /* ── Écart du KPI "Capital investi" vs l'objectif de capital investi fixé dans Paramétrage, sur le même scope plateforme(s)/détenteur que la page. Année de référence : celle sélectionnée (selectedYear), ou l'année en cours si "Depuis le début". ── */ const kpiCapitalObjectifYear = Number(selectedYear || new Date().getFullYear()); const kpiCapitalHasPlatFilter = filteredPlatIds.size > 0; const kpiCapitalScopeSet = useMemo( () => new Set(activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]), [activeView, investisseurs, activeId] ); const kpiCapitalObjectifTotal = useMemo(() => { const list = objectifsCapitalKpi.filter(o => o.annee === kpiCapitalObjectifYear && (kpiCapitalHasPlatFilter ? filteredPlatIds.has(o.plateforme_id) : kpiCapitalScopeSet.has(o.investisseur_id))); return list.length ? list.reduce((s, o) => s + o.montant, 0) : null; }, [objectifsCapitalKpi, kpiCapitalObjectifYear, kpiCapitalHasPlatFilter, filteredPlatIds, kpiCapitalScopeSet]); const kpiEcartCapitalObjectif = kpiCapitalObjectifTotal != null ? totals.encours - kpiCapitalObjectifTotal : null; /* ── Solde porte-monnaie (source unique : GET /dashboard[?asOf=], cf. dashboard.js) ── Snapshot backend filtré sur la sélection plateforme(s)/détenteur (filteredPlatIds). */ const soldeFromSnapshot = (snap) => { if (!snap) return null; const total = (snap.cashByPlatform ?? []) .filter(c => filteredPlatIds.has(c.plateforme_id)) .reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0); return Math.round(total * 100) / 100; }; const soldePortefeuille = useMemo( () => soldeFromSnapshot(dashDataAsOf[selectedYear ? `${selectedYear}-12-31` : 'live']), [dashDataAsOf, filteredPlatIds, selectedYear] ); const prevSoldePortefeuille = useMemo( () => soldeFromSnapshot(dashDataAsOf[`${prevYearLabel}-12-31`]), [dashDataAsOf, filteredPlatIds, prevYearLabel] ); /* ══════════════════════════════════════════════════════════════════════ ONGLET "MOUVEMENTS PORTE-MONNAIE" — réplique de l'onglet "Mouvements" de DepotsRetraits.jsx (cf. components/MouvementsShared.jsx pour les éléments présentationnels partagés). Reprend les mêmes données déjà chargées par load() ci-dessus (allDepots, allRembs, allRows=investissements, allReinvests, allCorrections, plats). Toute évolution de la logique de décomposition des mouvements doit être répercutée dans les deux fichiers. ══════════════════════════════════════════════════════════════════════ */ const [mvFilter, setMvFilter] = useState({ type: '', plateforme_ids: [], projet: '', year: '', month: '' }); const [mvListFocused, setMvListFocused] = useState(false); const [mvModalOpen, setMvModalOpen] = useState(false); const [mvEditingId, setMvEditingId] = useState(null); const [mvForm, setMvForm] = useState(emptyMouvementForm); const [mvErr, setMvErr] = useState(null); const [mvSelectedRow, setMvSelectedRow] = useState(null); const [mvOpenMenu, setMvOpenMenu] = useState(null); // { row, x, y } const [mvDeleteConfirm, setMvDeleteConfirm] = useState(null); const [mvCorrModal, setMvCorrModal] = 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 [mvCorrPickerOpen, setMvCorrPickerOpen] = useState(false); // Le périmètre plateforme de cet onglet est strictement verrouillé sur la sélection // "Plateforme" de la page (filteredPlatIds) — aucun contrôle interne ne permet de // l'élargir à d'autres plateformes, pour éviter toute confusion avec les KPIs affichés // au-dessus (qui portent, eux, sur la plateforme sélectionnée en haut de page). useEffect(() => { setMvFilter(f => ({ ...f, plateforme_ids: [...filteredPlatIds].map(String), projet: '' })); }, [filteredPlatIds]); // Ferme le menu contextuel au scroll useEffect(() => { const closeAll = () => setMvOpenMenu(null); window.addEventListener('scroll', closeAll, true); return () => window.removeEventListener('scroll', closeAll, true); }, []); const mvYears = useMemo( () => [...new Set(allDepots.map(r => r.date_operation.slice(0, 4)))].sort().reverse(), [allDepots] ); const mvInvestisseurForPlat = (platId) => { const plat = plats.find(p => String(p.id) === String(platId)); if (plat?.investisseur_id) return String(plat.investisseur_id); if (activeView === 'all') { const principal = investisseurs.find(i => i.is_principal); return String((principal || investisseurs[0])?.id || ''); } return String(activeId || ''); }; const mvRows = useMemo(() => allDepots.filter(r => { if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false; if (mvFilter.type && r.type !== mvFilter.type) return false; const [y, m] = r.date_operation.split('-'); if (mvFilter.year && y !== mvFilter.year) return false; if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false; return true; }), [allDepots, mvFilter]); const mvNormalizedCorrections = 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]); const mvFilteredCorrections = useMemo(() => mvNormalizedCorrections.filter(c => { if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(c.plateforme_id))) return false; const [y, m] = c.date_operation.split('-'); if (mvFilter.year && y !== mvFilter.year) return false; if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false; return true; }), [mvNormalizedCorrections, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]); const mvInvById = useMemo(() => new Map(allRows.map(i => [i.id, i])), [allRows]); const mvNormalizedRembMouvements = useMemo(() => { const out = []; for (const r of allRembs) { 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 = mvInvById.get(r.investissement_id); const isFlatTax = plat?.fiscalite === 'flat_tax'; const isExonere = inv?.fiscalite_override === 'exonere'; 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; }, [allRembs, plats, mvInvById]); const mvFilteredRembMouvements = useMemo(() => mvNormalizedRembMouvements.filter(r => { if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false; const [y, m] = r.date_operation.split('-'); if (mvFilter.year && y !== mvFilter.year) return false; if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false; return true; }), [mvNormalizedRembMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]); const mvRembProjetById = useMemo(() => new Map(allRembs.map(r => [r.id, r.nom_projet || ''])), [allRembs]); const mvNormalizedSouscriptionMouvements = useMemo(() => { const out = []; for (const inv of allRows) { 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 = mvInvById.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; }, [allRows, allReinvests, mvInvById]); const mvFilteredSouscriptionMouvements = useMemo(() => mvNormalizedSouscriptionMouvements.filter(r => { if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false; const [y, m] = r.date_operation.split('-'); if (mvFilter.year && y !== mvFilter.year) return false; if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false; return true; }), [mvNormalizedSouscriptionMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]); // Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais // pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours // d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les // retraits AUTO liés à un remboursement (rattachés via rembProjetById). const mvProjets = useMemo(() => { const ids = mvFilter.plateforme_ids; const inScope = pid => ids.length === 0 || ids.includes(String(pid)); const set = new Set(); for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle); for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle); for (const d of allDepots) { if (!inScope(d.plateforme_id) || !d.remboursement_id) continue; const p = mvRembProjetById.get(d.remboursement_id); if (p) set.add(p); } return [...set].sort((a, b) => a.localeCompare(b, 'fr')); }, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]); // Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique // COMPLET et NON FILTRÉ (allDepots/mvNormalizedCorrections/mvNormalizedRembMouvements/ // mvNormalizedSouscriptionMouvements), pas sur les variantes déjà filtrées année/mois/ // type/projet — sinon le cumul démarrerait arbitrairement au milieu de l'historique. // Purement indicatif (affichage/export) — ne remplace pas computeSoldePortefeuille. // Voir [[project_mouvements_solde_indicatif]]. const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([ ...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements, ]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements]); const mvTableRows = useMemo(() => { const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait'; const showCorr = !mvFilter.type || mvFilter.type === 'correction'; const drPart = showDR ? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '' })) : []; const corrPart = showCorr ? mvFilteredCorrections.map(c => ({ ...c, _projet: '' })) : []; const rembPart = mvFilteredRembMouvements .filter(r => !mvFilter.type || r.type === mvFilter.type) .map(r => ({ ...r, _projet: r.libelle || '' })); const subPart = mvFilteredSouscriptionMouvements .filter(r => !mvFilter.type || r.type === mvFilter.type) .map(r => ({ ...r, _projet: r.libelle || '' })); return sortMouvements( [...drPart, ...corrPart, ...rembPart, ...subPart] .filter(r => !mvFilter.projet || r._projet === mvFilter.projet) ).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) })); }, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]); const { pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage, pageSize: mvPageSize, setPageSize: setMvPageSize, totalPages: mvTotalPages, totalItems: mvTotalItems, PAGE_SIZES: mvPageSizes, } = usePagination(mvTableRows, 'cl_pagesize_mvpm', [mvFilter]); useEffect(() => { setMvSelectedRow(prev => { if (prev && mvTableRows.find(r => String(r.id) === String(prev.id))) return prev; return mvTableRows[0] || null; }); }, [mvTableRows]); const mvCloseEdit = () => { setMvModalOpen(false); setMvEditingId(null); setMvForm(emptyMouvementForm); setMvErr(null); }; const mvOpenEdit = (row) => { if (row.source === 'auto_remboursement') return; // retrait auto — non éditable ici setMvEditingId(row.id); setMvForm({ 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 || '', }); setMvErr(null); setMvModalOpen(true); }; const mvOpenCorrection = async (row) => { const platId = row ? row.plateforme_id : mvForm.plateforme_id; const investId = row ? (row.investisseur_id || null) : (Number(mvForm.investisseur_id) || null); const type = row ? row.type : mvForm.type; const date = row ? row.date_operation : mvForm.date_operation; const plat = plats.find(p => String(p.id) === String(platId)); 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); } setMvModalOpen(false); setMvEditingId(null); setMvCorrModal({ 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 Porte-monnaie) — pas de mouvement dépôt/retrait déclencheur, l'utilisateur choisit plateforme + date parmi les plateformes du périmètre de la page (filteredPlatIds). */ const mvOpenCorrectionManual = async ({ plateforme_id, date }) => { const plat = plats.find(p => p.id === plateforme_id); const investId = mvInvestisseurForPlat(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); } setMvCorrPickerOpen(false); setMvCorrModal({ open: true, plateforme_nom: plat?.nom || '', type_operation: null, computedBalance, date, platId: plateforme_id, investisseurId: investId ? Number(investId) : null, }); }; const mvSubmit = async (e) => { e?.preventDefault?.(); setMvErr(null); try { const payload = { investisseur_id: Number(mvForm.investisseur_id) || undefined, plateforme_id: Number(mvForm.plateforme_id), date_operation: mvForm.date_operation, type: mvForm.type, montant: Number(mvForm.montant), libelle: mvForm.libelle || undefined, reference: mvForm.reference || undefined, notes: mvForm.notes || undefined, }; if (mvEditingId) { await api.put(`/depots-retraits/${mvEditingId}`, payload); mvCloseEdit(); await Promise.all([load(), loadDashDataAsOf()]); return; } // Pas de création depuis cet onglet — le bouton global "+ Nouveau dépôt/retrait" // (topbar) couvre déjà ce besoin et redirige vers /depots-retraits. await api.post('/depots-retraits', payload); mvCloseEdit(); await Promise.all([load(), loadDashDataAsOf()]); } catch (e) { setMvErr(e.message); } }; const mvHandleCorrectionConfirm = async (diff) => { if (Math.abs(diff) >= 0.005) { try { await api.post('/corrections', { investisseur_id: mvCorrModal.investisseurId, plateforme_id: mvCorrModal.platId, date: mvCorrModal.date, montant: diff, notes: mvCorrModal.type_operation ? `Correction solde porte-monnaie — ${mvCorrModal.type_operation === 'retrait' ? 'retrait' : 'dépôt'}` : 'Correction solde porte-monnaie — saisie manuelle', }); } catch (e) { console.error('Correction creation failed:', e); } } setMvCorrModal(m => ({ ...m, open: false })); await Promise.all([load(), loadDashDataAsOf()]); }; const mvOpenRowMenu = (e, row) => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setMvOpenMenu({ row, x: rect.right, y: rect.bottom }); }; const mvDeleteRow = (rowId) => { setMvDeleteConfirm({ message: 'Supprimer définitivement ce mouvement ?', onConfirm: async () => { await api.del(`/depots-retraits/${rowId}`); setMvDeleteConfirm(null); setMvSelectedRow(null); await Promise.all([load(), loadDashDataAsOf()]); }, }); }; const mvDeleteCorrection = (corrId) => { setMvDeleteConfirm({ message: 'Supprimer définitivement cette correction de solde ?', onConfirm: async () => { await api.del(`/corrections/${corrId}`); setMvDeleteConfirm(null); setMvSelectedRow(null); await Promise.all([load(), loadDashDataAsOf()]); }, }); }; const mvOnDelete = () => { if (!mvEditingId) return; setMvDeleteConfirm({ message: 'Supprimer définitivement ce mouvement ?', onConfirm: async () => { await api.del(`/depots-retraits/${mvEditingId}`); setMvDeleteConfirm(null); mvCloseEdit(); setMvSelectedRow(null); await Promise.all([load(), loadDashDataAsOf()]); }, }); }; if (!loading && plats.length === 0) { return ( <>

Plateformes

); } if (!loading && platOptions.length === 0) { return ( <>

Plateformes

); } return ( <>

Plateformes

{/* ── Graphiques + Sélecteurs ── */} {!listFocused &&
{/* ── Panneau sélecteurs (remplace DistributionChart) ── */}
{platOptions.length > 0 && ( setSelectedPlatName(nom)} /> )} {detenteurOptions && ( )}
} {/* ── KPIs ── */} {!listFocused &&
{/* 1 — Capital investi */}
Capital investi
{fmtEUR(totals.encours)} {selectedYear && }
{selectedYear && (
{fmtEUR(prevTotals.encours)} en {prevYearLabel}
)} {objectifsActifs && kpiEcartCapitalObjectif != null && (
= 0 ? 'var(--success)' : 'var(--warning)', }}> {Math.abs(kpiEcartCapitalObjectif) < 0.01 ? `Conforme à l'objectif ${kpiCapitalObjectifYear}` : kpiEcartCapitalObjectif >= 0 ? `+${fmtEUR(kpiEcartCapitalObjectif)} au-delà de l'objectif ${kpiCapitalObjectifYear}` : `${fmtEUR(-kpiEcartCapitalObjectif)} en dessous de l'objectif ${kpiCapitalObjectifYear}`}
)}
{/* 2 — Investissements à risque */}
Investissements à risque
0 ? 'danger' : ''}> {fmtEUR(totals.defaut)} {selectedYear && totals.defaut > 0 && }
{selectedYear && (
{fmtEUR(prevTotals.defaut)} en {prevYearLabel}
)}
{/* 3 — Porte-monnaie */}
Porte-monnaie
{fmtEUR(soldePortefeuille)} {selectedYear && }
{selectedYear && (
{fmtEUR(prevSoldePortefeuille)} en {prevYearLabel}
)}
{/* 4 — Investissements depuis le début */}
Investissements depuis le début
{fmtEUR(totals.investi)} {selectedYear && }
{selectedYear && (
{fmtEUR(prevTotals.investi)} en {prevYearLabel}
)}
{/* 5 — Capital remboursé */}
Capital remboursé
{fmtEUR(totals.cap_remb)} {selectedYear && }
{selectedYear && (
{fmtEUR(prevTotals.cap_remb)} en {prevYearLabel}
)}
{/* 6 — Intérêts perçus */}
Intérêts perçus — {netMode ? 'Net' : 'Brut'}
{fmtEUR(netMode ? totals.int_perc_net : totals.int_perc)} {selectedYear && ( )}
{selectedYear && (
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
)}
{/* 7 — XIRR */} {(() => { const rendement = netMode ? plateformeXirr.net : plateformeXirr.brut; const rendementAttendu = netMode ? plateformeXirrAttendu.net : plateformeXirrAttendu.brut; const cutoffLabel = selectedYear ? `31/12/${selectedYear}` : "aujourd'hui"; const periodLabel = selectedYear ? `Sur l'année ${selectedYear}` : "Depuis le début"; const xirrExplication = `XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` + `des prêts de cette sélection (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` + `Chaque flux est pondéré par sa date exacte.\n\n` + (plateformeXirr.estime ? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé au ${cutoffLabel} — c'est donc une estimation, pas un taux définitif.` : 'Tous les prêts de cette sélection sont soldés : taux définitif.'); const xirrAttenduExplication = `XIRR attendu (théorique) : comme le taux de rendement interne affiché par les plateformes de crowdlending ` + `(ex. "Mon taux de rendement interne" chez Bienprêter, le %/an affiché chez La Première Brique), ce calcul suppose ` + `que toutes les échéances prévues (capital + intérêts) des prêts encore en cours seront honorées exactement à la ` + `date prévue par l'échéancier — contrairement au XIRR ci-dessus, qui valorise prudemment le capital restant dû ` + `"comme racheté au ${cutoffLabel}", sans compter les intérêts futurs.\n\n` + `C'est donc une projection optimiste : elle ne tient pas compte d'un éventuel retard ou défaut de paiement ` + `(voir le KPI "Capital en risque" pour ce risque). Si un prêt n'a pas d'échéancier prévisionnel en base, il est ` + `valorisé de la même façon que dans le XIRR réel.`; return (
XIRR — {netMode ? 'Net' : 'Brut'} {plateformeXirr.estime && rendement !== null ? (estimé) : ''}
= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }} > {rendement !== null ? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %` : 'Aucun remboursement'}
{periodLabel}
{rendementAttendu !== null && (
Attendu : {(rendementAttendu * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %
)}
); })()}
} {/* ── Onglets ── */}
{objectifsActifs && ( )}
{/* ====== ONGLET REMBOURSEMENTS ====== */} {activeTab === 'remboursements' && (() => { const ICONS_BASE = '/api/icons-files/'; const currentYear = new Date().getFullYear(); const currentMonth = new Date().getMonth() + 1; const displayYear = selectedYear ? Number(selectedYear) : currentYear; const MOIS_LONG = ['Jan.','Fév.','Mars','Avr.','Mai','Juin','Juil.','Août','Sep.','Oct.','Nov.','Déc.']; const hexToRgba = (hex, a) => { if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`; const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); return `rgba(${r},${g},${b},${a})`; }; const RIcon = ({ name, size = 26, active }) => { const filename = libIcons[name]; if (filename) return ( ); return ; }; const getPfuReduction = (yr) => { if (!pfuRates?.length) return 0.3; const r = pfuRates.find(p => p.annee === yr) ?? pfuRates.reduce((best, p) => p.annee > best.annee ? p : best, pfuRates[0]); return r ? (r.prelev_sociaux + r.impot_revenu) / 100 : 0.3; }; // Investissements filtrés const filteredInvIds = new Set(filteredRows.map(r => r.id)); const multiHolder = filteredPlatIds.size > 1; // Map plateforme_id → nom détenteur const detenteurByPlatId = {}; for (const pid of filteredPlatIds) { const p = plats.find(pl => pl.id === pid); detenteurByPlatId[pid] = p?.investisseur_nom || String(pid); } // Agréger rembs réels par investissement par mois-key const rembByInvByMonth = {}; for (const rb of allRembs) { if (!filteredInvIds.has(rb.investissement_id)) continue; const d = rb.date_remb?.slice(0, 10); if (!d) continue; if (selectedYear && d.slice(0, 4) !== selectedYear) continue; const moisStr = d.slice(0, 7); const id = rb.investissement_id; if (!rembByInvByMonth[id]) rembByInvByMonth[id] = {}; if (!rembByInvByMonth[id][moisStr]) rembByInvByMonth[id][moisStr] = { interets_bruts: 0, interets_nets: 0, cashback: 0, capital: 0 }; rembByInvByMonth[id][moisStr].interets_bruts += rb.interets_bruts || 0; rembByInvByMonth[id][moisStr].interets_nets += rb.interets_nets || 0; rembByInvByMonth[id][moisStr].cashback += rb.cashback || 0; rembByInvByMonth[id][moisStr].capital += rb.capital || 0; } // Agréger projections par investissement par mois-key const simulByInvByMonth = {}; for (const sr of allSimulRembs) { if (!filteredInvIds.has(sr.investissement_id)) continue; const d = sr.date_prevue?.slice(0, 10); if (!d) continue; if (selectedYear && d.slice(0, 4) !== selectedYear) continue; const moisStr = d.slice(0, 7); const id = sr.investissement_id; if (!simulByInvByMonth[id]) simulByInvByMonth[id] = {}; if (!simulByInvByMonth[id][moisStr]) simulByInvByMonth[id][moisStr] = { capital_prevu: 0, interets_prevus: 0 }; simulByInvByMonth[id][moisStr].capital_prevu += sr.capital_prevu || 0; simulByInvByMonth[id][moisStr].interets_prevus += sr.interets_prevus || 0; } // buildCellValue : renvoie { value, projected } | null const buildCellValue = (invId, mIdx) => { const m = mIdx + 1; const moisStr = `${displayYear}-${String(m).padStart(2, '0')}`; const isFuture = displayYear > currentYear || (displayYear === currentYear && m > currentMonth); const isCurrent = displayYear === currentYear && m === currentMonth; const pfu = getPfuReduction(displayYear); if (isFuture) { if (!rembShowProjected) return null; const proj = simulByInvByMonth[invId]?.[moisStr]; if (!proj) return null; let v = 0; if (rembInclureInterets) v += netMode ? proj.interets_prevus * (1 - pfu) : proj.interets_prevus; if (rembInclureCapital) v += proj.capital_prevu ?? 0; return v > 0 ? { value: v, projected: true } : null; } if (isCurrent) { const remb = rembByInvByMonth[invId]?.[moisStr]; const proj = simulByInvByMonth[invId]?.[moisStr]; let real = 0; if (rembShowActual && remb) { if (rembInclureInterets) real += netMode ? remb.interets_nets : remb.interets_bruts; if (rembInclureCashback) real += remb.cashback ?? 0; if (rembInclureCapital) real += remb.capital ?? 0; } let projAmt = 0; // simul/all ne filtre pas NOT EXISTS → guard real===0 obligatoire pour éviter le doublon if (rembShowProjected && proj && real === 0) { if (rembInclureInterets) projAmt += netMode ? proj.interets_prevus * (1 - pfu) : proj.interets_prevus; if (rembInclureCapital) projAmt += proj.capital_prevu ?? 0; } const val = real + projAmt; return val > 0 ? { value: val, projected: projAmt > 0 && real === 0 } : null; } // Mois passé if (!rembShowActual) return null; const remb = rembByInvByMonth[invId]?.[moisStr]; if (!remb) return null; let v = 0; if (rembInclureInterets) v += netMode ? remb.interets_nets : remb.interets_bruts; if (rembInclureCashback) v += remb.cashback ?? 0; if (rembInclureCapital) v += remb.capital ?? 0; return v > 0 ? { value: v, projected: false } : null; }; // Construire la grille const makeGrid = (invs) => invs .map(r => ({ inv: r, months: Array.from({ length: 12 }, (_, i) => buildCellValue(r.id, i)), })) .filter(g => g.months.some(v => v !== null)) .sort((a, b) => (a.inv.date_souscription || '') < (b.inv.date_souscription || '') ? -1 : 1); // Grouper par détenteur si détaillé const groups = (() => { if (rembDetaille && multiHolder) { const byPlatId = {}; for (const pid of filteredPlatIds) { const invs = filteredRows.filter(r => r.plateforme_id === pid); const grid = makeGrid(invs); if (grid.length > 0) byPlatId[pid] = { label: detenteurByPlatId[pid], grid }; } return Object.entries(byPlatId); } return [[null, { label: null, grid: makeGrid(filteredRows) }]]; })(); const allGridRows = groups.flatMap(([, g]) => g.grid); const monthTotals = Array.from({ length: 12 }, (_, i) => allGridRows.reduce((s, r) => s + (r.months[i]?.value ?? 0), 0)); const grandTotal = monthTotals.reduce((s, v) => s + v, 0); const visibleYearsR = availableYearsAsc.length ? availableYearsAsc.slice(windowStart, windowStart + 3) : [currentYear]; const canPrevR = windowStart > 0; const canNextR = windowStart + 3 < availableYearsAsc.length; const colClass = (i, projected = false) => `tip-td-num${projected ? ' tip-projected' : ''}${displayYear === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`; return (
{rembInclureInterets && ( {netMode ? 'Intérêts nets' : 'Intérêts bruts'} )} {rembInclureCapital && ( Capital )} {rembInclureCashback && ( Cashback )} {!rembInclureInterets && !rembInclureCapital && !rembInclureCashback && ( — )} · {selectedYear || 'Toutes les années'}
{fmtEUR(grandTotal)}
{/* Bouton Intérêts */} {/* Bouton Capital */} {/* Bouton Cashback */} {/* Sélecteur années */}
{visibleYearsR.map(y => ( ))}
{allGridRows.length === 0 ? (
Aucun remboursement{selectedYear ? ` en ${selectedYear}` : ''}.
) : (
{MOIS_LONG.map((m, i) => ( ))} {groups.map(([pid, { label, grid }]) => ( {label && ( )} {grid.map(({ inv, months }) => { const rowTotal = months.reduce((s, v) => s + (v?.value ?? 0), 0); return ( navigate(`/investissements/${inv.id}`, { state: { from: { path: location.pathname, search: location.search, label: 'Plateformes' } } })}> {(() => { const firstNonNull = months.findIndex(v => v !== null); const lastNonNull = months.reduce((last, v, i) => v !== null ? i : last, -1); return months.map((v, mi) => { const isAfter = inv.statut === 'rembourse' && (() => { const lastDate = lastRembDateMap[inv.id]; if (!lastDate) return lastNonNull >= 0 && mi > lastNonNull; const lastYear = Number(lastDate.slice(0, 4)); const lastMo = Number(lastDate.slice(5, 7)) - 1; if (lastYear < displayYear) return true; if (lastYear === displayYear) return mi > lastMo; return false; })(); const isBefore = firstNonNull > 0 && mi < firstNonNull; const isGrayed = (isAfter || isBefore) && !v; const curClass = displayYear === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : ''; if (isGrayed) return ); }); })()} ); })} ))} ))}
{displayYear}
Investissement {multiHolder && ( )} Statut{m}Total
{label}
{inv.nom_projet || '—'} {fmtStatut(inv.statut)} ; return ( {v ? fmtEUR(v.value) : —} {rowTotal > 0 ? fmtEUR(rowTotal) : —}
Total {monthTotals.map((v, i) => ( {v > 0 ? fmtEUR(v) : —} {grandTotal > 0 ? fmtEUR(grandTotal) : —}
)} {/* ── Sélecteur Reçu / Projeté ── */}
{[ { key: 'actual', label: 'Reçu', active: rembShowActual, toggle: () => setRembShowActual(v => !v) }, { key: 'projected', label: 'Projeté', active: rembShowProjected, toggle: () => setRembShowProjected(v => !v) }, ].map(btn => ( ))}
); })()} {/* ====== ONGLET MOIS EN COURS ====== */} {activeTab === 'mois-en-cours' && (
{moisEnCoursCell ? (
Mois en cours {moisEnCoursCell.moisLabel} {moisEnCoursCell.annee}
filteredPlatIds.has(p.id))} onBulkDone={() => Promise.all([load(), loadDashDataAsOf()])} />
) : (
Aucune plateforme sélectionnée.
)}
)} {/* ====== ONGLET DÉPÔTS / RETRAITS ====== */} {activeTab === 'depots-retraits' && (() => { const currentYear = new Date().getFullYear(); const currentMonth = new Date().getMonth() + 1; const displayYear = selectedYear ? Number(selectedYear) : currentYear; const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; const depotsByMonth = Array(12).fill(0); const retraitsByMonth = Array(12).fill(0); const corrByMonth = Array(12).fill(0); for (const d of allDepots) { if (!filteredPlatIds.has(d.plateforme_id)) continue; if (selectedYear && d.date_operation?.slice(0, 4) !== selectedYear) continue; const m = Number(d.date_operation.slice(5, 7)) - 1; if (d.type === 'depot') depotsByMonth[m] += d.montant || 0; else retraitsByMonth[m] += d.montant || 0; } for (const co of allCorrections) { if (!filteredPlatIds.has(co.plateforme_id)) continue; if (selectedYear && co.date?.slice(0, 4) !== selectedYear) continue; const m = Number(co.date.slice(5, 7)) - 1; corrByMonth[m] += co.montant || 0; } const netByMonth = depotsByMonth.map((v, i) => v - retraitsByMonth[i] + corrByMonth[i]); const totalDepots = depotsByMonth.reduce((s, v) => s + v, 0); const totalRetraits = retraitsByMonth.reduce((s, v) => s + v, 0); const totalCorr = corrByMonth.reduce((s, v) => s + v, 0); const totalNet = totalDepots - totalRetraits + totalCorr; const visibleYearsDR = availableYearsAsc.length ? availableYearsAsc.slice(windowStart, windowStart + 3) : [currentYear]; const canPrevDR = windowStart > 0; const canNextDR = windowStart + 3 < availableYearsAsc.length; return (

Mouvements de trésorerie — {selectedYear || 'Toutes les années'}

{visibleYearsDR.map(y => ( ))}
{/* Données par détenteur en mode détaillé */} {(() => { // Construire un map platId → { detenteurNom, depotsByM, retraitsByM, corrByM } const multiHolder = filteredPlatIds.size > 1; const showDetaille = drDetaille && multiHolder; // Map platId → détenteur nom (depuis plats) const detenteurByPlatId = {}; for (const pid of filteredPlatIds) { const p = plats.find(pl => pl.id === pid); detenteurByPlatId[pid] = p?.investisseur_nom || String(pid); } // Construire les données par platId const byPlatId = {}; for (const pid of filteredPlatIds) { byPlatId[pid] = { depots: Array(12).fill(0), retraits: Array(12).fill(0), corr: Array(12).fill(0) }; } for (const d of allDepots) { if (!filteredPlatIds.has(d.plateforme_id)) continue; if (selectedYear && d.date_operation?.slice(0, 4) !== selectedYear) continue; const m = Number(d.date_operation.slice(5, 7)) - 1; if (d.type === 'depot') byPlatId[d.plateforme_id].depots[m] += d.montant || 0; else byPlatId[d.plateforme_id].retraits[m] += d.montant || 0; } for (const co of allCorrections) { if (!filteredPlatIds.has(co.plateforme_id)) continue; if (selectedYear && co.date?.slice(0, 4) !== selectedYear) continue; const m = Number(co.date.slice(5, 7)) - 1; byPlatId[co.plateforme_id].corr[m] += co.montant || 0; } // Helpers navigation const makeGoTo = (type, month, platId) => () => { const p = new URLSearchParams({ tab: 'mouvements', type, year: String(displayYear) }); if (month !== null) p.set('month', String(month + 1).padStart(2, '0')); if (platId != null) p.set('plat_ids', String(platId)); else if (filteredPlatIds.size > 0) p.set('plat_ids', [...filteredPlatIds].join(',')); navigate('/depots-retraits?' + p.toString()); }; // Rendu d'un groupe de 3 lignes (depot/retrait/corr) pour un platId donné (null = consolidé) const renderGroup = (pid, key) => { const data = pid != null ? byPlatId[pid] : { depots: depotsByMonth, retraits: retraitsByMonth, corr: corrByMonth }; const label = pid != null ? detenteurByPlatId[pid] : null; const tDep = data.depots.reduce((s, v) => s + v, 0); const tRet = data.retraits.reduce((s, v) => s + v, 0); const tCor = data.corr.reduce((s, v) => s + v, 0); const hasCorr = data.corr.some(v => v !== 0); const colClass = (i) => `tip-td-num${displayYear === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`; return ( {label && ( {label} )} Dépôts {data.depots.map((v, i) => ( 0 ? ' tip-td-link' : ''}`} onClick={v > 0 ? makeGoTo('depot', i, pid) : undefined} style={v > 0 ? { cursor: 'pointer' } : undefined}> {v > 0 ? fmtEUR(v) : —} ))} 0 ? ' tip-td-link' : ''}`} onClick={tDep > 0 ? makeGoTo('depot', null, pid) : undefined} style={tDep > 0 ? { cursor: 'pointer' } : undefined}> {tDep > 0 ? fmtEUR(tDep) : —} Retraits {data.retraits.map((v, i) => ( 0 ? ' tip-td-link' : ''}`} onClick={v > 0 ? makeGoTo('retrait', i, pid) : undefined} style={v > 0 ? { cursor: 'pointer' } : undefined}> {v > 0 ? <>−{fmtEUR(v)} : —} ))} 0 ? ' tip-td-link' : ''}`} onClick={tRet > 0 ? makeGoTo('retrait', null, pid) : undefined} style={tRet > 0 ? { cursor: 'pointer' } : undefined}> {tRet > 0 ? <>−{fmtEUR(tRet)} : —} {hasCorr && ( Corrections {data.corr.map((v, i) => ( {v !== 0 ? <>{v < 0 ? '−' : '+'}{fmtEUR(Math.abs(v))} : —} ))} {tCor !== 0 ? <>{tCor < 0 ? '−' : '+'}{fmtEUR(Math.abs(tCor))} : —} )} ); }; return (
{MOIS_LONG.map((m, i) => ( ))} {showDetaille ? [...filteredPlatIds].map(pid => renderGroup(pid, pid)) : renderGroup(null, 'consolidated') } {netByMonth.map((v, i) => ( ))}
{displayYear}
Mouvement {multiHolder && ( )} {m}Total
Net {v !== 0 ? fmtEUR(v) : —} {totalNet !== 0 ? fmtEUR(totalNet) : —}
); })()}
); })()} {/* ====== ONGLET VISION MENSUELLE ====== */} {activeTab === 'capital-investi' && (() => { const currentYear = new Date().getFullYear(); const visibleYears = availableYearsAsc.length ? availableYearsAsc.slice(windowStart, windowStart + 3) : [currentYear]; const canPrev = windowStart > 0; const canNext = windowStart + 3 < availableYearsAsc.length; return (

Capital investi par investissement — {selectedYear || 'Toutes les années'}

{visibleYears.map(y => ( ))}
); })()} {/* ====== ONGLET INVESTISSEMENTS ====== */} {activeTab === 'investissements' && (

Investissements {selectedYear && — {selectedYear}}

{chartRows.length} investissement{chartRows.length !== 1 ? 's' : ''}
{!chartRows.length ? (
{loading ? 'Chargement…' : 'Aucun investissement'}
) : ( <> {multiDetenteur && } {pagedChartRows.map(r => { const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); const capRemb = capRembParInv[r.id] || 0; const capRestant = Math.max(0, capInv - capRemb); const interets = netMode ? (rembParInv[r.id]?.interets_nets || 0) : (rembParInv[r.id]?.interets_bruts || 0); return ( navigate(`/investissements/${r.id}`, { state: { from: { path: location.pathname, search: location.search, label: 'Plateformes' } } })}> {multiDetenteur && } ); })}
ProjetDétenteurDate de souscription Date cible Montant Capital restant Intérêts ({netMode ? 'Net' : 'Brut'}) Statut
{r.nom_projet}{(() => { const inv = investisseurs.find(i => i.id === r.investisseur_id); return inv ? memberLabel(inv) : '—'; })()}{fmtDate(r.date_souscription)} {fmtDate(r.date_cible)} {fmtEUR(r.montant_investi)} {capRestant > 0 ? fmtEUR(capRestant) : —} {interets > 0 ? fmtEUR(interets) : —} {fmtStatut(r.statut)}
)}
)} {/* ====== ONGLET MOUVEMENTS PORTE-MONNAIE ====== */} {activeTab === 'mouvements-pm' && (
{/* Colonne gauche 2/3 */}

Mouvements Porte-monnaie

dlBlob(mouvToCSV(mvTableRows), `Mouvements Porte-monnaie ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')} onXLS={() => dlBlob(mouvToXLS(mvTableRows, multiDetenteur), `Mouvements Porte-monnaie ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} onJSON={() => dlBlob(mouvToJSON(mvTableRows), `Mouvements Porte-monnaie ${timestampSuffix()}.json`, 'application/json')} /> setMvCorrPickerOpen(true)} />
{/* Filtres */}
{selectedPlatName || '—'}
{/* Tableau */}
{multiDetenteur && } {mvTableRows.length === 0 && ( )} {mvPagedTableRows.map(r => { if (r.type === 'correction') { const isPos = r.montant >= 0; return ( setMvSelectedRow(String(mvSelectedRow?.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 ( setMvSelectedRow(mvSelectedRow?.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 ( setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)} style={{ cursor: 'pointer' }}> {multiDetenteur && } ); } if (r.type === 'souscription') { return ( setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)} style={{ cursor: 'pointer' }}> {multiDetenteur && } ); } const isAuto = r.source === 'auto_remboursement'; return ( setMvSelectedRow(mvSelectedRow?.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 */} {mvTableRows.length > 0 && (
navigate(`/remboursements?edit-remb=${r._rembId}`)} onViewInvestissement={r => navigate(`/investissements/${r._invId}`)} />
)}
)} {/* ====== ONGLET OBJECTIFS ====== */} {objectifsActifs && activeTab === 'objectifs' && (
{/* Objectifs de capital investi : définis par plateforme dans Paramétrage > Fixation des objectifs. Ici, lecture seule — comparaison du capital réellement encore investi (encours) à l'objectif fixé, filtrée sur la plateforme sélectionnée le cas échéant. */} i.id) : [activeId]} plateformeIds={filteredPlatIds} title={selectedPlatName ? `Suivi de l'objectif de capital investi — ${selectedPlatName}${showMultiDetenteurBreakdown ? ' (vision multi détenteur)' : ''}` : 'Suivi de l\'objectif de capital investi'} /> {/* Plateforme détenue par plusieurs personnes et vue "Multi-détenteur" active : la table ci-dessus agrège tous les détenteurs — on ajoute un tableau complémentaire par détenteur, au même format, pour le détail. */} {showMultiDetenteurBreakdown && detenteurOptions .filter(opt => opt.id !== 'all') .map(opt => { const platIdsDet = platIdsByDetenteur[opt.id] || new Set(); return ( platIdsDet.has(inv.plateforme_id))} remboursementsCapital={remboursementsCapitalForObjectifs} reinvestissements={allReinvests} simulRembs={allSimulRembs} scopeInvestisseurIds={[Number(opt.id)]} plateformeIds={platIdsDet} title={`Suivi de l'objectif de capital investi — ${selectedPlatName} — ${opt.label}`} /> ); })}
)} {/* Modal création / édition d'un mouvement (onglet "Mouvements Porte-monnaie") */}
} >
{mvErr &&
{mvErr}
}
setMvForm({ ...mvForm, date_operation: e.target.value })} />
setMvForm({ ...mvForm, montant: e.target.value })} />
setMvForm({ ...mvForm, libelle: e.target.value })} placeholder="Ex. Virement initial" />
setMvForm({ ...mvForm, reference: e.target.value })} />
setMvForm({ ...mvForm, notes: e.target.value })} />
{mvOpenMenu && ( <>
setMvOpenMenu(null)} />
{mvOpenMenu.row.type !== 'correction' && ( )} {mvOpenMenu.row.type !== 'correction' && mvOpenMenu.row.source !== 'auto_remboursement' && ( )}
)} { setMvCorrModal(m => ({ ...m, open: false })); load(); }} onConfirm={mvHandleCorrectionConfirm} /> setMvCorrPickerOpen(false)} onContinue={mvOpenCorrectionManual} plateformes={plats.filter(p => filteredPlatIds.has(p.id))} multiDetenteur={multiDetenteur} defaultPlateformeId={filteredPlatIds.size === 1 ? [...filteredPlatIds][0] : undefined} /> {mvDeleteConfirm && ( setMvDeleteConfirm(null)} /> )} {/* ── Modal saisie/édition remboursement (section "Mois en cours") ── */} setRembForm(f => ({ ...f, ...patch }))} pfuRates={pfuRates} comptesInvestisseur={comptesRembInvestisseur} investissements={rembInv ? [rembInv] : []} plateformes={plats} locked /> ); }