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 DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
|
||||||
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
|
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
/* ── Helpers export ──────────────────────────────────────────── */
|
DEBIT_TYPES, signedMontant, MOUV_TYPE_LABELS, mouvToCSV, mouvToXLS, mouvToJSON,
|
||||||
/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
|
ExportDropdown, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm,
|
||||||
function timestampSuffix() {
|
timestampSuffix, dlBlob,
|
||||||
const d = new Date();
|
} from '../components/MouvementsShared.jsx';
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Indicateur de progression ── */
|
/* ── Indicateur de progression ── */
|
||||||
function TrendBadge({ current, prev, invert = false }) {
|
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) {
|
function platToCSV(rows) {
|
||||||
const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0);
|
const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0);
|
||||||
const BOM = ''; const sep = ';';
|
const BOM = ''; const sep = ';';
|
||||||
@@ -132,20 +78,6 @@ function platToXLS(rows) {
|
|||||||
XLSX.utils.book_append_sheet(wb, ws, 'Plateformes');
|
XLSX.utils.book_append_sheet(wb, ws, 'Plateformes');
|
||||||
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
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) {
|
function platToJSON(rows) {
|
||||||
const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0);
|
const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0);
|
||||||
const data = rows.map(p => ({
|
const data = rows.map(p => ({
|
||||||
@@ -159,345 +91,6 @@ function platToJSON(rows) {
|
|||||||
return JSON.stringify(data, null, 2);
|
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 ─────────────────────────────────────── */
|
/* ── Composant principal ─────────────────────────────────────── */
|
||||||
export default function DepotsRetraits() {
|
export default function DepotsRetraits() {
|
||||||
const { activeId, activeView, investisseurs } = useInvestisseur();
|
const { activeId, activeView, investisseurs } = useInvestisseur();
|
||||||
@@ -513,7 +106,7 @@ export default function DepotsRetraits() {
|
|||||||
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' });
|
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' });
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [form, setForm] = useState(empty);
|
const [form, setForm] = useState(emptyMouvementForm);
|
||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedRow, setSelectedRow] = useState(null);
|
const [selectedRow, setSelectedRow] = useState(null);
|
||||||
@@ -1030,7 +623,7 @@ export default function DepotsRetraits() {
|
|||||||
if (plats.length > 0) {
|
if (plats.length > 0) {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
const firstId = plats[0]?.id || '';
|
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);
|
setErr(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -1043,7 +636,7 @@ export default function DepotsRetraits() {
|
|||||||
pendingNew.current = false;
|
pendingNew.current = false;
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
const firstId = plats[0]?.id || '';
|
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);
|
setErr(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}, [plats]); // eslint-disable-line
|
}, [plats]); // eslint-disable-line
|
||||||
@@ -1052,7 +645,7 @@ export default function DepotsRetraits() {
|
|||||||
const openNew = () => {
|
const openNew = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
const firstId = plats[0]?.id || '';
|
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);
|
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) => {
|
const submit = async (e) => {
|
||||||
e?.preventDefault?.(); setErr(null);
|
e?.preventDefault?.(); setErr(null);
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.
|
|||||||
import { xirr } from '../utils/xirr.js';
|
import { xirr } from '../utils/xirr.js';
|
||||||
import { usePagination } from '../hooks/usePagination.js';
|
import { usePagination } from '../hooks/usePagination.js';
|
||||||
import Pagination from '../components/Pagination.jsx';
|
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/';
|
const LOGOS_BASE = '/api/logos/';
|
||||||
|
|
||||||
@@ -867,6 +874,393 @@ export default function Plateformes() {
|
|||||||
[allDepots, allRembs, allRows, allCorrections, plats, filteredPlatIds, prevYearLabel]
|
[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) {
|
if (!loading && plats.length === 0) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1073,6 +1467,8 @@ export default function Plateformes() {
|
|||||||
onClick={() => setActiveTab('capital-investi')}>Capital investi</button>
|
onClick={() => setActiveTab('capital-investi')}>Capital investi</button>
|
||||||
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
||||||
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
||||||
|
<button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ====== ONGLET REMBOURSEMENTS ====== */}
|
{/* ====== ONGLET REMBOURSEMENTS ====== */}
|
||||||
@@ -1952,6 +2348,451 @@ export default function Plateformes() {
|
|||||||
</div>
|
</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") ── */}
|
{/* ── Modal saisie/édition remboursement (section "Mois en cours") ── */}
|
||||||
<RembFormModal
|
<RembFormModal
|
||||||
open={rembModalOpen}
|
open={rembModalOpen}
|
||||||
|
|||||||
Reference in new issue
Block a user