Amélioration du traitement des mouvements

This commit is contained in:
ocroguennec committed 2026-08-21 09:20:48 +02:00
1 parent 9540221577
commit 6c96e023a9
8 files changed
+348 -82

No files matched your search

+14 -3
View File
@@ -71,14 +71,25 @@ export default function DistributionChart({ rows }) {
const svgRef = useRef(null); const svgRef = useRef(null);
const wrapRef = 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(() => { const { data, allRetraits } = useMemo(() => {
if (!rows?.length) return { data: [], allRetraits: false }; 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 = {}; const byPlat = {};
for (const r of rows) { for (const r of rows) {
const key = r.plateforme_nom || `#${r.plateforme_id}`; 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) const data = Object.entries(byPlat)
.filter(([, v]) => v > 0) .filter(([, v]) => v > 0)
+170 -2
View File
@@ -41,13 +41,179 @@ export const MOUV_TYPE_LABELS = {
interets_plateforme: 'Intérêts plateforme', 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) { export function mouvToCSV(rows) {
const BOM = ''; const sep = ';'; const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; 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 => [ const data = rows.map(r => [
r.date_operation, r.plateforme_nom||'', MOUV_TYPE_LABELS[r.type] || r.type, r._projet||'', 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'); 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, 'Type': MOUV_TYPE_LABELS[r.type] || r.type,
'Projet': r._projet || '', 'Projet': r._projet || '',
'Montant (€)': signedMontant(r), 'Montant (€)': signedMontant(r),
'Solde porte-monnaie indicatif (€)': r._soldeIndicatif ?? '',
})); }));
const ws = XLSX.utils.json_to_sheet(data); const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
@@ -74,6 +241,7 @@ export function mouvToJSON(rows) {
type: r.type, type: r.type,
projet: r._projet || null, projet: r._projet || null,
montant: signedMontant(r), montant: signedMontant(r),
solde_indicatif: r._soldeIndicatif ?? null,
libelle: r.libelle || null, libelle: r.libelle || null,
reference: r.reference || null, reference: r.reference || null,
source: r.source || null, source: r.source || null,
+7 -2
View File
@@ -38,8 +38,13 @@ function fmtTodayFull() {
} }
/* ── Composant ──────────────────────────────────────────────── */ /* ── Composant ──────────────────────────────────────────────── */
export default function SoldeChart({ rows }) { export default function SoldeChart({ rows, range: rangeProp, onRangeChange }) {
const [range, setRange] = useState('TOUT'); // 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 [hover, setHover] = useState(null); // { x, y, value, date }
const svgRef = useRef(null); const svgRef = useRef(null);
const wrapRef = useRef(null); const wrapRef = useRef(null);
+54 -22
View File
@@ -11,14 +11,14 @@ import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx';
import SoldeChart from '../components/SoldeChart.jsx'; import SoldeChart from '../components/SoldeChart.jsx';
import DistributionChart from '../components/DistributionChart.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 DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import SuiviObjectifs from '../components/SuiviObjectifs.jsx'; import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { import {
DEBIT_TYPES, signedMontant, MOUV_TYPE_LABELS, mouvToCSV, mouvToXLS, mouvToJSON, DEBIT_TYPES, signedMontant, MOUV_TYPE_LABELS, mouvToCSV, mouvToXLS, mouvToJSON,
ExportDropdown, AddDropdown, CorrectionPickerModal, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm, ExportDropdown, AddDropdown, CorrectionPickerModal, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm,
timestampSuffix, dlBlob, timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap,
} from '../components/MouvementsShared.jsx'; } from '../components/MouvementsShared.jsx';
/* ── Indicateur de progression ── */ /* ── Indicateur de progression ── */
@@ -109,15 +109,18 @@ export default function DepotsRetraits() {
// cf. mémoire project_taxreport_interets_plateforme. // cf. mémoire project_taxreport_interets_plateforme.
const [dashDataAsOf, setDashDataAsOf] = useState({}); const [dashDataAsOf, setDashDataAsOf] = useState({});
// Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements // Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements
const currentYear = String(new Date().getFullYear()); // — vide par défaut ("Toutes les années") au chargement initial de la page.
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' }); const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: '', month: '' });
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyMouvementForm); const [form, setForm] = useState(emptyMouvementForm);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null); 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 [allRemb, setAllRemb] = useState([]);
const [allInv, setAllInv] = useState([]); const [allInv, setAllInv] = useState([]);
const [allReinvests, setAllReinvests] = useState([]); const [allReinvests, setAllReinvests] = useState([]);
@@ -236,12 +239,26 @@ export default function DepotsRetraits() {
return true; 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 => { const chartRows = kpiRows.filter(r => {
if (filter.type && r.type !== filter.type) return false; if (filter.type && r.type !== filter.type) return false;
return true; 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) // rows : pour le tableau Mouvements (tous les filtres y compris mois)
const rows = chartRows.filter(r => { const rows = chartRows.filter(r => {
const [y, m] = r.date_operation.split('-'); 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 plat = plats.find(p => p.id === r.plateforme_id);
const base = { const base = {
_rembId: r.id, _rembId: r.id,
_invId: r.investissement_id ?? null,
plateforme_id: r.plateforme_id, plateforme_id: r.plateforme_id,
plateforme_nom: r.plateforme_nom, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, plateforme_detenteur_nom: r.plateforme_detenteur_nom,
@@ -435,6 +453,16 @@ export default function DepotsRetraits() {
return true; return true;
}), [normalizedSouscriptionMouvements, filter.plateforme_ids, filter.year, filter.month]); }), [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. // 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, // _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 // 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 const subPart = filteredSouscriptionMouvements
.filter(r => !filter.type || r.type === filter.type) .filter(r => !filter.type || r.type === filter.type)
.map(r => ({ ...r, _projet: r.libelle || '' })); .map(r => ({ ...r, _projet: r.libelle || '' }));
return [...drPart, ...corrPart, ...rembPart, ...subPart].sort((a, b) => return sortMouvements([...drPart, ...corrPart, ...rembPart, ...subPart])
b.date_operation.localeCompare(a.date_operation) || String(b.id).localeCompare(String(a.id)) .map(r => ({ ...r, _soldeIndicatif: soldeIndicatifByRowId.get(r.id) }));
); }, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById, soldeIndicatifByRowId]);
}, [rows, filteredCorrections, filteredRembMouvements, filteredSouscriptionMouvements, filter.type, rembProjetById]);
/* ── Pagination mouvements ── */ /* ── Pagination mouvements ── */
const { const {
@@ -880,8 +907,8 @@ export default function DepotsRetraits() {
{/* ── Graphiques ── */} {/* ── Graphiques ── */}
{!listFocused && <div className="charts-row"> {!listFocused && <div className="charts-row">
<SoldeChart rows={chartRows} /> <SoldeChart rows={chartRows} range={chartRange} onRangeChange={setChartRange} />
<DistributionChart rows={chartRows} /> <DistributionChart rows={distributionRows} />
</div>} </div>}
{/* ── KPIs ── */} {/* ── KPIs ── */}
@@ -894,9 +921,9 @@ export default function DepotsRetraits() {
<div className="label">Dépôts</div> <div className="label">Dépôts</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots)}</span>
<TrendBadge current={totals.depots} prev={prevTotals.depots} /> {drPlatYear && <TrendBadge current={totals.depots} prev={prevTotals.depots} />}
</div> </div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots)} en {prevYear}</div> {drPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots)} en {prevYear}</div>}
</div> </div>
<div <div
className={`kpi dr-kpi-clickable${filter.type === 'retrait' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`} className={`kpi dr-kpi-clickable${filter.type === 'retrait' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
@@ -906,9 +933,9 @@ export default function DepotsRetraits() {
<div className="label">Retraits</div> <div className="label">Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.retraits)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.retraits)}</span>
<TrendBadge current={totals.retraits} prev={prevTotals.retraits} invert={true} /> {drPlatYear && <TrendBadge current={totals.retraits} prev={prevTotals.retraits} invert={true} />}
</div> </div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div> {drPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>}
</div> </div>
<div <div
className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`} className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
@@ -919,9 +946,9 @@ export default function DepotsRetraits() {
<div className="label">Diff. Dépôts vs Retraits</div> <div className="label">Diff. Dépôts vs Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots - totals.retraits)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots - totals.retraits)}</span>
<TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} /> {drPlatYear && <TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />}
</div> </div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div> {drPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>}
{objectifsActifs && kpiEcartObjectif != null && ( {objectifsActifs && kpiEcartObjectif != null && (
<div style={{ <div style={{
fontSize: '0.8em', marginTop: 4, fontWeight: 600, fontSize: '0.8em', marginTop: 4, fontWeight: 600,
@@ -939,9 +966,9 @@ export default function DepotsRetraits() {
<div className="label">Porte-monnaie</div> <div className="label">Porte-monnaie</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'}</span>
{kpiSoldePortefeuille !== null && <TrendBadge current={kpiSoldePortefeuille} prev={prevKpiSoldePortefeuille} />} {drPlatYear && kpiSoldePortefeuille !== null && <TrendBadge current={kpiSoldePortefeuille} prev={prevKpiSoldePortefeuille} />}
</div> </div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}</div> {drPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}</div>}
</div> </div>
</div>} </div>}
@@ -1250,12 +1277,12 @@ export default function DepotsRetraits() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Type de mouvement</th><th>Projet</th><th className="num">Montant</th><th style={{ width: 36 }}></th> <th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Type de mouvement</th><th>Projet</th><th className="num">Montant</th><th className="num" title="Solde du porte-monnaie de la plateforme après ce mouvement — calculé à titre indicatif, pas une donnée officielle">Solde indicatif</th><th style={{ width: 36 }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tableRows.length === 0 && ( {tableRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}> <tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun mouvement'} {loading ? 'Chargement…' : 'Aucun mouvement'}
</td></tr> </td></tr>
)} )}
@@ -1283,6 +1310,7 @@ export default function DepotsRetraits() {
<td className={`num ${isPos ? 'success' : 'danger'}`}> <td className={`num ${isPos ? 'success' : 'danger'}`}>
{isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))} {isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}> <td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button <button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
@@ -1320,6 +1348,7 @@ export default function DepotsRetraits() {
<td className="num success"> <td className="num success">
+ {fmtEUR(r.montant)} + {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} /> <td style={{ width: 36, textAlign: 'center' }} />
</tr> </tr>
); );
@@ -1349,6 +1378,7 @@ export default function DepotsRetraits() {
<td className="num danger"> <td className="num danger">
− {fmtEUR(r.montant)} − {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} /> <td style={{ width: 36, textAlign: 'center' }} />
</tr> </tr>
); );
@@ -1385,6 +1415,7 @@ export default function DepotsRetraits() {
<td className="num danger"> <td className="num danger">
− {fmtEUR(r.montant)} − {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} /> <td style={{ width: 36, textAlign: 'center' }} />
</tr> </tr>
); );
@@ -1416,6 +1447,7 @@ export default function DepotsRetraits() {
style={ isAuto ? { fontStyle: 'italic' } : undefined }> style={ isAuto ? { fontStyle: 'italic' } : undefined }>
{r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}> <td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
{!isAuto && ( {!isAuto && (
<button <button
+10 -10
View File
@@ -825,9 +825,9 @@ export default function Investissements() {
<div className="label">Capital investi</div> <div className="label">Capital investi</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.encours)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.encours)}</span>
{prevTotals && <TrendBadge current={totals.encours} prev={prevTotals.encours} />} {platYear && <TrendBadge current={totals.encours} prev={prevTotals.encours} />}
</div> </div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.encours)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}</div>} {platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.encours)} en {Number(platYear) - 1}</div>}
</div> </div>
{/* 2 — Investissements à risque */} {/* 2 — Investissements à risque */}
@@ -844,9 +844,9 @@ export default function Investissements() {
<div className="label">Investissements à risque</div> <div className="label">Investissements à risque</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.defaut > 0 ? 'danger' : ''}>{fmtEUR(totals.defaut)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.defaut > 0 ? 'danger' : ''}>{fmtEUR(totals.defaut)}</span>
{prevTotals && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />} {platYear && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />}
</div> </div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.defaut)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}</div>} {platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.defaut)} en {Number(platYear) - 1}</div>}
</div> </div>
{/* 3 — Investissements depuis le début */} {/* 3 — Investissements depuis le début */}
@@ -859,9 +859,9 @@ export default function Investissements() {
<div className="label">Investissements depuis le début</div> <div className="label">Investissements depuis le début</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span>
{prevTotals && <TrendBadge current={totals.investi} prev={prevTotals.investi} />} {platYear && <TrendBadge current={totals.investi} prev={prevTotals.investi} />}
</div> </div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.investi)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}</div>} {platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.investi)} en {Number(platYear) - 1}</div>}
</div> </div>
{/* 4 — Investissements remboursés */} {/* 4 — Investissements remboursés */}
@@ -869,9 +869,9 @@ export default function Investissements() {
<div className="label">Investissements remboursés</div> <div className="label">Investissements remboursés</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cap_remb)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cap_remb)}</span>
{prevTotals && <TrendBadge current={totals.cap_remb} prev={prevTotals.cap_remb} />} {platYear && <TrendBadge current={totals.cap_remb} prev={prevTotals.cap_remb} />}
</div> </div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.cap_remb)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}</div>} {platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.cap_remb)} en {Number(platYear) - 1}</div>}
</div> </div>
{/* 5 — Intérêts perçus */} {/* 5 — Intérêts perçus */}
@@ -879,9 +879,9 @@ export default function Investissements() {
<div className="label">Intérêts perçus — {netMode ? 'Net' : 'Brut'}</div> <div className="label">Intérêts perçus — {netMode ? 'Net' : 'Brut'}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(netMode ? totals.int_perc_net : totals.int_perc)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(netMode ? totals.int_perc_net : totals.int_perc)}</span>
{prevTotals && <TrendBadge current={netMode ? totals.int_perc_net : totals.int_perc} prev={netMode ? prevTotals.int_perc_net : prevTotals.int_perc} />} {platYear && <TrendBadge current={netMode ? totals.int_perc_net : totals.int_perc} prev={netMode ? prevTotals.int_perc_net : prevTotals.int_perc} />}
</div> </div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}</div>} {platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {Number(platYear) - 1}</div>}
</div> </div>
</div>} </div>}
+65 -36
View File
@@ -20,7 +20,7 @@ import ConfirmModal from '../components/ConfirmModal.jsx';
import { import {
mouvToCSV, mouvToXLS, mouvToJSON, mouvToCSV, mouvToXLS, mouvToJSON,
ExportDropdown, AddDropdown, CorrectionPickerModal, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm, ExportDropdown, AddDropdown, CorrectionPickerModal, DetailPanel, CorrectionModal, MOIS_FR, emptyMouvementForm,
timestampSuffix, dlBlob, timestampSuffix, dlBlob, sortMouvements, computeSoldeIndicatifMap,
} from '../components/MouvementsShared.jsx'; } from '../components/MouvementsShared.jsx';
const LOGOS_BASE = '/api/logos/'; const LOGOS_BASE = '/api/logos/';
@@ -1039,6 +1039,7 @@ export default function Plateformes() {
const plat = plats.find(p => p.id === r.plateforme_id); const plat = plats.find(p => p.id === r.plateforme_id);
const base = { const base = {
_rembId: r.id, _rembId: r.id,
_invId: r.investissement_id ?? null,
plateforme_id: r.plateforme_id, plateforme_id: r.plateforme_id,
plateforme_nom: r.plateforme_nom, plateforme_nom: r.plateforme_nom,
plateforme_detenteur_nom: r.plateforme_detenteur_nom, plateforme_detenteur_nom: r.plateforme_detenteur_nom,
@@ -1177,6 +1178,16 @@ export default function Plateformes() {
return [...set].sort((a, b) => a.localeCompare(b, 'fr')); return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]); }, [mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements, allDepots, mvRembProjetById, mvFilter.plateforme_ids]);
// Solde indicatif du porte-monnaie après chaque mouvement — calculé sur l'historique
// COMPLET et NON FILTRÉ (allDepots/mvNormalizedCorrections/mvNormalizedRembMouvements/
// mvNormalizedSouscriptionMouvements), pas sur les variantes déjà filtrées année/mois/
// type/projet — sinon le cumul démarrerait arbitrairement au milieu de l'historique.
// Purement indicatif (affichage/export) — ne remplace pas computeSoldePortefeuille.
// Voir [[project_mouvements_solde_indicatif]].
const mvSoldeIndicatifByRowId = useMemo(() => computeSoldeIndicatifMap([
...allDepots, ...mvNormalizedCorrections, ...mvNormalizedRembMouvements, ...mvNormalizedSouscriptionMouvements,
]), [allDepots, mvNormalizedCorrections, mvNormalizedRembMouvements, mvNormalizedSouscriptionMouvements]);
const mvTableRows = useMemo(() => { const mvTableRows = useMemo(() => {
const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait'; const showDR = !mvFilter.type || mvFilter.type === 'depot' || mvFilter.type === 'retrait';
const showCorr = !mvFilter.type || mvFilter.type === 'correction'; const showCorr = !mvFilter.type || mvFilter.type === 'correction';
@@ -1190,12 +1201,11 @@ export default function Plateformes() {
const subPart = mvFilteredSouscriptionMouvements const subPart = mvFilteredSouscriptionMouvements
.filter(r => !mvFilter.type || r.type === mvFilter.type) .filter(r => !mvFilter.type || r.type === mvFilter.type)
.map(r => ({ ...r, _projet: r.libelle || '' })); .map(r => ({ ...r, _projet: r.libelle || '' }));
return [...drPart, ...corrPart, ...rembPart, ...subPart] return sortMouvements(
.filter(r => !mvFilter.projet || r._projet === mvFilter.projet) [...drPart, ...corrPart, ...rembPart, ...subPart]
.sort((a, b) => .filter(r => !mvFilter.projet || r._projet === mvFilter.projet)
b.date_operation.localeCompare(a.date_operation) || String(b.id).localeCompare(String(a.id)) ).map(r => ({ ...r, _soldeIndicatif: mvSoldeIndicatifByRowId.get(r.id) }));
); }, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById, mvSoldeIndicatifByRowId]);
}, [mvRows, mvFilteredCorrections, mvFilteredRembMouvements, mvFilteredSouscriptionMouvements, mvFilter.type, mvFilter.projet, mvRembProjetById]);
const { const {
pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage, pagedItems: mvPagedTableRows, page: mvPage, setPage: setMvPage,
@@ -1445,11 +1455,13 @@ export default function Plateformes() {
<div className="label">Capital investi</div> <div className="label">Capital investi</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.encours)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.encours)}</span>
<TrendBadge current={totals.encours} prev={prevTotals.encours} /> {selectedYear && <TrendBadge current={totals.encours} prev={prevTotals.encours} />}
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.encours)} en {prevYearLabel}
</div> </div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.encours)} en {prevYearLabel}
</div>
)}
{objectifsActifs && kpiEcartCapitalObjectif != null && ( {objectifsActifs && kpiEcartCapitalObjectif != null && (
<div style={{ <div style={{
fontSize: '0.8em', marginTop: 4, fontWeight: 600, fontSize: '0.8em', marginTop: 4, fontWeight: 600,
@@ -1471,11 +1483,13 @@ export default function Plateformes() {
<span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.defaut > 0 ? 'danger' : ''}> <span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.defaut > 0 ? 'danger' : ''}>
{fmtEUR(totals.defaut)} {fmtEUR(totals.defaut)}
</span> </span>
{totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />} {selectedYear && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />}
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.defaut)} en {prevYearLabel}
</div> </div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.defaut)} en {prevYearLabel}
</div>
)}
</div> </div>
{/* 3 — Porte-monnaie */} {/* 3 — Porte-monnaie */}
@@ -1483,11 +1497,13 @@ export default function Plateformes() {
<div className="label">Porte-monnaie</div> <div className="label">Porte-monnaie</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(soldePortefeuille)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(soldePortefeuille)}</span>
<TrendBadge current={soldePortefeuille} prev={prevSoldePortefeuille} /> {selectedYear && <TrendBadge current={soldePortefeuille} prev={prevSoldePortefeuille} />}
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevSoldePortefeuille)} en {prevYearLabel}
</div> </div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevSoldePortefeuille)} en {prevYearLabel}
</div>
)}
</div> </div>
{/* 4 — Investissements depuis le début */} {/* 4 — Investissements depuis le début */}
@@ -1495,11 +1511,13 @@ export default function Plateformes() {
<div className="label">Investissements depuis le début</div> <div className="label">Investissements depuis le début</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span>
<TrendBadge current={totals.investi} prev={prevTotals.investi} /> {selectedYear && <TrendBadge current={totals.investi} prev={prevTotals.investi} />}
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.investi)} en {prevYearLabel}
</div> </div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.investi)} en {prevYearLabel}
</div>
)}
</div> </div>
{/* 5 — Capital remboursé */} {/* 5 — Capital remboursé */}
@@ -1507,11 +1525,13 @@ export default function Plateformes() {
<div className="label">Capital remboursé</div> <div className="label">Capital remboursé</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cap_remb)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cap_remb)}</span>
<TrendBadge current={totals.cap_remb} prev={prevTotals.cap_remb} /> {selectedYear && <TrendBadge current={totals.cap_remb} prev={prevTotals.cap_remb} />}
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.cap_remb)} en {prevYearLabel}
</div> </div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.cap_remb)} en {prevYearLabel}
</div>
)}
</div> </div>
{/* 6 — Intérêts perçus */} {/* 6 — Intérêts perçus */}
@@ -1521,14 +1541,18 @@ export default function Plateformes() {
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>
{fmtEUR(netMode ? totals.int_perc_net : totals.int_perc)} {fmtEUR(netMode ? totals.int_perc_net : totals.int_perc)}
</span> </span>
<TrendBadge {selectedYear && (
current={netMode ? totals.int_perc_net : totals.int_perc} <TrendBadge
prev={netMode ? prevTotals.int_perc_net : prevTotals.int_perc} current={netMode ? totals.int_perc_net : totals.int_perc}
/> prev={netMode ? prevTotals.int_perc_net : prevTotals.int_perc}
</div> />
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}> )}
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
</div> </div>
{selectedYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
</div>
)}
</div> </div>
{/* 7 — XIRR */} {/* 7 — XIRR */}
@@ -2566,12 +2590,12 @@ export default function Plateformes() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Type de mouvement</th><th>Projet</th><th className="num">Montant</th><th style={{ width: 36 }}></th> <th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Type de mouvement</th><th>Projet</th><th className="num">Montant</th><th className="num" title="Solde du porte-monnaie de la plateforme après ce mouvement — calculé à titre indicatif, pas une donnée officielle">Solde indicatif</th><th style={{ width: 36 }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{mvTableRows.length === 0 && ( {mvTableRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}> <tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun mouvement'} {loading ? 'Chargement…' : 'Aucun mouvement'}
</td></tr> </td></tr>
)} )}
@@ -2599,6 +2623,7 @@ export default function Plateformes() {
<td className={`num ${isPos ? 'success' : 'danger'}`}> <td className={`num ${isPos ? 'success' : 'danger'}`}>
{isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))} {isPos ? '+' : '−'} {fmtEUR(Math.abs(r.montant))}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}> <td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button <button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
@@ -2636,6 +2661,7 @@ export default function Plateformes() {
<td className="num success"> <td className="num success">
+ {fmtEUR(r.montant)} + {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} /> <td style={{ width: 36, textAlign: 'center' }} />
</tr> </tr>
); );
@@ -2665,6 +2691,7 @@ export default function Plateformes() {
<td className="num danger"> <td className="num danger">
− {fmtEUR(r.montant)} − {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} /> <td style={{ width: 36, textAlign: 'center' }} />
</tr> </tr>
); );
@@ -2701,6 +2728,7 @@ export default function Plateformes() {
<td className="num danger"> <td className="num danger">
− {fmtEUR(r.montant)} − {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} /> <td style={{ width: 36, textAlign: 'center' }} />
</tr> </tr>
); );
@@ -2732,6 +2760,7 @@ export default function Plateformes() {
style={ isAuto ? { fontStyle: 'italic' } : undefined }> style={ isAuto ? { fontStyle: 'italic' } : undefined }>
{r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)} {r.type === 'depot' ? '+' : '−'} {fmtEUR(r.montant)}
</td> </td>
<td className="num text-muted" style={{ fontStyle: 'italic' }}>{r._soldeIndicatif != null ? fmtEUR(r._soldeIndicatif) : '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}> <td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
{!isAuto && ( {!isAuto && (
<button <button
+7 -7
View File
@@ -236,7 +236,7 @@ export default function Remboursements() {
const [tableFilter, setTableFilter] = useState({ plateforme_id: '', investissement_id: '', year: '', month: '', type: '' }); const [tableFilter, setTableFilter] = useState({ plateforme_id: '', investissement_id: '', year: '', month: '', type: '' });
/* Filtre année du tableau Plateformes */ /* Filtre année du tableau Plateformes */
const [rembPlatYear, setRembPlatYear] = useState(String(new Date().getFullYear())); const [rembPlatYear, setRembPlatYear] = useState(''); // 'Toutes les années' par défaut
/* Modal */ /* Modal */
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -847,25 +847,25 @@ export default function Remboursements() {
<div className="label">Capital remboursé</div> <div className="label">Capital remboursé</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.capital)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.capital)}</span>
<TrendBadge current={totals.capital} prev={prevTotals.capital} /> {rembPlatYear && <TrendBadge current={totals.capital} prev={prevTotals.capital} />}
</div> </div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.capital)} en {rembPrevYear}</div> {rembPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.capital)} en {rembPrevYear}</div>}
</div> </div>
<div className="kpi"> <div className="kpi">
<div className="label">Cashback</div> <div className="label">Cashback</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cashback)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cashback)}</span>
<TrendBadge current={totals.cashback} prev={prevTotals.cashback} /> {rembPlatYear && <TrendBadge current={totals.cashback} prev={prevTotals.cashback} />}
</div> </div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.cashback)} en {rembPrevYear}</div> {rembPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.cashback)} en {rembPrevYear}</div>}
</div> </div>
<div className="kpi"> <div className="kpi">
<div className="label">Intérêts — {netMode ? 'Net' : 'Brut'}</div> <div className="label">Intérêts — {netMode ? 'Net' : 'Brut'}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(netMode ? totals.interets_nets : totals.interets)}</span> <span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(netMode ? totals.interets_nets : totals.interets)}</span>
<TrendBadge current={netMode ? totals.interets_nets : totals.interets} prev={netMode ? prevTotals.interets_nets : prevTotals.interets} /> {rembPlatYear && <TrendBadge current={netMode ? totals.interets_nets : totals.interets} prev={netMode ? prevTotals.interets_nets : prevTotals.interets} />}
</div> </div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(netMode ? prevTotals.interets_nets : prevTotals.interets)} en {rembPrevYear}</div> {rembPlatYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(netMode ? prevTotals.interets_nets : prevTotals.interets)} en {rembPrevYear}</div>}
</div> </div>
</div>} </div>}
+21
View File
@@ -11,6 +11,27 @@ export const fmtEUR = (n) => {
// "-0,00 €" pour un montant nul. // "-0,00 €" pour un montant nul.
export const fmtRetrait = (n) => (n > 0 ? `-${fmtEUR(n)}` : fmtEUR(n)); export const fmtRetrait = (n) => (n > 0 ? `-${fmtEUR(n)}` : fmtEUR(n));
// Borne de date basse (YYYY-MM-DD) pour une plage de type sélecteur de graphique
// ('1J'|'7J'|'1M'|'3M'|'YTD'|'1A'|'TOUT'). Retourne null pour 'TOUT' (pas de borne).
// Logique identique à celle utilisée en interne par SoldeChart/InvChart, extraite
// ici pour pouvoir filtrer d'autres composants (ex. DistributionChart) sur la même
// plage que le graphique auquel ils sont associés.
export const rangeFromDate = (range) => {
if (!range || range === 'TOUT') return null;
const now = new Date();
let fromDate = new Date(now);
switch (range) {
case '1J': fromDate.setDate(now.getDate() - 1); break;
case '7J': fromDate.setDate(now.getDate() - 7); break;
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
default: return null;
}
return fromDate.toISOString().slice(0, 10);
};
export const fmtNum = (n, digits = 2) => export const fmtNum = (n, digits = 2) =>
(n == null ? '' : Number(n).toLocaleString('fr-FR', { minimumFractionDigits: digits, maximumFractionDigits: digits })); (n == null ? '' : Number(n).toLocaleString('fr-FR', { minimumFractionDigits: digits, maximumFractionDigits: digits }));