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