1737 lines
88 KiB
React
1737 lines
88 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 { 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)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|