diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 59d9bde..5934441 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2227,23 +2227,64 @@ 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é. +// Objectifs annuels. `type` permet plusieurs natures d'objectifs dans la même +// table : 'versement_annuel' (enveloppe globale de dépôts, par investisseur, +// plateforme_id NULL) et 'capital_investi' (cible de capital investi pour une +// plateforme donnée, par investisseur + plateforme). plateforme_id est NULL +// pour 'versement_annuel', renseigné pour 'capital_investi'. Deux index +// uniques partiels (au lieu d'un seul UNIQUE inline) car SQLite considère +// deux NULL comme distincts : un UNIQUE(investisseur_id,type,annee) global +// n'empêcherait pas plusieurs lignes capital_investi (une par plateforme) +// pour le même investisseur/année — cf. section Paramétrage > Fixation des +// objectifs. db.exec(` CREATE TABLE IF NOT EXISTS objectifs ( id INTEGER PRIMARY KEY AUTOINCREMENT, investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE, + plateforme_id INTEGER REFERENCES plateformes(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) + updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); + +// Migration pour les bases existantes créées avant plateforme_id : recrée la +// table (ALTER TABLE ne permet pas de changer une contrainte UNIQUE inline). +{ + const objCols = db.prepare('PRAGMA table_info(objectifs)').all().map(c => c.name); + if (!objCols.includes('plateforme_id')) { + db.exec('PRAGMA foreign_keys = OFF'); + db.exec('ALTER TABLE objectifs RENAME TO objectifs_old_migration'); + db.exec(` + CREATE TABLE objectifs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE, + plateforme_id INTEGER REFERENCES plateformes(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')) + ) + `); + db.exec(` + INSERT INTO objectifs (id, investisseur_id, plateforme_id, type, annee, montant, notes, created_at, updated_at) + SELECT id, investisseur_id, NULL, type, annee, montant, notes, created_at, updated_at + FROM objectifs_old_migration + `); + db.exec('DROP TABLE objectifs_old_migration'); + db.exec('PRAGMA foreign_keys = ON'); + } +} + db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_plateforme ON objectifs(plateforme_id, type, annee)'); +db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_objectifs_global_uniq ON objectifs(investisseur_id, type, annee) WHERE plateforme_id IS NULL'); +db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_objectifs_plateforme_uniq ON objectifs(plateforme_id, type, annee) WHERE plateforme_id IS NOT NULL'); // ── Migration : suivi anti-doublon des notifications "données incomplètes" ── // Stocke la signature (liste triée des champs essentiels manquants) au moment diff --git a/backend/src/routes/objectifs.js b/backend/src/routes/objectifs.js index 01a9573..d9f98d1 100644 --- a/backend/src/routes/objectifs.js +++ b/backend/src/routes/objectifs.js @@ -6,11 +6,22 @@ const router = Router(); const TYPE_DEFAUT = 'versement_annuel'; -/** Valide et normalise le corps d'une requête POST */ +/** + * Valide et normalise le corps d'une requête POST. + * - type='versement_annuel' (défaut) : objectif global, investisseur_id requis, plateforme_id absent. + * - type='capital_investi' : objectif par plateforme, plateforme_id requis ; investisseur_id est + * dérivé de la plateforme (ignoré s'il est fourni) car une plateforme appartient à un seul + * investisseur — cf. section Paramétrage > Fixation des objectifs. + */ function parseBody(body) { - const { investisseur_id, type = TYPE_DEFAUT, annee, montant, notes } = body; - if (!investisseur_id) throw new HttpError(400, 'investisseur_id est requis'); + const { investisseur_id, plateforme_id, type = TYPE_DEFAUT, annee, montant, notes } = body; if (!type?.trim()) throw new HttpError(400, 'type est requis'); + const trimmedType = type.trim(); + if (trimmedType === 'capital_investi') { + if (!plateforme_id) throw new HttpError(400, 'plateforme_id est requis pour un objectif capital_investi'); + } else if (!investisseur_id) { + throw new HttpError(400, 'investisseur_id est requis'); + } if (!Number.isInteger(Number(annee)) || Number(annee) < 2000 || Number(annee) > 2100) { throw new HttpError(400, 'annee invalide'); } @@ -18,24 +29,26 @@ function parseBody(body) { throw new HttpError(400, 'montant doit être un nombre positif'); } return { - investisseur_id: Number(investisseur_id), - type: type.trim(), + investisseur_id: investisseur_id ? Number(investisseur_id) : null, + plateforme_id: plateforme_id ? Number(plateforme_id) : null, + type: trimmedType, annee: Number(annee), montant: Number(montant), notes: notes?.trim() || null, }; } -/* ── GET /api/objectifs?type=&annee= ───────────────────────────────────── +/* ── GET /api/objectifs?type=&annee=&plateforme_id= ────────────────────── 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 { type, annee, plateforme_id } = 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)); } + if (type) { conds.push('o.type = ?'); args.push(type); } + if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); } + if (plateforme_id) { conds.push('o.plateforme_id = ?'); args.push(Number(plateforme_id)); } const rows = db.prepare(` SELECT o.*, i.nom AS investisseur_nom @@ -47,26 +60,53 @@ router.get('/', (req, res) => { res.json(rows); }); -/* ── POST /api/objectifs ── upsert (investisseur_id, type, annee) ───────── */ +/* ── POST /api/objectifs ── upsert manuel (select puis update/insert) ───── + Pas de ON CONFLICT : deux index uniques partiels différents selon le type + (cf. db/index.js), plus simple et plus sûr de faire l'upsert à la main que + de viser le bon index partiel en SQL. */ 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); + let investisseurId = data.investisseur_id; + if (data.type === 'capital_investi') { + const plat = db.prepare('SELECT id, investisseur_id FROM plateformes WHERE id = ? AND user_id = ?') + .get(data.plateforme_id, req.user.id); + if (!plat) throw new HttpError(404, 'Plateforme introuvable'); + investisseurId = plat.investisseur_id; + } else { + const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') + .get(investisseurId, req.user.id); + if (!inv) throw new HttpError(404, 'Investisseur introuvable'); + } - 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); + const existing = data.plateforme_id + ? db.prepare('SELECT id FROM objectifs WHERE plateforme_id = ? AND type = ? AND annee = ?') + .get(data.plateforme_id, data.type, data.annee) + : db.prepare('SELECT id FROM objectifs WHERE investisseur_id = ? AND type = ? AND annee = ? AND plateforme_id IS NULL') + .get(investisseurId, data.type, data.annee); + + if (existing) { + db.prepare(`UPDATE objectifs SET montant = ?, notes = ?, updated_at = datetime('now') WHERE id = ?`) + .run(data.montant, data.notes, existing.id); + } else { + db.prepare(` + INSERT INTO objectifs (investisseur_id, plateforme_id, type, annee, montant, notes) + VALUES (?,?,?,?,?,?) + `).run(investisseurId, data.plateforme_id, data.type, data.annee, data.montant, data.notes); + } + + const saved = data.plateforme_id + ? db.prepare(` + SELECT o.*, i.nom AS investisseur_nom + FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id + WHERE o.plateforme_id = ? AND o.type = ? AND o.annee = ? + `).get(data.plateforme_id, data.type, data.annee) + : 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 = ? AND o.plateforme_id IS NULL + `).get(investisseurId, data.type, data.annee); res.status(201).json(saved); } catch (e) { next(e); } }); diff --git a/backend/src/routes/preferences.js b/backend/src/routes/preferences.js index 0dfbc0a..d03aa89 100644 --- a/backend/src/routes/preferences.js +++ b/backend/src/routes/preferences.js @@ -9,6 +9,7 @@ const ALLOWED_KEYS = new Set([ 'chart_capital', 'chart_cashback', 'pfo_assujetti', + 'objectifs_actifs', // Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…) ]); diff --git a/frontend/src/components/ObjectifIndicators.jsx b/frontend/src/components/ObjectifIndicators.jsx new file mode 100644 index 0000000..332be74 --- /dev/null +++ b/frontend/src/components/ObjectifIndicators.jsx @@ -0,0 +1,60 @@ +import { fmtEUR } from '../utils/format.js'; + +/* + * Petits composants de présentation partagés par les tableaux de suivi + * d'objectifs (settings/ObjectifsSection.jsx, components/SuiviObjectifs.jsx, + * components/SuiviObjectifsCapital.jsx) : phrase d'écart colorée et jauge + * de taux d'atteinte. Centralisés ici pour éviter la duplication entre + * la page de paramétrage (éditable) et les vues de suivi (lecture seule). + */ + +// overColor : couleur utilisée quand l'écart est positif (objectif dépassé). +// Vert par défaut (ex. capital investi : dépasser l'objectif est positif). +// Pour les écarts de versement, l'appelant passe 'var(--danger)' — dépasser +// le montant de versement complémentaire prévu appelle une vigilance, pas +// une célébration. +export function EcartCell({ ecart, overColor = 'var(--success)' }) { + if (ecart == null) return —; + // Écart nul (aux arrondis flottants près) : un seul et même libellé, qu'il + // soit arrivé côté positif ou négatif du zéro. + if (Math.abs(ecart) < 0.01) return Conforme à l'objectif; + return ecart >= 0 + ? +{fmtEUR(ecart)} au-delà de l'objectif + : {fmtEUR(-ecart)} en dessous de l'objectif; +} + +// Dégradé gris (0 %) → orange (50 %) → vert (100 % et au-delà). +function pctColor(pct) { + const p = Math.max(0, Math.min(100, pct)); + const gray = [156, 163, 175]; + const orange = [245, 158, 11]; + const green = [34, 197, 94]; + const [c1, c2, t] = p <= 50 ? [gray, orange, p / 50] : [orange, green, (p - 50) / 50]; + const mix = i => Math.round(c1[i] + (c2[i] - c1[i]) * t); + return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`; +} + +// overColor : couleur utilisée quand le taux dépasse 100 % (au lieu de rester +// au vert du dégradé). Pour les taux de versement, l'appelant passe +// 'var(--danger)' — cf. EcartCell, même logique de vigilance en cas de +// dépassement du montant de versement complémentaire prévu. +export function TauxAtteinte({ realise, objectif, overColor = null }) { + if (objectif == null || !(objectif > 0)) return —; + const pct = (realise / objectif) * 100; + // Dépassement de plus d'un centime seulement — même tolérance que "Conforme + // à l'objectif" dans EcartCell, pour éviter qu'un écart d'arrondi flottant + // fasse basculer en rouge un taux réellement à 100 %. + const isOver = (realise - objectif) > 0.01; + const color = isOver && overColor ? overColor : pctColor(pct); + const barPct = Math.max(0, Math.min(100, pct)); + return ( +
+
+
+
+ + {Math.round(pct)} % + +
+ ); +} diff --git a/frontend/src/components/SuiviObjectifs.jsx b/frontend/src/components/SuiviObjectifs.jsx index 5755259..1deae33 100644 --- a/frontend/src/components/SuiviObjectifs.jsx +++ b/frontend/src/components/SuiviObjectifs.jsx @@ -1,23 +1,26 @@ import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { api } from '../api.js'; -import { fmtEUR, memberLabel } from '../utils/format.js'; +import { fmtEUR, fmtRetrait } from '../utils/format.js'; +import { EcartCell, TauxAtteinte } from './ObjectifIndicators.jsx'; /* - * SuiviObjectifs — tableau générique de suivi d'objectifs annuels. + * SuiviObjectifs — tableau générique de suivi d'objectifs annuels (lecture seule). * - * 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. + * Depuis la mise en place de la section Paramétrage > Fixation des objectifs, + * ce composant n'est plus éditable : les objectifs se définissent exclusivement + * dans les Paramétrages (cf. settings/ObjectifsSection.jsx), qui montre la + * relation entre l'enveloppe globale de versement et son allocation par + * plateforme. Ici, on affiche uniquement l'écart entre le réalisé et l'objectif + * déjà fixé, avec un lien vers la page de paramétrage pour le modifier. * * 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 + * - 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 = [], @@ -26,31 +29,21 @@ export default function SuiviObjectifs({ objectifType = 'versement_annuel', title = 'Suivi des objectifs de versement', }) { + const navigate = useNavigate(); 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 = () => { + useEffect(() => { 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] - ); + }, [objectifType]); /* ── Années à afficher : celles avec mouvements + celles avec objectif + année courante ── */ const years = useMemo(() => { - const set = new Set(extraYears); + const set = new Set(); for (const r of rows) { if (scopeInvestisseurIds.includes(r.investisseur_id) && r.date_operation) { set.add(Number(r.date_operation.slice(0, 4))); @@ -61,7 +54,7 @@ export default function SuiviObjectifs({ } set.add(new Date().getFullYear()); return [...set].sort((a, b) => b - a); - }, [rows, objectifs, scopeInvestisseurIds, extraYears]); + }, [rows, objectifs, scopeInvestisseurIds]); /* ── Totaux dépôts/retraits par année (scope courant) ── */ const totalsByYear = useMemo(() => { @@ -88,53 +81,17 @@ export default function SuiviObjectifs({ 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 ── */ + /* ── Ligne Total : somme des colonnes sur les années affichées ── + `diffAvecObjectif` ne cumule le versement net que sur les années où un + objectif est fixé — comparer le taux d'atteinte / l'écart à `diff` (qui + inclut aussi les années "Non défini") gonflerait artificiellement le + réalisé par rapport à l'objectif et ferait passer l'indicateur au rouge + à tort alors que chaque année avec objectif y est individuellement + conforme. */ const overallTotals = useMemo(() => { let depots = 0, retraits = 0; let objectifSum = 0, hasObjectif = false; - let ecartSum = 0, hasEcart = false; + let diffAvecObjectif = 0, ecartSum = 0, hasEcart = false; for (const year of years) { const t = totalsByYear[year] || { depots: 0, retraits: 0 }; depots += t.depots; @@ -144,12 +101,14 @@ export default function SuiviObjectifs({ const objectifTotal = objs.reduce((s, o) => s + o.montant, 0); objectifSum += objectifTotal; hasObjectif = true; + diffAvecObjectif += t.depots - t.retraits; ecartSum += (t.depots - t.retraits) - objectifTotal; hasEcart = true; } } return { depots, retraits, diff: depots - retraits, + diffAvecObjectif: hasObjectif ? diffAvecObjectif : null, objectifTotal: hasObjectif ? objectifSum : null, ecart: hasEcart ? ecartSum : null, }; @@ -159,8 +118,13 @@ export default function SuiviObjectifs({

{title}

-
@@ -170,9 +134,10 @@ export default function SuiviObjectifs({ Année Dépôts Retraits - Différence - Objectif annuel - Écart + Versement net + Objectifs (Versement) + Atteinte + Écart de versement @@ -182,69 +147,18 @@ export default function SuiviObjectifs({ 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 ( {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)} - - )} + {fmtRetrait(t.retraits)} + {fmtEUR(diff)} + + {objectifTotal != null ? fmtEUR(objectifTotal) : 'Non défini'} + + ); })} @@ -253,20 +167,13 @@ export default function SuiviObjectifs({ Total {fmtEUR(overallTotals.depots)} - {fmtEUR(overallTotals.retraits)} + {fmtRetrait(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)} - )} - + + diff --git a/frontend/src/components/SuiviObjectifsCapital.jsx b/frontend/src/components/SuiviObjectifsCapital.jsx new file mode 100644 index 0000000..329cceb --- /dev/null +++ b/frontend/src/components/SuiviObjectifsCapital.jsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api.js'; +import { fmtEUR } from '../utils/format.js'; +import { capitalEncoursParInvestissement } from '../utils/capitalInvesti.js'; +import { EcartCell, TauxAtteinte } from './ObjectifIndicators.jsx'; + +/* + * SuiviObjectifsCapital — pendant "capital investi" de SuiviObjectifs.jsx, + * pour la page Plateformes. Lecture seule : les objectifs eux-mêmes se + * définissent dans Paramétrage > Fixation des objectifs + * (cf. settings/ObjectifsSection.jsx), qui permet de répartir l'enveloppe + * globale de versement en objectifs de capital investi par plateforme. + * + * Une ligne par année (même présentation que le tableau "Objectifs de + * capital investi" par plateforme de ObjectifsSection.jsx, mais avec une + * colonne Année à la place de la colonne Plateforme puisqu'ici le scope + * — une ou plusieurs plateformes — est fixe et c'est l'année qui varie). + * + * La colonne "Capital investi" combine le réalisé et, si pertinent, la + * projection en une seule phrase : + * - année révolue : montant réel au 31/12/{année} + * - année en cours : montant réel à la date du jour, puis la projection au + * 31/12 entre parenthèses (calculée à partir des + * remboursements de capital prévus, non encore réalisés) + * - année future : uniquement la projection au 31/12 (rien n'est encore réalisé) + * + * Props : + * - investissements : investissements déjà filtrés sur le scope (ex. une plateforme + * sélectionnée, ou toutes les plateformes des investisseurs visibles) + * - remboursementsCapital : remboursements de type 'normal', non filtrés (rattachement par investissement_id) + * - reinvestissements : non filtrés (rattachement par investissement_id) + * - simulRembs : projections (simul_remboursements), non filtrées — alimente la projection + * des années en cours/futures (filtrage interne sur le scope via les + * plateforme_id présents dans `investissements`) + * - scopeInvestisseurIds : ids des investisseurs du scope courant (utilisé quand aucune + * plateforme précise n'est sélectionnée, pour retrouver les objectifs + * des plateformes leur appartenant) + * - plateformeIds : Set d'ids de plateformes précisément sélectionnées (optionnel) — + * si fourni et non vide, les objectifs sont filtrés sur ces plateformes + * plutôt que sur scopeInvestisseurIds + * - title : titre de la section + */ +export default function SuiviObjectifsCapital({ + investissements = [], + remboursementsCapital = [], + reinvestissements = [], + simulRembs = [], + scopeInvestisseurIds = [], + plateformeIds = null, + title = 'Suivi de l\'objectif de capital investi', +}) { + const navigate = useNavigate(); + const [objectifs, setObjectifs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + api.get('/objectifs', { type: 'capital_investi' }) + .then(rows => setObjectifs(Array.isArray(rows) ? rows : [])) + .catch(() => setObjectifs([])) + .finally(() => setLoading(false)); + }, []); + + const scopeSet = useMemo(() => new Set(scopeInvestisseurIds), [scopeInvestisseurIds]); + const hasPlatFilter = plateformeIds && plateformeIds.size > 0; + + const objectifsInScope = useMemo( + () => objectifs.filter(o => hasPlatFilter ? plateformeIds.has(o.plateforme_id) : scopeSet.has(o.investisseur_id)), + [objectifs, hasPlatFilter, plateformeIds, scopeSet] + ); + + const years = useMemo(() => { + const set = new Set(); + for (const inv of investissements) { + if (inv.date_souscription) set.add(Number(inv.date_souscription.slice(0, 4))); + } + for (const o of objectifsInScope) set.add(Number(o.annee)); + set.add(new Date().getFullYear()); + return [...set].sort((a, b) => b - a); + }, [investissements, objectifsInScope]); + + const currentYear = new Date().getFullYear(); + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const fmtDMY = (d) => `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; + + // Capital investi en début d'année (01/01) — point de référence. + const capitalDebutByYear = useMemo(() => { + const map = {}; + for (const year of years) { + const cutoff = `${year - 1}-12-31`; + const parInv = capitalEncoursParInvestissement(investissements, remboursementsCapital, reinvestissements, cutoff); + map[year] = Object.values(parInv).reduce((s, v) => s + v, 0); + } + return map; + }, [investissements, remboursementsCapital, reinvestissements, years]); + + // Capital investi réel : à la date du jour pour l'année en cours, au 31/12 pour une année révolue. + // N'a de sens que pour les années <= currentYear (une année future n'a encore rien de réel). + const capitalActuelByYear = useMemo(() => { + const map = {}; + for (const year of years) { + if (year > currentYear) continue; + const cutoff = year === currentYear ? todayStr : `${year}-12-31`; + const parInv = capitalEncoursParInvestissement(investissements, remboursementsCapital, reinvestissements, cutoff); + map[year] = Object.values(parInv).reduce((s, v) => s + v, 0); + } + return map; + }, [investissements, remboursementsCapital, reinvestissements, years, currentYear, todayStr]); + + const scopedPlatIds = useMemo(() => new Set(investissements.map(inv => inv.plateforme_id)), [investissements]); + + // Projection à une date donnée (année en cours ou future) : capital réel + // d'aujourd'hui moins les remboursements de capital PRÉVUS (non encore + // réalisés) entre aujourd'hui et cette date. + const projectedCapitalAt = (cutoffDate) => { + const capitalAujourdhui = capitalActuelByYear[currentYear] ?? 0; + let futur = 0; + for (const s of simulRembs) { + const d = s.date_prevue?.slice(0, 10); + if (!d || d <= todayStr || d > cutoffDate) continue; + if (!scopedPlatIds.has(s.plateforme_id)) continue; + futur += s.capital_prevu || 0; + } + return Math.max(0, capitalAujourdhui - futur); + }; + + const objectifByYear = useMemo(() => { + const map = {}; + for (const o of objectifsInScope) { + map[o.annee] = (map[o.annee] || 0) + o.montant; + } + return map; + }, [objectifsInScope]); + + // Une année sans aucun capital investi (ni en début d'année, ni à date/projeté) + // et sans objectif fixé (ou un objectif nul) n'apporte rien au suivi — elle est + // masquée plutôt que d'afficher une ligne entièrement à 0. + const rows = years.map(year => { + const capitalDebut = capitalDebutByYear[year] ?? 0; + + let capitalLabel; + let capitalPourEcart; + if (year < currentYear) { + const capitalActuel = capitalActuelByYear[year] ?? 0; + capitalPourEcart = capitalActuel; + capitalLabel = <>{fmtEUR(capitalActuel)} au 31/12/{year}; + } else if (year === currentYear) { + const capitalActuel = capitalActuelByYear[year] ?? 0; + const projete = projectedCapitalAt(`${year}-12-31`); + capitalPourEcart = capitalActuel; + capitalLabel = <>{fmtEUR(capitalActuel)} au {fmtDMY(today)} (Projection au 31/12/{year} : {fmtEUR(projete)}); + } else { + const projete = projectedCapitalAt(`${year}-12-31`); + capitalPourEcart = projete; + capitalLabel = <>{fmtEUR(projete)} projeté au 31/12/{year}; + } + + const objectif = objectifByYear[year]; + const ecart = objectif != null ? capitalPourEcart - objectif : null; + + return { year, capitalDebut, capitalLabel, capitalPourEcart, objectif, ecart }; + }).filter(r => !(r.capitalDebut === 0 && r.capitalPourEcart === 0 && !(r.objectif > 0))); + + return ( +
+
+

{title}

+ +
+ + + + + + + + + + + + + + {rows.map(({ year, capitalDebut, capitalLabel, capitalPourEcart, objectif, ecart }) => { + return ( + + + + + + + + + ); + })} + +
AnnéeCapital investi au 01/01Capital investiObjectifs (Capital)AtteinteÉcart de capital investi
+ {year}{year === currentYear && (en cours)} + {fmtEUR(capitalDebut)}{capitalLabel} + {objectif != null ? fmtEUR(objectif) : 'Non défini'} +
+ {loading &&
Chargement…
} +
+ "Capital investi" est un solde à une date donnée, pas un flux — il ne se cumule pas d'une année sur l'autre. + Les montants projetés (année en cours et années futures) sont calculés à partir des remboursements de capital prévus, non encore réalisés. +
+
+ ); +} diff --git a/frontend/src/context/UiContext.jsx b/frontend/src/context/UiContext.jsx index 7c9f380..7c19d27 100644 --- a/frontend/src/context/UiContext.jsx +++ b/frontend/src/context/UiContext.jsx @@ -18,6 +18,7 @@ const KEY_CHART_INTERETS = 'cl_chart_interets'; const KEY_CHART_CAPITAL = 'cl_chart_capital'; const KEY_CHART_CASHBACK = 'cl_chart_cashback'; const KEY_PFO_ASSUJETTI = 'cl_pfo_assujetti'; +const KEY_OBJECTIFS_ACTIFS = 'cl_objectifs_actifs'; const FONT_SIZES = ['large', 'medium', 'compact']; const LANGUES = ['fr', 'en']; @@ -70,6 +71,14 @@ export function UiProvider({ children }) { localStorage.getItem(KEY_PFO_ASSUJETTI) === '1' ); + // Activé par défaut (les utilisateurs existants ne doivent pas perdre la + // fonctionnalité silencieusement) — seule une valeur explicitement absente + // du localStorage retombe sur true. + const [objectifsActifs, setObjectifsActifsState] = useState(() => { + const saved = localStorage.getItem(KEY_OBJECTIFS_ACTIFS); + return saved === null ? true : saved === '1'; + }); + // ── Sync DB → localStorage au montage ────────────────────────── // La DB fait foi : ses valeurs écrasent le localStorage si présentes. useEffect(() => { @@ -91,6 +100,11 @@ export function UiProvider({ children }) { localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0'); setPfoAssujettiState(val); } + if (prefs.objectifs_actifs !== undefined) { + const val = prefs.objectifs_actifs === '1'; + localStorage.setItem(KEY_OBJECTIFS_ACTIFS, val ? '1' : '0'); + setObjectifsActifsState(val); + } }).catch(() => { // Silencieux : token absent au premier rendu, on garde le localStorage }); @@ -155,8 +169,14 @@ export function UiProvider({ children }) { persistPref('pfo_assujetti', val ? '1' : '0'); }, [persistPref]); + const setObjectifsActifs = useCallback((val) => { + setObjectifsActifsState(val); + localStorage.setItem(KEY_OBJECTIFS_ACTIFS, val ? '1' : '0'); + persistPref('objectifs_actifs', val ? '1' : '0'); + }, [persistPref]); + return ( - + {children} ); diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index c5494af..f50ec49 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -6,6 +6,7 @@ import EmptyState from '../components/EmptyState.jsx'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import { useInvestisseur } from '../context/InvestisseurContext.jsx'; +import { useUi } from '../context/UiContext.jsx'; import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; import SoldeChart from '../components/SoldeChart.jsx'; @@ -94,6 +95,7 @@ function platToJSON(rows) { /* ── Composant principal ─────────────────────────────────────── */ export default function DepotsRetraits() { const { activeId, activeView, investisseurs } = useInvestisseur(); + const { objectifsActifs } = useUi(); const { search } = useLocation(); const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('plateformes'); @@ -453,6 +455,12 @@ export default function DepotsRetraits() { totalPages: drTotalPages, totalItems: drTotalItems, PAGE_SIZES, } = usePagination(tableRows, 'cl_pagesize_dr', [filter]); + // Repli sur l'onglet Plateformes si la fixation des objectifs est désactivée + // pendant qu'on est sur l'onglet Objectifs (ex. dans un autre onglet du navigateur). + useEffect(() => { + if (!objectifsActifs && activeTab === 'vision-annuelle') setActiveTab('plateformes'); + }, [objectifsActifs, activeTab]); + // Auto-sélectionne le premier mouvement quand la liste change dans l'onglet mouvements // (déclaré après `tableRows` pour éviter la temporal dead zone) useEffect(() => { @@ -575,6 +583,7 @@ export default function DepotsRetraits() { 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)); @@ -904,14 +913,16 @@ export default function DepotsRetraits() {
{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}
- {kpiEcartObjectif != null && ( + {objectifsActifs && kpiEcartObjectif != null && (
= 0 ? 'var(--success)' : 'var(--warning)', + color: Math.abs(kpiEcartObjectif) < 0.01 ? 'var(--success)' : kpiEcartObjectif >= 0 ? 'var(--danger)' : 'var(--warning)', }}> - {kpiEcartObjectif >= 0 - ? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}` - : `Reste ${fmtEUR(-kpiEcartObjectif)} pour l'objectif ${kpiObjectifYear}`} + {Math.abs(kpiEcartObjectif) < 0.01 + ? `Conforme à l'objectif ${kpiObjectifYear}` + : kpiEcartObjectif >= 0 + ? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}` + : `${fmtEUR(-kpiEcartObjectif)} en dessous de l'objectif ${kpiObjectifYear}`}
)}
@@ -929,16 +940,18 @@ export default function DepotsRetraits() { {!listFocused &&
- + {objectifsActifs && ( + + )}
} {/* ====== ONGLET VISION ANNUELLE ====== */} - {activeTab === 'vision-annuelle' && ( + {objectifsActifs && activeTab === 'vision-annuelle' && (
{ + if (!objectifsActifs && activeTab === 'objectifs') setActiveTab('remboursements'); + // eslint-disable-next-line + }, [objectifsActifs, activeTab]); + /* ── Chargement ── */ const load = async () => { if (!activeId && activeView !== 'all') return; @@ -616,6 +624,10 @@ export default function Plateformes() { /* ── Vrai/faux multi-détenteur global (indépendant de la plateforme sélectionnée) ── */ const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1; + /* ── Vision "Multi-détenteur" active sur la plateforme sélectionnée — alimente + la ventilation par détenteur du tableau Objectifs (cf. detenteurOptions). ── */ + const showMultiDetenteurBreakdown = !!detenteurOptions && selectedDetenteurId === 'all'; + /* ── Reset détenteur quand plateforme change (skip restauration initiale) ── */ const isFirstPlatChange = useRef(true); useEffect(() => { @@ -636,6 +648,44 @@ export default function Plateformes() { return new Set(matching.filter(p => String(p.investisseur_id) === String(selectedDetenteurId)).map(p => p.id)); }, [plats, selectedPlatName, selectedDetenteurId]); + /* ── IDs des plateformes du nom sélectionné, regroupés par détenteur — + alimente la ventilation par détenteur du tableau Objectifs quand la + plateforme est détenue par plusieurs personnes (cf. detenteurOptions). ── */ + const platIdsByDetenteur = useMemo(() => { + if (!selectedPlatName) return {}; + const map = {}; + for (const p of plats) { + if (p.nom !== selectedPlatName) continue; + const key = String(p.investisseur_id); + (map[key] ??= new Set()).add(p.id); + } + return map; + }, [plats, selectedPlatName]); + + /* ── Investissements filtrés sur la plateforme sélectionnée — alimente la + colonne "Capital investi" (encours à fin d'année) de SuiviObjectifs, + cf. onglet Dépôts/Retraits plus bas. remboursements/réinvestissements + ne sont pas filtrés ici : le rattachement se fait par investissement_id, + déjà scopé via cette liste. ── */ + const investissementsForObjectifs = useMemo( + () => allRows.filter(inv => filteredPlatIds.size === 0 || filteredPlatIds.has(inv.plateforme_id)), + [allRows, filteredPlatIds] + ); + const remboursementsCapitalForObjectifs = useMemo( + () => allRembs.filter(r => r.type === 'normal'), + [allRembs] + ); + + /* Objectifs de capital investi — pour enrichir le KPI "Capital investi" (écart + vs objectif de l'année affichée, sur le même scope plateforme/détenteur que + la page). Chargés une fois, indépendamment du filtre en cours. */ + const [objectifsCapitalKpi, setObjectifsCapitalKpi] = useState([]); + useEffect(() => { + api.get('/objectifs', { type: 'capital_investi' }) + .then(rows => setObjectifsCapitalKpi(Array.isArray(rows) ? rows : [])) + .catch(() => setObjectifsCapitalKpi([])); + }, []); + /* ── Cellule "mois en cours" pour la section DrillCellPanel ── */ const MOIS_LONG_CELL = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; const moisEnCoursCell = useMemo(() => { @@ -848,6 +898,23 @@ export default function Plateformes() { const netMode = displayMode === 'net'; const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1; + /* ── Écart du KPI "Capital investi" vs l'objectif de capital investi fixé + dans Paramétrage, sur le même scope plateforme(s)/détenteur que la page. + Année de référence : celle sélectionnée (selectedYear), ou l'année en + cours si "Depuis le début". ── */ + const kpiCapitalObjectifYear = Number(selectedYear || new Date().getFullYear()); + const kpiCapitalHasPlatFilter = filteredPlatIds.size > 0; + const kpiCapitalScopeSet = useMemo( + () => new Set(activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]), + [activeView, investisseurs, activeId] + ); + const kpiCapitalObjectifTotal = useMemo(() => { + const list = objectifsCapitalKpi.filter(o => o.annee === kpiCapitalObjectifYear && + (kpiCapitalHasPlatFilter ? filteredPlatIds.has(o.plateforme_id) : kpiCapitalScopeSet.has(o.investisseur_id))); + return list.length ? list.reduce((s, o) => s + o.montant, 0) : null; + }, [objectifsCapitalKpi, kpiCapitalObjectifYear, kpiCapitalHasPlatFilter, filteredPlatIds, kpiCapitalScopeSet]); + const kpiEcartCapitalObjectif = kpiCapitalObjectifTotal != null ? totals.encours - kpiCapitalObjectifTotal : null; + /* ── Solde porte-monnaie (source unique : GET /dashboard[?asOf=], cf. dashboard.js) ── Snapshot backend filtré sur la sélection plateforme(s)/détenteur (filteredPlatIds). */ const soldeFromSnapshot = (snap) => { @@ -1374,6 +1441,18 @@ export default function Plateformes() {
{fmtEUR(prevTotals.encours)} en {prevYearLabel}
+ {objectifsActifs && kpiEcartCapitalObjectif != null && ( +
= 0 ? 'var(--success)' : 'var(--warning)', + }}> + {Math.abs(kpiEcartCapitalObjectif) < 0.01 + ? `Conforme à l'objectif ${kpiCapitalObjectifYear}` + : kpiEcartCapitalObjectif >= 0 + ? `+${fmtEUR(kpiEcartCapitalObjectif)} au-delà de l'objectif ${kpiCapitalObjectifYear}` + : `${fmtEUR(-kpiEcartCapitalObjectif)} en dessous de l'objectif ${kpiCapitalObjectifYear}`} +
+ )}
{/* 2 — Investissements à risque */} @@ -1502,6 +1581,10 @@ export default function Plateformes() { onClick={() => setActiveTab('investissements')}>Investissements + {objectifsActifs && ( + + )} {/* ====== ONGLET REMBOURSEMENTS ====== */} @@ -2213,14 +2296,6 @@ 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]} - /> ); @@ -2685,6 +2760,48 @@ export default function Plateformes() { )} + {/* ====== ONGLET OBJECTIFS ====== */} + {objectifsActifs && activeTab === 'objectifs' && ( +
+ {/* Objectifs de capital investi : définis par plateforme dans Paramétrage > + Fixation des objectifs. Ici, lecture seule — comparaison du capital + réellement encore investi (encours) à l'objectif fixé, filtrée sur la + plateforme sélectionnée le cas échéant. */} + i.id) : [activeId]} + plateformeIds={filteredPlatIds} + title={selectedPlatName + ? `Suivi de l'objectif de capital investi — ${selectedPlatName}${showMultiDetenteurBreakdown ? ' (vision multi détenteur)' : ''}` + : 'Suivi de l\'objectif de capital investi'} + /> + + {/* Plateforme détenue par plusieurs personnes et vue "Multi-détenteur" + active : la table ci-dessus agrège tous les détenteurs — on ajoute un + tableau complémentaire par détenteur, au même format, pour le détail. */} + {showMultiDetenteurBreakdown && detenteurOptions + .filter(opt => opt.id !== 'all') + .map(opt => { + const platIdsDet = platIdsByDetenteur[opt.id] || new Set(); + return ( + platIdsDet.has(inv.plateforme_id))} + remboursementsCapital={remboursementsCapitalForObjectifs} + reinvestissements={allReinvests} + simulRembs={allSimulRembs} + scopeInvestisseurIds={[Number(opt.id)]} + plateformeIds={platIdsDet} + title={`Suivi de l'objectif de capital investi — ${selectedPlatName} — ${opt.label}`} + /> + ); + })} +
+ )} + {/* Modal création / édition d'un mouvement (onglet "Mouvements Porte-monnaie") */}