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: 'Cashback', souscription: 'Souscription', prelevement_ir: 'Prélèvement IR', prelevement_csg: 'Prélèvement CSG', prelevement_local: "Prélèvement impôts à l'étranger", interets_plateforme: 'Intérêts plateforme', }; 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 && (
)}
); } /* ── Menu déroulant "+" — actions rapides sur les mouvements (extensible : pour l'instant uniquement la création d'une correction de solde) ── */ export function AddDropdown({ disabled, onNewCorrection }) { 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 && (
)}
); } /** Petite modale de sélection plateforme + date, avant d'ouvrir CorrectionModal en saisie libre (déclenchée depuis AddDropdown, pas depuis un mouvement existant). */ export function CorrectionPickerModal({ open, onClose, onContinue, plateformes = [], multiDetenteur, defaultPlateformeId }) { const [platId, setPlatId] = useState(''); const [date, setDate] = useState(''); useEffect(() => { if (open) { setPlatId(defaultPlateformeId ? String(defaultPlateformeId) : ''); setDate(today()); } }, [open, defaultPlateformeId]); if (!open) return null; return (
Nouvelle correction de solde
Choisissez la plateforme et la date de référence pour la correction.
setDate(e.target.value)} style={{ width: '100%' }} />
); } /* ── 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 isBonus = row.type === 'bonus'; const isCashback = row.type === 'cashback'; const isInteretsPlateforme = row.type === 'interets_plateforme'; const isRemb = row.type === 'remboursement' || isBonus || isCashback || isInteretsPlateforme; 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' : isCashback ? 'Cashback' : row.type === 'remboursement' ? 'Remboursement (porte-monnaie, brut)' : isInteretsPlateforme ? 'Intérêts plateforme (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/cashback ; // 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 isRembOnly = row.type === 'remboursement' || isInteretsPlateforme; 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) }, isRembOnly && row.libelle && { label: 'Projet', value: row.libelle }, (isBonus || isCashback) && row.libelle && { label: 'Projet', value: row.libelle }, isRembOnly && !isInteretsPlateforme && { label: 'Capital', value: fmtEUR(row._capital) }, isRembOnly && row._cashback > 0 && { label: 'Cashback', value: fmtEUR(row._cashback) }, isRembOnly && { label: 'Intérêts bruts', value: fmtEUR(row._interets_bruts) }, isRembOnly && { 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.' : isCashback ? "Cashback crédité au porte-monnaie de la plateforme, saisi sur un remboursement classique plutôt qu'en tant que bonus. Modifiable depuis la page Remboursements." : isInteretsPlateforme ? "Intérêts versés par la plateforme, hors investissement précis — crédités au porte-monnaie (montant brut). Les prélèvements éventuels apparaissent en lignes séparées. Modifiable depuis la page Remboursements." : 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
{type_operation ? ( <>Après ce {isRetrait ? 'retrait' : 'dépôt'} sur {plateforme_nom}, quel est le solde indiqué sur la plateforme ? ) : ( <>Quel est le solde indiqué sur {plateforme_nom} à la date du {date ? fmtDate(date) : ''} ? )} {' '}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.
)}
); }