From 9ef32cef9061d9851941ffba7439ab866901e609 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sun, 16 Aug 2026 11:03:00 +0200 Subject: [PATCH] Gestion du Porte-monnaie Page plateforme --- frontend/src/components/MouvementsShared.jsx | 421 ++++++++++ frontend/src/pages/DepotsRetraits.jsx | 427 +--------- frontend/src/pages/Plateformes.jsx | 841 +++++++++++++++++++ 3 files changed, 1272 insertions(+), 417 deletions(-) create mode 100644 frontend/src/components/MouvementsShared.jsx diff --git a/frontend/src/components/MouvementsShared.jsx b/frontend/src/components/MouvementsShared.jsx new file mode 100644 index 0000000..b2caf2e --- /dev/null +++ b/frontend/src/components/MouvementsShared.jsx @@ -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 ( +
+ + {open && ( +
+ + + +
+ )} +
+ ); +} + +/* ── Panneau de détail d'un mouvement ────────────────────────── */ +export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvestissement }) { + if (!row) return ( +
+ + + + Sélectionnez un mouvement +
+ ); + + 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 ( +
+
Détails du mouvement
+ + {isAuto && ( +
+ + + + + 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. + +
+ )} + + {isRemb && ( +
+ + + + + {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."} + +
+ )} + + {isPrelevement && ( +
+ + + + + {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."} + +
+ )} + + {isSouscription && ( +
+ + + + + {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."} + +
+ )} + +
+ {fields.map(f => ( +
+ {f.label} + {f.value} +
+ ))} +
+ +
+ {isAuto ? null : (isRemb || isPrelevement) ? ( + + ) : isSouscription ? ( + + ) : isCorrection ? ( + + ) : ( + + )} +
+
+ ); +} + +/** 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 ( +
+
+
+ Vérification du solde porte-monnaie +
+
+ Après ce {isRetrait ? 'retrait' : 'dépôt'} sur {plateforme_nom}, + quel est le solde indiqué sur la plateforme ? + {' '}Si différent du solde calculé, une correction sera créée automatiquement. +
+ +
+
+ Solde calculé + {Math.round(computedBalance * 100) / 100} € +
+ + setDeclared(e.target.value)} + style={{ width: '100%', fontSize: 'var(--fs-base)' }} + autoFocus + /> +
+ + {hasDiff && ( +
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 0 ? 'var(--success)' : 'var(--danger)' }}> + {diff > 0 ? '+' : ''}{diff} € + sera créée. +
+ )} + {!hasDiff && declared !== '' && ( +
+ Solde identique au calcul — aucune correction nécessaire. +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index 589085b..b21cc83 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -14,21 +14,11 @@ import { fmtEUR, fmtDate, today } from '../utils/format.js'; import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx'; import SuiviObjectifs from '../components/SuiviObjectifs.jsx'; import * as XLSX from 'xlsx'; - -/* ── Helpers export ──────────────────────────────────────────── */ -/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */ -function timestampSuffix() { - const d = new Date(); - const pad = n => String(n).padStart(2, '0'); - return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; -} -function dlBlob(content, filename, type) { - const blob = new Blob([content], { type }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = filename; a.click(); - URL.revokeObjectURL(url); -} +import { + DEBIT_TYPES, signedMontant, MOUV_TYPE_LABELS, mouvToCSV, mouvToXLS, mouvToJSON, + ExportDropdown, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm, + timestampSuffix, dlBlob, +} from '../components/MouvementsShared.jsx'; /* ── Indicateur de progression ── */ function TrendBadge({ current, prev, invert = false }) { @@ -58,50 +48,6 @@ function TrendBadge({ current, prev, invert = false }) { -const DEBIT_TYPES = new Set(['retrait', 'souscription', 'prelevement_ir', 'prelevement_csg', 'prelevement_local']); - -// Montant signé, identique à ce qui est affiché à l'écran (+ crédit / − débit), -// pour permettre de sommer directement l'extraction et contrôler le solde. -// depot/remboursement/bonus → crédit (+) ; retrait/souscription/prélèvements → débit (−) ; -// correction → déjà signé en base (montant peut être positif ou négatif). -function signedMontant(r) { - if (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant); - if (r.type === 'correction') return r.montant; - return Math.abs(r.montant); -} - -function mouvToCSV(rows) { - const BOM = ''; const sep = ';'; - const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; - const headers = ['Date','Plateforme','Type','Projet','Montant (€)','Libellé','Référence','Source']; - const data = rows.map(r => [ - r.date_operation, r.plateforme_nom||'', MOUV_TYPE_LABELS[r.type] || r.type, r._projet||'', - String(signedMontant(r)).replace('.',','), r.libelle||'', r.reference||'', r.source||'', - ]); - return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n'); -} - -const MOUV_TYPE_LABELS = { - depot: 'Dépôt', retrait: 'Retrait', correction: 'Correction de solde', - remboursement: 'Remboursement', bonus: 'Bonus / Cashback', souscription: 'Souscription', - prelevement_ir: 'Prélèvement IR', prelevement_csg: 'Prélèvement CSG', - prelevement_local: "Prélèvement impôts à l'étranger", -}; - -function mouvToXLS(rows, multiDetenteur) { - const data = rows.map(r => ({ - 'Date': r.date_operation, - 'Plateforme': r.plateforme_nom || '', - ...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}), - 'Type': MOUV_TYPE_LABELS[r.type] || r.type, - 'Projet': r._projet || '', - 'Montant (€)': signedMontant(r), - })); - const ws = XLSX.utils.json_to_sheet(data); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, 'Mouvements'); - return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); -} function platToCSV(rows) { const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0); const BOM = ''; const sep = ';'; @@ -132,20 +78,6 @@ function platToXLS(rows) { XLSX.utils.book_append_sheet(wb, ws, 'Plateformes'); return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); } -function mouvToJSON(rows) { - const data = rows.map(r => ({ - date_operation: r.date_operation, - plateforme_nom: r.plateforme_nom || '', - type: r.type, - projet: r._projet || null, - montant: signedMontant(r), - libelle: r.libelle || null, - reference: r.reference || null, - source: r.source || null, - })); - return JSON.stringify(data, null, 2); -} - function platToJSON(rows) { const totalSolde = rows.reduce((s, p) => s + p.solde_net, 0); const data = rows.map(p => ({ @@ -159,345 +91,6 @@ function platToJSON(rows) { return JSON.stringify(data, null, 2); } -function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - useEffect(() => { - if (!open) return; - const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); }; - document.addEventListener('mousedown', h); - return () => document.removeEventListener('mousedown', h); - }, [open]); - const choose = fn => { setOpen(false); fn(); }; - return ( -
- - {open && ( -
- - - -
- )} -
- ); -} - -/* ── Panneau de détail d'un mouvement ────────────────────────── */ -function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onViewInvestissement }) { - if (!row) return ( -
- - - - Sélectionnez un mouvement -
- ); - - 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 ( -
-
Détails du mouvement
- - {isAuto && ( -
- - - - - 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. - -
- )} - - {isRemb && ( -
- - - - - {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."} - -
- )} - - {isPrelevement && ( -
- - - - - {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."} - -
- )} - - {isSouscription && ( -
- - - - - {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."} - -
- )} - -
- {fields.map(f => ( -
- {f.label} - {f.value} -
- ))} -
- -
- {isAuto ? null : (isRemb || isPrelevement) ? ( - - ) : isSouscription ? ( - - ) : isCorrection ? ( - - ) : ( - - )} -
-
- ); -} - -/* ── 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 ( -
-
-
- Vérification du solde porte-monnaie -
-
- Après ce {isRetrait ? 'retrait' : 'dépôt'} sur {plateforme_nom}, - quel est le solde indiqué sur la plateforme ? - {' '}Si différent du solde calculé, une correction sera créée automatiquement. -
- -
-
- Solde calculé - {Math.round(computedBalance * 100) / 100} € -
- - setDeclared(e.target.value)} - style={{ width: '100%', fontSize: 'var(--fs-base)' }} - autoFocus - /> -
- - {hasDiff && ( -
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 0 ? 'var(--success)' : 'var(--danger)' }}> - {diff > 0 ? '+' : ''}{diff} € - sera créée. -
- )} - {!hasDiff && declared !== '' && ( -
- Solde identique au calcul — aucune correction nécessaire. -
- )} - -
- - -
-
-
- ); -} - /* ── Composant principal ─────────────────────────────────────── */ export default function DepotsRetraits() { const { activeId, activeView, investisseurs } = useInvestisseur(); @@ -513,7 +106,7 @@ export default function DepotsRetraits() { const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' }); const [modalOpen, setModalOpen] = useState(false); const [editingId, setEditingId] = useState(null); - const [form, setForm] = useState(empty); + const [form, setForm] = useState(emptyMouvementForm); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); const [selectedRow, setSelectedRow] = useState(null); @@ -1030,7 +623,7 @@ export default function DepotsRetraits() { if (plats.length > 0) { setEditingId(null); const firstId = plats[0]?.id || ''; - setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); + setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); setErr(null); setModalOpen(true); } else { @@ -1043,7 +636,7 @@ export default function DepotsRetraits() { pendingNew.current = false; setEditingId(null); const firstId = plats[0]?.id || ''; - setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); + setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); setErr(null); setModalOpen(true); }, [plats]); // eslint-disable-line @@ -1052,7 +645,7 @@ export default function DepotsRetraits() { const openNew = () => { setEditingId(null); const firstId = plats[0]?.id || ''; - setForm({ ...empty, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); + setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) }); setErr(null); setModalOpen(true); }; @@ -1103,7 +696,7 @@ export default function DepotsRetraits() { }); }; - const close = () => { setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); }; + const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); }; const submit = async (e) => { e?.preventDefault?.(); setErr(null); diff --git a/frontend/src/pages/Plateformes.jsx b/frontend/src/pages/Plateformes.jsx index 671c43d..cf42323 100644 --- a/frontend/src/pages/Plateformes.jsx +++ b/frontend/src/pages/Plateformes.jsx @@ -15,6 +15,13 @@ import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format. import { xirr } from '../utils/xirr.js'; import { usePagination } from '../hooks/usePagination.js'; import Pagination from '../components/Pagination.jsx'; +import Modal from '../components/Modal.jsx'; +import ConfirmModal from '../components/ConfirmModal.jsx'; +import { + mouvToCSV, mouvToXLS, mouvToJSON, + ExportDropdown, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm, + timestampSuffix, dlBlob, +} from '../components/MouvementsShared.jsx'; const LOGOS_BASE = '/api/logos/'; @@ -867,6 +874,393 @@ export default function Plateformes() { [allDepots, allRembs, allRows, allCorrections, plats, filteredPlatIds, prevYearLabel] ); + /* ══════════════════════════════════════════════════════════════════════ + ONGLET "MOUVEMENTS PORTE-MONNAIE" — réplique de l'onglet "Mouvements" + de DepotsRetraits.jsx (cf. components/MouvementsShared.jsx pour les + éléments présentationnels partagés). Reprend les mêmes données déjà + chargées par load() ci-dessus (allDepots, allRembs, allRows=investissements, + allReinvests, allCorrections, plats). Toute évolution de la logique de + décomposition des mouvements doit être répercutée dans les deux fichiers. + ══════════════════════════════════════════════════════════════════════ */ + const [mvFilter, setMvFilter] = useState({ type: '', plateforme_ids: [], projet: '', year: '', month: '' }); + const [mvListFocused, setMvListFocused] = useState(false); + const [mvModalOpen, setMvModalOpen] = useState(false); + const [mvEditingId, setMvEditingId] = useState(null); + const [mvForm, setMvForm] = useState(emptyMouvementForm); + const [mvErr, setMvErr] = useState(null); + const [mvSelectedRow, setMvSelectedRow] = useState(null); + const [mvOpenMenu, setMvOpenMenu] = useState(null); // { row, x, y } + const [mvDeleteConfirm, setMvDeleteConfirm] = useState(null); + const [mvCorrModal, setMvCorrModal] = useState({ + open: false, plateforme_nom: '', type_operation: 'retrait', + computedBalance: 0, date: '', platId: null, investisseurId: null, + }); + + // Le périmètre plateforme de cet onglet est strictement verrouillé sur la sélection + // "Plateforme" de la page (filteredPlatIds) — aucun contrôle interne ne permet de + // l'élargir à d'autres plateformes, pour éviter toute confusion avec les KPIs affichés + // au-dessus (qui portent, eux, sur la plateforme sélectionnée en haut de page). + useEffect(() => { + setMvFilter(f => ({ ...f, plateforme_ids: [...filteredPlatIds].map(String), projet: '' })); + }, [filteredPlatIds]); + + // Ferme le menu contextuel au scroll + useEffect(() => { + const closeAll = () => setMvOpenMenu(null); + window.addEventListener('scroll', closeAll, true); + return () => window.removeEventListener('scroll', closeAll, true); + }, []); + + const mvYears = useMemo( + () => [...new Set(allDepots.map(r => r.date_operation.slice(0, 4)))].sort().reverse(), + [allDepots] + ); + + const mvInvestisseurForPlat = (platId) => { + const plat = plats.find(p => String(p.id) === String(platId)); + if (plat?.investisseur_id) return String(plat.investisseur_id); + if (activeView === 'all') { + const principal = investisseurs.find(i => i.is_principal); + return String((principal || investisseurs[0])?.id || ''); + } + return String(activeId || ''); + }; + + const mvRows = useMemo(() => allDepots.filter(r => { + if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false; + if (mvFilter.type && r.type !== mvFilter.type) return false; + const [y, m] = r.date_operation.split('-'); + if (mvFilter.year && y !== mvFilter.year) return false; + if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false; + return true; + }), [allDepots, mvFilter]); + + const mvNormalizedCorrections = useMemo(() => allCorrections.map(c => ({ + id: `corr_${c.id}`, + _correctionId: c.id, + plateforme_id: c.plateforme_id, + plateforme_nom: c.plateforme_nom, + plateforme_detenteur_nom: c.investisseur_nom, + date_operation: c.date, + type: 'correction', + montant: c.montant, + libelle: c.notes || '', + notes: c.notes || '', + source: 'correction', + investisseur_id: c.investisseur_id, + })), [allCorrections]); + + const mvFilteredCorrections = useMemo(() => mvNormalizedCorrections.filter(c => { + if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(c.plateforme_id))) return false; + const [y, m] = c.date_operation.split('-'); + if (mvFilter.year && y !== mvFilter.year) return false; + if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false; + return true; + }), [mvNormalizedCorrections, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]); + + const mvInvById = useMemo(() => new Map(allRows.map(i => [i.id, i])), [allRows]); + + const mvNormalizedRembMouvements = useMemo(() => { + const out = []; + for (const r of allRembs) { + const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme'; + const plat = plats.find(p => p.id === r.plateforme_id); + const base = { + _rembId: r.id, + plateforme_id: r.plateforme_id, + plateforme_nom: r.plateforme_nom, + plateforme_detenteur_nom: r.plateforme_detenteur_nom, + date_operation: r.date_remb, + libelle: r.nom_projet || '', + source: 'remboursement', + investisseur_id: r.resolved_investisseur_id ?? null, + }; + + if (isBonus) { + const montant = r.cashback || 0; + if (!montant) continue; + out.push({ ...base, id: `remb_${r.id}`, type: 'bonus', montant }); + continue; + } + + const inv = mvInvById.get(r.investissement_id); + const isFlatTax = plat?.fiscalite === 'flat_tax'; + const isExonere = inv?.fiscalite_override === 'exonere'; + const pfuReellementRetenu = isFlatTax && !isExonere; + const taxeLocale = r.taxe_locale || 0; + const grossInterets = r.interets_bruts_avant_local > 0 ? r.interets_bruts_avant_local : (r.interets_bruts || 0); + const grossMontant = (r.capital || 0) + (r.cashback || 0) + grossInterets; + + if (grossMontant) { + out.push({ + ...base, + id: `remb_${r.id}`, + type: 'remboursement', + montant: grossMontant, + _isFlatTax: isFlatTax, + _isAutoSweep: r.methode_remboursement === 'compte_courant', + _taxeLocale: taxeLocale, + _capital: r.capital || 0, + _cashback: r.cashback || 0, + _interets_bruts: r.interets_bruts || 0, + _interets_nets: r.interets_nets || 0, + _net_recu: r.net_recu || 0, + }); + } + + if (taxeLocale > 0) { + out.push({ ...base, id: `remb_${r.id}_local`, type: 'prelevement_local', montant: taxeLocale }); + } + if (pfuReellementRetenu && r.prelev_forfaitaire > 0) { + out.push({ ...base, id: `remb_${r.id}_ir`, type: 'prelevement_ir', montant: r.prelev_forfaitaire }); + } + if (pfuReellementRetenu && r.prelev_sociaux > 0) { + out.push({ ...base, id: `remb_${r.id}_csg`, type: 'prelevement_csg', montant: r.prelev_sociaux }); + } + } + return out; + }, [allRembs, plats, mvInvById]); + + const mvFilteredRembMouvements = useMemo(() => mvNormalizedRembMouvements.filter(r => { + if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false; + const [y, m] = r.date_operation.split('-'); + if (mvFilter.year && y !== mvFilter.year) return false; + if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false; + return true; + }), [mvNormalizedRembMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]); + + const mvRembProjetById = useMemo(() => new Map(allRembs.map(r => [r.id, r.nom_projet || ''])), [allRembs]); + + const mvNormalizedSouscriptionMouvements = useMemo(() => { + const out = []; + for (const inv of allRows) { + if (!inv.montant_investi) continue; + out.push({ + id: `sub_${inv.id}`, + _invId: inv.id, + plateforme_id: inv.plateforme_id, + plateforme_nom: inv.plateforme_nom, + plateforme_detenteur_nom: inv.plateforme_detenteur_nom, + date_operation: inv.date_souscription, + type: 'souscription', + montant: inv.montant_investi, + libelle: inv.nom_projet || '', + source: 'souscription', + investisseur_id: inv.investisseur_id ?? null, + _isReinvest: false, + }); + } + for (const rv of allReinvests) { + const inv = mvInvById.get(rv.investissement_id); + if (!inv || !rv.montant) continue; + out.push({ + id: `reinv_${rv.id}`, + _invId: inv.id, + plateforme_id: inv.plateforme_id, + plateforme_nom: inv.plateforme_nom, + plateforme_detenteur_nom: inv.plateforme_detenteur_nom, + date_operation: rv.date_reinvestissement, + type: 'souscription', + montant: rv.montant, + libelle: inv.nom_projet || '', + source: 'reinvestissement', + investisseur_id: inv.investisseur_id ?? null, + _isReinvest: true, + }); + } + return out; + }, [allRows, allReinvests, mvInvById]); + + const mvFilteredSouscriptionMouvements = useMemo(() => mvNormalizedSouscriptionMouvements.filter(r => { + if (mvFilter.plateforme_ids.length > 0 && !mvFilter.plateforme_ids.includes(String(r.plateforme_id))) return false; + const [y, m] = r.date_operation.split('-'); + if (mvFilter.year && y !== mvFilter.year) return false; + if (mvFilter.month && m !== mvFilter.month.padStart(2,'0')) return false; + return true; + }), [mvNormalizedSouscriptionMouvements, mvFilter.plateforme_ids, mvFilter.year, mvFilter.month]); + + // Projets disponibles pour le filtre "Projet" — dépend de la plateforme (verrouillée) mais + // pas des autres filtres (type/année/mois), pour ne pas faire disparaître d'options en cours + // d'affinage. Inclut les remboursements/souscriptions/réinvestissements ainsi que les + // retraits AUTO liés à un remboursement (rattachés via rembProjetById). + const mvProjets = useMemo(() => { + const ids = mvFilter.plateforme_ids; + const inScope = pid => ids.length === 0 || ids.includes(String(pid)); + const set = new Set(); + for (const r of mvNormalizedRembMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle); + for (const r of mvNormalizedSouscriptionMouvements) if (inScope(r.plateforme_id) && r.libelle) set.add(r.libelle); + for (const d of allDepots) { + if (!inScope(d.plateforme_id) || !d.remboursement_id) continue; + const p = mvRembProjetById.get(d.remboursement_id); + if (p) set.add(p); + } + return [...set].sort((a, b) => a.localeCompare(b, 'fr')); + }, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]); + + const mvTableRows = useMemo(() => { + const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait'; + const showCorr = !mvFilter.type || mvFilter.type === 'correction'; + const drPart = showDR + ? mvRows.map(r => ({ ...r, _projet: r.remboursement_id ? (mvRembProjetById.get(r.remboursement_id) || '') : '' })) + : []; + const corrPart = showCorr ? mvFilteredCorrections.map(c => ({ ...c, _projet: '' })) : []; + const rembPart = mvFilteredRembMouvements + .filter(r => !mvFilter.type || r.type === mvFilter.type) + .map(r => ({ ...r, _projet: r.type === 'bonus' ? '' : (r.libelle || '') })); + const subPart = mvFilteredSouscriptionMouvements + .filter(r => !mvFilter.type || r.type === mvFilter.type) + .map(r => ({ ...r, _projet: r.libelle || '' })); + return [...drPart, ...corrPart, ...rembPart, ...subPart] + .filter(r => !mvFilter.projet || r._projet === mvFilter.projet) + .sort((a, b) => + b.date_operation.localeCompare(a.date_operation) || String(b.id).localeCompare(String(a.id)) + ); + }, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById]); + + const { + pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage, + pageSize: mvPageSize, setPageSize: setMvPageSize, + totalPages: mvTotalPages, totalItems: mvTotalItems, PAGE_SIZES: mvPageSizes, + } = usePagination(mvTableRows, 'cl_pagesize_mvpm', [mvFilter]); + + useEffect(() => { + setMvSelectedRow(prev => { + if (prev && mvTableRows.find(r => String(r.id) === String(prev.id))) return prev; + return mvTableRows[0] || null; + }); + }, [mvTableRows]); + + const mvCloseEdit = () => { setMvModalOpen(false); setMvEditingId(null); setMvForm(emptyMouvementForm); setMvErr(null); }; + + const mvOpenEdit = (row) => { + if (row.source === 'auto_remboursement') return; // retrait auto — non éditable ici + setMvEditingId(row.id); + setMvForm({ + investisseur_id: String(row.investisseur_id || ''), + plateforme_id: row.plateforme_id, + date_operation: row.date_operation, + type: row.type, + montant: row.montant, + libelle: row.libelle || '', + reference: row.reference || '', + notes: row.notes || '', + }); + setMvErr(null); setMvModalOpen(true); + }; + + const mvOpenCorrection = async (row) => { + const platId = row ? row.plateforme_id : mvForm.plateforme_id; + const investId = row ? (row.investisseur_id || null) : (Number(mvForm.investisseur_id) || null); + const type = row ? row.type : mvForm.type; + const date = row ? row.date_operation : mvForm.date_operation; + const plat = plats.find(p => String(p.id) === String(platId)); + + let computedBalance = 0; + try { + const result = await api.get('/dashboard/solde-historique', { plateforme_id: platId, date }); + computedBalance = result.solde ?? 0; + } catch (e) { + console.error('solde-historique error:', e); + } + + setMvModalOpen(false); + setMvEditingId(null); + setMvCorrModal({ + open: true, + plateforme_nom: plat?.nom || '', + type_operation: type, + computedBalance, + date, + platId: Number(platId), + investisseurId: investId ? Number(investId) : null, + }); + }; + + const mvSubmit = async (e) => { + e?.preventDefault?.(); setMvErr(null); + try { + const payload = { + investisseur_id: Number(mvForm.investisseur_id) || undefined, + plateforme_id: Number(mvForm.plateforme_id), + date_operation: mvForm.date_operation, + type: mvForm.type, + montant: Number(mvForm.montant), + libelle: mvForm.libelle || undefined, + reference: mvForm.reference || undefined, + notes: mvForm.notes || undefined, + }; + if (mvEditingId) { + await api.put(`/depots-retraits/${mvEditingId}`, payload); + mvCloseEdit(); await load(); + return; + } + // Pas de création depuis cet onglet — le bouton global "+ Nouveau dépôt/retrait" + // (topbar) couvre déjà ce besoin et redirige vers /depots-retraits. + await api.post('/depots-retraits', payload); + mvCloseEdit(); await load(); + } catch (e) { setMvErr(e.message); } + }; + + const mvHandleCorrectionConfirm = async (diff) => { + if (Math.abs(diff) >= 0.005) { + try { + await api.post('/corrections', { + investisseur_id: mvCorrModal.investisseurId, + plateforme_id: mvCorrModal.platId, + date: mvCorrModal.date, + montant: diff, + notes: `Correction solde porte-monnaie — ${mvCorrModal.type_operation === 'retrait' ? 'retrait' : 'dépôt'}`, + }); + } catch (e) { + console.error('Correction creation failed:', e); + } + } + setMvCorrModal(m => ({ ...m, open: false })); + await load(); + }; + + const mvOpenRowMenu = (e, row) => { + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + setMvOpenMenu({ row, x: rect.right, y: rect.bottom }); + }; + + const mvDeleteRow = (rowId) => { + setMvDeleteConfirm({ + message: 'Supprimer définitivement ce mouvement ?', + onConfirm: async () => { + await api.del(`/depots-retraits/${rowId}`); + setMvDeleteConfirm(null); + setMvSelectedRow(null); + await load(); + }, + }); + }; + + const mvDeleteCorrection = (corrId) => { + setMvDeleteConfirm({ + message: 'Supprimer définitivement cette correction de solde ?', + onConfirm: async () => { + await api.del(`/corrections/${corrId}`); + setMvDeleteConfirm(null); + setMvSelectedRow(null); + await load(); + }, + }); + }; + + const mvOnDelete = () => { + if (!mvEditingId) return; + setMvDeleteConfirm({ + message: 'Supprimer définitivement ce mouvement ?', + onConfirm: async () => { + await api.del(`/depots-retraits/${mvEditingId}`); + setMvDeleteConfirm(null); + mvCloseEdit(); setMvSelectedRow(null); await load(); + }, + }); + }; + if (!loading && plats.length === 0) { return ( <> @@ -1073,6 +1467,8 @@ export default function Plateformes() { onClick={() => setActiveTab('capital-investi')}>Capital investi + {/* ====== ONGLET REMBOURSEMENTS ====== */} @@ -1952,6 +2348,451 @@ export default function Plateformes() { )} + {/* ====== ONGLET MOUVEMENTS PORTE-MONNAIE ====== */} + {activeTab === 'mouvements-pm' && ( +
+ + {/* Colonne gauche 2/3 */} +
+
+ +
+

Mouvements Porte-monnaie

+
+ + 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')} + /> +
+
+ + {/* Filtres */} +
+
+ + +
+
+ +
+ {selectedPlatName || '—'} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Tableau */} +
+ + + + {multiDetenteur && } + + + + {mvTableRows.length === 0 && ( + + )} + {mvPagedTableRows.map(r => { + if (r.type === 'correction') { + const isPos = r.montant >= 0; + return ( + setMvSelectedRow(String(mvSelectedRow?.id) === String(r.id) ? null : r)} + style={{ cursor: 'pointer' }}> + + + {multiDetenteur && } + + + + + + ); + } + if (r.type === 'remboursement' || r.type === 'bonus') { + const isBonus = r.type === 'bonus'; + return ( + setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)} + style={{ cursor: 'pointer' }}> + + + {multiDetenteur && } + + + + + ); + } + 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 ( + setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)} + style={{ cursor: 'pointer' }}> + + + {multiDetenteur && } + + + + + ); + } + if (r.type === 'souscription') { + return ( + setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)} + style={{ cursor: 'pointer' }}> + + + {multiDetenteur && } + + + + + ); + } + const isAuto = r.source === 'auto_remboursement'; + return ( + setMvSelectedRow(mvSelectedRow?.id === r.id ? null : r)} + style={{ cursor: 'pointer', opacity: isAuto ? 0.8 : 1 }}> + + + {multiDetenteur && } + + + + + + ); + })} + +
DatePlateformeDétenteurType de mouvementProjetMontant
+ {loading ? 'Chargement…' : 'Aucun mouvement'} +
{fmtDate(r.date_operation)}{r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} + correction + — + {isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))} + e.stopPropagation()}> + +
{fmtDate(r.date_operation)}{r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} + {isBonus ? 'bonus' : 'remboursement'} + {r._projet || '—'} + + {fmtEUR(r.montant)} + +
{fmtDate(r.date_operation)}{r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} + {label} + {r._projet || '—'} + − {fmtEUR(r.montant)} + +
{fmtDate(r.date_operation)}{r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} + souscription + {r._isReinvest && ( + RÉINVEST. + )} + {r._projet || '—'} + − {fmtEUR(r.montant)} + +
{fmtDate(r.date_operation)}{r.plateforme_nom}{r.plateforme_detenteur_nom || '—'} + {r.type} + {isAuto && ( + AUTO + )} + {r._projet || '—'} + {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} + e.stopPropagation()}> + {!isAuto && ( + + )} +
+ +
+
+
+ + {/* Colonne droite 1/3 */} + {mvTableRows.length > 0 && ( +
+ navigate(`/remboursements?edit-remb=${r._rembId}`)} + onViewInvestissement={r => navigate(`/investissements/${r._invId}`)} + /> +
+ )} +
+ )} + + {/* Modal création / édition d'un mouvement (onglet "Mouvements Porte-monnaie") */} + +
+ + +
+
+ + +
+ + } + > +
+ {mvErr &&
{mvErr}
} +
+
+ + +
+
+ + setMvForm({ ...mvForm, date_operation: e.target.value })} /> +
+
+ + +
+
+ + setMvForm({ ...mvForm, montant: e.target.value })} /> +
+
+ + setMvForm({ ...mvForm, libelle: e.target.value })} placeholder="Ex. Virement initial" /> +
+
+ + setMvForm({ ...mvForm, reference: e.target.value })} /> +
+
+ + +
+
+ + setMvForm({ ...mvForm, notes: e.target.value })} /> +
+
+
+
+ + {mvOpenMenu && ( + <> +
setMvOpenMenu(null)} /> +
+ {mvOpenMenu.row.type !== 'correction' && ( + + )} + {mvOpenMenu.row.type !== 'correction' && mvOpenMenu.row.source !== 'auto_remboursement' && ( + + )} + +
+ + )} + + { setMvCorrModal(m => ({ ...m, open: false })); load(); }} + onConfirm={mvHandleCorrectionConfirm} + /> + + {mvDeleteConfirm && ( + setMvDeleteConfirm(null)} + /> + )} + {/* ── Modal saisie/édition remboursement (section "Mois en cours") ── */}