Files
crowdlending-app/frontend/src/components/MouvementsShared.jsx
T
2026-09-19 14:37:02 +02:00

816 lines
45 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import * as XLSX from 'xlsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
import { useAsyncAction } from '../hooks/useAsyncAction.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) {
// Frais d'opération : impact réel sur le solde uniquement si réglé 'source' (débit du
// montant brut, cf. cas Fundora confirmé par Olivier le 31/08/26 — [[project_frais_operation]])
// ou 'portefeuille' (versement complémentaire) ; 'compte_courant' n'a jamais d'effet sur le
// porte-monnaie (sortie d'argent ailleurs) — on l'affiche quand même dans la liste, à titre
// de traçabilité, avec un badge "hors porte-monnaie", mais sans effet sur le solde indicatif.
// 'remboursement' (Étape 4, 31/08/26) : frais de distribution synchronisé depuis le champ
// "Frais" d'un remboursement — DOIT être un débit réel ici (comme un prélèvement), pas 0 :
// la ligne "remboursement" du tableau crédite le coupon BRUT (interets_bruts, cf.
// normalizedRembMouvements), et les prélèvements sont déjà des lignes de débit séparées pour
// retomber sur interets_nets — le frais doit suivre exactement la même convention, sinon la
// chaîne ne retombe jamais à 0 après un retrait AUTO (confirmé par Olivier le 01/09/26 : le
// reliquat observé était exactement égal au montant du frais).
// Frais PE prélevés "à la source" (19/09/26, confirmé par Olivier) : contrairement au
// crowdlending où montant_investi est le capital NET (frais 'source' = débit supplémentaire
// légitime, cf. commentaire ci-dessus), côté PE le montant_investi affiché en ligne
// "souscription" est déjà le montant BRUT transféré à la plateforme, frais compris — un
// frais 'source' PE n'est donc PAS un débit supplémentaire du porte-monnaie, sous peine de
// compter deux fois la même sortie d'argent. Affiché quand même (traçabilité), à 0 d'effet,
// comme 'compte_courant'. Repéré via r._investType === 'pe' (posé par Plateformes.jsx /
// mvNormalizedPeMouvements) plutôt que par plateforme : ne dépend pas de la plateforme, mais
// du modèle de données PE lui-même (toujours vrai pour tout deal PE).
if (r.type === 'frais') {
if (r.mode_reglement === 'compte_courant') return 0;
if (r._investType === 'pe' && r.mode_reglement === 'source') return 0;
return -Math.abs(r.montant);
}
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',
frais: "Frais d'opération",
};
// Labels de mode de règlement / origine d'un frais d'opération — dupliqués depuis
// InvestissementDetail.jsx (bloc "Frais d'opération") pour l'affichage dans les
// Mouvements ; garder synchronisés en cas d'évolution des libellés.
export const FRAIS_MODE_LABELS = { source: 'Déduit du versement lors de la souscription', portefeuille: 'Porte-monnaie', compte_courant: 'Débité du compte courant', remboursement: 'Déduit du remboursement' };
export const FRAIS_ORIGINE_LABELS = { souscription: 'Souscription', remboursement: 'Remboursement', manuel: 'Manuel' };
// Catégorie de frais (31/08/26, demande Olivier) — classement visible côté utilisateur,
// distinct de FRAIS_ORIGINE_LABELS (détail technique de création). Dupliqué depuis
// InvestissementDetail.jsx, à garder synchronisé.
export const FRAIS_CATEGORIE_LABELS = { souscription: 'Souscription', gestion: 'Gestion', distribution: 'Distribution', autre: 'Autre' };
/* ── Tri des mouvements pour une même date ───────────────────────────
1) Toutes les lignes d'une même plateforme, pour une même date, sont regroupées
ensemble (ordre alphabétique des plateformes entre elles — décidé avec Olivier
le 2026-08-21, cf. incident du 20/08 où Indemo et La première brique
s'entrelaçaient).
2) À l'intérieur d'un bloc plateforme+date, TOUT est ordonné du plus récent au plus
ancien, à tous les niveaux — familles, chaînes distinctes, ET position au sein
d'une même chaîne (cohérent avec le tri global le plus récent d'abord) :
1. Correction de solde
2. Retrait (manuel)
3. Souscription / Réinvestissement — argent qui sort pour investir
└─ Cashback "prime à la souscription" du même jour sur le même
investissement (affiché AVANT sa souscription : chronologiquement
postérieur), si ce remboursement n'a par ailleurs aucune ligne
crédit propre (cf. ci-dessous)
4. Dépôt (manuel) — alimente le compte
5. Remboursement / Intérêts plateforme / Bonus — argent qui entre
└─ Retrait AUTO (sweep compte_courant) qui reverse ce remboursement
└─ Prélèvement CSG
└─ Prélèvement IR
└─ Prélèvement impôts à l'étranger
└─ Cashback (si rattaché à ce remboursement)
└─ (le remboursement lui-même, ligne la plus ancienne de sa chaîne,
apparaît donc en dernier — ses prélèvements/son retrait AUTO sont
chronologiquement postérieurs)
Plusieurs chaînes de même famille le même jour (ex. deux souscriptions) sont
ordonnées par id décroissant (la plus récemment créée en premier). L'ordre
interne d'une chaîne (constante MOUV_REMB_CHAIN_POS) reste défini "parent=0,
enfants=1,2,3…" mais est PARCOURU EN DÉCROISSANT par le comparateur — inverser
MOUV_REMB_CHAIN_POS lui-même serait une erreur, seul le sens du tri doit changer.
Correction du 2026-08-21 (2e retour d'Olivier) : la 1ère version de cette règle
laissait le sous-ordre d'une chaîne "parent puis enfants" non inversé, ce qui
affichait "remboursement, puis ses prélèvements" — incohérent avec le reste de
la journée en "plus récent d'abord".
3) DÉPÔT ENTRE SOUSCRIPTION ET REMBOURSEMENT (et non plus toujours le plus ancien) :
cas typique signalé par Olivier le 2026-08-21 sur BienPrêter/17-08 — un
remboursement (Tradimex) déclenche un dépôt pour "relancer des investissements",
qui finance ensuite 2 nouvelles souscriptions le même jour. Chronologiquement :
remboursement → dépôt → souscriptions. Il n'existe AUCUN lien en base entre un
dépôt manuel et le remboursement/les souscriptions qu'il finance (contrairement
au retrait AUTO ou au cashback de souscription, qui ont un `remboursement_id`/
`investissement_id` explicite) — impossible donc de rattacher précisément CE
dépôt à CE remboursement. Le compromis retenu est une RÈGLE GÉNÉRALE (pas un
chaînage par ligne) : un dépôt manuel est toujours supposé plus récent qu'un
remboursement de la même plateforme/journée (donc affiché au-dessus de sa
chaîne), mais plus ancien qu'une souscription de la même plateforme/journée
(donc affiché en dessous). C'est une approximation assumée : si un dépôt sans
lien avec un remboursement du jour se trouve être réellement antérieur à ce
remboursement, l'affichage sera dans le mauvais sens — aucune donnée ne permet
de trancher. Règle validée avec Olivier (retour direct sur capture, pas
d'AskUserQuestion cette fois — cas jugé suffisamment clair). ── */
const MOUV_FAMILY_RANK = {
remboursement: 1, interets_plateforme: 1, bonus: 1, cashback: 1,
prelevement_local: 1, prelevement_ir: 1, prelevement_csg: 1,
depot: 2,
souscription: 3, frais: 3,
retrait: 4,
correction: 5,
};
// Position au sein d'une chaîne "remboursement" (lignes partageant le même _rembId)
const MOUV_REMB_CHAIN_POS = {
remboursement: 0, interets_plateforme: 0, bonus: 0,
// frais (Étape 4, 31/08/26) : déduit du coupon brut AVANT calcul des prélèvements (base
// imposable = interets_bruts - frais_ttc, cf. computeChamps) — doit donc apparaître entre le
// remboursement et les prélèvements dans la chaîne, pas au-dessus de tout (demande Olivier,
// 01/09/26, avec l'avis d'opéré Kyoseil-AM/Fundora à l'appui).
frais: 1,
cashback: 2, prelevement_local: 3, prelevement_ir: 4, prelevement_csg: 5,
};
export function sortMouvements(list) {
// _rembId des remboursements qui ont une vraie ligne crédit (remboursement / intérêts
// plateforme) — sert à NE PAS rattacher leur cashback à une souscription du même jour :
// un cashback qui accompagne un vrai remboursement reste dans la chaîne de ce remboursement.
const rembHasCreditLine = new Set(
list.filter(r => r.type === 'remboursement' || r.type === 'interets_plateforme').map(r => r._rembId)
);
// Souscriptions/réinvestissements par (investissement, date) — pour rattacher un cashback
// "prime à la souscription" (remboursement sans ligne crédit propre, même investissement,
// même date) à la souscription qu'il accompagne.
const subByInvAndDate = new Map(
list.filter(r => r.type === 'souscription').map(r => [`${r._invId}|${r.date_operation}`, r])
);
const enriched = list.map(row => {
// Retrait AUTO (sweep compte_courant) : reste collé juste après le remboursement qui le déclenche.
if (row.type === 'retrait' && row.source === 'auto_remboursement' && row.remboursement_id != null) {
return { row, family: MOUV_FAMILY_RANK.remboursement, anchor: row.remboursement_id, pos: 6 };
}
// Cashback "prime à la souscription" : pas de ligne crédit propre + souscription du même
// jour sur le même investissement → rattaché à cette souscription plutôt qu'isolé.
if (row.type === 'cashback' && !rembHasCreditLine.has(row._rembId) && row._invId != null) {
const sub = subByInvAndDate.get(`${row._invId}|${row.date_operation}`);
if (sub) return { row, family: MOUV_FAMILY_RANK.souscription, anchor: sub._invId, pos: 1 };
}
// Frais lié à un remboursement (mode_reglement='remboursement', Étape 4, 31/08/26) :
// rejoint la chaîne du remboursement lui-même (même family que prélèvements/retrait AUTO),
// positionné juste après le remboursement et avant les prélèvements — cf.
// MOUV_REMB_CHAIN_POS.frais ci-dessus. Doit être vérifié avant le bloc générique
// `row._rembId != null` plus bas, qui sinon l'attraperait avec la family 'frais' (3),
// qui n'a rien à voir avec la chaîne du remboursement.
if (row.type === 'frais' && row._rembId != null) {
return { row, family: MOUV_FAMILY_RANK.remboursement, anchor: row._rembId, pos: MOUV_REMB_CHAIN_POS.frais };
}
// Lignes d'un remboursement (parent + cashback resté attaché + prélèvements)
if (row._rembId != null) {
return {
row,
family: MOUV_FAMILY_RANK[row.type] ?? MOUV_FAMILY_RANK.remboursement,
anchor: row._rembId,
pos: MOUV_REMB_CHAIN_POS[row.type] ?? 0,
};
}
// Frais d'opération : rattaché à l'investissement concerné (même anchor que sa
// souscription éventuelle) — pos élevé pour apparaître au-dessus d'elle le même jour,
// cohérent avec le principe "plus récent d'abord" (le frais est perçu comme consécutif
// à la souscription). Ne rejoint jamais la chaîne d'un remboursement (_rembId) même si
// origine='remboursement' : pas encore de lien fin construit ici (cf. [[project_frais_operation]]).
if (row.type === 'frais') {
return { row, family: MOUV_FAMILY_RANK.frais, anchor: row._invId, pos: 2 };
}
// Souscription / réinvestissement (parent de sa propre chaîne)
if (row.type === 'souscription') {
return { row, family: MOUV_FAMILY_RANK.souscription, anchor: row._invId, pos: 0 };
}
// Correction de solde
if (row.type === 'correction') {
return { row, family: MOUV_FAMILY_RANK.correction, anchor: row._correctionId, pos: 0 };
}
// Dépôt / retrait manuel (hors sweep AUTO déjà traité plus haut)
return { row, family: MOUV_FAMILY_RANK[row.type] ?? MOUV_FAMILY_RANK.retrait, anchor: row.id, pos: 0 };
});
return enriched
.sort((a, b) =>
b.row.date_operation.localeCompare(a.row.date_operation) || // date : plus récent d'abord
(a.row.plateforme_nom || '').localeCompare(b.row.plateforme_nom || '', 'fr') || // plateforme : ordre alphabétique
b.family - a.family || // famille : plus récent de la journée d'abord
(Number(b.anchor) - Number(a.anchor)) || // chaînes distinctes : id décroissant (plus récente d'abord)
b.pos - a.pos || // au sein d'une chaîne : le plus récent (enfant) d'abord
String(b.row.id).localeCompare(String(a.row.id)) // filet de sécurité
)
.map(e => e.row);
}
/* ── Solde indicatif du porte-monnaie, ligne par ligne ────────────────────────────
Calcule, pour CHAQUE mouvement d'une plateforme, le solde du porte-monnaie de cette
plateforme immédiatement après ce mouvement — à titre indicatif (aide à repérer des
anomalies visuellement), PAS une source de vérité chiffrée : c'est une somme cumulée
de signedMontant() dans l'ordre chronologique probable (cf. sortMouvements ci-dessus,
lui-même basé sur des règles de départage à date égale qui restent des approximations
en l'absence d'horodatage précis dans les données).
IMPORTANT : `list` DOIT être la liste COMPLÈTE et NON FILTRÉE (aucun filtre
année/mois/type) des mouvements du scope investisseur courant — sinon le cumul
démarrerait arbitrairement au milieu de l'historique et le solde n'aurait aucun sens.
Le filtre plateforme n'a en revanche pas d'importance : le calcul est de toute façon
fait indépendamment pour chaque plateforme.
Ne touche PAS aux formules officielles du solde porte-monnaie (kpiSoldePortefeuille /
computeSoldePortefeuille / walletMap, cf. [[project_solde_portefeuille]] et
[[project_mouvements_tab_wallet_events]]) : ce solde-ci est un indicateur d'affichage
indépendant, recalculé à partir des mêmes lignes déjà utilisées pour l'affichage et
l'export — jamais l'inverse. Retourne une Map id de ligne → solde après cette ligne.
Décidé avec Olivier le 2026-08-21. ── */
export function computeSoldeIndicatifMap(list) {
const byPlateforme = new Map();
for (const row of list) {
const key = row.plateforme_id ?? row.plateforme_nom ?? '?';
if (!byPlateforme.has(key)) byPlateforme.set(key, []);
byPlateforme.get(key).push(row);
}
const soldeById = new Map();
for (const rows of byPlateforme.values()) {
// sortMouvements() trie du plus récent au plus ancien ; on inverse pour cumuler
// dans l'ordre chronologique probable (du plus ancien au plus récent).
const chrono = sortMouvements(rows).slice().reverse();
let running = 0;
for (const row of chrono) {
running += signedMontant(row);
soldeById.set(row.id, running);
}
}
return soldeById;
}
export function mouvToCSV(rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Date','Plateforme','Type','Projet','Montant (€)','Solde porte-monnaie indicatif (€)','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._soldeIndicatif != null ? String(r._soldeIndicatif).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),
'Solde porte-monnaie indicatif (€)': r._soldeIndicatif ?? '',
}));
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),
solde_indicatif: r._soldeIndicatif ?? null,
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>
);
}
/* ── 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 (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title="Ajouter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onNewCorrection)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/>
</svg>
<span><strong>Correction de solde</strong><small>Ajuster manuellement le solde porte-monnaie</small></span>
</button>
</div>
)}
</div>
);
}
/** 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 (
<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: 380, 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)' }}>
Nouvelle correction de solde
</div>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', marginBottom: 18, lineHeight: 1.5 }}>
Choisissez la plateforme et la date de référence pour la correction.
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 'var(--fs-sm)', fontWeight: 500, display: 'block', marginBottom: 4 }}>Plateforme</label>
<select value={platId} onChange={e => setPlatId(e.target.value)} style={{ width: '100%' }} autoFocus>
<option value="">— Choisir —</option>
{plateformes.map(p => (
<option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>
))}
</select>
</div>
<div style={{ marginBottom: 18 }}>
<label style={{ fontSize: 'var(--fs-sm)', fontWeight: 500, display: 'block', marginBottom: 4 }}>Date</label>
<input type="date" value={date} onChange={e => setDate(e.target.value)} style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={onClose}>Annuler</button>
<button className="primary" disabled={!platId || !date}
onClick={() => onContinue({ plateforme_id: Number(platId), date })}>
Continuer
</button>
</div>
</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 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 isFrais = row.type === 'frais';
const isFraisHorsPM = isFrais && row.mode_reglement === 'compte_courant';
const isFraisRembLinked = isFrais && row.mode_reglement === 'remboursement';
const isAuto = row.source === 'auto_remboursement';
// Mouvement PE fusionné dans le porte-monnaie crowdlending (19/09/26, cf. Plateformes.jsx /
// mvNormalizedPeMouvements) : les liens "Voir l'investissement" / "Voir le remboursement" /
// "Modifier" ci-dessous supposent tous des ids et des routes du workspace crowdlending
// (/investissements/:id, /remboursements?edit-remb=, le modal dépôt/retrait crowdlending) —
// inapplicables tels quels à un id de la table PE ou à un dépôt enregistré sous un autre
// workspace_id. Plutôt que de risquer une navigation vers le mauvais enregistrement (voire,
// en cas de collision d'id entre investissements et investissements_pe, vers un investissement
// crowdlending sans rapport), on affiche ici une mention neutre : à modifier depuis son
// workspace d'origine.
const isForeign = row._investType === 'pe';
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')
: isFrais ? "Frais d'opération" + (isFraisHorsPM ? ' (hors porte-monnaie)' : isFraisRembLinked ? ' (déduit du remboursement)' : '')
: 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 || isFrais;
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 },
isFrais && row.libelle && { label: 'Projet', value: row.libelle },
isFrais && { label: 'Mode de règlement', value: FRAIS_MODE_LABELS[row.mode_reglement] || row.mode_reglement },
isFrais && row.mode_reglement === 'compte_courant' && row.compte_nom && { label: 'Compte', value: row.compte_nom },
isFrais && row.categorie && { label: 'Catégorie', value: FRAIS_CATEGORIE_LABELS[row.categorie] || row.categorie },
isFrais && row.origine && { label: 'Origine', value: FRAIS_ORIGINE_LABELS[row.origine] || row.origine },
!isCorrection && !isRemb && !isSouscription && !isPrelevement && !isFrais && 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.'
: 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."}
</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>
)}
{isFrais && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8,
margin: '5px 5px 12px',
padding: '9px 12px', borderRadius: 6,
background: `color-mix(in srgb, ${isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b'} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b'} 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: isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b' }}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>
{isFraisHorsPM
? "Frais réglé via un compte courant du détenteur — n'a jamais d'effet sur le solde du porte-monnaie de la plateforme, affiché ici à titre de traçabilité uniquement."
: isFraisRembLinked
? "Frais de distribution déduit du coupon brut de ce remboursement, avant calcul des prélèvements — réduit d'autant le montant net reçu (cf. avis d'opéré de la plateforme)."
: (isForeign && row.mode_reglement === 'source')
? "Frais prélevé à la source par la plateforme sur le montant transféré lors de la souscription au deal — déjà inclus dans le montant de la souscription, sans effet supplémentaire sur le solde du porte-monnaie."
: row.mode_reglement === 'source'
? "Frais prélevé à la source par la plateforme sur le montant transféré — sort du porte-monnaie en plus du capital net déjà déduit de la souscription."
: "Versement complémentaire réglé depuis le porte-monnaie de la plateforme — sort du porte-monnaie indépendamment du capital investi."}
{' '}{isForeign ? null : isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."}
</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">
{isForeign ? (
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', fontStyle: 'italic', padding: '4px 2px' }}>
Mouvement Private Equity — modifiable depuis le workspace Private Equity.
</div>
) : isAuto ? null : (isRemb || isPrelevement) ? (
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
Voir / modifier le remboursement
</button>
) : isFraisRembLinked ? (
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
Voir / modifier le remboursement
</button>
) : isSouscription || isFrais ? (
<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;
// Anti-double-soumission (cf. useAsyncAction) : évite de créer deux fois la même correction
// sur un double-clic / une connexion lente, avant que la modale ne se ferme.
const { run, pending } = useAsyncAction();
const handleConfirm = () => run(() => 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,
}}>
{type_operation ? (
<>Après ce {isRetrait ? 'retrait' : 'dépôt'} sur <strong>{plateforme_nom}</strong>, quel est le solde indiqué sur la plateforme ?</>
) : (
<>Quel est le solde indiqué sur <strong>{plateforme_nom}</strong> à la date du {date ? fmtDate(date) : ''} ?</>
)}
{' '}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 disabled={pending} onClick={onClose}>Passer</button>
<button className="primary" disabled={pending} onClick={handleConfirm}>
{pending ? '…' : (hasDiff ? 'Créer la correction' : 'OK')}
</button>
</div>
</div>
</div>
);
}