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

@@ -0,0 +1,421 @@
import { useEffect, useRef, useState } from 'react';
import * as XLSX from 'xlsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
/* ── Éléments partagés entre l'onglet "Mouvements" de DepotsRetraits.jsx et l'onglet
"Mouvements Porte-monnaie" de Plateformes.jsx. Toute évolution ici s'applique aux deux
pages — c'est le but : éviter la dérive entre les deux implémentations. ── */
/* ── Helpers export ──────────────────────────────────────────── */
/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
export function timestampSuffix() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
export function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
export const DEBIT_TYPES = new Set(['retrait', 'souscription', 'prelevement_ir', 'prelevement_csg', 'prelevement_local']);
// Montant signé, identique à ce qui est affiché à l'écran (+ crédit / − débit),
// pour permettre de sommer directement l'extraction et contrôler le solde.
// depot/remboursement/bonus → crédit (+) ; retrait/souscription/prélèvements → débit (−) ;
// correction → déjà signé en base (montant peut être positif ou négatif).
export function signedMontant(r) {
if (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant);
if (r.type === 'correction') return r.montant;
return Math.abs(r.montant);
}
export const MOUV_TYPE_LABELS = {
depot: 'Dépôt', retrait: 'Retrait', correction: 'Correction de solde',
remboursement: 'Remboursement', bonus: 'Bonus / Cashback', souscription: 'Souscription',
prelevement_ir: 'Prélèvement IR', prelevement_csg: 'Prélèvement CSG',
prelevement_local: "Prélèvement impôts à l'étranger",
};
export function mouvToCSV(rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Date','Plateforme','Type','Projet','Montant (€)','Libellé','Référence','Source'];
const data = rows.map(r => [
r.date_operation, r.plateforme_nom||'', MOUV_TYPE_LABELS[r.type] || r.type, r._projet||'',
String(signedMontant(r)).replace('.',','), r.libelle||'', r.reference||'', r.source||'',
]);
return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n');
}
export function mouvToXLS(rows, multiDetenteur) {
const data = rows.map(r => ({
'Date': r.date_operation,
'Plateforme': r.plateforme_nom || '',
...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}),
'Type': MOUV_TYPE_LABELS[r.type] || r.type,
'Projet': r._projet || '',
'Montant (€)': signedMontant(r),
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Mouvements');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
export function mouvToJSON(rows) {
const data = rows.map(r => ({
date_operation: r.date_operation,
plateforme_nom: r.plateforme_nom || '',
type: r.type,
projet: r._projet || null,
montant: signedMontant(r),
libelle: r.libelle || null,
reference: r.reference || null,
source: r.source || null,
}));
return JSON.stringify(data, null, 2);
}
/* ── Constantes ──────────────────────────────────────────────── */
export const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
export const emptyMouvementForm = { investisseur_id: '', plateforme_id: '', date_operation: today(), type: 'depot', montant: '', libelle: '', reference: '', notes: '' };
/* ── Menu déroulant d'export CSV/XLS/JSON ────────────────────── */
export function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title="Exporter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
</svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
/* ── Panneau de détail d'un mouvement ────────────────────────── */
export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvestissement }) {
if (!row) return (
<div className="dr-detail dr-detail-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25, marginBottom: 8 }}>
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>Sélectionnez un mouvement</span>
</div>
);
const isCorrection = row.type === 'correction';
const isRemb = row.type === 'remboursement' || row.type === 'bonus';
const isBonus = row.type === 'bonus';
const isSouscription = row.type === 'souscription';
const isPrelevement = row.type === 'prelevement_ir' || row.type === 'prelevement_csg' || row.type === 'prelevement_local';
const isAuto = row.source === 'auto_remboursement';
const typeLabel = isCorrection ? 'Correction de solde'
: isBonus ? 'Bonus / Cashback'
: row.type === 'remboursement' ? 'Remboursement (porte-monnaie, brut)'
: row.type === 'prelevement_ir' ? 'Prélèvement impôt sur le revenu'
: row.type === 'prelevement_csg' ? 'Prélèvement CSG'
: row.type === 'prelevement_local' ? "Prélèvement impôts à l'étranger"
: isSouscription ? (row._isReinvest ? 'Réinvestissement' : 'Souscription à un investissement')
: row.type === 'depot' ? 'Dépôt' : 'Retrait';
const editLabel = isCorrection ? 'Modifier la correction de solde'
: row.type === 'depot' ? 'Modifier le dépôt' : 'Modifier le retrait';
// Débit (−) pour retrait/souscription/prélèvements, crédit (+) pour dépôt/remboursement/bonus ;
// la correction est déjà signée en base.
const isDebitType = row.type === 'retrait' || isSouscription || isPrelevement;
const isPos = isCorrection ? row.montant >= 0 : !isDebitType;
const sign = isPos ? '+' : '−';
const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`;
const montantColor = undefined;
const fields = [
{ label: 'Plateforme', value: row.plateforme_nom },
{ label: 'Type de mouvement', value: typeLabel },
{ label: 'Montant', value: montantFmt, color: montantColor },
{ label: 'Date', value: fmtDate(row.date_operation) },
isRemb && !isBonus && row.libelle && { label: 'Projet', value: row.libelle },
isRemb && !isBonus && { label: 'Capital', value: fmtEUR(row._capital) },
isRemb && !isBonus && row._cashback > 0 && { label: 'Cashback', value: fmtEUR(row._cashback) },
isRemb && !isBonus && { label: 'Intérêts bruts', value: fmtEUR(row._interets_bruts) },
isRemb && !isBonus && { label: 'Traitement fiscal', value: row._isFlatTax
? 'Flat tax France (PFU prélevé à la source — voir prélèvements associés)'
: row._taxeLocale > 0
? "Étranger avec retenue fiscale locale — voir prélèvement associé"
: 'Étranger — indicatif, aucun prélèvement à la source ici' },
isPrelevement && row.libelle && { label: 'Projet', value: row.libelle },
isSouscription && row.libelle && { label: 'Projet', value: row.libelle },
!isCorrection && !isRemb && !isSouscription && !isPrelevement && row.libelle && { label: 'Libellé', value: row.libelle },
!isCorrection && !isRemb && row.reference && { label: 'Référence', value: row.reference },
row.notes && { label: 'Notes', value: row.notes },
].filter(Boolean);
return (
<div className="dr-detail">
<div className="dr-detail-title">Détails du mouvement</div>
{isAuto && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: 'color-mix(in srgb, var(--primary) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: 'var(--primary)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
Retrait automatique — généré lors d'un remboursement sur compte courant.
Modifiable via la page Remboursements ou depuis la fiche détail de l'investissement.
</span>
</div>
)}
{isRemb && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: 'color-mix(in srgb, var(--success) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--success) 25%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: 'var(--success)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
{isBonus
? 'Bonus crédité directement au porte-monnaie de la plateforme.'
: row._isAutoSweep
? "Remboursement (montant brut) crédité sur le porte-monnaie puis reversé aussitôt sur le compte courant (retrait automatique associé). Les prélèvements éventuels apparaissent en lignes séparées. Modifiable depuis la page Remboursements."
: "Montant brut du remboursement versé sur le porte-monnaie de la plateforme (méthode « portefeuille ») — les prélèvements éventuels apparaissent en lignes séparées. Modifiable depuis la page Remboursements."}
</span>
</div>
)}
{isPrelevement && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: `color-mix(in srgb, ${row.type === 'prelevement_local' ? '#f97316' : 'var(--danger)'} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${row.type === 'prelevement_local' ? '#f97316' : 'var(--danger)'} 25%, transparent)`,
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: row.type === 'prelevement_local' ? '#f97316' : 'var(--danger)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
{row.type === 'prelevement_local'
? "Retenue fiscale prélevée localement à l'étranger, avant que le remboursement n'atteigne le porte-monnaie de la plateforme."
: "Prélèvement forfaitaire unique (PFU France) retenu à la source sur ce remboursement. Modifiable depuis la page Remboursements."}
</span>
</div>
)}
{isSouscription && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: 'color-mix(in srgb, var(--danger) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--danger) 25%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: 'var(--danger)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
{row._isReinvest
? "Réinvestissement automatique du capital d'intérêts dans un nouvel investissement — capital sorti du porte-monnaie de la plateforme."
: "Souscription à un investissement — capital sorti du porte-monnaie de la plateforme."}
</span>
</div>
)}
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
<span className="dr-detail-label">{f.label}</span>
<span className="dr-detail-value" style={f.color ? { color: f.color, fontWeight: 600 } : undefined}>{f.value}</span>
</div>
))}
</div>
<div className="dr-detail-footer">
{isAuto ? null : (isRemb || isPrelevement) ? (
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
Voir / modifier le remboursement
</button>
) : isSouscription ? (
<button className="dr-detail-edit-btn" onClick={() => onViewInvestissement(row)}>
Voir l'investissement
</button>
) : isCorrection ? (
<button className="dr-detail-edit-btn" onClick={() => onDeleteCorrection(row._correctionId)}>
Supprimer la correction
</button>
) : (
<button className="dr-detail-edit-btn" onClick={() => onEdit(row)}>
{editLabel}
</button>
)}
</div>
</div>
);
}
/** Modal de correction de solde porte-monnaie (micro-écarts flat_tax) */
export function CorrectionModal({ open, onClose, onConfirm,
plateforme_nom, type_operation, computedBalance, date }) {
const isRetrait = type_operation === 'retrait';
// Défaut : 0 pour un retrait (vider le PM), solde calculé pour un dépôt
const [declared, setDeclared] = useState('');
useEffect(() => {
if (open) setDeclared(isRetrait ? '0' : String(Math.round(computedBalance * 100) / 100));
}, [open, isRetrait, computedBalance]);
const diff = Math.round((Number(declared) - computedBalance) * 100) / 100;
const hasDiff = Math.abs(diff) >= 0.005;
const handleConfirm = () => {
onConfirm(hasDiff ? diff : 0);
};
if (!open) return null;
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.4)',
}}>
<div style={{
background: 'var(--surface)', borderRadius: 10,
padding: '24px 28px', width: 420, maxWidth: '90vw',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
border: '1px solid var(--border)',
}}>
<div style={{ marginBottom: 6, fontWeight: 700, fontSize: 'var(--fs-base)' }}>
Vérification du solde porte-monnaie
</div>
<div style={{
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
marginBottom: 18, lineHeight: 1.5,
}}>
Après ce {isRetrait ? 'retrait' : 'dépôt'} sur <strong>{plateforme_nom}</strong>,
quel est le solde indiqué sur la plateforme ?
{' '}Si différent du solde calculé, une correction sera créée automatiquement.
</div>
<div style={{ marginBottom: 12 }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderRadius: 6, marginBottom: 6,
background: 'var(--surface-2)', fontSize: 'var(--fs-sm)',
}}>
<span style={{ color: 'var(--text-muted)' }}>Solde calculé</span>
<span style={{ fontWeight: 600 }}>{Math.round(computedBalance * 100) / 100} €</span>
</div>
<label style={{ fontSize: 'var(--fs-sm)', fontWeight: 500, display: 'block', marginBottom: 4 }}>
Solde constaté sur la plateforme (€)
</label>
<input
type="number" step="0.01"
value={declared}
onChange={e => setDeclared(e.target.value)}
style={{ width: '100%', fontSize: 'var(--fs-base)' }}
autoFocus
/>
</div>
{hasDiff && (
<div style={{
padding: '8px 12px', borderRadius: 6, marginBottom: 12,
background: diff > 0
? 'color-mix(in srgb, var(--success) 12%, transparent)'
: 'color-mix(in srgb, var(--danger) 12%, transparent)',
border: `1px solid ${diff > 0 ? 'color-mix(in srgb, var(--success) 30%, transparent)' : 'color-mix(in srgb, var(--danger) 30%, transparent)'}`,
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
}}>
Une correction de <strong style={{ color: diff > 0 ? 'var(--success)' : 'var(--danger)' }}>
{diff > 0 ? '+' : ''}{diff} €
</strong> sera créée.
</div>
)}
{!hasDiff && declared !== '' && (
<div style={{
padding: '8px 12px', borderRadius: 6, marginBottom: 12,
background: 'color-mix(in srgb, var(--primary) 10%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
}}>
Solde identique au calcul — aucune correction nécessaire.
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button onClick={onClose}>Passer</button>
<button className="primary" onClick={handleConfirm}>
{hasDiff ? 'Créer la correction' : 'OK'}
</button>
</div>
</div>
</div>
);
}
+10 -417
View File
@@ -14,21 +14,11 @@ import { fmtEUR, fmtDate, today } from '../utils/format.js';
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
import * as XLSX from 'xlsx';
/* ── Helpers export ──────────────────────────────────────────── */
/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
function timestampSuffix() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
import {
DEBIT_TYPES, signedMontant, MOUV_TYPE_LABELS, mouvToCSV, mouvToXLS, mouvToJSON,
ExportDropdown, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm,
timestampSuffix, dlBlob,
} from '../components/MouvementsShared.jsx';
/* ── Indicateur de progression ── */
function TrendBadge({ current, prev, invert = false }) {
@@ -58,50 +48,6 @@ function TrendBadge({ current, prev, invert = false }) {
const DEBIT_TYPES = new Set(['retrait', 'souscription', 'prelevement_ir', 'prelevement_csg', 'prelevement_local']);
// Montant signé, identique à ce qui est affiché à l'écran (+ crédit / − débit),
// pour permettre de sommer directement l'extraction et contrôler le solde.
// depot/remboursement/bonus → crédit (+) ; retrait/souscription/prélèvements → débit (−) ;
// correction → déjà signé en base (montant peut être positif ou négatif).
function signedMontant(r) {
if (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant);
if (r.type === 'correction') return r.montant;
return Math.abs(r.montant);
}
function mouvToCSV(rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Date','Plateforme','Type','Projet','Montant (€)','Libellé','Référence','Source'];
const data = rows.map(r => [
r.date_operation, r.plateforme_nom||'', MOUV_TYPE_LABELS[r.type] || r.type, r._projet||'',
String(signedMontant(r)).replace('.',','), r.libelle||'', r.reference||'', r.source||'',
]);
return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n');
}
const MOUV_TYPE_LABELS = {
depot: 'Dépôt', retrait: 'Retrait', correction: 'Correction de solde',
remboursement: 'Remboursement', bonus: 'Bonus / Cashback', souscription: 'Souscription',
prelevement_ir: 'Prélèvement IR', prelevement_csg: 'Prélèvement CSG',
prelevement_local: "Prélèvement impôts à l'étranger",
};
function mouvToXLS(rows, multiDetenteur) {
const data = rows.map(r => ({
'Date': r.date_operation,
'Plateforme': r.plateforme_nom || '',
...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}),
'Type': MOUV_TYPE_LABELS[r.type] || r.type,
'Projet': r._projet || '',
'Montant (€)': signedMontant(r),
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Mouvements');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function platToCSV(rows) {
const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0);
const BOM = ''; const sep = ';';
@@ -132,20 +78,6 @@ function platToXLS(rows) {
XLSX.utils.book_append_sheet(wb, ws, 'Plateformes');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function mouvToJSON(rows) {
const data = rows.map(r => ({
date_operation: r.date_operation,
plateforme_nom: r.plateforme_nom || '',
type: r.type,
projet: r._projet || null,
montant: signedMontant(r),
libelle: r.libelle || null,
reference: r.reference || null,
source: r.source || null,
}));
return JSON.stringify(data, null, 2);
}
function platToJSON(rows) {
const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0);
const data = rows.map(p => ({
@@ -159,345 +91,6 @@ function platToJSON(rows) {
return JSON.stringify(data, null, 2);
}
function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title="Exporter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
</svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
/* ── Panneau de détail d'un mouvement ────────────────────────── */
function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvestissement }) {
if (!row) return (
<div className="dr-detail dr-detail-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25, marginBottom: 8 }}>
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>Sélectionnez un mouvement</span>
</div>
);
const isCorrection = row.type === 'correction';
const isRemb = row.type === 'remboursement' || row.type === 'bonus';
const isBonus = row.type === 'bonus';
const isSouscription = row.type === 'souscription';
const isPrelevement = row.type === 'prelevement_ir' || row.type === 'prelevement_csg' || row.type === 'prelevement_local';
const isAuto = row.source === 'auto_remboursement';
const typeLabel = isCorrection ? 'Correction de solde'
: isBonus ? 'Bonus / Cashback'
: row.type === 'remboursement' ? 'Remboursement (porte-monnaie, brut)'
: row.type === 'prelevement_ir' ? 'Prélèvement impôt sur le revenu'
: row.type === 'prelevement_csg' ? 'Prélèvement CSG'
: row.type === 'prelevement_local' ? "Prélèvement impôts à l'étranger"
: isSouscription ? (row._isReinvest ? 'Réinvestissement' : 'Souscription à un investissement')
: row.type === 'depot' ? 'Dépôt' : 'Retrait';
const editLabel = isCorrection ? 'Modifier la correction de solde'
: row.type === 'depot' ? 'Modifier le dépôt' : 'Modifier le retrait';
// Débit (−) pour retrait/souscription/prélèvements, crédit (+) pour dépôt/remboursement/bonus ;
// la correction est déjà signée en base.
const isDebitType = row.type === 'retrait' || isSouscription || isPrelevement;
const isPos = isCorrection ? row.montant >= 0 : !isDebitType;
const sign = isPos ? '+' : '−';
const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`;
const montantColor = undefined;
const fields = [
{ label: 'Plateforme', value: row.plateforme_nom },
{ label: 'Type de mouvement', value: typeLabel },
{ label: 'Montant', value: montantFmt, color: montantColor },
{ label: 'Date', value: fmtDate(row.date_operation) },
isRemb && !isBonus && row.libelle && { label: 'Projet', value: row.libelle },
isRemb && !isBonus && { label: 'Capital', value: fmtEUR(row._capital) },
isRemb && !isBonus && row._cashback > 0 && { label: 'Cashback', value: fmtEUR(row._cashback) },
isRemb && !isBonus && { label: 'Intérêts bruts', value: fmtEUR(row._interets_bruts) },
isRemb && !isBonus && { label: 'Traitement fiscal', value: row._isFlatTax
? 'Flat tax France (PFU prélevé à la source — voir prélèvements associés)'
: row._taxeLocale > 0
? "Étranger avec retenue fiscale locale — voir prélèvement associé"
: 'Étranger — indicatif, aucun prélèvement à la source ici' },
isPrelevement && row.libelle && { label: 'Projet', value: row.libelle },
isSouscription && row.libelle && { label: 'Projet', value: row.libelle },
!isCorrection && !isRemb && !isSouscription && !isPrelevement && row.libelle && { label: 'Libellé', value: row.libelle },
!isCorrection && !isRemb && row.reference && { label: 'Référence', value: row.reference },
row.notes && { label: 'Notes', value: row.notes },
].filter(Boolean);
return (
<div className="dr-detail">
<div className="dr-detail-title">Détails du mouvement</div>
{isAuto && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: 'color-mix(in srgb, var(--primary) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: 'var(--primary)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
Retrait automatique — généré lors d'un remboursement sur compte courant.
Modifiable via la page Remboursements ou depuis la fiche détail de l'investissement.
</span>
</div>
)}
{isRemb && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: 'color-mix(in srgb, var(--success) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--success) 25%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: 'var(--success)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
{isBonus
? 'Bonus crédité directement au porte-monnaie de la plateforme.'
: row._isAutoSweep
? "Remboursement (montant brut) crédité sur le porte-monnaie puis reversé aussitôt sur le compte courant (retrait automatique associé). Les prélèvements éventuels apparaissent en lignes séparées. Modifiable depuis la page Remboursements."
: "Montant brut du remboursement versé sur le porte-monnaie de la plateforme (méthode « portefeuille ») — les prélèvements éventuels apparaissent en lignes séparées. Modifiable depuis la page Remboursements."}
</span>
</div>
)}
{isPrelevement && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: `color-mix(in srgb, ${row.type === 'prelevement_local' ? '#f97316' : 'var(--danger)'} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${row.type === 'prelevement_local' ? '#f97316' : 'var(--danger)'} 25%, transparent)`,
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: row.type === 'prelevement_local' ? '#f97316' : 'var(--danger)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
{row.type === 'prelevement_local'
? "Retenue fiscale prélevée localement à l'étranger, avant que le remboursement n'atteigne le porte-monnaie de la plateforme."
: "Prélèvement forfaitaire unique (PFU France) retenu à la source sur ce remboursement. Modifiable depuis la page Remboursements."}
</span>
</div>
)}
{isSouscription && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: 'color-mix(in srgb, var(--danger) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--danger) 25%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
lineHeight: 1.4,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ marginTop: 1, flexShrink: 0, color: 'var(--danger)' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
{row._isReinvest
? "Réinvestissement automatique du capital d'intérêts dans un nouvel investissement — capital sorti du porte-monnaie de la plateforme."
: "Souscription à un investissement — capital sorti du porte-monnaie de la plateforme."}
</span>
</div>
)}
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
<span className="dr-detail-label">{f.label}</span>
<span className="dr-detail-value" style={f.color ? { color: f.color, fontWeight: 600 } : undefined}>{f.value}</span>
</div>
))}
</div>
<div className="dr-detail-footer">
{isAuto ? null : (isRemb || isPrelevement) ? (
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
Voir / modifier le remboursement
</button>
) : isSouscription ? (
<button className="dr-detail-edit-btn" onClick={() => onViewInvestissement(row)}>
Voir l'investissement
</button>
) : isCorrection ? (
<button className="dr-detail-edit-btn" onClick={() => onDeleteCorrection(row._correctionId)}>
Supprimer la correction
</button>
) : (
<button className="dr-detail-edit-btn" onClick={() => onEdit(row)}>
{editLabel}
</button>
)}
</div>
</div>
);
}
/* ── Constantes ──────────────────────────────────────────────── */
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const empty = { investisseur_id: '', plateforme_id: '', date_operation: today(), type: 'depot', montant: '', libelle: '', reference: '', notes: '' };
/** Modal de correction de solde porte-monnaie (micro-écarts flat_tax) */
function CorrectionModal({ open, onClose, onConfirm,
plateforme_nom, type_operation, computedBalance, date }) {
const isRetrait = type_operation === 'retrait';
// Défaut : 0 pour un retrait (vider le PM), solde calculé pour un dépôt
const [declared, setDeclared] = useState('');
useEffect(() => {
if (open) setDeclared(isRetrait ? '0' : String(Math.round(computedBalance * 100) / 100));
}, [open, isRetrait, computedBalance]);
const diff = Math.round((Number(declared) - computedBalance) * 100) / 100;
const hasDiff = Math.abs(diff) >= 0.005;
const handleConfirm = () => {
onConfirm(hasDiff ? diff : 0);
};
if (!open) return null;
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.4)',
}}>
<div style={{
background: 'var(--surface)', borderRadius: 10,
padding: '24px 28px', width: 420, maxWidth: '90vw',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
border: '1px solid var(--border)',
}}>
<div style={{ marginBottom: 6, fontWeight: 700, fontSize: 'var(--fs-base)' }}>
Vérification du solde porte-monnaie
</div>
<div style={{
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
marginBottom: 18, lineHeight: 1.5,
}}>
Après ce {isRetrait ? 'retrait' : 'dépôt'} sur <strong>{plateforme_nom}</strong>,
quel est le solde indiqué sur la plateforme ?
{' '}Si différent du solde calculé, une correction sera créée automatiquement.
</div>
<div style={{ marginBottom: 12 }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderRadius: 6, marginBottom: 6,
background: 'var(--surface-2)', fontSize: 'var(--fs-sm)',
}}>
<span style={{ color: 'var(--text-muted)' }}>Solde calculé</span>
<span style={{ fontWeight: 600 }}>{Math.round(computedBalance * 100) / 100} €</span>
</div>
<label style={{ fontSize: 'var(--fs-sm)', fontWeight: 500, display: 'block', marginBottom: 4 }}>
Solde constaté sur la plateforme (€)
</label>
<input
type="number" step="0.01"
value={declared}
onChange={e => setDeclared(e.target.value)}
style={{ width: '100%', fontSize: 'var(--fs-base)' }}
autoFocus
/>
</div>
{hasDiff && (
<div style={{
padding: '8px 12px', borderRadius: 6, marginBottom: 12,
background: diff > 0
? 'color-mix(in srgb, var(--success) 12%, transparent)'
: 'color-mix(in srgb, var(--danger) 12%, transparent)',
border: `1px solid ${diff > 0 ? 'color-mix(in srgb, var(--success) 30%, transparent)' : 'color-mix(in srgb, var(--danger) 30%, transparent)'}`,
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
}}>
Une correction de <strong style={{ color: diff > 0 ? 'var(--success)' : 'var(--danger)' }}>
{diff > 0 ? '+' : ''}{diff} €
</strong> sera créée.
</div>
)}
{!hasDiff && declared !== '' && (
<div style={{
padding: '8px 12px', borderRadius: 6, marginBottom: 12,
background: 'color-mix(in srgb, var(--primary) 10%, transparent)',
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
}}>
Solde identique au calcul — aucune correction nécessaire.
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button onClick={onClose}>Passer</button>
<button className="primary" onClick={handleConfirm}>
{hasDiff ? 'Créer la correction' : 'OK'}
</button>
</div>
</div>
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function DepotsRetraits() {
const { activeId, activeView, investisseurs } = useInvestisseur();
@@ -513,7 +106,7 @@ export default function DepotsRetraits() {
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' });
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [form, setForm] = useState(emptyMouvementForm);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
@@ -1030,7 +623,7 @@ export default function DepotsRetraits() {
if (plats.length > 0) {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
} else {
@@ -1043,7 +636,7 @@ export default function DepotsRetraits() {
pendingNew.current = false;
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
}, [plats]); // eslint-disable-line
@@ -1052,7 +645,7 @@ export default function DepotsRetraits() {
const openNew = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null); setModalOpen(true);
};
@@ -1103,7 +696,7 @@ export default function DepotsRetraits() {
});
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); };
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); };
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
+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}