Files
crowdlending-app/frontend/src/pages/Plateformes.jsx
T
2026-08-21 15:02:55 +02:00

3125 lines
160 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600, whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
/* ── 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 (
<div ref={ref} style={{ position: 'relative' }}>
<div
onClick={() => 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',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
Plateforme
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{selected?.icon_filename && (
<img src={`${LOGOS_BASE}${selected.icon_filename}`}
width={28} height={28}
style={{ objectFit: 'contain', borderRadius: 4, background: 'rgba(255,255,255,0.12)', padding: 2, flexShrink: 0 }}
alt="" />
)}
<span style={{ color: '#fff', fontSize: '1.1rem', fontWeight: 700, lineHeight: 1.2 }}>
{selected?.nom || '—'}
</span>
</div>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
maxHeight: 280, overflowY: 'auto',
}}>
{platOptions.map(p => (
<div
key={p.nom}
onClick={() => { 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 ? (
<img src={`${LOGOS_BASE}${p.icon_filename}`}
width={20} height={20}
style={{ objectFit: 'contain', flexShrink: 0, opacity: 0.8 }}
alt="" />
) : (
<span style={{ width: 20, height: 20, borderRadius: 4, background: 'var(--surface-2)', flexShrink: 0, display: 'inline-block' }} />
)}
<span>{p.nom}</span>
</div>
))}
</div>
)}
</div>
);
}
/* ── 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 (
<div ref={ref} style={{ position: 'relative' }}>
<div
onClick={() => 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',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
Détenteur
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<span style={{ color: '#fff', fontSize: '1rem', fontWeight: 700, lineHeight: 1.2 }}>
{selected?.label || '—'}
</span>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
}}>
{detenteurOptions.map(d => (
<div
key={d.id}
onClick={() => { 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}
</div>
))}
</div>
)}
</div>
);
}
/* ── 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 (
<div ref={ref} style={{ position: 'relative' }}>
<div
onClick={() => 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',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
Période
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<span style={{ color: '#fff', fontSize: isGlobal ? '1rem' : '1.8rem', fontWeight: 700, lineHeight: 1.1 }}>
{displayLabel}
</span>
</div>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
}}>
{options.map(opt => (
<div
key={opt.value}
onClick={() => { 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}
</div>
))}
</div>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
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 (
<>
<div className="topbar">
<h2><PageIcon name="plateforme" />Plateformes</h2>
</div>
<EmptyState />
</>
);
}
if (!loading && platOptions.length === 0) {
return (
<>
<div className="topbar">
<h2><PageIcon name="plateforme" />Plateformes</h2>
</div>
<EmptyState
icon="📊"
title="Aucun investissement pour le moment"
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez un investissement pour voir apparaître les données de vos plateformes."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
</>
);
}
return (
<>
<div className="topbar">
<h2><PageIcon name="plateforme" />Plateformes</h2>
</div>
{/* ── Graphiques + Sélecteurs ── */}
{!listFocused && <div className="charts-row">
<InvChart
rows={chartRows}
remboursements={allRembs}
reinvestissements={allReinvests}
platYear={selectedYear}
/>
{/* ── Panneau sélecteurs (remplace DistributionChart) ── */}
<div className="dist-chart-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{platOptions.length > 0 && (
<PlatSelector
platOptions={platOptions}
selectedPlatName={selectedPlatName}
onSelect={nom => setSelectedPlatName(nom)}
/>
)}
{detenteurOptions && (
<DetenteurSelector
detenteurOptions={detenteurOptions}
selectedId={selectedDetenteurId}
onSelect={setSelectedDetenteurId}
/>
)}
<AnneeSelector
availableYears={availableYears}
selectedYear={selectedYear}
onSelect={setSelectedYear}
/>
</div>
</div>}
{/* ── KPIs ── */}
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(7, 1fr)' }}>
{/* 1 — Capital investi */}
<div className="kpi">
<div className="label">Capital investi</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.encours)}</span>
{selectedYear && <TrendBadge current={totals.encours} prev={prevTotals.encours} />}
</div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.encours)} en {prevYearLabel}
</div>
)}
{objectifsActifs && kpiEcartCapitalObjectif != null && (
<div style={{
fontSize: '0.8em', marginTop: 4, fontWeight: 600,
color: Math.abs(kpiEcartCapitalObjectif) < 0.01 ? 'var(--success)' : kpiEcartCapitalObjectif >= 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}`}
</div>
)}
</div>
{/* 2 — Investissements à risque */}
<div className="kpi">
<div className="label">Investissements à risque</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.defaut > 0 ? 'danger' : ''}>
{fmtEUR(totals.defaut)}
</span>
{selectedYear && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />}
</div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.defaut)} en {prevYearLabel}
</div>
)}
</div>
{/* 3 — Porte-monnaie */}
<div className="kpi" title="Cash disponible sur les porte-monnaie des plateformes (hors compte courant)">
<div className="label">Porte-monnaie</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(soldePortefeuille)}</span>
{selectedYear && <TrendBadge current={soldePortefeuille} prev={prevSoldePortefeuille} />}
</div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevSoldePortefeuille)} en {prevYearLabel}
</div>
)}
</div>
{/* 4 — Investissements depuis le début */}
<div className="kpi">
<div className="label">Investissements depuis le début</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span>
{selectedYear && <TrendBadge current={totals.investi} prev={prevTotals.investi} />}
</div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.investi)} en {prevYearLabel}
</div>
)}
</div>
{/* 5 — Capital remboursé */}
<div className="kpi">
<div className="label">Capital remboursé</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cap_remb)}</span>
{selectedYear && <TrendBadge current={totals.cap_remb} prev={prevTotals.cap_remb} />}
</div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.cap_remb)} en {prevYearLabel}
</div>
)}
</div>
{/* 6 — Intérêts perçus */}
<div className="kpi">
<div className="label">Intérêts perçus — {netMode ? 'Net' : 'Brut'}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>
{fmtEUR(netMode ? totals.int_perc_net : totals.int_perc)}
</span>
{selectedYear && (
<TrendBadge
current={netMode ? totals.int_perc_net : totals.int_perc}
prev={netMode ? prevTotals.int_perc_net : prevTotals.int_perc}
/>
)}
</div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
</div>
)}
</div>
{/* 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 (
<div className="kpi">
<div className="label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span>
XIRR — {netMode ? 'Net' : 'Brut'}
{plateformeXirr.estime && rendement !== null ? <span className="text-muted"> (estimé)</span> : ''}
</span>
<span
className="cell-tooltip tooltip-down"
data-tooltip={xirrExplication}
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span
style={{ fontSize: '1.35rem', fontWeight: 700, color: rendement !== null ? (rendement >= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}
>
{rendement !== null
? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %`
: 'Aucun remboursement'}
</span>
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{periodLabel}
</div>
{rendementAttendu !== null && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 6 }}>
<span style={{
fontSize: '0.78em', fontWeight: 600, padding: '2px 8px', borderRadius: 20,
background: 'rgba(124,58,237,0.10)', color: '#7c3aed', whiteSpace: 'nowrap',
}}>
Attendu : {(rendementAttendu * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %
</span>
<span
className="cell-tooltip tooltip-down"
data-tooltip={xirrAttenduExplication}
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</span>
</div>
)}
</div>
);
})()}
</div>}
{/* ── Onglets ── */}
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`}
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
<button className={`dr-tab${activeTab === 'mois-en-cours' ? ' active' : ''}`}
onClick={() => setActiveTab('mois-en-cours')}>Mois en cours</button>
<button className={`dr-tab${activeTab === 'depots-retraits' ? ' active' : ''}`}
onClick={() => setActiveTab('depots-retraits')}>Dépôts / Retraits</button>
<button className={`dr-tab${activeTab === 'capital-investi' ? ' active' : ''}`}
onClick={() => setActiveTab('capital-investi')}>Capital investi</button>
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
onClick={() => setActiveTab('investissements')}>Investissements</button>
<button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`}
onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button>
{objectifsActifs && (
<button className={`dr-tab${activeTab === 'objectifs' ? ' active' : ''}`}
onClick={() => setActiveTab('objectifs')}>Objectifs</button>
)}
</div>
{/* ====== 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 (
<img src={ICONS_BASE + filename} className="app-lib-icon" width={size} height={size}
aria-hidden="true"
style={{ opacity: active ? 1 : 0.35, display: 'block', transition: 'opacity .15s' }} />
);
return <span style={{ width: size, height: size, display: 'block', borderRadius: 4,
background: 'var(--text-muted)', opacity: active ? 0.55 : 0.2, transition: 'opacity .15s' }} />;
};
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 (
<div style={{ padding: '0 24px' }}>
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
<div className="solde-chart-header" style={{ marginBottom: 16 }}>
<div className="solde-chart-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap', marginBottom: 2 }}>
{rembInclureInterets && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4,
background: hexToRgba(chartInterets, 0.12), borderRadius: 5, padding: '3px 8px' }}>
<span style={{ width: 7, height: 7, borderRadius: 2, background: chartInterets, flexShrink: 0 }} />
<span style={{ fontSize: 13, color: chartInterets, fontWeight: 600 }}>
{netMode ? 'Intérêts nets' : 'Intérêts bruts'}
</span>
</span>
)}
{rembInclureCapital && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4,
background: hexToRgba(chartCapital, 0.12), borderRadius: 5, padding: '3px 8px' }}>
<span style={{ width: 7, height: 7, borderRadius: 2, background: chartCapital, flexShrink: 0 }} />
<span style={{ fontSize: 13, color: chartCapital, fontWeight: 600 }}>Capital</span>
</span>
)}
{rembInclureCashback && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4,
background: hexToRgba(chartCashback, 0.12), borderRadius: 5, padding: '3px 8px' }}>
<span style={{ width: 7, height: 7, borderRadius: 2, background: chartCashback, flexShrink: 0 }} />
<span style={{ fontSize: 13, color: chartCashback, fontWeight: 600 }}>Cashback</span>
</span>
)}
{!rembInclureInterets && !rembInclureCapital && !rembInclureCashback && (
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>—</span>
)}
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
· {selectedYear || 'Toutes les années'}
</span>
</div>
<div className="solde-chart-value">{fmtEUR(grandTotal)}</div>
</div>
<div className="solde-chart-controls" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{/* Bouton Intérêts */}
<button
title={rembInclureInterets ? 'Intérêts inclus — cliquer pour exclure' : 'Cliquer pour inclure les intérêts'}
onClick={() => setRembInclureInterets(v => { const n = !v; try { localStorage.setItem('cl_remb_plat_interets', String(n)); } catch {} return n; })}
style={{ background: rembInclureInterets ? hexToRgba(chartInterets, 0.13) : 'none',
border: '1px solid ' + (rembInclureInterets ? chartInterets : 'transparent'),
borderRadius: 8, padding: '4px 6px', cursor: 'pointer', display: 'flex', alignItems: 'center',
transition: 'background .15s,border-color .15s', marginRight: 2 }}>
<RIcon name="interets" active={rembInclureInterets} />
</button>
{/* Bouton Capital */}
<button
title={rembInclureCapital ? 'Capital inclus — cliquer pour exclure' : 'Cliquer pour inclure le capital'}
onClick={() => setRembInclureCapital(v => { const n = !v; try { localStorage.setItem('cl_remb_plat_capital', String(n)); } catch {} return n; })}
style={{ background: rembInclureCapital ? hexToRgba(chartCapital, 0.13) : 'none',
border: '1px solid ' + (rembInclureCapital ? chartCapital : 'transparent'),
borderRadius: 8, padding: '4px 6px', cursor: 'pointer', display: 'flex', alignItems: 'center',
transition: 'background .15s,border-color .15s', marginRight: 2 }}>
<RIcon name="capital" active={rembInclureCapital} />
</button>
{/* Bouton Cashback */}
<button
title={rembInclureCashback ? 'Cashback inclus — cliquer pour exclure' : 'Cliquer pour inclure le cashback'}
onClick={() => setRembInclureCashback(v => { const n = !v; try { localStorage.setItem('cl_remb_plat_cashback', String(n)); } catch {} return n; })}
style={{ background: rembInclureCashback ? hexToRgba(chartCashback, 0.13) : 'none',
border: '1px solid ' + (rembInclureCashback ? chartCashback : 'transparent'),
borderRadius: 8, padding: '4px 6px', cursor: 'pointer', display: 'flex', alignItems: 'center',
transition: 'background .15s,border-color .15s', marginRight: 2 }}>
<RIcon name="cashback" active={rembInclureCashback} />
</button>
{/* Sélecteur années */}
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w - 1))}
disabled={!canPrevR} style={{ opacity: canPrevR ? 1 : 0.3 }}>‹</button>
{visibleYearsR.map(y => (
<button key={y}
className={`solde-range-btn${selectedYear === String(y) ? ' active' : ''}`}
onClick={() => setSelectedYear(String(y))}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYearsAsc.length - 3), w + 1))}
disabled={!canNextR} style={{ opacity: canNextR ? 1 : 0.3 }}>›</button>
<button
className={`solde-range-btn${!selectedYear ? ' active' : ''}`}
onClick={() => setSelectedYear('')}>
TOUT
</button>
<button type="button" className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
style={{ marginLeft: 4 }}
onClick={() => setListFocused(v => !v)}>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
</div>
</div>
</div>
{allGridRows.length === 0 ? (
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Aucun remboursement{selectedYear ? ` en ${selectedYear}` : ''}.
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" style={{ minWidth: 200 }} />
<th className="tip-th-empty" style={{ minWidth: 80 }} />
<th className="tip-th-year" colSpan={12}>{displayYear}</th>
<th className="tip-th-empty" />
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber">
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
Investissement
{multiHolder && (
<button onClick={() => toggleRembDetaille()}
title={rembDetaille ? 'Vue détaillée — cliquer pour consolider' : 'Vue consolidée — cliquer pour détailler par détenteur'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
background: 'rgba(255,255,255,0.15)', border: '1px solid rgba(255,255,255,0.3)',
borderRadius: 4, padding: '2px 5px', cursor: 'pointer',
fontSize: 10, fontWeight: 600, color: '#fff', letterSpacing: '.03em',
lineHeight: 1.4, whiteSpace: 'nowrap',
}}>
{rembDetaille ? 'Consolidé' : 'Détaillé'}
<span style={{ display: 'inline-flex', transition: 'transform .2s', transform: rembDetaille ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</span>
</button>
)}
</span>
</th>
<th className="tip-th-name" style={{ minWidth: 'unset', position: 'static', fontSize: 'var(--fs-xs)', textAlign: 'left' }}>Statut</th>
{MOIS_LONG.map((m, i) => (
<th key={m} className={`tip-th-month${displayYear === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>{m}</th>
))}
<th className="tip-th-total">Total</th>
</tr>
</thead>
<tbody>
{groups.map(([pid, { label, grid }]) => (
<Fragment key={pid ?? 'all'}>
{label && (
<tr>
<td colSpan={15} style={{ padding: '8px 10px 2px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', borderBottom: '1px solid var(--border)', letterSpacing: '.04em', textTransform: 'uppercase' }}>
{label}
</td>
</tr>
)}
{grid.map(({ inv, months }) => {
const rowTotal = months.reduce((s, v) => s + (v?.value ?? 0), 0);
return (
<tr key={inv.id} className="tip-row-plat" style={{ cursor: 'pointer' }}
onClick={() => navigate(`/investissements/${inv.id}`, { state: { from: { path: location.pathname, search: location.search, label: 'Plateformes' } } })}>
<td className="tip-td-name" style={{ whiteSpace: 'normal', maxWidth: '40ch', wordBreak: 'break-word' }}>
{inv.nom_projet || '—'}
</td>
<td style={{ padding: '8px 10px', whiteSpace: 'nowrap', borderRight: '1px solid var(--border)' }}>
<span className={`badge ${inv.statut}`}>{fmtStatut(inv.statut)}</span>
</td>
{(() => {
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 <td key={mi} className={`tip-td-closed${curClass}`} />;
return (
<td key={mi} className={colClass(mi, v?.projected)}>
{v ? fmtEUR(v.value) : <span className="tip-dash">—</span>}
</td>
);
});
})()}
<td className="tip-td-total">
{rowTotal > 0 ? fmtEUR(rowTotal) : <span className="tip-dash">—</span>}
</td>
</tr>
);
})}
</Fragment>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Total</td>
<td />
{monthTotals.map((v, i) => (
<td key={i} className={colClass(i)}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
</td>
))}
<td className="tip-td-total">
{grandTotal > 0 ? fmtEUR(grandTotal) : <span className="tip-dash">—</span>}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* ── Sélecteur Reçu / Projeté ── */}
<div style={{ display: 'flex', alignItems: 'center', marginTop: 12 }}>
<div style={{
display: 'inline-flex',
background: '#f0f0f0',
borderRadius: 8,
padding: 3,
gap: 2,
flexShrink: 0,
}}>
{[
{ key: 'actual', label: 'Reçu', active: rembShowActual, toggle: () => setRembShowActual(v => !v) },
{ key: 'projected', label: 'Projeté', active: rembShowProjected, toggle: () => setRembShowProjected(v => !v) },
].map(btn => (
<button key={btn.key} onClick={() => btn.toggle()} style={{
border: 'none', cursor: 'pointer', padding: '5px 14px', borderRadius: 6,
fontSize: 'var(--fs-sm)',
fontWeight: btn.active ? 600 : 400,
background: btn.active ? '#ffffff' : 'transparent',
color: btn.active ? '#1a1a2e' : '#9ca3af',
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
transition: 'all .15s', lineHeight: 1.4,
}}>{btn.label}</button>
))}
</div>
</div>
</div>
</div>
);
})()}
{/* ====== ONGLET MOIS EN COURS ====== */}
{activeTab === 'mois-en-cours' && (
<div style={{ padding: '0 24px' }}>
{moisEnCoursCell ? (
<div className="solde-chart-wrap" style={{ padding: '20px 24px 24px' }}>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)' }}>
Mois en cours
</span>
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
{moisEnCoursCell.moisLabel} {moisEnCoursCell.annee}
</span>
</div>
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
<DrillCellPanel
cell={moisEnCoursCell}
alwaysOpen
pfuRates={pfuRates}
activeView={activeView}
activeId={activeId}
onEditRecu={openEditRecu}
onEditProjet={openFromProjet}
refreshKey={drillRefreshKey}
investissements={platRows}
plateformes={plats.filter(p => filteredPlatIds.has(p.id))}
onBulkDone={() => Promise.all([load(), loadDashDataAsOf()])}
/>
</InteretsChartProvider>
</div>
) : (
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
Aucune plateforme sélectionnée.
</div>
)}
</div>
)}
{/* ====== 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 (
<div style={{ padding: '0 24px' }}>
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
<div className="solde-chart-header" style={{ marginBottom: 16 }}>
<div className="solde-chart-info">
<h3 style={{ margin: 0 }}>
Mouvements de trésorerie
<span style={{ marginLeft: 8, fontWeight: 400, color: 'var(--text-muted)', fontSize: '0.85em' }}>— {selectedYear || 'Toutes les années'}</span>
</h3>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w - 1))}
disabled={!canPrevDR} style={{ opacity: canPrevDR ? 1 : 0.3 }}>‹</button>
{visibleYearsDR.map(y => (
<button key={y}
className={`solde-range-btn${selectedYear === String(y) ? ' active' : ''}`}
onClick={() => setSelectedYear(String(y))}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYearsAsc.length - 3), w + 1))}
disabled={!canNextDR} style={{ opacity: canNextDR ? 1 : 0.3 }}>›</button>
<button
className={`solde-range-btn${!selectedYear ? ' active' : ''}`}
onClick={() => setSelectedYear('')}>
TOUT
</button>
<button type="button" className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
style={{ marginLeft: 4 }}
onClick={() => setListFocused(v => !v)}>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
</div>
</div>
</div>
{/* 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 (
<Fragment key={key}>
{label && (
<tr>
<td colSpan={14} style={{ padding: '8px 10px 2px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', borderBottom: '1px solid var(--border)', letterSpacing: '.04em', textTransform: 'uppercase' }}>
{label}
</td>
</tr>
)}
<tr className="tip-row-plat">
<td className="tip-td-name" style={{ fontWeight: 600 }}>Dépôts</td>
{data.depots.map((v, i) => (
<td key={i} className={`${colClass(i)}${v > 0 ? ' tip-td-link' : ''}`}
onClick={v > 0 ? makeGoTo('depot', i, pid) : undefined}
style={v > 0 ? { cursor: 'pointer' } : undefined}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
</td>
))}
<td className={`tip-td-total${tDep > 0 ? ' tip-td-link' : ''}`}
onClick={tDep > 0 ? makeGoTo('depot', null, pid) : undefined}
style={tDep > 0 ? { cursor: 'pointer' } : undefined}>
{tDep > 0 ? fmtEUR(tDep) : <span className="tip-dash">—</span>}
</td>
</tr>
<tr className="tip-row-plat">
<td className="tip-td-name" style={{ fontWeight: 600 }}>Retraits</td>
{data.retraits.map((v, i) => (
<td key={i} className={`${colClass(i)}${v > 0 ? ' tip-td-link' : ''}`}
onClick={v > 0 ? makeGoTo('retrait', i, pid) : undefined}
style={v > 0 ? { cursor: 'pointer' } : undefined}>
{v > 0 ? <>−{fmtEUR(v)}</> : <span className="tip-dash">—</span>}
</td>
))}
<td className={`tip-td-total${tRet > 0 ? ' tip-td-link' : ''}`}
onClick={tRet > 0 ? makeGoTo('retrait', null, pid) : undefined}
style={tRet > 0 ? { cursor: 'pointer' } : undefined}>
{tRet > 0 ? <>−{fmtEUR(tRet)}</> : <span className="tip-dash">—</span>}
</td>
</tr>
{hasCorr && (
<tr className="tip-row-plat">
<td className="tip-td-name" style={{ fontWeight: 600 }}>Corrections</td>
{data.corr.map((v, i) => (
<td key={i} className={`${colClass(i)}${v !== 0 ? ' tip-td-link' : ''}`}
onClick={v !== 0 ? makeGoTo('correction', i, pid) : undefined}
style={v !== 0 ? { cursor: 'pointer' } : undefined}>
{v !== 0 ? <>{v < 0 ? '−' : '+'}{fmtEUR(Math.abs(v))}</> : <span className="tip-dash">—</span>}
</td>
))}
<td className={`tip-td-total${tCor !== 0 ? ' tip-td-link' : ''}`}
onClick={tCor !== 0 ? makeGoTo('correction', null, pid) : undefined}
style={tCor !== 0 ? { cursor: 'pointer' } : undefined}>
{tCor !== 0 ? <>{tCor < 0 ? '−' : '+'}{fmtEUR(Math.abs(tCor))}</> : <span className="tip-dash">—</span>}
</td>
</tr>
)}
</Fragment>
);
};
return (
<div style={{ overflowX: 'auto' }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" style={{ minWidth: 160 }} />
<th className="tip-th-year" colSpan={12}>{displayYear}</th>
<th className="tip-th-empty" />
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber">
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
Mouvement
{multiHolder && (
<button onClick={() => toggleDrDetaille()}
title={drDetaille ? 'Vue détaillée — cliquer pour consolider' : 'Vue consolidée — cliquer pour détailler par détenteur'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
background: 'rgba(255,255,255,0.15)', border: '1px solid rgba(255,255,255,0.3)',
borderRadius: 4, padding: '2px 5px', cursor: 'pointer',
fontSize: 10, fontWeight: 600, color: '#fff', letterSpacing: '.03em',
lineHeight: 1.4, whiteSpace: 'nowrap',
}}>
{drDetaille ? 'Consolidé' : 'Détaillé'}
<span style={{ display: 'inline-flex', transition: 'transform .2s', transform: drDetaille ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</span>
</button>
)}
</span>
</th>
{MOIS_LONG.map((m, i) => (
<th key={m} className={`tip-th-month${displayYear === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>{m}</th>
))}
<th className="tip-th-total">Total</th>
</tr>
</thead>
<tbody>
{showDetaille
? [...filteredPlatIds].map(pid => renderGroup(pid, pid))
: renderGroup(null, 'consolidated')
}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Net</td>
{netByMonth.map((v, i) => (
<td key={i} className={`tip-td-num${displayYear === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v !== 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
</td>
))}
<td className="tip-td-total">
{totalNet !== 0 ? fmtEUR(totalNet) : <span className="tip-dash">—</span>}
</td>
</tr>
</tfoot>
</table>
</div>
);
})()}
</div>
</div>
);
})()}
{/* ====== 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 (
<div style={{ padding: '0 24px' }}>
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
<div className="solde-chart-header">
<div className="solde-chart-info">
<h3 style={{ margin: 0 }}>
Capital investi par investissement
<span style={{ marginLeft: 8, fontWeight: 400, color: 'var(--text-muted)', fontSize: '0.85em' }}>— {selectedYear || 'Toutes les années'}</span>
</h3>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w - 1))}
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}>‹</button>
{visibleYears.map(y => (
<button key={y}
className={`solde-range-btn${selectedYear === String(y) ? ' active' : ''}`}
onClick={() => setSelectedYear(String(y))}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYearsAsc.length - 3), w + 1))}
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}>›</button>
<button
className={`solde-range-btn${!selectedYear ? ' active' : ''}`}
onClick={() => setSelectedYear('')}>
TOUT
</button>
<button type="button" className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
style={{ marginLeft: 4 }}
onClick={() => setListFocused(v => !v)}>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
</div>
</div>
</div>
<InvMensuelTable
rows={filteredRows}
allRembs={allRembs}
allReinvests={allReinvests}
year={selectedYear}
originFrom={{ path: location.pathname, search: location.search, label: 'Plateformes' }}
/>
</div>
</div>
);
})()}
{/* ====== ONGLET INVESTISSEMENTS ====== */}
{activeTab === 'investissements' && (
<div style={{ padding: '0 24px' }}>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Investissements
{selectedYear && <span style={{ marginLeft: 8, fontWeight: 400, color: 'var(--text-muted)', fontSize: '0.85em' }}>— {selectedYear}</span>}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
{chartRows.length} investissement{chartRows.length !== 1 ? 's' : ''}
</span>
<button type="button" className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
onClick={() => setListFocused(v => !v)}>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
</div>
</div>
{!chartRows.length ? (
<div className="text-muted" style={{ padding: '24px 0', textAlign: 'center' }}>
{loading ? 'Chargement…' : 'Aucun investissement'}
</div>
) : (
<>
<table>
<thead>
<tr>
<th>Projet</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Date de souscription</th>
<th className="num">Date cible</th>
<th className="num">Montant</th>
<th className="num">Capital restant</th>
<th className="num">Intérêts ({netMode ? 'Net' : 'Brut'})</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={r.id} style={{ cursor: 'pointer' }}
onClick={() => navigate(`/investissements/${r.id}`, { state: { from: { path: location.pathname, search: location.search, label: 'Plateformes' } } })}>
<td style={{ fontWeight: 500 }}>{r.nom_projet}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{(() => { const inv = investisseurs.find(i => i.id === r.investisseur_id); return inv ? memberLabel(inv) : '—'; })()}</td>}
<td className="num" style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>{fmtDate(r.date_souscription)}</td>
<td className="num" style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>{fmtDate(r.date_cible)}</td>
<td className="num">{fmtEUR(r.montant_investi)}</td>
<td className="num">{capRestant > 0 ? fmtEUR(capRestant) : <span className="text-muted">—</span>}</td>
<td className="num">{interets > 0 ? fmtEUR(interets) : <span className="text-muted">—</span>}</td>
<td><span className={`badge ${r.statut}`}>{fmtStatut(r.statut)}</span></td>
</tr>
);
})}
</tbody>
</table>
<Pagination
page={platInvPage} setPage={setPlatInvPage}
pageSize={platInvPageSize} setPageSize={setPlatInvPageSize}
totalPages={platInvTotalPages} totalItems={platInvTotalItems}
PAGE_SIZES={platInvPageSizes}
/>
</>
)}
</div>
</div>
)}
{/* ====== ONGLET MOUVEMENTS PORTE-MONNAIE ====== */}
{activeTab === 'mouvements-pm' && (
<div className="dr-mouvements-layout">
{/* Colonne gauche 2/3 */}
<div className="dr-mouvements-list">
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Mouvements Porte-monnaie</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
className="icon-btn"
title={mvListFocused ? 'Réduire' : 'Agrandir'}
onClick={() => { const next = !mvListFocused; setMvListFocused(next); setMvPageSize(next ? 25 : 15); }}
>
{mvListFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
<ExportDropdown
disabled={mvTableRows.length === 0}
onCSV={() => 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')}
/>
<AddDropdown
disabled={filteredPlatIds.size === 0}
onNewCorrection={() => setMvCorrPickerOpen(true)}
/>
</div>
</div>
{/* Filtres */}
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
<div style={{ flex: 1, minWidth: 100 }}>
<label>Type de mouvement</label>
<select value={mvFilter.type} onChange={e => setMvFilter({ ...mvFilter, type: e.target.value })}>
<option value="">Tous</option>
<option value="depot">Dépôts</option>
<option value="retrait">Retraits</option>
<option value="correction">Corrections de solde</option>
<option value="remboursement">Remboursements</option>
<option value="bonus">Bonus</option>
<option value="cashback">Cashback</option>
<option value="interets_plateforme">Intérêts plateforme</option>
<option value="souscription">Souscriptions</option>
<option value="prelevement_ir">Prélèvements IR</option>
<option value="prelevement_csg">Prélèvements CSG</option>
<option value="prelevement_local">Prélèvements étranger</option>
</select>
</div>
<div style={{ flex: 2, minWidth: 160 }}>
<label>Plateforme</label>
<div style={{
display: 'flex', alignItems: 'center',
height: 'var(--input-h, 36px)', padding: '0 10px',
borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-muted)',
fontSize: 'var(--fs-sm)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}} title="Déterminée par la plateforme sélectionnée en haut de page">
{selectedPlatName || '—'}
</div>
</div>
<div style={{ flex: 2, minWidth: 160 }}>
<label>Projet</label>
<select value={mvFilter.projet} onChange={e => setMvFilter(f => ({ ...f, projet: e.target.value }))}>
<option value="">Tous</option>
{mvProjets.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div style={{ flex: 1, minWidth: 100 }}>
<label>Année</label>
<select value={mvFilter.year} onChange={e => setMvFilter(f => ({ ...f, year: e.target.value }))}>
<option value="">Toutes</option>
{mvYears.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div style={{ flex: 1, minWidth: 110 }}>
<label>Mois</label>
<select value={mvFilter.month} onChange={e => setMvFilter({ ...mvFilter, month: e.target.value })}>
<option value="">Tous</option>
{MOIS_FR.map((m, i) => <option key={i+1} value={String(i+1).padStart(2,'0')}>{m}</option>)}
</select>
</div>
</div>
{/* Tableau */}
<div style={{ overflowY: 'auto' }}>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Type de mouvement</th><th>Projet</th><th className="num">Montant</th><th className="num" title="Solde du porte-monnaie de la plateforme après ce mouvement — calculé à titre indicatif, pas une donnée officielle">Solde indicatif</th><th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{mvTableRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun mouvement'}
</td></tr>
)}
{mvPagedTableRows.map(r => {
if (r.type === 'correction') {
const isPos = r.montant >= 0;
return (
<tr key={r.id}
className={`dr-row${String(mvSelectedRow?.id) === String(r.id) ? ' dr-row-selected' : ''}`}
onClick={() => setMvSelectedRow(String(mvSelectedRow?.id) === String(r.id) ? null : r)}
style={{ cursor: 'pointer' }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span style={{
display: 'inline-block',
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
padding: '2px 7px', borderRadius: 4,
background: 'color-mix(in srgb, var(--text-muted) 12%, transparent)',
color: 'var(--text-muted)',
}}>correction</span>
</td>
<td className="text-muted">—</td>
<td className={`num ${isPos ? 'success' : 'danger'}`}>
{isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => mvOpenRowMenu(e, r)}>⋮</button>
</td>
</tr>
);
}
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 (
<tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer' }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span style={{
display: 'inline-block',
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
padding: '2px 7px', borderRadius: 4,
background: (isBonus || isCashback)
? 'color-mix(in srgb, var(--warning) 18%, transparent)'
: isInteretsPlateforme
? 'color-mix(in srgb, var(--primary) 18%, transparent)'
: 'color-mix(in srgb, var(--success) 15%, transparent)',
color: (isBonus || isCashback) ? 'var(--warning)' : isInteretsPlateforme ? 'var(--primary)' : 'var(--success)',
}}>{isBonus ? 'bonus' : isCashback ? 'cashback' : isInteretsPlateforme ? 'intérêts plateforme' : 'remboursement'}</span>
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="num success">
+ {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} />
</tr>
);
}
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 (
<tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer' }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span style={{
display: 'inline-block',
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
padding: '2px 7px', borderRadius: 4,
background: `color-mix(in srgb, ${color} 15%, transparent)`,
color,
}}>{label}</span>
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="num danger">
− {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} />
</tr>
);
}
if (r.type === 'souscription') {
return (
<tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer' }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span style={{
display: 'inline-block',
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
padding: '2px 7px', borderRadius: 4,
background: 'color-mix(in srgb, #a855f7 15%, transparent)',
color: '#a855f7',
}}>souscription</span>
{r._isReinvest && (
<span style={{
marginLeft: 6,
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
padding: '1px 5px', borderRadius: 3,
background: 'color-mix(in srgb, var(--primary) 15%, transparent)',
color: 'var(--primary)',
verticalAlign: 'middle',
}}>RÉINVEST.</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="num danger">
− {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} />
</tr>
);
}
const isAuto = r.source === 'auto_remboursement';
return (
<tr key={r.id}
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer', opacity: isAuto ? 0.8 : 1 }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span className={`badge ${r.type}`}>{r.type}</span>
{isAuto && (
<span style={{
marginLeft: 6,
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
padding: '1px 5px', borderRadius: 3,
background: 'color-mix(in srgb, var(--primary) 15%, transparent)',
color: 'var(--primary)',
verticalAlign: 'middle',
}}>AUTO</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className={`num ${r.type === 'depot' ? 'success' : 'danger'}`}
style={ isAuto ? { fontStyle: 'italic' } : undefined }>
{r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
{!isAuto && (
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => mvOpenRowMenu(e, r)}>⋮</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<Pagination
page={mvPage} setPage={setMvPage}
pageSize={mvPageSize} setPageSize={setMvPageSize}
totalPages={mvTotalPages} totalItems={mvTotalItems}
PAGE_SIZES={mvPageSizes}
/>
</div>
</div>
</div>
{/* Colonne droite 1/3 */}
{mvTableRows.length > 0 && (
<div className="dr-mouvements-detail">
<DetailPanel
row={mvSelectedRow}
onEdit={mvOpenEdit}
onDeleteCorrection={mvDeleteCorrection}
onViewRemb={r => navigate(`/remboursements?edit-remb=${r._rembId}`)}
onViewInvestissement={r => navigate(`/investissements/${r._invId}`)}
/>
</div>
)}
</div>
)}
{/* ====== ONGLET OBJECTIFS ====== */}
{objectifsActifs && activeTab === 'objectifs' && (
<div style={{ padding: '0 24px' }}>
{/* 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. */}
<SuiviObjectifsCapital
investissements={investissementsForObjectifs}
remboursementsCapital={remboursementsCapitalForObjectifs}
reinvestissements={allReinvests}
simulRembs={allSimulRembs}
scopeInvestisseurIds={activeView === 'all' ? investisseurs.map(i => 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 (
<SuiviObjectifsCapital
key={opt.id}
investissements={allRows.filter(inv => 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}`}
/>
);
})}
</div>
)}
{/* Modal création / édition d'un mouvement (onglet "Mouvements Porte-monnaie") */}
<Modal
open={mvModalOpen}
title="Modifier le mouvement"
onClose={mvCloseEdit}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button className="danger" onClick={mvOnDelete}>Supprimer</button>
<button onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={mvCloseEdit}>Annuler</button>
<button className="primary" onClick={mvSubmit}>Enregistrer</button>
</div>
</div>
}
>
<form onSubmit={mvSubmit}>
{mvErr && <div className="error">{mvErr}</div>}
<div className="form-grid">
<div>
<label>Plateforme *</label>
<select required value={mvForm.plateforme_id} onChange={e => {
const platId = e.target.value;
setMvForm(f => ({ ...f, plateforme_id: platId, investisseur_id: mvInvestisseurForPlat(platId) }));
}}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
<div>
<label>Date *</label>
<input type="date" required value={mvForm.date_operation} onChange={e => setMvForm({ ...mvForm, date_operation: e.target.value })} />
</div>
<div>
<label>Type *</label>
<select required value={mvForm.type} onChange={e => setMvForm({ ...mvForm, type: e.target.value })}>
<option value="depot">Dépôt</option>
<option value="retrait">Retrait</option>
</select>
</div>
<div>
<label>Montant (€) *</label>
<input type="number" step="0.01" min="0" required value={mvForm.montant} onChange={e => setMvForm({ ...mvForm, montant: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Libellé</label>
<input value={mvForm.libelle} onChange={e => setMvForm({ ...mvForm, libelle: e.target.value })} placeholder="Ex. Virement initial" />
</div>
<div>
<label>Référence</label>
<input value={mvForm.reference} onChange={e => setMvForm({ ...mvForm, reference: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={mvForm.investisseur_id} onChange={e => setMvForm({ ...mvForm, investisseur_id: e.target.value })}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>
{i.nom}{i.is_principal ? ' (principal)' : ''}
</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={mvForm.notes} onChange={e => setMvForm({ ...mvForm, notes: e.target.value })} />
</div>
</div>
</form>
</Modal>
{mvOpenMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setMvOpenMenu(null)} />
<div style={{
position: 'fixed', left: mvOpenMenu.x, top: mvOpenMenu.y,
transform: 'translateX(-100%) translateY(4px)',
zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: '4px 0', minWidth: 140,
}}>
{mvOpenMenu.row.type !== 'correction' && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setMvOpenMenu(null); mvOpenEdit(mvOpenMenu.row); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Modifier
</button>
)}
{mvOpenMenu.row.type !== 'correction' && mvOpenMenu.row.source !== 'auto_remboursement' && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setMvOpenMenu(null); mvOpenCorrection(mvOpenMenu.row); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Correction de solde
</button>
)}
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => {
const row = mvOpenMenu.row;
setMvOpenMenu(null);
if (row.type === 'correction') mvDeleteCorrection(row._correctionId);
else mvDeleteRow(row.id);
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Supprimer
</button>
</div>
</>
)}
<CorrectionModal
open={mvCorrModal.open}
plateforme_nom={mvCorrModal.plateforme_nom}
type_operation={mvCorrModal.type_operation}
computedBalance={mvCorrModal.computedBalance}
date={mvCorrModal.date}
onClose={() => { setMvCorrModal(m => ({ ...m, open: false })); load(); }}
onConfirm={mvHandleCorrectionConfirm}
/>
<CorrectionPickerModal
open={mvCorrPickerOpen}
onClose={() => setMvCorrPickerOpen(false)}
onContinue={mvOpenCorrectionManual}
plateformes={plats.filter(p => filteredPlatIds.has(p.id))}
multiDetenteur={multiDetenteur}
defaultPlateformeId={filteredPlatIds.size === 1 ? [...filteredPlatIds][0] : undefined}
/>
{mvDeleteConfirm && (
<ConfirmModal
open={true}
message={mvDeleteConfirm?.message}
onConfirm={mvDeleteConfirm?.onConfirm}
onCancel={() => setMvDeleteConfirm(null)}
/>
)}
{/* ── Modal saisie/édition remboursement (section "Mois en cours") ── */}
<RembFormModal
open={rembModalOpen}
editingId={rembEditingId}
onClose={closeRembModal}
onSubmit={submitRemb}
onDelete={onDeleteRemb}
confirmingDelete={rembConfirmingDelete}
setConfirmingDelete={setRembConfirmingDelete}
err={rembErr}
form={rembForm}
onFieldChange={setRembField}
onFormPatch={patch => setRembForm(f => ({ ...f, ...patch }))}
pfuRates={pfuRates}
comptesInvestisseur={comptesRembInvestisseur}
investissements={rembInv ? [rembInv] : []}
plateformes={plats}
locked
/>
</>
);
}