Gestion du Porte-monnaie Page plateforme
This commit is contained in:
1 parent
c2d5712e2c
commit
9ef32cef90
3 files changed
+1272
-417
No files matched your search
@@ -15,6 +15,13 @@ import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import {
|
||||
mouvToCSV, mouvToXLS, mouvToJSON,
|
||||
ExportDropdown, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm,
|
||||
timestampSuffix, dlBlob,
|
||||
} from '../components/MouvementsShared.jsx';
|
||||
|
||||
const LOGOS_BASE = '/api/logos/';
|
||||
|
||||
@@ -867,6 +874,393 @@ export default function Plateformes() {
|
||||
[allDepots, allRembs, allRows, allCorrections, plats, filteredPlatIds, prevYearLabel]
|
||||
);
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
ONGLET "MOUVEMENTS PORTE-MONNAIE" — réplique de l'onglet "Mouvements"
|
||||
de DepotsRetraits.jsx (cf. components/MouvementsShared.jsx pour les
|
||||
éléments présentationnels partagés). Reprend les mêmes données déjà
|
||||
chargées par load() ci-dessus (allDepots, allRembs, allRows=investissements,
|
||||
allReinvests, allCorrections, plats). Toute évolution de la logique de
|
||||
décomposition des mouvements doit être répercutée dans les deux fichiers.
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
const [mvFilter, setMvFilter] = useState({ type: '', plateforme_ids: [], projet: '', year: '', month: '' });
|
||||
const [mvListFocused, setMvListFocused] = useState(false);
|
||||
const [mvModalOpen, setMvModalOpen] = useState(false);
|
||||
const [mvEditingId, setMvEditingId] = useState(null);
|
||||
const [mvForm, setMvForm] = useState(emptyMouvementForm);
|
||||
const [mvErr, setMvErr] = useState(null);
|
||||
const [mvSelectedRow, setMvSelectedRow] = useState(null);
|
||||
const [mvOpenMenu, setMvOpenMenu] = useState(null); // { row, x, y }
|
||||
const [mvDeleteConfirm, setMvDeleteConfirm] = useState(null);
|
||||
const [mvCorrModal, setMvCorrModal] = useState({
|
||||
open: false, plateforme_nom: '', type_operation: 'retrait',
|
||||
computedBalance: 0, date: '', platId: null, investisseurId: null,
|
||||
});
|
||||
|
||||
// Le périmètre plateforme de cet onglet est strictement verrouillé sur la sélection
|
||||
// "Plateforme" de la page (filteredPlatIds) — aucun contrôle interne ne permet de
|
||||
// l'élargir à d'autres plateformes, pour éviter toute confusion avec les KPIs affichés
|
||||
// au-dessus (qui portent, eux, sur la plateforme sélectionnée en haut de page).
|
||||
useEffect(() => {
|
||||
setMvFilter(f => ({ ...f, plateforme_ids: [...filteredPlatIds].map(String), projet: '' }));
|
||||
}, [filteredPlatIds]);
|
||||
|
||||
// Ferme le menu contextuel au scroll
|
||||
useEffect(() => {
|
||||
const closeAll = () => setMvOpenMenu(null);
|
||||
window.addEventListener('scroll', closeAll, true);
|
||||
return () => window.removeEventListener('scroll', closeAll, true);
|
||||
}, []);
|
||||
|
||||
const mvYears = useMemo(
|
||||
() => [...new Set(allDepots.map(r => r.date_operation.slice(0, 4)))].sort().reverse(),
|
||||
[allDepots]
|
||||
);
|
||||
|
||||
const mvInvestisseurForPlat = (platId) => {
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
if (plat?.investisseur_id) return String(plat.investisseur_id);
|
||||
if (activeView === 'all') {
|
||||
const principal = investisseurs.find(i => i.is_principal);
|
||||
return String((principal || investisseurs[0])?.id || '');
|
||||
}
|
||||
return String(activeId || '');
|
||||
};
|
||||
|
||||
const mvRows = useMemo(() => allDepots.filter(r => {
|
||||
if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
if (mvFilter.type && r.type !== mvFilter.type) return false;
|
||||
const [y, m] = r.date_operation.split('-');
|
||||
if (mvFilter.year && y !== mvFilter.year) return false;
|
||||
if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false;
|
||||
return true;
|
||||
}), [allDepots, mvFilter]);
|
||||
|
||||
const mvNormalizedCorrections = 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]);
|
||||
|
||||
const mvFilteredCorrections = useMemo(() => mvNormalizedCorrections.filter(c => {
|
||||
if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(c.plateforme_id))) return false;
|
||||
const [y, m] = c.date_operation.split('-');
|
||||
if (mvFilter.year && y !== mvFilter.year) return false;
|
||||
if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false;
|
||||
return true;
|
||||
}), [mvNormalizedCorrections, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
|
||||
|
||||
const mvInvById = useMemo(() => new Map(allRows.map(i => [i.id, i])), [allRows]);
|
||||
|
||||
const mvNormalizedRembMouvements = useMemo(() => {
|
||||
const out = [];
|
||||
for (const r of allRembs) {
|
||||
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme';
|
||||
const plat = plats.find(p => p.id === r.plateforme_id);
|
||||
const base = {
|
||||
_rembId: r.id,
|
||||
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 = mvInvById.get(r.investissement_id);
|
||||
const isFlatTax = plat?.fiscalite === 'flat_tax';
|
||||
const isExonere = inv?.fiscalite_override === 'exonere';
|
||||
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);
|
||||
const grossMontant = (r.capital || 0) + (r.cashback || 0) + grossInterets;
|
||||
|
||||
if (grossMontant) {
|
||||
out.push({
|
||||
...base,
|
||||
id: `remb_${r.id}`,
|
||||
type: 'remboursement',
|
||||
montant: grossMontant,
|
||||
_isFlatTax: isFlatTax,
|
||||
_isAutoSweep: r.methode_remboursement === 'compte_courant',
|
||||
_taxeLocale: taxeLocale,
|
||||
_capital: r.capital || 0,
|
||||
_cashback: r.cashback || 0,
|
||||
_interets_bruts: r.interets_bruts || 0,
|
||||
_interets_nets: r.interets_nets || 0,
|
||||
_net_recu: r.net_recu || 0,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}, [allRembs, plats, mvInvById]);
|
||||
|
||||
const mvFilteredRembMouvements = useMemo(() => mvNormalizedRembMouvements.filter(r => {
|
||||
if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
const [y, m] = r.date_operation.split('-');
|
||||
if (mvFilter.year && y !== mvFilter.year) return false;
|
||||
if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false;
|
||||
return true;
|
||||
}), [mvNormalizedRembMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
|
||||
|
||||
const mvRembProjetById = useMemo(() => new Map(allRembs.map(r => [r.id, r.nom_projet || ''])), [allRembs]);
|
||||
|
||||
const mvNormalizedSouscriptionMouvements = useMemo(() => {
|
||||
const out = [];
|
||||
for (const inv of allRows) {
|
||||
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 = mvInvById.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;
|
||||
}, [allRows, allReinvests, mvInvById]);
|
||||
|
||||
const mvFilteredSouscriptionMouvements = useMemo(() => mvNormalizedSouscriptionMouvements.filter(r => {
|
||||
if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
const [y, m] = r.date_operation.split('-');
|
||||
if (mvFilter.year && y !== mvFilter.year) return false;
|
||||
if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false;
|
||||
return true;
|
||||
}), [mvNormalizedSouscriptionMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]);
|
||||
|
||||
// Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais
|
||||
// pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours
|
||||
// d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les
|
||||
// retraits AUTO liés à un remboursement (rattachés via rembProjetById).
|
||||
const mvProjets = useMemo(() => {
|
||||
const ids = mvFilter.plateforme_ids;
|
||||
const inScope = pid => ids.length === 0 || ids.includes(String(pid));
|
||||
const set = new Set();
|
||||
for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
||||
for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle);
|
||||
for (const d of allDepots) {
|
||||
if (!inScope(d.plateforme_id) || !d.remboursement_id) continue;
|
||||
const p = mvRembProjetById.get(d.remboursement_id);
|
||||
if (p) set.add(p);
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
|
||||
|
||||
const mvTableRows = useMemo(() => {
|
||||
const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait';
|
||||
const showCorr = !mvFilter.type || mvFilter.type === 'correction';
|
||||
const drPart = showDR
|
||||
? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '' }))
|
||||
: [];
|
||||
const corrPart = showCorr ? mvFilteredCorrections.map(c => ({ ...c, _projet: '' })) : [];
|
||||
const rembPart = mvFilteredRembMouvements
|
||||
.filter(r => !mvFilter.type || r.type === mvFilter.type)
|
||||
.map(r => ({ ...r, _projet: r.type === 'bonus' ? '' : (r.libelle || '') }));
|
||||
const subPart = mvFilteredSouscriptionMouvements
|
||||
.filter(r => !mvFilter.type || r.type === mvFilter.type)
|
||||
.map(r => ({ ...r, _projet: r.libelle || '' }));
|
||||
return [...drPart, ...corrPart, ...rembPart, ...subPart]
|
||||
.filter(r => !mvFilter.projet || r._projet === mvFilter.projet)
|
||||
.sort((a, b) =>
|
||||
b.date_operation.localeCompare(a.date_operation) || String(b.id).localeCompare(String(a.id))
|
||||
);
|
||||
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById]);
|
||||
|
||||
const {
|
||||
pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage,
|
||||
pageSize: mvPageSize, setPageSize: setMvPageSize,
|
||||
totalPages: mvTotalPages, totalItems: mvTotalItems, PAGE_SIZES: mvPageSizes,
|
||||
} = usePagination(mvTableRows, 'cl_pagesize_mvpm', [mvFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setMvSelectedRow(prev => {
|
||||
if (prev && mvTableRows.find(r => String(r.id) === String(prev.id))) return prev;
|
||||
return mvTableRows[0] || null;
|
||||
});
|
||||
}, [mvTableRows]);
|
||||
|
||||
const mvCloseEdit = () => { setMvModalOpen(false); setMvEditingId(null); setMvForm(emptyMouvementForm); setMvErr(null); };
|
||||
|
||||
const mvOpenEdit = (row) => {
|
||||
if (row.source === 'auto_remboursement') return; // retrait auto — non éditable ici
|
||||
setMvEditingId(row.id);
|
||||
setMvForm({
|
||||
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 || '',
|
||||
});
|
||||
setMvErr(null); setMvModalOpen(true);
|
||||
};
|
||||
|
||||
const mvOpenCorrection = async (row) => {
|
||||
const platId = row ? row.plateforme_id : mvForm.plateforme_id;
|
||||
const investId = row ? (row.investisseur_id || null) : (Number(mvForm.investisseur_id) || null);
|
||||
const type = row ? row.type : mvForm.type;
|
||||
const date = row ? row.date_operation : mvForm.date_operation;
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
setMvModalOpen(false);
|
||||
setMvEditingId(null);
|
||||
setMvCorrModal({
|
||||
open: true,
|
||||
plateforme_nom: plat?.nom || '',
|
||||
type_operation: type,
|
||||
computedBalance,
|
||||
date,
|
||||
platId: Number(platId),
|
||||
investisseurId: investId ? Number(investId) : null,
|
||||
});
|
||||
};
|
||||
|
||||
const mvSubmit = async (e) => {
|
||||
e?.preventDefault?.(); setMvErr(null);
|
||||
try {
|
||||
const payload = {
|
||||
investisseur_id: Number(mvForm.investisseur_id) || undefined,
|
||||
plateforme_id: Number(mvForm.plateforme_id),
|
||||
date_operation: mvForm.date_operation,
|
||||
type: mvForm.type,
|
||||
montant: Number(mvForm.montant),
|
||||
libelle: mvForm.libelle || undefined,
|
||||
reference: mvForm.reference || undefined,
|
||||
notes: mvForm.notes || undefined,
|
||||
};
|
||||
if (mvEditingId) {
|
||||
await api.put(`/depots-retraits/${mvEditingId}`, payload);
|
||||
mvCloseEdit(); await load();
|
||||
return;
|
||||
}
|
||||
// Pas de création depuis cet onglet — le bouton global "+ Nouveau dépôt/retrait"
|
||||
// (topbar) couvre déjà ce besoin et redirige vers /depots-retraits.
|
||||
await api.post('/depots-retraits', payload);
|
||||
mvCloseEdit(); await load();
|
||||
} catch (e) { setMvErr(e.message); }
|
||||
};
|
||||
|
||||
const mvHandleCorrectionConfirm = async (diff) => {
|
||||
if (Math.abs(diff) >= 0.005) {
|
||||
try {
|
||||
await api.post('/corrections', {
|
||||
investisseur_id: mvCorrModal.investisseurId,
|
||||
plateforme_id: mvCorrModal.platId,
|
||||
date: mvCorrModal.date,
|
||||
montant: diff,
|
||||
notes: `Correction solde porte-monnaie — ${mvCorrModal.type_operation === 'retrait' ? 'retrait' : 'dépôt'}`,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Correction creation failed:', e);
|
||||
}
|
||||
}
|
||||
setMvCorrModal(m => ({ ...m, open: false }));
|
||||
await load();
|
||||
};
|
||||
|
||||
const mvOpenRowMenu = (e, row) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMvOpenMenu({ row, x: rect.right, y: rect.bottom });
|
||||
};
|
||||
|
||||
const mvDeleteRow = (rowId) => {
|
||||
setMvDeleteConfirm({
|
||||
message: 'Supprimer définitivement ce mouvement ?',
|
||||
onConfirm: async () => {
|
||||
await api.del(`/depots-retraits/${rowId}`);
|
||||
setMvDeleteConfirm(null);
|
||||
setMvSelectedRow(null);
|
||||
await load();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const mvDeleteCorrection = (corrId) => {
|
||||
setMvDeleteConfirm({
|
||||
message: 'Supprimer définitivement cette correction de solde ?',
|
||||
onConfirm: async () => {
|
||||
await api.del(`/corrections/${corrId}`);
|
||||
setMvDeleteConfirm(null);
|
||||
setMvSelectedRow(null);
|
||||
await load();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const mvOnDelete = () => {
|
||||
if (!mvEditingId) return;
|
||||
setMvDeleteConfirm({
|
||||
message: 'Supprimer définitivement ce mouvement ?',
|
||||
onConfirm: async () => {
|
||||
await api.del(`/depots-retraits/${mvEditingId}`);
|
||||
setMvDeleteConfirm(null);
|
||||
mvCloseEdit(); setMvSelectedRow(null); await load();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!loading && plats.length === 0) {
|
||||
return (
|
||||
<>
|
||||
@@ -1073,6 +1467,8 @@ export default function Plateformes() {
|
||||
onClick={() => setActiveTab('capital-investi')}>Capital investi</button>
|
||||
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
||||
<button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button>
|
||||
</div>
|
||||
|
||||
{/* ====== ONGLET REMBOURSEMENTS ====== */}
|
||||
@@ -1952,6 +2348,451 @@ export default function Plateformes() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET MOUVEMENTS PORTE-MONNAIE ====== */}
|
||||
{activeTab === 'mouvements-pm' && (
|
||||
<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 Porte-monnaie</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title={mvListFocused ? 'Réduire' : 'Agrandir'}
|
||||
onClick={() => { const next = !mvListFocused; setMvListFocused(next); setMvPageSize(next ? 25 : 15); }}
|
||||
>
|
||||
{mvListFocused ? (
|
||||
<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={mvTableRows.length === 0}
|
||||
onCSV={() => dlBlob(mouvToCSV(mvTableRows), `Mouvements Porte-monnaie ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(mouvToXLS(mvTableRows, multiDetenteur), `Mouvements Porte-monnaie ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(mouvToJSON(mvTableRows), `Mouvements Porte-monnaie ${timestampSuffix()}.json`, 'application/json')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<label>Type de mouvement</label>
|
||||
<select value={mvFilter.type} onChange={e => setMvFilter({ ...mvFilter, 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 / Cashback</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 }}>
|
||||
<label>Plateforme</label>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
height: 'var(--input-h, 36px)', padding: '0 10px',
|
||||
borderRadius: 6, border: '1px solid var(--border)',
|
||||
background: 'var(--surface-2)', color: 'var(--text-muted)',
|
||||
fontSize: 'var(--fs-sm)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}} title="Déterminée par la plateforme sélectionnée en haut de page">
|
||||
{selectedPlatName || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 2, minWidth: 160 }}>
|
||||
<label>Projet</label>
|
||||
<select value={mvFilter.projet} onChange={e => setMvFilter(f => ({ ...f, projet: e.target.value }))}>
|
||||
<option value="">Tous</option>
|
||||
{mvProjets.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<label>Année</label>
|
||||
<select value={mvFilter.year} onChange={e => setMvFilter(f => ({ ...f, year: e.target.value }))}>
|
||||
<option value="">Toutes</option>
|
||||
{mvYears.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 110 }}>
|
||||
<label>Mois</label>
|
||||
<select value={mvFilter.month} onChange={e => setMvFilter({ ...mvFilter, 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 style={{ width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mvTableRows.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun mouvement'}
|
||||
</td></tr>
|
||||
)}
|
||||
{mvPagedTableRows.map(r => {
|
||||
if (r.type === 'correction') {
|
||||
const isPos = r.montant >= 0;
|
||||
return (
|
||||
<tr key={r.id}
|
||||
className={`dr-row${String(mvSelectedRow?.id) === String(r.id) ? ' dr-row-selected' : ''}`}
|
||||
onClick={() => setMvSelectedRow(String(mvSelectedRow?.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 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 => mvOpenRowMenu(e, r)}>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (r.type === 'remboursement' || r.type === 'bonus') {
|
||||
const isBonus = r.type === 'bonus';
|
||||
return (
|
||||
<tr key={r.id}
|
||||
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
|
||||
onClick={() => setMvSelectedRow(mvSelectedRow?.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
|
||||
? 'color-mix(in srgb, var(--warning) 18%, transparent)'
|
||||
: 'color-mix(in srgb, var(--success) 15%, transparent)',
|
||||
color: isBonus ? 'var(--warning)' : 'var(--success)',
|
||||
}}>{isBonus ? 'bonus' : 'remboursement'}</span>
|
||||
</td>
|
||||
<td className="text-muted">{r._projet || '—'}</td>
|
||||
<td className="num success">
|
||||
+ {fmtEUR(r.montant)}
|
||||
</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${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
|
||||
onClick={() => setMvSelectedRow(mvSelectedRow?.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 style={{ width: 36, textAlign: 'center' }} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (r.type === 'souscription') {
|
||||
return (
|
||||
<tr key={r.id}
|
||||
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
|
||||
onClick={() => setMvSelectedRow(mvSelectedRow?.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 style={{ width: 36, textAlign: 'center' }} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
const isAuto = r.source === 'auto_remboursement';
|
||||
return (
|
||||
<tr key={r.id}
|
||||
className={`dr-row${mvSelectedRow?.id === r.id ? ' dr-row-selected' : ''}`}
|
||||
onClick={() => setMvSelectedRow(mvSelectedRow?.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 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 => mvOpenRowMenu(e, r)}>⋮</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
page={mvPage} setPage={setMvPage}
|
||||
pageSize={mvPageSize} setPageSize={setMvPageSize}
|
||||
totalPages={mvTotalPages} totalItems={mvTotalItems}
|
||||
PAGE_SIZES={mvPageSizes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colonne droite 1/3 */}
|
||||
{mvTableRows.length > 0 && (
|
||||
<div className="dr-mouvements-detail">
|
||||
<DetailPanel
|
||||
row={mvSelectedRow}
|
||||
onEdit={mvOpenEdit}
|
||||
onDeleteCorrection={mvDeleteCorrection}
|
||||
onViewRemb={r => navigate(`/remboursements?edit-remb=${r._rembId}`)}
|
||||
onViewInvestissement={r => navigate(`/investissements/${r._invId}`)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal création / édition d'un mouvement (onglet "Mouvements Porte-monnaie") */}
|
||||
<Modal
|
||||
open={mvModalOpen}
|
||||
title="Modifier le mouvement"
|
||||
onClose={mvCloseEdit}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="danger" onClick={mvOnDelete}>Supprimer</button>
|
||||
<button onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={mvCloseEdit}>Annuler</button>
|
||||
<button className="primary" onClick={mvSubmit}>Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={mvSubmit}>
|
||||
{mvErr && <div className="error">{mvErr}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={mvForm.plateforme_id} onChange={e => {
|
||||
const platId = e.target.value;
|
||||
setMvForm(f => ({ ...f, plateforme_id: platId, investisseur_id: mvInvestisseurForPlat(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={mvForm.date_operation} onChange={e => setMvForm({ ...mvForm, date_operation: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Type *</label>
|
||||
<select required value={mvForm.type} onChange={e => setMvForm({ ...mvForm, 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={mvForm.montant} onChange={e => setMvForm({ ...mvForm, montant: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Libellé</label>
|
||||
<input value={mvForm.libelle} onChange={e => setMvForm({ ...mvForm, libelle: e.target.value })} placeholder="Ex. Virement initial" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Référence</label>
|
||||
<input value={mvForm.reference} onChange={e => setMvForm({ ...mvForm, reference: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Détenteur *</label>
|
||||
<select required value={mvForm.investisseur_id} onChange={e => setMvForm({ ...mvForm, 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={mvForm.notes} onChange={e => setMvForm({ ...mvForm, notes: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{mvOpenMenu && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setMvOpenMenu(null)} />
|
||||
<div style={{
|
||||
position: 'fixed', left: mvOpenMenu.x, top: mvOpenMenu.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,
|
||||
}}>
|
||||
{mvOpenMenu.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={() => { setMvOpenMenu(null); mvOpenEdit(mvOpenMenu.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>
|
||||
)}
|
||||
{mvOpenMenu.row.type !== 'correction' && mvOpenMenu.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={() => { setMvOpenMenu(null); mvOpenCorrection(mvOpenMenu.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 = mvOpenMenu.row;
|
||||
setMvOpenMenu(null);
|
||||
if (row.type === 'correction') mvDeleteCorrection(row._correctionId);
|
||||
else mvDeleteRow(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={mvCorrModal.open}
|
||||
plateforme_nom={mvCorrModal.plateforme_nom}
|
||||
type_operation={mvCorrModal.type_operation}
|
||||
computedBalance={mvCorrModal.computedBalance}
|
||||
date={mvCorrModal.date}
|
||||
onClose={() => { setMvCorrModal(m => ({ ...m, open: false })); load(); }}
|
||||
onConfirm={mvHandleCorrectionConfirm}
|
||||
/>
|
||||
|
||||
{mvDeleteConfirm && (
|
||||
<ConfirmModal
|
||||
open={true}
|
||||
message={mvDeleteConfirm?.message}
|
||||
onConfirm={mvDeleteConfirm?.onConfirm}
|
||||
onCancel={() => setMvDeleteConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Modal saisie/édition remboursement (section "Mois en cours") ── */}
|
||||
<RembFormModal
|
||||
open={rembModalOpen}
|
||||
|
||||
Reference in new issue
Block a user