1512 lines
72 KiB
React
1512 lines
72 KiB
React
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)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|