3125 lines
160 KiB
React
3125 lines
160 KiB
React
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
|
||
/>
|
||
</>
|
||
);
|
||
}
|