From 86a047a0c5d88f6453dd8fd5cd3f48c480ee7c54 Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 13 Jul 2026 17:53:22 +0200 Subject: [PATCH] Mise en place des objectifs --- MEMORY.md | 63 ++++- backend/src/db/index.js | 19 ++ backend/src/routes/objectifs.js | 88 +++++++ backend/src/server.js | 2 + frontend/src/components/SuiviObjectifs.jsx | 276 +++++++++++++++++++++ frontend/src/pages/DepotsRetraits.jsx | 52 ++++ frontend/src/pages/Plateformes.jsx | 9 + 7 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/objectifs.js create mode 100644 frontend/src/components/SuiviObjectifs.jsx diff --git a/MEMORY.md b/MEMORY.md index 281cade..4460810 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,5 +1,5 @@ # MEMORY.md — Crowdlending Tracker -*Dernière mise à jour: 2026-07-13 (session 11)* +*Dernière mise à jour: 2026-07-13 (session 13)* --- @@ -151,6 +151,7 @@ GET /api/fiscal-2778/export?annee=YYYY GET/POST /api/pfu GET/POST/PUT/DELETE /api/notation GET/POST/PUT/DELETE /api/garanties +GET/POST/DELETE /api/objectifs?type=&annee= GET/POST/PUT/DELETE /api/categories POST /api/imports/preview | apply GET /api/imports/history @@ -572,3 +573,63 @@ Distincte de `investissement_historique` (audit générique auto-détecté sur t ### Piège outillage — confirmé à nouveau (cf. session 9) Le mount bash de `crowdlending-app` était figé sur un instantané ancien (dates de fichiers plusieurs semaines avant la session), sans lien avec les éditions faites via l'outil `Edit`/`Write` dans cette session — `wc -l`/`node --check` sur le mount bash donnaient un fichier tronqué non représentatif. Solution utilisée : reconstruire le fichier édité dans le dossier `outputs` (accessible en écriture réelle depuis bash) via lecture complète par l'outil `Read`, puis `node --check` / `esbuild --loader=jsx` dessus pour une vérification syntaxique fiable — en complément (pas remplacement) de la relecture visuelle des zones éditées via `Read`. + +--- + +## Session 12 — KPI XIRR sur la page Plateformes (2026-07-13) + +### Rendement annualisé (XIRR) estimé pour les prêts en cours + renommage KPI +Sur `InvestissementDetail.jsx`, le KPI "Rendement annualisé" a été renommé **"XIRR — Brut/Net"** et calculé désormais pour **tous les statuts** (plus seulement `rembourse`) : pour un prêt non soldé, on ajoute un flux de trésorerie synthétique final = capital restant dû, daté du jour ("valorisation à date"), en plus des flux réels (versement initial, réinvestissements, remboursements). Le label affiche "(estimé)" tant que le prêt n'est pas intégralement remboursé. Un popup (icône info + `cell-tooltip tooltip-down`) explique la méthodologie. Le modificateur CSS `.tooltip-down` (`top: calc(100% + 6px)`) a été ajouté car le tooltip par défaut s'ouvre vers le haut et était coupé en haut de viewport pour ce KPI proche du sommet de page. + +### Fonction `xirr()` extraite en utilitaire partagé +Déplacée de `InvestissementDetail.jsx` (définition locale) vers `frontend/src/utils/xirr.js` (export nommé `xirr`), pour réutilisation sans duplication. Newton-Raphson sur flux datés, retourne `null` si non convergent ou < 2 flux. + +### Nouveau KPI XIRR sur la page Plateformes +Ajouté en 6e position dans `dr-kpi-row` de `Plateformes.jsx` (grid passée de `repeat(5,1fr)` à `repeat(6,1fr)`), juste après "Intérêts perçus" — même format que la fiche investissement (label + icône info + tooltip explicatif, "(estimé)" si applicable). +- Calcul agrégé (`plateformeXirr`, `useMemo`) sur `chartRows` (déjà filtré plateforme + détenteur + année) : flux datés individuels (pas des sommes) — investissement initial (négatif) + réinvestissements (négatif) + remboursements réels (positif, brut = capital+cashback+interets_bruts, net = `net_recu`), tous filtrés par la même coupure `cutoff` que les autres agrégats de la page (`${selectedYear}-12-31` si une année est sélectionnée, sinon `today()`). +- Valorisation à date : pour chaque investissement de `chartRows` encore actif (capital restant dû > 0 à la coupure), un flux positif = capital restant est ajouté à la date de coupure — cohérent avec la logique déjà utilisée pour `totals.encours`. +- Flux triés chronologiquement avant l'appel à `xirr()` (invariant mathématiquement par rapport à l'ancre `t0`, mais plus robuste/lisible). +- Choix assumé : pas de `TrendBadge` (comparaison N-1) sur ce KPI — contrairement aux 5 autres — car un XIRR n'est pas additif d'une année sur l'autre comme un total cumulé ; à la place, un sous-texte indique la date de valorisation ("Valorisé au 31/12/2025" ou "Valorisé au aujourd'hui"). + +### Piège outillage — reconfirmé une 3e fois (sessions 9, 11, 12) +Le mount bash reste figé sur un instantané qui ne reflète pas les éditions de la session (`wc -l` sur le mount stagnait à 1591 lignes alors que le fichier réel via l'outil `Read` en comptait 1675, et ce même après un nouveau `cp` explicite). Vérification faite intégralement via relecture complète du fichier par l'outil `Read` (comptage de lignes, relecture des zones éditées, vérification de fermeture des blocs). Ne plus perdre de temps à essayer de resynchroniser le mount bash pour de la vérification syntaxique sur ce projet — se fier à une relecture `Read` complète et méthodique en priorité. + +--- + +## Session 13 — Objectifs annuels de versement (2026-07-13) + +### Nouvelle fonctionnalité : suivi d'objectifs de versement annuel +Demande initiale : pouvoir fixer un objectif de dépôts nets par an et suivre l'écart, en partant de la page Plateformes (onglet Dépôts/Retraits), avec une architecture réutilisable ailleurs. + +**Décisions produit actées après clarification (questions posées avant implémentation, conformément à la règle "poser des questions avant tâche complexe")** : +- Objectif défini **par investisseur et par année** (pas global, pas par plateforme) — en vue "tous les investisseurs", affichage = somme des objectifs individuels. +- Le suivi est **toujours calculé sur le portefeuille entier** (toutes plateformes), indépendamment du filtre plateforme actif sur la page qui l'affiche. + +### Modèle de données +Nouvelle table `objectifs` (migration dans `db/index.js`, avant `export default db`) : +```sql +CREATE TABLE objectifs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'versement_annuel', + annee INTEGER NOT NULL, + montant REAL NOT NULL, + notes TEXT, + created_at/updated_at TEXT, + UNIQUE(investisseur_id, type, annee) +) +``` +Le champ `type` est prévu pour réutiliser la table plus tard (ex. `'rendement_annuel'`) sans nouvelle migration — seul `'versement_annuel'` est utilisé actuellement. + +### Backend +`backend/src/routes/objectifs.js` (monté sur `/api/objectifs` dans `server.js`, entre `garantiesRouter` et `reinvestissementsRouter`) : `GET /` (filtres `?type=&annee=`, scope implicite = tous les investisseurs de `req.user.id` via jointure), `POST /` (upsert `ON CONFLICT(investisseur_id, type, annee)`), `DELETE /:id`. Pattern calqué sur `notation.js` (pas de middleware `requireInvestisseur`, ownership vérifiée par jointure SQL). + +### Composant frontend réutilisable +`frontend/src/components/SuiviObjectifs.jsx` — props : `rows` (mouvements bruts dépôts/retraits), `investisseurs`, `scopeInvestisseurIds` (tous les ids en vue "all", sinon `[activeId]`), `objectifType` (défaut `'versement_annuel'`), `title`. Gère lui-même le fetch/upsert/delete des objectifs. Tableau année par année : Année | Dépôts | Retraits | Différence | **Objectif annuel** | Écart, + **ligne Total** en `` (somme des colonnes sur les années affichées ; Objectif/Écart totaux affichent "—" si aucune année n'a d'objectif défini). Édition : clic sur la cellule Objectif annuel → un input par investisseur du scope (un seul si scope=1), Enregistrer fait un `Promise.all` de POST upsert (et DELETE si champ vidé). Bouton "+ Ajouter une année" pour anticiper une année future sans mouvement. + +### Intégration (2 emplacements) +1. `Plateformes.jsx` — onglet Dépôts/Retraits, section ajoutée sous le tableau "Mouvements de trésorerie" (`rows={allDepots}`). +2. `DepotsRetraits.jsx` (page dédiée du menu latéral) — **onglet dédié "Vision annuelle"** dans `.dr-tabs`, positionné entre "Plateformes" et "Vision mensuelle" (`activeTab === 'vision-annuelle'`, `rows={allRows}`). Le KPI existant "Diff. Dépôts vs Retraits" est enrichi d'une ligne sous le chiffre : "Reste X € pour l'objectif AAAA" ou "+X € au-delà de l'objectif AAAA" (calcul indépendant du filtre plateforme, année = `drPlatYear` ou année en cours ; nécessite un fetch local `objectifsKpi` séparé de celui du composant, car le composant gère son propre state). + +### Piège outillage — vérification syntaxique de fichiers JSX volumineux +Le mount bash reste périmé (cf. sessions précédentes). Pour vérifier un **nouveau** composant JSX isolé (pas une édition dans un fichier existant de 1000+ lignes), la méthode fiable trouvée cette session : `npm init -y && npm install esbuild` dans `/tmp` (indépendant du node_modules Windows du projet, incompatible avec le sandbox Linux), copier le contenu exact du fichier via `cat > fichier << 'EOF'` puis `esbuild fichier.jsx --bundle --format=esm --jsx=automatic --external:react --outfile=...`. Pour une **édition ponctuelle** dans un gros fichier existant, la relecture `Read` ciblée des zones modifiées (comptage d'accolades/balises JSX) reste suffisante et plus rapide. diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 59ba84a..eb02a6a 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2222,4 +2222,23 @@ console.log('[DB] Migrations 2FA OK'); ]); } +// ── Migration : table objectifs ────────────────────────────────────────── +// Objectifs annuels par investisseur. `type` permet de réutiliser la table +// pour d'autres natures d'objectifs plus tard (ex: 'rendement_annuel') — +// pour l'instant seul 'versement_annuel' (objectif de dépôts nets) est utilisé. +db.exec(` + CREATE TABLE IF NOT EXISTS objectifs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'versement_annuel', + annee INTEGER NOT NULL, + montant REAL NOT NULL, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(investisseur_id, type, annee) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)'); + export default db; diff --git a/backend/src/routes/objectifs.js b/backend/src/routes/objectifs.js new file mode 100644 index 0000000..01a9573 --- /dev/null +++ b/backend/src/routes/objectifs.js @@ -0,0 +1,88 @@ +import { Router } from 'express'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +const TYPE_DEFAUT = 'versement_annuel'; + +/** Valide et normalise le corps d'une requête POST */ +function parseBody(body) { + const { investisseur_id, type = TYPE_DEFAUT, annee, montant, notes } = body; + if (!investisseur_id) throw new HttpError(400, 'investisseur_id est requis'); + if (!type?.trim()) throw new HttpError(400, 'type est requis'); + if (!Number.isInteger(Number(annee)) || Number(annee) < 2000 || Number(annee) > 2100) { + throw new HttpError(400, 'annee invalide'); + } + if (montant === undefined || montant === null || Number.isNaN(Number(montant)) || Number(montant) < 0) { + throw new HttpError(400, 'montant doit être un nombre positif'); + } + return { + investisseur_id: Number(investisseur_id), + type: type.trim(), + annee: Number(annee), + montant: Number(montant), + notes: notes?.trim() || null, + }; +} + +/* ── GET /api/objectifs?type=&annee= ───────────────────────────────────── + Retourne tous les objectifs des investisseurs de l'utilisateur connecté + (toutes plateformes / tout le portefeuille — pas de notion de scope=all + vs single ici, un objectif appartient toujours à un investisseur donné). */ +router.get('/', (req, res) => { + const { type, annee } = req.query; + const conds = ['i.user_id = ?']; + const args = [req.user.id]; + if (type) { conds.push('o.type = ?'); args.push(type); } + if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); } + + const rows = db.prepare(` + SELECT o.*, i.nom AS investisseur_nom + FROM objectifs o + JOIN investisseurs i ON i.id = o.investisseur_id + WHERE ${conds.join(' AND ')} + ORDER BY o.annee DESC, i.nom + `).all(...args); + res.json(rows); +}); + +/* ── POST /api/objectifs ── upsert (investisseur_id, type, annee) ───────── */ +router.post('/', (req, res, next) => { + try { + const data = parseBody(req.body); + const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') + .get(data.investisseur_id, req.user.id); + if (!inv) throw new HttpError(404, 'Investisseur introuvable'); + + db.prepare(` + INSERT INTO objectifs (investisseur_id, type, annee, montant, notes) + VALUES (?,?,?,?,?) + ON CONFLICT(investisseur_id, type, annee) + DO UPDATE SET montant = excluded.montant, notes = excluded.notes, updated_at = datetime('now') + `).run(data.investisseur_id, data.type, data.annee, data.montant, data.notes); + + const saved = db.prepare(` + SELECT o.*, i.nom AS investisseur_nom + FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id + WHERE o.investisseur_id = ? AND o.type = ? AND o.annee = ? + `).get(data.investisseur_id, data.type, data.annee); + res.status(201).json(saved); + } catch (e) { next(e); } +}); + +/* ── DELETE /api/objectifs/:id ────────────────────────────────────────── */ +router.delete('/:id', (req, res, next) => { + try { + const existing = db.prepare(` + SELECT o.id FROM objectifs o + JOIN investisseurs i ON i.id = o.investisseur_id + WHERE o.id = ? AND i.user_id = ? + `).get(req.params.id, req.user.id); + if (!existing) throw new HttpError(404, 'Objectif introuvable'); + db.prepare('DELETE FROM objectifs WHERE id = ?').run(req.params.id); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js index 64f4a64..4cbb7bd 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -24,6 +24,7 @@ import importsRouter from './routes/imports.js'; import pfuRouter from './routes/pfu.js'; import notationRouter from './routes/notation.js'; import garantiesRouter from './routes/garanties.js'; +import objectifsRouter from './routes/objectifs.js'; import reinvestissementsRouter from './routes/reinvestissements.js'; import correctionsRouter from './routes/corrections.js'; import comptesRouter from './routes/comptes.js'; @@ -117,6 +118,7 @@ app.use('/api/imports', requireAuth, importsRouter); app.use('/api/pfu', requireAuth, pfuRouter); app.use('/api/notation', requireAuth, notationRouter); app.use('/api/garanties', requireAuth, garantiesRouter); +app.use('/api/objectifs', requireAuth, objectifsRouter); app.use('/api/reinvestissements', requireAuth, reinvestissementsRouter); app.use('/api/corrections', requireAuth, correctionsRouter); app.use('/api/comptes', requireAuth, comptesRouter); diff --git a/frontend/src/components/SuiviObjectifs.jsx b/frontend/src/components/SuiviObjectifs.jsx new file mode 100644 index 0000000..5755259 --- /dev/null +++ b/frontend/src/components/SuiviObjectifs.jsx @@ -0,0 +1,276 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../api.js'; +import { fmtEUR, memberLabel } from '../utils/format.js'; + +/* + * SuiviObjectifs — tableau générique de suivi d'objectifs annuels. + * + * Conçu pour être réutilisable au-delà des Dépôts/Retraits : il suffit de + * fournir des lignes de mouvements bruts (avec investisseur_id, type, montant, + * date) et la liste des investisseurs concernés. La table `objectifs` + * (backend) porte un champ `type` qui permet de stocker d'autres natures + * d'objectifs plus tard sans nouvelle table. + * + * Props : + * - rows : mouvements bruts [{ investisseur_id, type: 'depot'|'retrait', montant, date_operation }] + * - investisseurs : liste complète des investisseurs (pour affichage des noms) + * - scopeInvestisseurIds : ids des investisseurs actuellement dans le scope de vue + * (tous les investisseurs si vue "tous", sinon [activeId]) + * - objectifType : clé de type stockée en base (défaut 'versement_annuel') + * - title : titre de la section + */ +export default function SuiviObjectifs({ + rows = [], + investisseurs = [], + scopeInvestisseurIds = [], + objectifType = 'versement_annuel', + title = 'Suivi des objectifs de versement', +}) { + const [objectifs, setObjectifs] = useState([]); + const [loading, setLoading] = useState(false); + const [editingYear, setEditingYear] = useState(null); + const [draft, setDraft] = useState({}); + const [saving, setSaving] = useState(false); + const [extraYears, setExtraYears] = useState([]); + + const loadObjectifs = () => { + setLoading(true); + api.get('/objectifs', { type: objectifType }) + .then(rows => setObjectifs(Array.isArray(rows) ? rows : [])) + .catch(() => setObjectifs([])) + .finally(() => setLoading(false)); + }; + + useEffect(() => { loadObjectifs(); /* eslint-disable-next-line */ }, [objectifType]); + + const scopedInvestisseurs = useMemo( + () => investisseurs.filter(i => scopeInvestisseurIds.includes(i.id)), + [investisseurs, scopeInvestisseurIds] + ); + + /* ── Années à afficher : celles avec mouvements + celles avec objectif + année courante ── */ + const years = useMemo(() => { + const set = new Set(extraYears); + for (const r of rows) { + if (scopeInvestisseurIds.includes(r.investisseur_id) && r.date_operation) { + set.add(Number(r.date_operation.slice(0, 4))); + } + } + for (const o of objectifs) { + if (scopeInvestisseurIds.includes(o.investisseur_id)) set.add(Number(o.annee)); + } + set.add(new Date().getFullYear()); + return [...set].sort((a, b) => b - a); + }, [rows, objectifs, scopeInvestisseurIds, extraYears]); + + /* ── Totaux dépôts/retraits par année (scope courant) ── */ + const totalsByYear = useMemo(() => { + const map = {}; + for (const year of years) map[year] = { depots: 0, retraits: 0 }; + for (const r of rows) { + if (!scopeInvestisseurIds.includes(r.investisseur_id)) continue; + const y = Number(r.date_operation?.slice(0, 4)); + if (!map[y]) continue; + if (r.type === 'depot') map[y].depots += r.montant; + else if (r.type === 'retrait') map[y].retraits += r.montant; + } + return map; + }, [rows, years, scopeInvestisseurIds]); + + /* ── Objectifs par année (scope courant) ── */ + const objectifsByYear = useMemo(() => { + const map = {}; + for (const o of objectifs) { + if (!scopeInvestisseurIds.includes(o.investisseur_id)) continue; + if (!map[o.annee]) map[o.annee] = []; + map[o.annee].push(o); + } + return map; + }, [objectifs, scopeInvestisseurIds]); + + const openEditor = (year) => { + const existing = objectifsByYear[year] || []; + const d = {}; + for (const inv of scopedInvestisseurs) { + const found = existing.find(o => o.investisseur_id === inv.id); + d[inv.id] = found ? String(found.montant) : ''; + } + setDraft(d); + setEditingYear(year); + }; + + const cancelEditor = () => { setEditingYear(null); setDraft({}); }; + + const saveEditor = async (year) => { + setSaving(true); + try { + const existing = objectifsByYear[year] || []; + const calls = []; + for (const inv of scopedInvestisseurs) { + const raw = draft[inv.id]; + const found = existing.find(o => o.investisseur_id === inv.id); + if (raw === '' || raw === undefined) { + if (found) calls.push(api.del(`/objectifs/${found.id}`)); + continue; + } + const montant = Number(raw); + if (Number.isNaN(montant) || montant < 0) continue; + calls.push(api.post('/objectifs', { + investisseur_id: inv.id, type: objectifType, annee: year, montant, + })); + } + await Promise.all(calls); + loadObjectifs(); + cancelEditor(); + } finally { setSaving(false); } + }; + + const addYear = () => { + const maxYear = years.length ? Math.max(...years) : new Date().getFullYear(); + setExtraYears(prev => [...prev, maxYear + 1]); + }; + + /* ── Ligne Total : somme des colonnes sur les années affichées ── */ + const overallTotals = useMemo(() => { + let depots = 0, retraits = 0; + let objectifSum = 0, hasObjectif = false; + let ecartSum = 0, hasEcart = false; + for (const year of years) { + const t = totalsByYear[year] || { depots: 0, retraits: 0 }; + depots += t.depots; + retraits += t.retraits; + const objs = objectifsByYear[year] || []; + if (objs.length > 0) { + const objectifTotal = objs.reduce((s, o) => s + o.montant, 0); + objectifSum += objectifTotal; + hasObjectif = true; + ecartSum += (t.depots - t.retraits) - objectifTotal; + hasEcart = true; + } + } + return { + depots, retraits, diff: depots - retraits, + objectifTotal: hasObjectif ? objectifSum : null, + ecart: hasEcart ? ecartSum : null, + }; + }, [years, totalsByYear, objectifsByYear]); + + return ( +
+
+

{title}

+ +
+ + + + + + + + + + + + + + {years.map(year => { + const t = totalsByYear[year] || { depots: 0, retraits: 0 }; + const diff = t.depots - t.retraits; + const objs = objectifsByYear[year] || []; + const objectifTotal = objs.length > 0 ? objs.reduce((s, o) => s + o.montant, 0) : null; + const ecart = objectifTotal != null ? diff - objectifTotal : null; + const isEditing = editingYear === year; + + return ( + + + + + + + + + ); + })} + + + + + + + + + + + +
AnnéeDépôtsRetraitsDifférenceObjectif annuelÉcart
{year}{fmtEUR(t.depots)}{fmtEUR(t.retraits)}{fmtEUR(diff)} + {isEditing ? ( +
+ {scopedInvestisseurs.map(inv => ( +
+ {scopedInvestisseurs.length > 1 && ( + + {memberLabel(inv)} + + )} + setDraft(d => ({ ...d, [inv.id]: e.target.value }))} + style={{ width: 110, padding: '4px 6px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface)' }} + /> +
+ ))} +
+ + +
+
+ ) : ( + + )} +
+ {ecart == null ? ( + + ) : ecart >= 0 ? ( + + +{fmtEUR(ecart)} au-delà de l'objectif + + ) : ( + + Reste {fmtEUR(-ecart)} + + )} +
Total{fmtEUR(overallTotals.depots)}{fmtEUR(overallTotals.retraits)}{fmtEUR(overallTotals.diff)} + {overallTotals.objectifTotal != null ? fmtEUR(overallTotals.objectifTotal) : } + + {overallTotals.ecart == null ? ( + + ) : overallTotals.ecart >= 0 ? ( + +{fmtEUR(overallTotals.ecart)} au-delà de l'objectif + ) : ( + Reste {fmtEUR(-overallTotals.ecart)} + )} +
+ {loading &&
Chargement…
} +
+ ); +} diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index 64769d2..bfc47eb 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -12,6 +12,7 @@ import SoldeChart from '../components/SoldeChart.jsx'; import DistributionChart from '../components/DistributionChart.jsx'; import { fmtEUR, fmtDate, today } from '../utils/format.js'; import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx'; +import SuiviObjectifs from '../components/SuiviObjectifs.jsx'; import * as XLSX from 'xlsx'; /* ── Helpers export ──────────────────────────────────────────── */ @@ -397,6 +398,14 @@ export default function DepotsRetraits() { const [allCorrections, setAllCorrections] = useState([]); const [deleteConfirm, setDeleteConfirm] = useState(null); + /* Objectifs de versement annuel — pour enrichir le KPI "Diff. Dépôts vs Retraits" */ + const [objectifsKpi, setObjectifsKpi] = useState([]); + useEffect(() => { + api.get('/objectifs', { type: 'versement_annuel' }) + .then(r => setObjectifsKpi(Array.isArray(r) ? r : [])) + .catch(() => setObjectifsKpi([])); + }, []); + /* Lecture des params de navigation depuis la page Plateformes */ useEffect(() => { const p = new URLSearchParams(search); @@ -681,6 +690,26 @@ export default function DepotsRetraits() { return balance; })(); + /* ── Enrichissement du KPI "Diff. Dépôts vs Retraits" avec l'objectif de l'année ── + Toujours calculé sur le portefeuille entier (ignore le filtre plateforme), + pour l'année sélectionnée (drPlatYear, ou l'année en cours si "Toutes les années"). */ + const scopeInvestisseurIdsKpi = activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]; + const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear()); + const kpiObjectifTotal = (() => { + const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id)); + return list.length ? list.reduce((s, o) => s + o.montant, 0) : null; + })(); + const kpiDiffGlobalYear = (() => { + let d = 0, r = 0; + for (const row of allRows) { + if (!scopeInvestisseurIdsKpi.includes(row.investisseur_id)) continue; + if (row.date_operation?.slice(0, 4) !== String(kpiObjectifYear)) continue; + if (row.type === 'depot') d += row.montant; else r += row.montant; + } + return d - r; + })(); + const kpiEcartObjectif = kpiObjectifTotal != null ? kpiDiffGlobalYear - kpiObjectifTotal : null; + const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1; /** Investisseur par défaut selon la vue active */ @@ -966,6 +995,16 @@ export default function DepotsRetraits() {
{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}
+ {kpiEcartObjectif != null && ( +
= 0 ? 'var(--success)' : 'var(--warning)', + }}> + {kpiEcartObjectif >= 0 + ? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}` + : `Reste ${fmtEUR(-kpiEcartObjectif)} pour l'objectif ${kpiObjectifYear}`} +
+ )}
Porte-monnaie
@@ -981,12 +1020,25 @@ export default function DepotsRetraits() { {!listFocused &&
+
} + {/* ====== ONGLET VISION ANNUELLE ====== */} + {activeTab === 'vision-annuelle' && ( +
+ +
+ )} + {/* ====== ONGLET PLATEFORMES ====== */} {activeTab === 'plateformes' && (
diff --git a/frontend/src/pages/Plateformes.jsx b/frontend/src/pages/Plateformes.jsx index efff4ec..53df07e 100644 --- a/frontend/src/pages/Plateformes.jsx +++ b/frontend/src/pages/Plateformes.jsx @@ -7,6 +7,7 @@ import PageIcon from '../components/PageIcon.jsx'; import EmptyState from '../components/EmptyState.jsx'; import InvChart from '../components/InvChart.jsx'; import InvMensuelTable from '../components/InvMensuelTable.jsx'; +import SuiviObjectifs from '../components/SuiviObjectifs.jsx'; import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js'; import { xirr } from '../utils/xirr.js'; import { usePagination } from '../hooks/usePagination.js'; @@ -1510,6 +1511,14 @@ export default function Plateformes() {
); })()} + + {/* Suivi des objectifs de versement — toujours sur le portefeuille entier, + indépendamment du filtre plateforme de cette page (cf. décision produit). */} + i.id) : [activeId]} + />
);