diff --git a/frontend/src/components/DistributionChart.jsx b/frontend/src/components/DistributionChart.jsx index 1e3654d..d9345eb 100644 --- a/frontend/src/components/DistributionChart.jsx +++ b/frontend/src/components/DistributionChart.jsx @@ -71,14 +71,25 @@ export default function DistributionChart({ rows }) { const svgRef = useRef(null); const wrapRef = useRef(null); - /* Solde net par plateforme */ + /* Solde net par plateforme. + Deux usages du composant : + - lignes avec `type` ('depot'/'retrait', page Dépôts/Retraits) : si le + jeu de lignes mélange dépôts ET retraits (aucun filtre de type actif), + on calcule le NET (dépôts − retraits) par plateforme, pas la somme + brute — sinon un retrait s'additionnait au lieu de se soustraire. + - lignes sans `type` (ex. capital restant dû sur Investissements.jsx) : + comportement inchangé, simple somme de `montant`. */ const { data, allRetraits } = useMemo(() => { if (!rows?.length) return { data: [], allRetraits: false }; - const allRetraits = rows.every(r => r.type === 'retrait'); + const hasTypes = rows.some(r => r.type === 'depot' || r.type === 'retrait'); + const allDepots = hasTypes && rows.every(r => r.type === 'depot'); + const allRetraits = hasTypes && rows.every(r => r.type === 'retrait'); + const mixedTypes = hasTypes && !allDepots && !allRetraits; const byPlat = {}; for (const r of rows) { const key = r.plateforme_nom || `#${r.plateforme_id}`; - byPlat[key] = (byPlat[key] || 0) + r.montant; + const delta = mixedTypes ? (r.type === 'depot' ? r.montant : -r.montant) : r.montant; + byPlat[key] = (byPlat[key] || 0) + delta; } const data = Object.entries(byPlat) .filter(([, v]) => v > 0) diff --git a/frontend/src/components/MouvementsShared.jsx b/frontend/src/components/MouvementsShared.jsx index 026bfd5..99edd64 100644 --- a/frontend/src/components/MouvementsShared.jsx +++ b/frontend/src/components/MouvementsShared.jsx @@ -41,13 +41,179 @@ export const MOUV_TYPE_LABELS = { interets_plateforme: 'Intérêts plateforme', }; +/* ── 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, + 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, + cashback: 1, prelevement_local: 2, prelevement_ir: 3, prelevement_csg: 4, +}; + +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: 5 }; + } + // 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 }; + } + // 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, + }; + } + // 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 (€)','Libellé','Référence','Source']; + 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.libelle||'', r.reference||'', r.source||'', + 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'); } @@ -60,6 +226,7 @@ export function mouvToXLS(rows, multiDetenteur) { '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(); @@ -74,6 +241,7 @@ export function mouvToJSON(rows) { 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, diff --git a/frontend/src/components/SoldeChart.jsx b/frontend/src/components/SoldeChart.jsx index 64d889f..c6a17e8 100644 --- a/frontend/src/components/SoldeChart.jsx +++ b/frontend/src/components/SoldeChart.jsx @@ -38,8 +38,13 @@ function fmtTodayFull() { } /* ── Composant ──────────────────────────────────────────────── */ -export default function SoldeChart({ rows }) { - const [range, setRange] = useState('TOUT'); +export default function SoldeChart({ rows, range: rangeProp, onRangeChange }) { + // Contrôlé si le parent fournit `range`/`onRangeChange` (pour synchroniser + // d'autres composants, ex. DistributionChart, sur la même plage) — sinon + // repli sur un state interne (usage autonome). + const [rangeState, setRangeState] = useState('TOUT'); + const range = rangeProp !== undefined ? rangeProp : rangeState; + const setRange = (r) => { if (onRangeChange) onRangeChange(r); else setRangeState(r); }; const [hover, setHover] = useState(null); // { x, y, value, date } const svgRef = useRef(null); const wrapRef = useRef(null); diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index c9477c0..3515a05 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -11,14 +11,14 @@ import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; import SoldeChart from '../components/SoldeChart.jsx'; import DistributionChart from '../components/DistributionChart.jsx'; -import { fmtEUR, fmtDate, today } from '../utils/format.js'; +import { fmtEUR, fmtDate, today, rangeFromDate } from '../utils/format.js'; import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx'; import SuiviObjectifs from '../components/SuiviObjectifs.jsx'; import * as XLSX from 'xlsx'; import { DEBIT_TYPES, signedMontant, MOUV_TYPE_LABELS, mouvToCSV, mouvToXLS, mouvToJSON, ExportDropdown, AddDropdown, CorrectionPickerModal, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm, - timestampSuffix, dlBlob, + timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap, } from '../components/MouvementsShared.jsx'; /* ── Indicateur de progression ── */ @@ -109,15 +109,18 @@ export default function DepotsRetraits() { // cf. mémoire project_taxreport_interets_plateforme. const [dashDataAsOf, setDashDataAsOf] = useState({}); // Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements - const currentYear = String(new Date().getFullYear()); - const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' }); + // — vide par défaut ("Toutes les années") au chargement initial de la page. + const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: '', month: '' }); const [modalOpen, setModalOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyMouvementForm); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); const [selectedRow, setSelectedRow] = useState(null); - const [drPlatYear, setDrPlatYear] = useState(currentYear); + const [drPlatYear, setDrPlatYear] = useState(''); + // Plage du graphique de solde (1J/7J/1M/3M/YTD/1A/TOUT), partagée avec la + // Distribution pour qu'elle affiche toujours la même période que le graphique. + const [chartRange, setChartRange] = useState('TOUT'); const [allRemb, setAllRemb] = useState([]); const [allInv, setAllInv] = useState([]); const [allReinvests, setAllReinvests] = useState([]); @@ -236,12 +239,26 @@ export default function DepotsRetraits() { return true; }); - // chartRows : kpiRows + filtre type (pour les graphiques) + // chartRows : plateforme + type + année sélectionnée (comme sur Remboursements — + // choisir une année dans le sélecteur "Répartition par plateforme" recentre aussi + // le graphique de solde et la Distribution sur cette année). Par défaut, l'année + // vaut "Toutes les années" (drPlatYear === ''), donc "Tout" dans le graphique de + // solde remonte bien jusqu'au tout premier mouvement. const chartRows = kpiRows.filter(r => { if (filter.type && r.type !== filter.type) return false; return true; }); + // distributionRows : chartRows restreint à la plage du graphique de solde + // (1J/7J/1M/3M/YTD/1A/TOUT) — pour que la Distribution reflète toujours la + // même période que le graphique auquel elle est associée. + const distributionRows = useMemo(() => { + const from = rangeFromDate(chartRange); + if (!from) return chartRows; + return chartRows.filter(r => r.date_operation.slice(0, 10) >= from); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chartRows, chartRange]); + // rows : pour le tableau Mouvements (tous les filtres y compris mois) const rows = chartRows.filter(r => { const [y, m] = r.date_operation.split('-'); @@ -306,6 +323,7 @@ export default function DepotsRetraits() { const plat = plats.find(p => p.id === r.plateforme_id); const base = { _rembId: r.id, + _invId: r.investissement_id ?? null, plateforme_id: r.plateforme_id, plateforme_nom: r.plateforme_nom, plateforme_detenteur_nom: r.plateforme_detenteur_nom, @@ -435,6 +453,16 @@ export default function DepotsRetraits() { return true; }), [normalizedSouscriptionMouvements, filter.plateforme_ids, filter.year, filter.month]); + // Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique + // COMPLET et NON FILTRÉ (allRows/normalizedCorrections/normalizedRembMouvements/ + // normalizedSouscriptionMouvements, pas rows/filteredCorrections/...), sinon le cumul + // démarrerait arbitrairement au milieu de l'historique quand un filtre année/mois/type + // est actif. Purement indicatif (affichage/export) — ne remplace pas kpiSoldePortefeuille. + // Voir [[project_mouvements_solde_indicatif]]. + const soldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([ + ...allRows, ...normalizedCorrections, ...normalizedRembMouvements, ...normalizedSouscriptionMouvements, + ]), [allRows, normalizedCorrections, normalizedRembMouvements, normalizedSouscriptionMouvements]); + // tableRows : dépôts/retraits + corrections + remboursements/bonus + souscriptions, triés par date desc. // _projet : nom du projet quand cela a du sens (remboursement, souscription/réinvestissement, // et retrait AUTO lié à un remboursement via remboursement_id) — vide pour dépôt/retrait @@ -452,10 +480,9 @@ export default function DepotsRetraits() { const subPart = filteredSouscriptionMouvements .filter(r => !filter.type || r.type === filter.type) .map(r => ({ ...r, _projet: r.libelle || '' })); - return [...drPart, ...corrPart, ...rembPart, ...subPart].sort((a, b) => - b.date_operation.localeCompare(a.date_operation) || String(b.id).localeCompare(String(a.id)) - ); - }, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById]); + return sortMouvements([...drPart, ...corrPart, ...rembPart, ...subPart]) + .map(r => ({ ...r, _soldeIndicatif: soldeIndicatifByRowId.get(r.id) })); + }, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById, soldeIndicatifByRowId]); /* ── Pagination mouvements ── */ const { @@ -880,8 +907,8 @@ export default function DepotsRetraits() { {/* ── Graphiques ── */} {!listFocused &&
- - + +
} {/* ── KPIs ── */} @@ -894,9 +921,9 @@ export default function DepotsRetraits() {
Dépôts
{fmtEUR(totals.depots)} - + {drPlatYear && }
-
{fmtEUR(prevTotals.depots)} en {prevYear}
+ {drPlatYear &&
{fmtEUR(prevTotals.depots)} en {prevYear}
}
Retraits
{fmtEUR(totals.retraits)} - + {drPlatYear && }
-
{fmtEUR(prevTotals.retraits)} en {prevYear}
+ {drPlatYear &&
{fmtEUR(prevTotals.retraits)} en {prevYear}
}
0 || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`} @@ -919,9 +946,9 @@ export default function DepotsRetraits() {
Diff. Dépôts vs Retraits
{fmtEUR(totals.depots - totals.retraits)} - + {drPlatYear && }
-
{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}
+ {drPlatYear &&
{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}
} {objectifsActifs && kpiEcartObjectif != null && (
Porte-monnaie
{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'} - {kpiSoldePortefeuille !== null && } + {drPlatYear && kpiSoldePortefeuille !== null && }
-
{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}
+ {drPlatYear &&
{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}
}
} @@ -1250,12 +1277,12 @@ export default function DepotsRetraits() { - {multiDetenteur && } + {multiDetenteur && } {tableRows.length === 0 && ( - )} @@ -1283,6 +1310,7 @@ export default function DepotsRetraits() { + + ); @@ -1349,6 +1378,7 @@ export default function DepotsRetraits() { + ); @@ -1385,6 +1415,7 @@ export default function DepotsRetraits() { + ); @@ -1416,6 +1447,7 @@ export default function DepotsRetraits() { style={ isAuto ? { fontStyle: 'italic' } : undefined }> {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} +
DatePlateformeDétenteurType de mouvementProjetMontantDatePlateformeDétenteurType de mouvementProjetMontantSolde indicatif
+
{loading ? 'Chargement…' : 'Aucun mouvement'}
{isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'} e.stopPropagation()}> {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}
− {fmtEUR(r.montant)} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}
− {fmtEUR(r.montant)} {r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}
{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'} e.stopPropagation()}> {!isAuto && (