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 (
setOpen(o => !o)} title="Exporter">
{open && (
choose(onCSV)}>
Format CSV Compatible Excel, LibreOffice
choose(onXLS)}>
Format Excel Fichier .xlsx Microsoft Excel
choose(onJSON)}>
Format JSON Réimportable, structuré
)}
);
}
/* ── 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 (
setOpen(o => !o)} title="Ajouter">
{open && (
choose(onNewCorrection)}>
Correction de solde Ajuster manuellement le solde porte-monnaie
)}
);
}
/** 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.
Plateforme
setPlatId(e.target.value)} style={{ width: '100%' }} autoFocus>
— Choisir —
{plateformes.map(p => (
{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}
))}
Date
setDate(e.target.value)} style={{ width: '100%' }} />
Annuler
onContinue({ plateforme_id: Number(platId), date })}>
Continuer
);
}
/* ── 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) ? (
onViewRemb(row)}>
Voir / modifier le remboursement
) : isSouscription ? (
onViewInvestissement(row)}>
Voir l'investissement
) : isCorrection ? (
onDeleteCorrection(row._correctionId)}>
Supprimer la correction
) : (
onEdit(row)}>
{editLabel}
)}
);
}
/** 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.
{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.
)}
Passer
{hasDiff ? 'Créer la correction' : 'OK'}
);
}