Files
crowdlending-app/frontend/src/pages/DepotsRetraits.jsx
T

1512 lines
72 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import SoldeChart from '../components/SoldeChart.jsx';
import DistributionChart from '../components/DistributionChart.jsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import * as XLSX from 'xlsx';
/* ── Helpers export ──────────────────────────────────────────── */
/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
function timestampSuffix() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
/* ── Indicateur de progression ── */
function TrendBadge({ current, prev, invert = false }) {
if (prev == null || prev === 0) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<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>
);
}
function mouvToCSV(rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Date','Plateforme','Type','Montant (€)','Libellé','Référence','Source'];
const data = rows.map(r => [
r.date_operation, r.plateforme_nom||'', r.type,
String(r.montant).replace('.',','), r.libelle||'', r.reference||'', r.source||'',
]);
return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function mouvToXLS(rows, multiDetenteur) {
const data = rows.map(r => ({
'Date': r.date_operation,
'Plateforme': r.plateforme_nom || '',
...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}),
'Type': r.type === 'depot' ? 'Dépôt' : 'Retrait',
'Montant (€)': r.montant,
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Mouvements');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function platToCSV(rows) {
const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0);
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Plateforme','Dépôts (€)','Retraits (€)','Diff. Dépôts vs Retraits (€)','Solde porte-monnaie (€)','Poids (%)'];
const data = rows.map(p => {
const poids = totalSolde !== 0 ? ((p.solde_net / totalSolde) * 100).toFixed(2) : '0.00';
return [
p.nom,
String(p.depots).replace('.',','), String(p.retraits).replace('.',','),
String(p.solde_net).replace('.',','),
String(p.solde_portefeuille ?? 0).replace('.',','),
poids.replace('.',','),
];
});
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function platToXLS(rows) {
const data = rows.map(p => ({
'Plateforme': p.nom,
'Dépôts (€)': p.depots,
'Retraits (€)': p.retraits,
'Net (€)': p.net,
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Plateformes');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function mouvToJSON(rows) {
const data = rows.map(r => ({
date_operation: r.date_operation,
plateforme_nom: r.plateforme_nom || '',
type: r.type,
montant: r.montant,
libelle: r.libelle || null,
reference: r.reference || null,
source: r.source || null,
}));
return JSON.stringify(data, null, 2);
}
function platToJSON(rows) {
const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0);
const data = rows.map(p => ({
plateforme: p.nom,
depots: p.depots,
retraits: p.retraits,
solde_net: p.solde_net,
solde_portefeuille: p.solde_portefeuille ?? 0,
poids_pct: totalSolde !== 0 ? +((p.solde_net / totalSolde) * 100).toFixed(2) : 0,
}));
return JSON.stringify(data, null, 2);
}
function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title="Exporter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
</svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
/* ── Panneau de détail d'un mouvement ────────────────────────── */
function DetailPanel({ row, onEdit, onDeleteCorrection }) {
if (!row) return (
<div className="dr-detail dr-detail-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25, marginBottom: 8 }}>
<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>
<span>Sélectionnez un mouvement</span>
</div>
);
const isCorrection = row.type === 'correction';
const isAuto = row.source === 'auto_remboursement';
const typeLabel = isCorrection ? 'Correction de solde'
: row.type === 'depot' ? 'Dépôt' : 'Retrait';
const editLabel = isCorrection ? 'Modifier la correction de solde'
: row.type === 'depot' ? 'Modifier le dépôt' : 'Modifier le retrait';
const isPos = row.montant >= 0;
const sign = isPos ? '+' : '';
const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`;
const montantColor = undefined;
const fields = [
{ label: 'Plateforme', value: row.plateforme_nom },
{ label: 'Type de mouvement', value: typeLabel },
{ label: 'Montant', value: montantFmt, color: montantColor },
{ label: 'Date', value: fmtDate(row.date_operation) },
!isCorrection && row.libelle && { label: 'Libellé', value: row.libelle },
!isCorrection && row.reference && { label: 'Référence', value: row.reference },
row.notes && { label: 'Notes', value: row.notes },
].filter(Boolean);
return (
<div className="dr-detail">
<div className="dr-detail-title">Détails du mouvement</div>
{isAuto && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: 'color-mix(in srgb, var(--primary) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: 'var(--primary)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
Retrait automatique généré lors d'un remboursement sur compte courant.
Modifiable via la page Remboursements ou depuis la fiche détail de l'investissement.
</span>
</div>
)}
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
<span className="dr-detail-label">{f.label}</span>
<span className="dr-detail-value" style={f.color ? { color: f.color, fontWeight: 600 } : undefined}>{f.value}</span>
</div>
))}
</div>
<div className="dr-detail-footer">
{isAuto ? null : isCorrection ? (
<button className="dr-detail-edit-btn" onClick={() => onDeleteCorrection(row._correctionId)}>
Supprimer la correction
</button>
) : (
<button className="dr-detail-edit-btn" onClick={() => onEdit(row)}>
{editLabel}
</button>
)}
</div>
</div>
);
}
/* ── Constantes ──────────────────────────────────────────────── */
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const empty = { investisseur_id: '', plateforme_id: '', date_operation: today(), type: 'depot', montant: '', libelle: '', reference: '', notes: '' };
/** Modal de correction de solde porte-monnaie (micro-écarts flat_tax) */
function CorrectionModal({ open, onClose, onConfirm,
plateforme_nom, type_operation, computedBalance, date }) {
const isRetrait = type_operation === 'retrait';
// Défaut : 0 pour un retrait (vider le PM), solde calculé pour un dépôt
const [declared, setDeclared] = useState('');
useEffect(() => {
if (open) setDeclared(isRetrait ? '0' : String(Math.round(computedBalance * 100) / 100));
}, [open, isRetrait, computedBalance]);
const diff = Math.round((Number(declared) - computedBalance) * 100) / 100;
const hasDiff = Math.abs(diff) >= 0.005;
const handleConfirm = () => {
onConfirm(hasDiff ? diff : 0);
};
if (!open) return null;
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.4)',
}}>
<div style={{
background: 'var(--surface)', borderRadius: 10,
padding: '24px 28px', width: 420, maxWidth: '90vw',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
border: '1px solid var(--border)',
}}>
<div style={{ marginBottom: 6, fontWeight: 700, fontSize: 'var(--fs-base)' }}>
Vérification du solde porte-monnaie
</div>
<div style={{
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
marginBottom: 18, lineHeight: 1.5,
}}>
Après ce {isRetrait ? 'retrait' : 'dépôt'} sur <strong>{plateforme_nom}</strong>,
quel est le solde indiqué sur la plateforme ?
{' '}Si différent du solde calculé, une correction sera créée automatiquement.
</div>
<div style={{ marginBottom: 12 }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderRadius: 6, marginBottom: 6,
background: 'var(--surface-2)', fontSize: 'var(--fs-sm)',
}}>
<span style={{ color: 'var(--text-muted)' }}>Solde calculé</span>
<span style={{ fontWeight: 600 }}>{Math.round(computedBalance * 100) / 100} </span>
</div>
<label style={{ fontSize: 'var(--fs-sm)', fontWeight: 500, display: 'block', marginBottom: 4 }}>
Solde constaté sur la plateforme ()
</label>
<input
type="number" step="0.01"
value={declared}
onChange={e => setDeclared(e.target.value)}
style={{ width: '100%', fontSize: 'var(--fs-base)' }}
autoFocus
/>
</div>
{hasDiff && (
<div style={{
padding: '8px 12px', borderRadius: 6, marginBottom: 12,
background: diff > 0
? 'color-mix(in srgb, var(--success) 12%, transparent)'
: 'color-mix(in srgb, var(--danger) 12%, transparent)',
border: `1px solid ${diff > 0 ? 'color-mix(in srgb, var(--success) 30%, transparent)' : 'color-mix(in srgb, var(--danger) 30%, transparent)'}`,
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
}}>
Une correction de <strong style={{ color: diff > 0 ? 'var(--success)' : 'var(--danger)' }}>
{diff > 0 ? '+' : ''}{diff}
</strong> sera créée.
</div>
)}
{!hasDiff && declared !== '' && (
<div style={{
padding: '8px 12px', borderRadius: 6, marginBottom: 12,
background: 'color-mix(in srgb, var(--primary) 10%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
}}>
Solde identique au calcul aucune correction nécessaire.
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button onClick={onClose}>Passer</button>
<button className="primary" onClick={handleConfirm}>
{hasDiff ? 'Créer la correction' : 'OK'}
</button>
</div>
</div>
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function DepotsRetraits() {
const { activeId, activeView, investisseurs } = useInvestisseur();
const { search } = useLocation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('plateformes');
const [listFocused, setListFocused] = useState(false);
const [allRows, setAllRows] = useState([]);
const [plats, setPlats] = useState([]);
const [dashData, setDashData] = useState(null);
// Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements
const currentYear = String(new Date().getFullYear());
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' });
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
const [drPlatYear, setDrPlatYear] = useState(currentYear);
const [allRemb, setAllRemb] = useState([]);
const [allInv, setAllInv] = useState([]);
const [allCorrections, setAllCorrections] = useState([]);
const [deleteConfirm, setDeleteConfirm] = useState(null);
/* Lecture des params de navigation depuis la page Plateformes */
useEffect(() => {
const p = new URLSearchParams(search);
if (p.get('tab') !== 'mouvements') return;
setActiveTab('mouvements');
const newFilter = { type: '', plateforme_ids: [], year: '', month: '' };
if (p.get('type')) newFilter.type = p.get('type');
if (p.get('year')) newFilter.year = p.get('year');
if (p.get('month')) newFilter.month = p.get('month');
const platIds = p.get('plat_ids');
if (platIds) newFilter.plateforme_ids = platIds.split(',').filter(Boolean);
setFilter(newFilter);
setDrPlatYear(newFilter.year); // garde l'onglet Plateformes synchronisé avec l'année du drill-down
navigate('/depots-retraits', { replace: true });
}, []); // eslint-disable-line
const [openMenu, setOpenMenu] = useState(null); // { row, x, y }
const [platFilterOpen, setPlatFilterOpen] = useState(false);
const platFilterRef = useRef(null);
useEffect(() => {
if (!platFilterOpen) return;
const h = e => { if (!platFilterRef.current?.contains(e.target)) setPlatFilterOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [platFilterOpen]);
// État du modal de correction solde
const [corrModal, setCorrModal] = useState({
open: false, plateforme_nom: '', type_operation: 'retrait',
computedBalance: 0, date: '', platId: null, investisseurId: null,
});
const load = async () => {
if (!activeId && activeView !== 'all') return;
setLoading(true);
setAllRows([]);
setDashData(null);
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
try {
const [r, p, d, remb, inv, corr] = await Promise.all([
api.get('/depots-retraits', scopeParams),
api.get('/plateformes'),
api.get('/dashboard', scopeParams),
api.get('/remboursements', scopeParams),
api.get('/investissements', scopeParams),
api.get('/corrections', scopeParams),
]);
setAllRows(r); setPlats(p); setDashData(d); setAllRemb(remb); setAllInv(inv); setAllCorrections(corr);
} catch (e) {
console.error('DepotsRetraits load error:', e);
} finally { setLoading(false); }
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
// Ferme tous les menus contextuels au scroll
useEffect(() => {
const closeAll = () => {
setOpenMenu(null);
};
window.addEventListener('scroll', closeAll, true);
return () => window.removeEventListener('scroll', closeAll, true);
}, []);
/* Filtrage */
// kpiRows : plateforme + année sélectionnée (sans filtre type — pour que les KPIs
// affichent toujours dépôts ET retraits même quand un type est actif)
const kpiRows = allRows.filter(r => {
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
if (drPlatYear && r.date_operation.slice(0, 4) !== drPlatYear) return false;
return true;
});
// chartRows : kpiRows + filtre type (pour les graphiques)
const chartRows = kpiRows.filter(r => {
if (filter.type && r.type !== filter.type) return false;
return true;
});
// rows : pour le tableau Mouvements (tous les filtres y compris mois)
const rows = chartRows.filter(r => {
const [y, m] = r.date_operation.split('-');
if (filter.year && y !== filter.year) return false;
if (filter.month && m !== filter.month.padStart(2,'0')) return false;
return true;
});
// Corrections normalisées pour affichage dans le tableau mouvements
const normalizedCorrections = useMemo(() => allCorrections.map(c => ({
id: `corr_${c.id}`,
_correctionId: c.id,
plateforme_id: c.plateforme_id,
plateforme_nom: c.plateforme_nom,
plateforme_detenteur_nom: c.investisseur_nom,
date_operation: c.date,
type: 'correction',
montant: c.montant,
libelle: c.notes || '',
notes: c.notes || '',
source: 'correction',
investisseur_id: c.investisseur_id,
})), [allCorrections]);
// Corrections filtrées (plateforme + année + mois, sans filtre type)
const filteredCorrections = useMemo(() => normalizedCorrections.filter(c => {
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(c.plateforme_id))) return false;
const [y, m] = c.date_operation.split('-');
if (filter.year && y !== filter.year) return false;
if (filter.month && m !== filter.month.padStart(2,'0')) return false;
return true;
}), [normalizedCorrections, filter.plateforme_ids, filter.year, filter.month]);
// tableRows : dépôts/retraits + corrections, triés par date desc
const tableRows = useMemo(() => {
const drPart = filter.type === 'correction' ? [] : rows;
const corrPart = (filter.type === 'depot' || filter.type === 'retrait') ? [] : filteredCorrections;
return [...drPart, ...corrPart].sort((a, b) =>
b.date_operation.localeCompare(a.date_operation) || String(b.id).localeCompare(String(a.id))
);
}, [rows, filteredCorrections, filter.type]);
/* ── Pagination mouvements ── */
const {
pagedItems: pagedTableRows, page: drPage, setPage: setDrPage,
pageSize: drPageSize, setPageSize: setDrPageSize,
totalPages: drTotalPages, totalItems: drTotalItems, PAGE_SIZES,
} = usePagination(tableRows, 'cl_pagesize_dr', [filter]);
// Auto-sélectionne le premier mouvement quand la liste change dans l'onglet mouvements
// (déclaré après `tableRows` pour éviter la temporal dead zone)
useEffect(() => {
if (activeTab === 'mouvements') {
setSelectedRow(prev => {
if (prev && tableRows.find(r => String(r.id) === String(prev.id))) return prev;
return tableRows[0] || null;
});
}
// eslint-disable-next-line
}, [tableRows, activeTab]);
const years = [...new Set(allRows.map(r => r.date_operation.slice(0,4)))].sort().reverse();
/* Données tableau Plateformes — filtrées par drPlatYear si actif */
const drPlatData = useMemo(() => {
if (!drPlatYear) return dashData?.cashByPlatform ?? [];
// Mouvements de l'année sélectionnée (dépôts/retraits de l'année)
const src = allRows.filter(r => r.date_operation.slice(0, 4) === drPlatYear);
const map = {};
for (const r of src) {
const key = String(r.plateforme_id);
if (!map[key]) map[key] = { plateforme_id: r.plateforme_id, nom: r.plateforme_nom, detenteur_nom: r.plateforme_detenteur_nom || null, depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 };
if (r.type === 'depot') map[key].depots += r.montant;
else map[key].retraits += r.montant;
}
// Solde porte-monnaie = cumul jusqu'au 31/12 de l'année sélectionnée
// = dépôts/retraits + remboursements crédités au portefeuille
// Règle fiscale : flat_tax → net_recu (PFU prélevé à la source) ;
// hors France → capital + cashback + interets_bruts (pas de PFU à la source)
const yearEnd = `${drPlatYear}-12-31`;
const cumul = {};
// 1. Dépôts et retraits manuels (hors auto-retraits — générés par remboursements
// sur compte courant, ils ne représentent pas un mouvement du porte-monnaie)
for (const r of allRows) {
if (r.date_operation > yearEnd) continue;
if (r.source === 'auto_remboursement') continue;
const key = String(r.plateforme_id);
cumul[key] = (cumul[key] ?? 0) + (r.type === 'depot' ? r.montant : -r.montant);
}
// 2. Remboursements crédités au portefeuille
// Les remboursements vers compte_courant ne crédient pas le porte-monnaie.
// Pour flat_tax : net_recu (PFU déjà prélevé à la source).
// Pour hors France : capital + cashback + interets_bruts (pas de PFU à la source ;
// interets_bruts est déjà net de la retenue locale si applicable).
for (const r of allRemb) {
if (!r.date_remb || r.date_remb > yearEnd) continue;
if (r.methode_remboursement !== 'portefeuille') continue;
const key = String(r.plateforme_id);
const plat = plats.find(p => p.id === r.plateforme_id);
const credit = plat?.fiscalite === 'flat_tax'
? (r.net_recu ?? 0)
: (r.capital ?? 0) + (r.cashback ?? 0) + (r.interets_bruts ?? 0);
cumul[key] = (cumul[key] ?? 0) + credit;
}
// 3. Déduire le capital investi (capital_total = montant_investi + réinvestissements)
// Le capital revient progressivement via net_recu (étape 2) au fil des remboursements
for (const inv of allInv) {
if (!inv.date_souscription || inv.date_souscription > yearEnd) continue;
const key = String(inv.plateforme_id);
cumul[key] = (cumul[key] ?? 0) - (inv.capital_total ?? inv.montant_investi);
}
return Object.values(map).map(p => {
p.solde_net = p.depots - p.retraits;
p.solde_portefeuille = cumul[String(p.plateforme_id)] ?? 0;
return p;
}).sort((a, b) => b.solde_net - a.solde_net);
}, [allRows, allRemb, allInv, dashData, drPlatYear]);
const drPlatTotals = useMemo(() =>
drPlatData.reduce((acc, p) => {
acc.depots += p.depots;
acc.retraits += p.retraits;
acc.solde_net += p.solde_net;
acc.solde_portefeuille += p.solde_portefeuille ?? 0;
return acc;
}, { depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }),
[drPlatData]
);
// Totaux KPI depuis kpiRows (réagit à plateforme + année)
const totals = kpiRows.reduce((acc, r) => {
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
return acc;
}, { depots: 0, retraits: 0 });
// Solde porte-monnaie pour le KPI :
// - sans filtre année → live depuis le backend (inclut déjà les remboursements au portefeuille)
// - avec filtre année → cumul jusqu'au 31/12 de l'année (dépôts/retraits + remboursements au portefeuille)
const kpiSoldePortefeuille = (() => {
if (!drPlatYear) {
if (filter.plateforme_ids.length === 1 && dashData) {
const p = dashData.cashByPlatform?.find(c => c.plateforme_id === Number(filter.plateforme_ids[0]));
return p?.solde_portefeuille ?? null;
}
return dashData?.solde_portefeuille_total ?? null;
}
const yearEnd = `${drPlatYear}-12-31`;
let balance = 0;
for (const r of allRows) {
if (r.date_operation > yearEnd) continue;
if (r.source === 'auto_remboursement') continue;
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue;
balance += r.type === 'depot' ? r.montant : -r.montant;
}
for (const r of allRemb) {
if (!r.date_remb || r.date_remb > yearEnd) continue;
if (r.methode_remboursement !== 'portefeuille') continue;
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue;
balance += r.net_recu ?? 0;
}
for (const inv of allInv) {
if (!inv.date_souscription || inv.date_souscription > yearEnd) continue;
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(inv.plateforme_id))) continue;
balance -= inv.capital_total ?? inv.montant_investi;
}
return balance;
})();
// Totaux KPI et solde porte-monnaie pour l'année N-1 (TrendBadge)
const prevYear = String(Number(drPlatYear || new Date().getFullYear()) - 1);
const prevTotals = (() => {
const src = allRows.filter(r => r.date_operation.slice(0, 4) === prevYear);
return src.reduce((acc, r) => {
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
return acc;
}, { depots: 0, retraits: 0 });
})();
const prevKpiSoldePortefeuille = (() => {
const yearEnd = `${prevYear}-12-31`;
let balance = 0;
for (const r of allRows) {
if (r.date_operation > yearEnd) continue;
if (r.source === 'auto_remboursement') continue;
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue;
balance += r.type === 'depot' ? r.montant : -r.montant;
}
for (const r of allRemb) {
if (!r.date_remb || r.date_remb > yearEnd) continue;
if (r.methode_remboursement !== 'portefeuille') continue;
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) continue;
balance += r.net_recu ?? 0;
}
for (const inv of allInv) {
if (!inv.date_souscription || inv.date_souscription > yearEnd) continue;
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(inv.plateforme_id))) continue;
balance -= inv.capital_total ?? inv.montant_investi;
}
return balance;
})();
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
/** Investisseur par défaut selon la vue active */
const defaultInvestisseurId = () => {
if (activeView === 'all') {
const principal = investisseurs.find(i => i.is_principal);
return String((principal || investisseurs[0])?.id || '');
}
return String(activeId || '');
};
/** Retourne l'investisseur_id de la plateforme, ou le défaut si absent */
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : defaultInvestisseurId();
};
/* Ouverture auto depuis le bouton global "Nouveau dépôt/retrait" */
const pendingNew = useRef(false);
useEffect(() => {
if (new URLSearchParams(search).get('new') !== '1') return;
navigate('/depots-retraits', { replace: true });
if (plats.length > 0) {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
} else {
pendingNew.current = true;
}
}, [search]); // eslint-disable-line
useEffect(() => {
if (!pendingNew.current || !plats.length) return;
pendingNew.current = false;
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
}, [plats]); // eslint-disable-line
/* CRUD */
const openNew = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null); setModalOpen(true);
};
const openEdit = (row) => {
if (row.source === 'auto_remboursement') return; // retrait auto — non éditable ici
setEditingId(row.id);
setForm({
investisseur_id: String(row.investisseur_id || ''),
plateforme_id: row.plateforme_id,
date_operation: row.date_operation,
type: row.type,
montant: row.montant,
libelle: row.libelle || '',
reference: row.reference || '',
notes: row.notes || '',
});
setErr(null); setModalOpen(true);
};
const openCorrection = async (row) => {
// row = objet ligne existante, ou null pour utiliser les données du formulaire ouvert
const platId = row ? row.plateforme_id : form.plateforme_id;
const investId = row ? (row.investisseur_id || null) : (Number(form.investisseur_id) || null);
const type = row ? row.type : form.type;
const date = row ? row.date_operation : form.date_operation;
const plat = plats.find(p => String(p.id) === String(platId));
// Solde calculé par le backend à la date exacte du mouvement
let computedBalance = 0;
try {
const result = await api.get('/dashboard/solde-historique', { plateforme_id: platId, date });
computedBalance = result.solde ?? 0;
} catch (e) {
console.error('solde-historique error:', e);
}
// Ferme le modal d'édition sans sauvegarder
setModalOpen(false);
setEditingId(null);
setCorrModal({
open: true,
plateforme_nom: plat?.nom || '',
type_operation: type,
computedBalance,
date,
platId: Number(platId),
investisseurId: investId ? Number(investId) : null,
});
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); };
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
try {
const payload = {
investisseur_id: Number(form.investisseur_id) || undefined,
plateforme_id: Number(form.plateforme_id),
date_operation: form.date_operation,
type: form.type,
montant: Number(form.montant),
libelle: form.libelle || undefined,
reference: form.reference || undefined,
notes: form.notes || undefined,
};
if (editingId) {
await api.put(`/depots-retraits/${editingId}`, payload);
close(); await load();
return;
}
// Nouvelle opération
await api.post('/depots-retraits', payload);
close();
// Vérifier si la plateforme est flat_tax → proposer correction de solde
const plat = plats.find(p => String(p.id) === String(form.plateforme_id));
if (plat?.fiscalite === 'flat_tax') {
// Solde attendu = solde actuel ± montant de l'opération (avant reload)
const currentPlatData = dashData?.cashByPlatform?.find(
p => String(p.plateforme_id) === String(form.plateforme_id)
);
const currentBalance = currentPlatData?.solde_portefeuille ?? 0;
const montant = Number(form.montant);
const expectedBalance = Math.round(
(form.type === 'retrait' ? currentBalance - montant : currentBalance + montant) * 100
) / 100;
setCorrModal({
open: true,
plateforme_nom: plat.nom,
type_operation: form.type,
computedBalance: expectedBalance,
date: form.date_operation,
platId: Number(form.plateforme_id),
investisseurId: Number(form.investisseur_id) || null,
});
} else {
await load();
}
} catch (e) { setErr(e.message); }
};
const handleCorrectionConfirm = async (diff) => {
if (Math.abs(diff) >= 0.005) {
try {
await api.post('/corrections', {
investisseur_id: corrModal.investisseurId,
plateforme_id: corrModal.platId,
date: corrModal.date,
montant: diff,
notes: `Correction solde porte-monnaie — ${corrModal.type_operation === 'retrait' ? 'retrait' : 'dépôt'}`,
});
} catch (e) {
console.error('Correction creation failed:', e);
}
}
setCorrModal(m => ({ ...m, open: false }));
await load();
};
const openRowMenu = (e, row) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ row, x: rect.right, y: rect.bottom });
};
const deleteRow = (rowId) => {
setDeleteConfirm({
message: 'Supprimer définitivement ce mouvement ?',
onConfirm: async () => {
await api.del(`/depots-retraits/${rowId}`);
setDeleteConfirm(null);
setSelectedRow(null);
await load();
},
});
};
const deleteCorrection = (corrId) => {
setDeleteConfirm({
message: 'Supprimer définitivement cette correction de solde ?',
onConfirm: async () => {
await api.del(`/corrections/${corrId}`);
setDeleteConfirm(null);
setSelectedRow(null);
await load();
},
});
};
const onDelete = () => {
if (!editingId) return;
setDeleteConfirm({
message: 'Supprimer définitivement ce mouvement ?',
onConfirm: async () => {
await api.del(`/depots-retraits/${editingId}`);
setDeleteConfirm(null);
close(); setSelectedRow(null); await load();
},
});
};
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2>Dépôts / Retraits</h2></div>
<EmptyState />
</>
);
if (!loading && !modalOpen && !corrModal.open && allRows.length === 0) return (
<>
<div className="topbar"><h2>Dépôts / Retraits</h2></div>
<EmptyState
icon="💶"
title="Aucun dépôt enregistré"
message="Vos plateformes sont configurées, mais aucun dépôt n'a encore été enregistré. Ajoutez un dépôt pour commencer à suivre votre solde."
cta="Ajouter un dépôt"
to="/depots-retraits?new=1"
/>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2>
{(filter.type || filter.plateforme_ids.length > 0 || filter.month) && (
<button className="dr-clear-btn"
onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}>
Effacer les filtres
</button>
)}
</div>
{/* ── Graphiques ── */}
{!listFocused && <div className="charts-row">
<SoldeChart rows={chartRows} />
<DistributionChart rows={chartRows} />
</div>}
{/* ── KPIs ── */}
{!listFocused && <div className="dr-kpi-row">
<div
className={`kpi dr-kpi-clickable${filter.type === 'depot' ? ' dr-kpi-active dr-kpi-active-success' : ''}`}
onClick={() => setFilter(f => ({ ...f, type: f.type === 'depot' ? '' : 'depot' }))}
title="Filtrer sur les dépôts"
>
<div className="label">Dépôts</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots)}</span>
<TrendBadge current={totals.depots} prev={prevTotals.depots} />
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots)} en {prevYear}</div>
</div>
<div
className={`kpi dr-kpi-clickable${filter.type === 'retrait' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
onClick={() => setFilter(f => ({ ...f, type: f.type === 'retrait' ? '' : 'retrait' }))}
title="Filtrer sur les retraits"
>
<div className="label">Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.retraits)}</span>
<TrendBadge current={totals.retraits} prev={prevTotals.retraits} invert={true} />
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>
</div>
<div
className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}
title={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? 'Effacer les filtres' : undefined}
style={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? { cursor: 'pointer' } : {}}
>
<div className="label">Diff. Dépôts vs Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots - totals.retraits)}</span>
<TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>
</div>
<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 }}>{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'}</span>
{kpiSoldePortefeuille !== null && <TrendBadge current={kpiSoldePortefeuille} prev={prevKpiSoldePortefeuille} />}
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}</div>
</div>
</div>}
{/* ── Onglets ── */}
{!listFocused && <div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
onClick={() => { setActiveTab('mouvements'); setSelectedRow(r => r ?? (rows[0] || null)); }}>Mouvements</button>
</div>}
{/* ====== ONGLET PLATEFORMES ====== */}
{activeTab === 'plateformes' && (
<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 }}>
Répartition par plateforme
{drPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {drPlatYear}</span>}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select value={drPlatYear} onChange={e => { const v = e.target.value; setDrPlatYear(v); setFilter(f => ({ ...f, year: v })); }}
style={{
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-muted)',
cursor: 'pointer', outline: 'none',
}}>
<option value="">Toutes les années</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
onClick={() => { const next = !listFocused; setListFocused(next); setDrPageSize(next ? 25 : 15); }}
>
{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>
<ExportDropdown
disabled={drPlatData.length === 0}
onCSV={() => dlBlob(platToCSV(drPlatData), 'plateformes.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(platToXLS(drPlatData), 'plateformes.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(platToJSON(drPlatData), 'plateformes.json', 'application/json')}
/>
</div>
</div>
{!dashData ? (
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
) : (
<table>
<thead>
<tr><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
</thead>
<tbody>
{drPlatData.length === 0 && (
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
)}
{(() => {
const totalSolde = drPlatTotals.solde_net;
return drPlatData.map(p => {
const isActive = filter.plateforme_ids.includes(String(p.plateforme_id));
const poids = totalSolde !== 0 ? (p.solde_net / totalSolde) * 100 : 0;
return (
<tr key={p.plateforme_id}
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setFilter(f => ({
...f,
plateforme_ids: isActive
? f.plateforme_ids.filter(id => id !== String(p.plateforme_id))
: [...f.plateforme_ids, String(p.plateforme_id)]
}))}>
<td>
<span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span>
</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || ''}</td>}
<td className="num">{fmtEUR(p.depots)}</td>
<td className="num">{fmtEUR(p.retraits)}</td>
<td className="num">{fmtEUR(p.solde_net)}</td>
<td className="num">{fmtEUR(p.solde_portefeuille ?? 0)}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>
{isActive ? '' : ''}
</td>
</tr>
);
});
})()}
</tbody>
{drPlatData.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {drPlatData.length} plateformes</td>
{multiDetenteur && <td />}
<td className="num">{fmtEUR(drPlatTotals.depots)}</td>
<td className="num">{fmtEUR(drPlatTotals.retraits)}</td>
<td className="num">{fmtEUR(drPlatTotals.solde_net)}</td>
<td className="num">{fmtEUR(drPlatTotals.solde_portefeuille)}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
<td />
</tr>
</tfoot>
)}
</table>
)}
</div>
</div>
)}
{/* ====== ONGLET VISION MENSUELLE ====== */}
{activeTab === 'vision-mensuelle' && (
<div style={{ padding: '0 24px' }}>
<DepotsMensuelTable allRows={allRows} plats={plats} expandButton={
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
style={{ marginLeft: 4 }}
onClick={() => { const next = !listFocused; setListFocused(next); setDrPageSize(next ? 25 : 15); }}
>
{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>
)}
{/* ====== ONGLET MOUVEMENTS ====== */}
{activeTab === 'mouvements' && (
<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</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
onClick={() => { const next = !listFocused; setListFocused(next); setDrPageSize(next ? 25 : 15); }}
>
{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>
<ExportDropdown
disabled={rows.length === 0}
onCSV={() => dlBlob(mouvToCSV(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(mouvToJSON(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')}
/>
<button
type="button"
className="icon-btn"
title="Importer des dépôts/retraits"
onClick={() => navigate('/settings?section=imports&module=depots_retraits')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</button>
</div>
</div>
{/* Filtres */}
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
<div style={{ flex: 1, minWidth: 100 }}>
<label>Type de mouvement</label>
<select value={filter.type} onChange={e => setFilter({ ...filter, 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>
</select>
</div>
<div style={{ flex: 2, minWidth: 160, position: 'relative' }} ref={platFilterRef}>
<label>Plateforme</label>
<button
type="button"
className="plat-multiselect-trigger"
onClick={() => setPlatFilterOpen(o => !o)}
>
<span>
{filter.plateforme_ids.length === 0
? 'Toutes'
: filter.plateforme_ids.length === 1
? plats.find(p => String(p.id) === filter.plateforme_ids[0])
? (plats.find(p => String(p.id) === filter.plateforme_ids[0]).nom + (multiDetenteur && plats.find(p => String(p.id) === filter.plateforme_ids[0]).investisseur_nom ? ` — ${plats.find(p => String(p.id) === filter.plateforme_ids[0]).investisseur_nom}` : ''))
: '1 sélectionnée'
: `${filter.plateforme_ids.length} sélectionnées`}
</span>
<svg width="10" height="10" viewBox="0 0 10 6" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,1 5,5 9,1"/></svg>
</button>
{platFilterOpen && (
<div className="plat-multiselect-dropdown">
<label className="plat-multiselect-item" onClick={() => setFilter(f => ({ ...f, plateforme_ids: [] }))}>
<input type="checkbox" readOnly checked={filter.plateforme_ids.length === 0} style={{ width: 'auto' }} />
<span>Toutes</span>
</label>
{plats.map(p => {
const sid = String(p.id);
const checked = filter.plateforme_ids.includes(sid);
return (
<label key={p.id} className="plat-multiselect-item"
onClick={() => setFilter(f => ({
...f,
plateforme_ids: checked
? f.plateforme_ids.filter(id => id !== sid)
: [...f.plateforme_ids, sid]
}))}>
<input type="checkbox" readOnly checked={checked} style={{ width: 'auto' }} />
<span>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</span>
</label>
);
})}
</div>
)}
</div>
<div style={{ flex: 1, minWidth: 100 }}>
<label>Année</label>
<select value={filter.year} onChange={e => { const v = e.target.value; setFilter(f => ({ ...f, year: v })); setDrPlatYear(v); }}>
<option value="">Toutes</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div style={{ flex: 1, minWidth: 110 }}>
<label>Mois</label>
<select value={filter.month} onChange={e => setFilter({ ...filter, 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 className="num">Montant</th><th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{tableRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement' : 'Aucun mouvement'}
</td></tr>
)}
{pagedTableRows.map(r => {
if (r.type === 'correction') {
const isPos = r.montant >= 0;
return (
<tr key={r.id}
className={`dr-row${String(selectedRow?.id) === String(r.id) ? ' dr-row-selected' : ''}`}
onClick={() => setSelectedRow(String(selectedRow?.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={`num ${isPos ? 'success' : 'danger'}`}>
{isPos ? '+' : ''} {fmtEUR(Math.abs(r.montant))}
</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 => openRowMenu(e, r)}>⋮</button>
</td>
</tr>
);
}
const isAuto = r.source === 'auto_remboursement';
return (
<tr key={r.id}
className={`dr-row${selectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setSelectedRow(selectedRow?.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={`num ${r.type === 'depot' ? 'success' : 'danger'}`}
style={ isAuto ? { fontStyle: 'italic' } : undefined }>
{r.type === 'depot' ? '+' : ''} {fmtEUR(r.montant)}
</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 => openRowMenu(e, r)}>⋮</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<Pagination
page={drPage} setPage={setDrPage}
pageSize={drPageSize} setPageSize={setDrPageSize}
totalPages={drTotalPages} totalItems={drTotalItems}
PAGE_SIZES={PAGE_SIZES}
/>
</div>
</div>
</div>
{/* Colonne droite 1/3 */}
{tableRows.length > 0 && (
<div className="dr-mouvements-detail">
<DetailPanel
row={selectedRow}
onEdit={openEdit}
onDeleteCorrection={deleteCorrection}
/>
</div>
)}
</div>
)}
{/* Modal création / édition */}
<Modal
open={modalOpen}
title={editingId ? 'Modifier le mouvement' : 'Nouveau mouvement'}
onClose={close}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', gap: 8 }}>
{editingId && <button className="danger" onClick={onDelete}>Supprimer</button>}
{editingId && <button onClick={() => openCorrection(null)}>Correction de solde</button>}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={close}>Annuler</button>
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
</div>
</div>
}
>
<form onSubmit={submit}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Plateforme *</label>
<select required value={form.plateforme_id} onChange={e => {
const platId = e.target.value;
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(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={form.date_operation} onChange={e => setForm({ ...form, date_operation: e.target.value })} />
</div>
<div>
<label>Type *</label>
<select required value={form.type} onChange={e => setForm({ ...form, 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={form.montant} onChange={e => setForm({ ...form, montant: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Libellé</label>
<input value={form.libelle} onChange={e => setForm({ ...form, libelle: e.target.value })} placeholder="Ex. Virement initial" />
</div>
<div>
<label>Référence</label>
<input value={form.reference} onChange={e => setForm({ ...form, reference: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.investisseur_id} onChange={e => setForm({ ...form, 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={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
</div>
</form>
</Modal>
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{
position: 'fixed', left: openMenu.x, top: openMenu.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,
}}>
{openMenu.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={() => { setOpenMenu(null); openEdit(openMenu.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>
)}
{openMenu.row.type !== 'correction' && openMenu.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={() => { setOpenMenu(null); openCorrection(openMenu.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 = openMenu.row;
setOpenMenu(null);
if (row.type === 'correction') deleteCorrection(row._correctionId);
else deleteRow(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={corrModal.open}
plateforme_nom={corrModal.plateforme_nom}
type_operation={corrModal.type_operation}
computedBalance={corrModal.computedBalance}
date={corrModal.date}
onClose={() => { setCorrModal(m => ({ ...m, open: false })); load(); }}
onConfirm={handleCorrectionConfirm}
/>
{deleteConfirm && (
<ConfirmModal
open={true}
message={deleteConfirm?.message}
onConfirm={deleteConfirm?.onConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
)}
</>
);
}