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

1737 lines
88 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 { useUi } from '../context/UiContext.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, rangeFromDate } from '../utils/format.js';
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
import * as XLSX from 'xlsx';
import {
DEBIT_TYPES, signedMontant, MOUV_TYPE_LABELS, mouvToCSV, mouvToXLS, mouvToJSON,
ExportDropdown, AddDropdown, CorrectionPickerModal, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm,
timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap,
} from '../components/MouvementsShared.jsx';
/* ── Indicateur de progression ── */
function TrendBadge({ current, prev, invert = false }) {
// prev quasi-nul (résidu flottant, ex. 1e-12) → pourcentage non significatif (aurait
// affiché des variations du type "+39628021025%"). Seuil aligné sur l'arrondi 2 décimales.
if (prev == null || Math.abs(prev) < 0.005) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
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 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);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function DepotsRetraits() {
const { activeId, activeView, investisseurs } = useInvestisseur();
const { objectifsActifs } = useUi();
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);
// Snapshots du solde porte-monnaie "à la fin de l'année N" (GET /dashboard?asOf=),
// clé = 'YYYY-12-31'. Remplace un ancien recalcul client-side qui avait divergé
// de la formule backend (bonus/intérêts plateforme/corrections manquants) —
// cf. mémoire project_taxreport_interets_plateforme.
const [dashDataAsOf, setDashDataAsOf] = useState({});
// Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements
// — vide par défaut ("Toutes les années") au chargement initial de la page.
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: '', month: '' });
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyMouvementForm);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
const [drPlatYear, setDrPlatYear] = useState('');
// Plage du graphique de solde (1J/7J/1M/3M/YTD/1A/TOUT), partagée avec la
// Distribution pour qu'elle affiche toujours la même période que le graphique.
const [chartRange, setChartRange] = useState('TOUT');
const [allRemb, setAllRemb] = useState([]);
const [allInv, setAllInv] = useState([]);
const [allReinvests, setAllReinvests] = useState([]);
const [allCorrections, setAllCorrections] = useState([]);
const [deleteConfirm, setDeleteConfirm] = useState(null);
/* Objectifs de versement annuel — pour enrichir le KPI "Diff. Dépôts vs Retraits" */
const [objectifsKpi, setObjectifsKpi] = useState([]);
useEffect(() => {
api.get('/objectifs', { type: 'versement_annuel' })
.then(r => setObjectifsKpi(Array.isArray(r) ? r : []))
.catch(() => setObjectifsKpi([]));
}, []);
/* 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,
});
// État de la modale de sélection plateforme/date (correction de solde autonome, bouton "+")
const [corrPickerOpen, setCorrPickerOpen] = useState(false);
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, reinvests] = 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),
// Toujours scope 'all' — filtré côté client via allInv (déjà scopé) pour respecter
// le même pattern que Plateformes.jsx (route /reinvestissements ne connaît que l'utilisateur).
api.get('/reinvestissements', { scope: 'all' }),
]);
setAllRows(r); setPlats(p); setDashData(d); setAllRemb(remb); setAllInv(inv); setAllCorrections(corr);
setAllReinvests(Array.isArray(reinvests) ? reinvests : []);
} catch (e) {
console.error('DepotsRetraits load error:', e);
} finally { setLoading(false); }
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
// Snapshots "fin d'année" du solde porte-monnaie, calculés côté backend
// (GET /dashboard?asOf=YYYY-12-31) : année sélectionnée (si filtre actif) +
// année précédente (toujours nécessaire pour le TrendBadge).
// Extrait en fonction nommée pour pouvoir aussi être rappelée après chaque
// mutation (remboursement, mouvement, correction) affectant le porte-monnaie —
// `load()` seul ne suffit pas, ces snapshots vivent dans un state à part.
const loadDashDataAsOf = async () => {
if (!activeId && activeView !== 'all') return;
const scopeParams = activeView === 'all' ? { scope: 'all' } : undefined;
const prevYear = String(Number(drPlatYear || new Date().getFullYear()) - 1);
const dates = new Set([`${prevYear}-12-31`]);
if (drPlatYear) dates.add(`${drPlatYear}-12-31`);
try {
const entries = await Promise.all([...dates].map(asOf =>
api.get('/dashboard', { ...scopeParams, asOf }).then(d => [asOf, d])
));
setDashDataAsOf(prev => ({ ...prev, ...Object.fromEntries(entries) }));
} catch (e) { console.error('dashDataAsOf load error:', e); }
};
useEffect(() => {
loadDashDataAsOf();
// eslint-disable-next-line
}, [drPlatYear, activeView, activeId]);
// 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 : plateforme + type + année sélectionnée (comme sur Remboursements —
// choisir une année dans le sélecteur "Répartition par plateforme" recentre aussi
// le graphique de solde et la Distribution sur cette année). Par défaut, l'année
// vaut "Toutes les années" (drPlatYear === ''), donc "Tout" dans le graphique de
// solde remonte bien jusqu'au tout premier mouvement.
const chartRows = kpiRows.filter(r => {
if (filter.type && r.type !== filter.type) return false;
return true;
});
// distributionRows : chartRows restreint à la plage du graphique de solde
// (1J/7J/1M/3M/YTD/1A/TOUT) — pour que la Distribution reflète toujours la
// même période que le graphique auquel elle est associée.
const distributionRows = useMemo(() => {
const from = rangeFromDate(chartRange);
if (!from) return chartRows;
return chartRows.filter(r => r.date_operation.slice(0, 10) >= from);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartRows, chartRange]);
// 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]);
// Investissements par id (déjà scopés) — sert à retrouver fiscalite_override et à
// rattacher les réinvestissements (qui ne portent que investissement_id) à leur
// plateforme/projet/détenteur.
const invById = useMemo(() => new Map(allInv.map(i => [i.id, i])), [allInv]);
// Remboursements affichés comme mouvements du porte-monnaie de la plateforme, décomposés
// en plusieurs lignes pour rendre les prélèvements visibles :
// 1. "remboursement" (crédit, montant BRUT = capital + cashback + intérêts avant tout
// prélèvement, y compris avant retenue locale étrangère le cas échéant)
// 2. "prelevement_local" (débit = taxe_locale) si la plateforme retient une fiscalité
// locale à l'étranger — toujours réellement retenue, indépendamment du flat_tax.
// 3. "prelevement_ir" et "prelevement_csg" (débits) uniquement si le PFU est réellement
// retenu à la source ici (plateforme flat_tax France, non exonérée) — sinon les
// prélèvements ne sont qu'indicatifs (déclaratif) et ne doivent pas apparaître comme
// un mouvement réel du porte-monnaie.
// La somme de ces lignes reste égale à l'ancien montant unique (net_recu / brut selon le
// cas), donc le solde porte-monnaie n'est pas affecté — seule la présentation change.
// Les bonus (parrainage/plateforme) créditent le cashback, sans prélèvement.
// Note : les remboursements methode_remboursement='compte_courant' transitent bien par le
// porte-monnaie (crédit) avant d'en être aussitôt sortis par le retrait automatique
// (source='auto_remboursement', même montant net) — on affiche donc ce mouvement aussi,
// même si l'effet net sur le solde est nul (cf. calcul KPI porte-monnaie ci-dessous, qui
// lui continue d'ignorer ces remboursements).
const normalizedRembMouvements = useMemo(() => {
const out = [];
for (const r of allRemb) {
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme';
const isInteretsPlateforme = r.type === 'interets_plateforme';
const plat = plats.find(p => p.id === r.plateforme_id);
const base = {
_rembId: r.id,
_invId: r.investissement_id ?? null,
plateforme_id: r.plateforme_id,
plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom,
date_operation: r.date_remb,
libelle: r.nom_projet || '',
source: 'remboursement',
investisseur_id: r.resolved_investisseur_id ?? null,
};
if (isBonus) {
const montant = r.cashback || 0;
if (!montant) continue;
out.push({ ...base, id: `remb_${r.id}`, type: 'bonus', montant });
continue;
}
const inv = invById.get(r.investissement_id);
const isFlatTax = plat?.fiscalite === 'flat_tax';
const isExonere = inv?.fiscalite_override === 'exonere';
// PFU réellement retenu à la source uniquement si flat_tax France non exonérée ;
// sinon prélèvements indicatifs seulement (déclaratif), pas un vrai mouvement de cash.
const pfuReellementRetenu = isFlatTax && !isExonere;
const taxeLocale = r.taxe_locale || 0;
const grossInterets = r.interets_bruts_avant_local > 0 ? r.interets_bruts_avant_local : (r.interets_bruts || 0);
// Le cashback est désormais toujours sorti dans sa propre ligne "Bonus / Cashback",
// même sur un remboursement de type 'normal' — le champ cashback du formulaire est
// libre et saisissable sur n'importe quel type, sans lien avec bonus_parrainage/plateforme.
const cashback = r.cashback || 0;
const grossMontant = (r.capital || 0) + grossInterets;
if (grossMontant) {
out.push({
...base,
id: `remb_${r.id}`,
type: isInteretsPlateforme ? 'interets_plateforme' : 'remboursement',
montant: grossMontant,
_isFlatTax: isFlatTax,
_isAutoSweep: r.methode_remboursement === 'compte_courant',
_taxeLocale: taxeLocale,
_capital: r.capital || 0,
_cashback: 0,
_interets_bruts: r.interets_bruts || 0,
_interets_nets: r.interets_nets || 0,
_net_recu: r.net_recu || 0,
});
}
if (cashback > 0) {
out.push({ ...base, id: `remb_${r.id}_cashback`, type: 'cashback', montant: cashback });
}
if (taxeLocale > 0) {
out.push({ ...base, id: `remb_${r.id}_local`, type: 'prelevement_local', montant: taxeLocale });
}
if (pfuReellementRetenu && r.prelev_forfaitaire > 0) {
out.push({ ...base, id: `remb_${r.id}_ir`, type: 'prelevement_ir', montant: r.prelev_forfaitaire });
}
if (pfuReellementRetenu && r.prelev_sociaux > 0) {
out.push({ ...base, id: `remb_${r.id}_csg`, type: 'prelevement_csg', montant: r.prelev_sociaux });
}
}
return out;
}, [allRemb, plats, invById]);
// Filtrés (plateforme + année + mois, sans filtre type — appliqué au merge dans tableRows)
const filteredRembMouvements = useMemo(() => normalizedRembMouvements.filter(r => {
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
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;
}), [normalizedRembMouvements, filter.plateforme_ids, filter.year, filter.month]);
// Projet lié à un remboursement (utilisé pour retrouver le nom du projet
// sur les retraits AUTO, qui ne portent que remboursement_id)
const rembProjetById = useMemo(() => new Map(allRemb.map(r => [r.id, r.nom_projet || ''])), [allRemb]);
// Souscriptions à un investissement (débit du porte-monnaie) + réinvestissements —
// les réinvestissements automatiques de capital d'intérêts sont aussi traités comme une
// souscription (débit) : cohérent avec la formule du solde porte-monnaie, qui les
// soustrait tous deux (capital_investi = montant_investi + Σ réinvestissements).
const normalizedSouscriptionMouvements = useMemo(() => {
const out = [];
for (const inv of allInv) {
if (!inv.montant_investi) continue;
out.push({
id: `sub_${inv.id}`,
_invId: inv.id,
plateforme_id: inv.plateforme_id,
plateforme_nom: inv.plateforme_nom,
plateforme_detenteur_nom: inv.plateforme_detenteur_nom,
date_operation: inv.date_souscription,
type: 'souscription',
montant: inv.montant_investi,
libelle: inv.nom_projet || '',
source: 'souscription',
investisseur_id: inv.investisseur_id ?? null,
_isReinvest: false,
});
}
for (const rv of allReinvests) {
const inv = invById.get(rv.investissement_id);
if (!inv || !rv.montant) continue;
out.push({
id: `reinv_${rv.id}`,
_invId: inv.id,
plateforme_id: inv.plateforme_id,
plateforme_nom: inv.plateforme_nom,
plateforme_detenteur_nom: inv.plateforme_detenteur_nom,
date_operation: rv.date_reinvestissement,
type: 'souscription',
montant: rv.montant,
libelle: inv.nom_projet || '',
source: 'reinvestissement',
investisseur_id: inv.investisseur_id ?? null,
_isReinvest: true,
});
}
return out;
}, [allInv, allReinvests, invById]);
const filteredSouscriptionMouvements = useMemo(() => normalizedSouscriptionMouvements.filter(r => {
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
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;
}), [normalizedSouscriptionMouvements, filter.plateforme_ids, filter.year, filter.month]);
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
// COMPLET et NON FILTRÉ (allRows/normalizedCorrections/normalizedRembMouvements/
// normalizedSouscriptionMouvements, pas rows/filteredCorrections/...), sinon le cumul
// démarrerait arbitrairement au milieu de l'historique quand un filtre année/mois/type
// est actif. Purement indicatif (affichage/export) — ne remplace pas kpiSoldePortefeuille.
// Voir [[project_mouvements_solde_indicatif]].
const soldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
...allRows, ...normalizedCorrections, ...normalizedRembMouvements, ...normalizedSouscriptionMouvements,
]), [allRows, normalizedCorrections, normalizedRembMouvements, normalizedSouscriptionMouvements]);
// tableRows : dépôts/retraits + corrections + remboursements/bonus + souscriptions, triés par date desc.
// _projet : nom du projet quand cela a du sens (remboursement, souscription/réinvestissement,
// et retrait AUTO lié à un remboursement via remboursement_id) — vide pour dépôt/retrait
// manuel, correction, bonus.
const tableRows = useMemo(() => {
const showDR = !filter.type || filter.type === 'depot' || filter.type === 'retrait';
const showCorr = !filter.type || filter.type === 'correction';
const drPart = showDR
? rows.map(r => ({ ...r, _projet: r.remboursement_id ? (rembProjetById.get(r.remboursement_id) || '') : '' }))
: [];
const corrPart = showCorr ? filteredCorrections.map(c => ({ ...c, _projet: '' })) : [];
const rembPart = filteredRembMouvements
.filter(r => !filter.type || r.type === filter.type)
.map(r => ({ ...r, _projet: r.libelle || '' }));
const subPart = filteredSouscriptionMouvements
.filter(r => !filter.type || r.type === filter.type)
.map(r => ({ ...r, _projet: r.libelle || '' }));
return sortMouvements([...drPart, ...corrPart, ...rembPart, ...subPart])
.map(r => ({ ...r, _soldeIndicatif: soldeIndicatifByRowId.get(r.id) }));
}, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById, soldeIndicatifByRowId]);
/* ── 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]);
// Repli sur l'onglet Plateformes si la fixation des objectifs est désactivée
// pendant qu'on est sur l'onglet Objectifs (ex. dans un autre onglet du navigateur).
useEffect(() => {
if (!objectifsActifs && activeTab === 'vision-annuelle') setActiveTab('plateformes');
}, [objectifsActifs, activeTab]);
// 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 */
// Tri unique (alphabétique, détenteur en second critère) appliqué aux deux
// branches de drPlatData — évite que l'ordre des plateformes change selon le
// filtre année sélectionné.
const sortPlatAlpha = (a, b) =>
a.nom.localeCompare(b.nom, 'fr') || (a.detenteur_nom ?? '').localeCompare(b.detenteur_nom ?? '', 'fr');
const drPlatData = useMemo(() => {
if (!drPlatYear) return [...(dashData?.cashByPlatform ?? [])].sort(sortPlatAlpha);
// 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;
}
// Une plateforme sans dépôt/retrait cette année-là (ex. uniquement des
// remboursements ou un réinvestissement) doit quand même apparaître dans le
// tableau — sinon elle disparaît alors qu'elle a eu de vrais mouvements.
for (const r of allRemb) {
if (!r.date_remb || r.date_remb.slice(0, 4) !== drPlatYear || !r.plateforme_id) continue;
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 };
}
for (const rv of allReinvests) {
if (!rv.date_reinvestissement || rv.date_reinvestissement.slice(0, 4) !== drPlatYear) continue;
const inv = allInv.find(i => i.id === rv.investissement_id);
if (!inv?.plateforme_id) continue;
const key = String(inv.plateforme_id);
if (!map[key]) map[key] = { plateforme_id: inv.plateforme_id, nom: inv.plateforme_nom, detenteur_nom: inv.plateforme_detenteur_nom || null, depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 };
}
// Solde porte-monnaie = cumul jusqu'au 31/12 de l'année sélectionnée, calculé
// côté backend (GET /dashboard?asOf=) pour rester la même formule que le solde
// "live" — bonus, intérêts plateforme et corrections de solde inclus.
const soldeMap = {};
for (const p of dashDataAsOf[`${drPlatYear}-12-31`]?.cashByPlatform ?? []) {
soldeMap[String(p.plateforme_id)] = p.solde_portefeuille;
}
return Object.values(map).map(p => {
p.solde_net = p.depots - p.retraits;
p.solde_portefeuille = soldeMap[String(p.plateforme_id)] ?? 0;
return p;
}).sort(sortPlatAlpha);
}, [allRows, allRemb, allInv, allReinvests, dashDataAsOf, 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 → snapshot backend "fin d'année" (GET /dashboard?asOf=), même
// formule que le solde live (dashDataAsOf, cf. effet plus haut)
const soldeFromSnapshot = (snap) => {
if (!snap) return null;
if (filter.plateforme_ids.length > 0) {
return (snap.cashByPlatform ?? [])
.filter(c => filter.plateforme_ids.includes(String(c.plateforme_id)))
.reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0);
}
return snap.solde_portefeuille_total ?? null;
};
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;
}
return soldeFromSnapshot(dashDataAsOf[`${drPlatYear}-12-31`]);
})();
// 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 = soldeFromSnapshot(dashDataAsOf[`${prevYear}-12-31`]);
/* ── Enrichissement du KPI "Diff. Dépôts vs Retraits" avec l'objectif de l'année ──
Toujours calculé sur le portefeuille entier (ignore le filtre plateforme),
pour l'année sélectionnée (drPlatYear, ou l'année en cours si "Toutes les années"). */
const scopeInvestisseurIdsKpi = activeView === 'all' ? investisseurs.map(i => i.id) : [activeId];
const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear());
const kpiObjectifTotal = (() => {
const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id));
return list.length ? list.reduce((s, o) => s + o.montant, 0) : null;
})();
const kpiDiffGlobalYear = (() => {
let d = 0, r = 0;
for (const row of allRows) {
if (!scopeInvestisseurIdsKpi.includes(row.investisseur_id)) continue;
if (row.date_operation?.slice(0, 4) !== String(kpiObjectifYear)) continue;
if (row.type === 'depot') d += row.montant; else r += row.montant;
}
return d - r;
})();
const kpiEcartObjectif = kpiObjectifTotal != null ? kpiDiffGlobalYear - kpiObjectifTotal : null;
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", ou depuis le formulaire
investissement — soit le bouton "Déposer" (ajoute ?plateforme_id=… pour présélectionner
la plateforme plutôt que de retomber sur plats[0], et ?resumeInvestissement=1 pour
demander un retour automatique vers l'investissement une fois le dépôt créé — cf.
pendingReturnActive/maybeReturnToInvestissement plus bas), soit la proposition de dépôt
après création d'un investissement dont le porte-monnaie ne couvrait pas le montant
(ajoute ?montant=… pour pré-remplir le montant du dépôt, et ?autoCreate=1 pour créer le
dépôt directement sans repasser par la modale — l'utilisateur a déjà validé/rectifié le
montant dans la proposition affichée côté formulaire investissement). */
const pendingNew = useRef(false);
const pendingNewPlatId = useRef(null);
const pendingNewMontant = useRef(null);
const pendingNewAutoCreate = useRef(false);
const pendingReturnActive = useRef(false);
useEffect(() => {
const params = new URLSearchParams(search);
if (params.get('new') !== '1') return;
const platParam = params.get('plateforme_id');
const montantParam = params.get('montant');
const autoCreateParam = params.get('autoCreate') === '1';
if (params.get('resumeInvestissement') === '1') pendingReturnActive.current = true;
navigate('/depots-retraits', { replace: true });
if (plats.length > 0) {
setEditingId(null);
const firstId = (platParam && plats.some(p => String(p.id) === platParam)) ? platParam : (plats[0]?.id || '');
const built = { ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId), montant: montantParam || '' };
if (autoCreateParam && montantParam) {
setErr(null);
doCreateDeposit(built).catch(err => {
// Création auto échouée (réseau, validation serveur…) — on retombe sur la modale
// pré-remplie pour que l'utilisateur voie l'erreur et puisse retenter manuellement.
setForm(built);
setErr(err.message);
setModalOpen(true);
});
} else {
setForm(built);
setErr(null);
setModalOpen(true);
}
} else {
pendingNew.current = true;
pendingNewPlatId.current = platParam;
pendingNewMontant.current = montantParam;
pendingNewAutoCreate.current = autoCreateParam;
}
}, [search]); // eslint-disable-line
/** Si le dépôt en cours de création vient du bouton "Déposer" du formulaire investissement
* (cf. ci-dessus), navigue vers la page d'origine avec la saisie restaurée depuis
* sessionStorage. Ne fait rien sinon (comportement normal, inchangé). À appeler après
* que le dépôt est effectivement créé (pas à l'annulation) — les 3 points de sortie
* possibles de la création (sans correction de solde / correction confirmée / correction
* "Passer") appellent tous cette fonction. */
const maybeReturnToInvestissement = () => {
if (!pendingReturnActive.current) return;
pendingReturnActive.current = false;
try {
const raw = sessionStorage.getItem('cl_investissement_draft');
if (!raw) return;
sessionStorage.removeItem('cl_investissement_draft');
const draft = JSON.parse(raw);
if (!draft?.returnTo) return;
const sep = draft.returnTo.includes('?') ? '&' : '?';
navigate(`${draft.returnTo}${sep}resumeInvestissement=1`);
} catch {
// sessionStorage/JSON invalide — pas de retour auto, on reste simplement sur cette page.
}
};
useEffect(() => {
if (!pendingNew.current || !plats.length) return;
pendingNew.current = false;
setEditingId(null);
const platParam = pendingNewPlatId.current;
pendingNewPlatId.current = null;
const montantParam = pendingNewMontant.current;
pendingNewMontant.current = null;
const autoCreateParam = pendingNewAutoCreate.current;
pendingNewAutoCreate.current = false;
const firstId = (platParam && plats.some(p => String(p.id) === platParam)) ? platParam : (plats[0]?.id || '');
const built = { ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId), montant: montantParam || '' };
if (autoCreateParam && montantParam) {
setErr(null);
doCreateDeposit(built).catch(err => {
setForm(built);
setErr(err.message);
setModalOpen(true);
});
} else {
setForm(built);
setErr(null);
setModalOpen(true);
}
}, [plats]); // eslint-disable-line
/* CRUD */
const openNew = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...emptyMouvementForm, 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,
});
};
/* Correction de solde autonome (bouton "+" du tableau Mouvements) — pas de
mouvement dépôt/retrait déclencheur, l'utilisateur choisit plateforme + date. */
const openCorrectionManual = async ({ plateforme_id, date }) => {
const plat = plats.find(p => p.id === plateforme_id);
const investId = investisseurForPlat(plateforme_id);
let computedBalance = 0;
try {
const result = await api.get('/dashboard/solde-historique', { plateforme_id, date });
computedBalance = result.solde ?? 0;
} catch (e) {
console.error('solde-historique error:', e);
}
setCorrPickerOpen(false);
setCorrModal({
open: true,
plateforme_nom: plat?.nom || '',
type_operation: null,
computedBalance,
date,
platId: plateforme_id,
investisseurId: investId ? Number(investId) : null,
});
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); };
/** Crée un dépôt/retrait à partir d'un objet form donné. Extrait de submit() pour être
* réutilisable par la création automatique déclenchée depuis ?new=1&autoCreate=1 (cf.
* effets ci-dessus) — l'utilisateur y a déjà validé/rectifié le montant en amont, donc pas
* besoin de repasser par la modale. Les erreurs remontent à l'appelant (try/catch). */
const doCreateDeposit = async (f) => {
const payload = {
investisseur_id: Number(f.investisseur_id) || undefined,
plateforme_id: Number(f.plateforme_id),
date_operation: f.date_operation,
type: f.type,
montant: Number(f.montant),
libelle: f.libelle || undefined,
reference: f.reference || undefined,
notes: f.notes || undefined,
};
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(f.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(f.plateforme_id)
);
const currentBalance = currentPlatData?.solde_portefeuille ?? 0;
const montant = Number(f.montant);
const expectedBalance = Math.round(
(f.type === 'retrait' ? currentBalance - montant : currentBalance + montant) * 100
) / 100;
setCorrModal({
open: true,
plateforme_nom: plat.nom,
type_operation: f.type,
computedBalance: expectedBalance,
date: f.date_operation,
platId: Number(f.plateforme_id),
investisseurId: Number(f.investisseur_id) || null,
});
} else {
await Promise.all([load(), loadDashDataAsOf()]);
maybeReturnToInvestissement();
}
};
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
try {
if (editingId) {
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,
};
await api.put(`/depots-retraits/${editingId}`, payload);
close(); await Promise.all([load(), loadDashDataAsOf()]);
return;
}
await doCreateDeposit(form);
} 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: corrModal.type_operation
? `Correction solde porte-monnaie — ${corrModal.type_operation === 'retrait' ? 'retrait' : 'dépôt'}`
: 'Correction solde porte-monnaie — saisie manuelle',
});
} catch (e) {
console.error('Correction creation failed:', e);
}
}
setCorrModal(m => ({ ...m, open: false }));
await Promise.all([load(), loadDashDataAsOf()]);
maybeReturnToInvestissement();
};
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 Promise.all([load(), loadDashDataAsOf()]);
},
});
};
const deleteCorrection = (corrId) => {
setDeleteConfirm({
message: 'Supprimer définitivement cette correction de solde ?',
onConfirm: async () => {
await api.del(`/corrections/${corrId}`);
setDeleteConfirm(null);
setSelectedRow(null);
await Promise.all([load(), loadDashDataAsOf()]);
},
});
};
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 Promise.all([load(), loadDashDataAsOf()]);
},
});
};
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} range={chartRange} onRangeChange={setChartRange} />
<DistributionChart rows={distributionRows} />
</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>
{drPlatYear && <TrendBadge current={totals.depots} prev={prevTotals.depots} />}
</div>
{drPlatYear && <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>
{drPlatYear && <TrendBadge current={totals.retraits} prev={prevTotals.retraits} invert={true} />}
</div>
{drPlatYear && <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>
{drPlatYear && <TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />}
</div>
{drPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>}
{objectifsActifs && kpiEcartObjectif != null && (
<div style={{
fontSize: '0.8em', marginTop: 4, fontWeight: 600,
color: Math.abs(kpiEcartObjectif) < 0.01 ? 'var(--success)' : kpiEcartObjectif >= 0 ? 'var(--danger)' : 'var(--warning)',
}}>
{Math.abs(kpiEcartObjectif) < 0.01
? `Conforme à l'objectif ${kpiObjectifYear}`
: kpiEcartObjectif >= 0
? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}`
: `${fmtEUR(-kpiEcartObjectif)} en dessous de l'objectif ${kpiObjectifYear}`}
</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>
{drPlatYear && kpiSoldePortefeuille !== null && <TrendBadge current={kpiSoldePortefeuille} prev={prevKpiSoldePortefeuille} />}
</div>
{drPlatYear && <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>
{objectifsActifs && (
<button className={`dr-tab${activeTab === 'vision-annuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-annuelle')}>Objectifs</button>
)}
</div>}
{/* ====== ONGLET VISION ANNUELLE ====== */}
{objectifsActifs && activeTab === 'vision-annuelle' && (
<div style={{ padding: '0 24px' }}>
<SuiviObjectifs
rows={allRows}
investisseurs={investisseurs}
scopeInvestisseurIds={scopeInvestisseurIdsKpi}
/>
</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={tableRows.length === 0}
onCSV={() => dlBlob(mouvToCSV(tableRows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(mouvToXLS(tableRows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(mouvToJSON(tableRows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')}
/>
<AddDropdown
disabled={plats.length === 0}
onNewCorrection={() => setCorrPickerOpen(true)}
/>
<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>
<option value="remboursement">Remboursements</option>
<option value="bonus">Bonus</option>
<option value="cashback">Cashback</option>
<option value="interets_plateforme">Intérêts plateforme</option>
<option value="souscription">Souscriptions</option>
<option value="prelevement_ir">Prélèvements IR</option>
<option value="prelevement_csg">Prélèvements CSG</option>
<option value="prelevement_local">Prélèvements étranger</option>
</select>
</div>
<div style={{ flex: 2, minWidth: 160, 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>Projet</th><th className="num">Montant</th><th className="num" title="Solde du porte-monnaie de la plateforme après ce mouvement — calculé à titre indicatif, pas une donnée officielle">Solde indicatif</th><th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{tableRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 8 : 7} 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="text-muted">—</td>
<td className={`num ${isPos ? 'success' : 'danger'}`}>
{isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => openRowMenu(e, r)}>⋮</button>
</td>
</tr>
);
}
if (r.type === 'remboursement' || r.type === 'bonus' || r.type === 'cashback' || r.type === 'interets_plateforme') {
const isBonus = r.type === 'bonus';
const isCashback = r.type === 'cashback';
const isInteretsPlateforme = r.type === 'interets_plateforme';
return (
<tr key={r.id}
className={`dr-row${selectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setSelectedRow(selectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer' }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span style={{
display: 'inline-block',
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
padding: '2px 7px', borderRadius: 4,
background: (isBonus || isCashback)
? 'color-mix(in srgb, var(--warning) 18%, transparent)'
: isInteretsPlateforme
? 'color-mix(in srgb, var(--primary) 18%, transparent)'
: 'color-mix(in srgb, var(--success) 15%, transparent)',
color: (isBonus || isCashback) ? 'var(--warning)' : isInteretsPlateforme ? 'var(--primary)' : 'var(--success)',
}}>{isBonus ? 'bonus' : isCashback ? 'cashback' : isInteretsPlateforme ? 'intérêts plateforme' : 'remboursement'}</span>
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="num success">
+ {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} />
</tr>
);
}
if (r.type === 'prelevement_ir' || r.type === 'prelevement_csg' || r.type === 'prelevement_local') {
const isLocal = r.type === 'prelevement_local';
const label = isLocal ? "prélèvement étranger" : r.type === 'prelevement_ir' ? 'prélèvement IR' : 'prélèvement CSG';
const color = isLocal ? '#f97316' : 'var(--danger)';
return (
<tr key={r.id}
className={`dr-row${selectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setSelectedRow(selectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer' }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span style={{
display: 'inline-block',
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
padding: '2px 7px', borderRadius: 4,
background: `color-mix(in srgb, ${color} 15%, transparent)`,
color,
}}>{label}</span>
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="num danger">
− {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} />
</tr>
);
}
if (r.type === 'souscription') {
return (
<tr key={r.id}
className={`dr-row${selectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
onClick={() => setSelectedRow(selectedRow?.id === r.id ? null : r)}
style={{ cursor: 'pointer' }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span style={{
display: 'inline-block',
fontSize: 11, fontWeight: 600, letterSpacing: '0.03em',
padding: '2px 7px', borderRadius: 4,
background: 'color-mix(in srgb, #a855f7 15%, transparent)',
color: '#a855f7',
}}>souscription</span>
{r._isReinvest && (
<span style={{
marginLeft: 6,
fontSize: 10, fontWeight: 600, letterSpacing: '0.03em',
padding: '1px 5px', borderRadius: 3,
background: 'color-mix(in srgb, var(--primary) 15%, transparent)',
color: 'var(--primary)',
verticalAlign: 'middle',
}}>RÉINVEST.</span>
)}
</td>
<td className="text-muted">{r._projet || '—'}</td>
<td className="num danger">
− {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} />
</tr>
);
}
const isAuto = r.source === 'auto_remboursement';
return (
<tr key={r.id}
className={`dr-row${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="text-muted">{r._projet || '—'}</td>
<td className={`num ${r.type === 'depot' ? 'success' : 'danger'}`}
style={ isAuto ? { fontStyle: 'italic' } : undefined }>
{r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)}
</td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
{!isAuto && (
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => 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}
onViewRemb={r => navigate(`/remboursements?edit-remb=${r._rembId}`)}
onViewInvestissement={r => navigate(`/investissements/${r._invId}`)}
/>
</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(); maybeReturnToInvestissement(); }}
onConfirm={handleCorrectionConfirm}
/>
<CorrectionPickerModal
open={corrPickerOpen}
onClose={() => setCorrPickerOpen(false)}
onContinue={openCorrectionManual}
plateformes={plats}
multiDetenteur={multiDetenteur}
/>
{deleteConfirm && (
<ConfirmModal
open={true}
message={deleteConfirm?.message}
onConfirm={deleteConfirm?.onConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
)}
</>
);
}