Gestion du Porte-monnaie Page plateforme

This commit is contained in:
ocroguennec committed 2026-08-16 11:03:00 +02:00
1 parent c2d5712e2c
commit 9ef32cef90
3 files changed
+1272 -417

No files matched your search

+841
View File
@@ -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}