787 lines
43 KiB
React
787 lines
43 KiB
React
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).
|
||
if (r.type === 'frais') return r.mode_reglement === 'compte_courant' ? 0 : -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';
|
||
|
||
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)."
|
||
: 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."}
|
||
{' '}{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">
|
||
{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>
|
||
);
|
||
}
|