Files
crowdlending-app/frontend/src/pages/Plateformes.jsx
T
2026-06-18 08:45:23 +02:00

1567 lines
80 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import InvChart from '../components/InvChart.jsx';
import InvMensuelTable from '../components/InvMensuelTable.jsx';
import { fmtEUR, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
const LOGOS_BASE = '/api/logos/';
/* ── TrendBadge ── */
function TrendBadge({ current, prev, invert = false }) {
if (prev == null || prev === 0) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<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 } = 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);
/* ── É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);
/* ── 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); }
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
/* ── 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]);
/* ── 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]);
/* ── 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]);
const netMode = displayMode === 'net';
const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1;
if (!loading && platOptions.length === 0) {
return (
<>
<div className="topbar">
<h2><PageIcon name="plateforme" />Plateformes</h2>
</div>
<EmptyState />
</>
);
}
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(5, 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>
<TrendBadge current={totals.encours} prev={prevTotals.encours} />
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.encours)} en {prevYearLabel}
</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>
{totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />}
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.defaut)} en {prevYearLabel}
</div>
</div>
{/* 3 — 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>
<TrendBadge current={totals.investi} prev={prevTotals.investi} />
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.investi)} en {prevYearLabel}
</div>
</div>
{/* 4 — 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>
<TrendBadge current={totals.cap_remb} prev={prevTotals.cap_remb} />
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.cap_remb)} en {prevYearLabel}
</div>
</div>
{/* 5 — 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>
<TrendBadge
current={netMode ? totals.int_perc_net : totals.int_perc}
prev={netMode ? prevTotals.int_perc_net : prevTotals.int_perc}
/>
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
</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 === '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>
</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 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>
<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>
<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>
)}
</>
);
}