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({
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 (
+
+ "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}
= 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
setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie
+ {objectifsActifs && (
+ setActiveTab('objectifs')}>Objectifs
+ )}
{/* ====== 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") */}
; }
function IconGrid() { return ; }
function IconMyFiscal() { return ; }
+function IconTarget() { return ; }
function IconBroom() { return ; }
function IconUpload() { return ; }
@@ -35,6 +37,7 @@ const NAV = [
{ id: 'plateformes', label: 'Mes plateformes', icon: },
{ id: 'comptes', label: 'Mes comptes courants', icon: },
{ id: 'ma-fiscalite', label: 'Ma fiscalité', icon: },
+ { id: 'objectifs', label: 'Fixation des objectifs', icon: },
],
},
{
@@ -87,6 +90,7 @@ export default function Settings() {
{section === 'plateformes' && }
{section === 'comptes' && }
{section === 'ma-fiscalite' && }
+ {section === 'objectifs' && }
{section === 'categories-inv' && }
{section === 'secteurs-inv' && }
{section === 'nettoyage' && }
diff --git a/frontend/src/pages/settings/ObjectifsSection.jsx b/frontend/src/pages/settings/ObjectifsSection.jsx
new file mode 100644
index 0000000..1f8806f
--- /dev/null
+++ b/frontend/src/pages/settings/ObjectifsSection.jsx
@@ -0,0 +1,1142 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { api } from '../../api.js';
+import { fmtEUR, fmtRetrait, memberLabel } from '../../utils/format.js';
+import { capitalEncoursParGroupe } from '../../utils/capitalInvesti.js';
+import ConfirmModal from '../../components/ConfirmModal.jsx';
+import { EcartCell, TauxAtteinte } from '../../components/ObjectifIndicators.jsx';
+import { useUi } from '../../context/UiContext.jsx';
+
+/*
+ * ObjectifsSection — "Fixation des objectifs" (Paramétrage).
+ *
+ * Hiérarchie à trois niveaux, du plus large au plus fin :
+ * - un dépôt global à partager entre les détenteurs ("versement_global" —
+ * combien la famille/l'ensemble des détenteurs veut déposer cette année,
+ * tous détenteurs confondus) ; stocké sur l'investisseur principal
+ * (is_principal) faute de notion de "foyer" dans le modèle ;
+ * - un objectif de versement par détenteur ("versement_annuel" — comment ce
+ * dépôt global se répartit entre les détenteurs) ;
+ * - des objectifs de capital investi par plateforme ("capital_investi" —
+ * comment l'enveloppe de chaque détenteur se répartit une fois les fonds
+ * effectivement investis).
+ *
+ * Les trois niveaux sont montrés avec des totaux recalculés en direct, pour
+ * que l'utilisateur voie immédiatement l'impact d'un changement à un niveau
+ * sur les autres. Un détenteur sans plateforme n'apparaît nulle part ici
+ * (rien à répartir pour lui). Les pages Dépôts/Retraits et Plateformes
+ * n'affichent plus qu'un suivi en lecture seule de l'écart par rapport à ce
+ * qui est fixé ici (cf. SuiviObjectifs.jsx et SuiviObjectifsCapital.jsx).
+ */
+
+const TYPE_GLOBAL = 'versement_global';
+const TYPE_VERSEMENT = 'versement_annuel';
+const TYPE_CAPITAL = 'capital_investi';
+
+function IconPencil({ color = 'currentColor' }) {
+ return (
+
+ );
+}
+function IconCheck({ color = 'currentColor' }) {
+ return (
+
+ );
+}
+function IconTrash() {
+ return (
+
+ );
+}
+function IconSync() {
+ return (
+
+ );
+}
+
+/*
+ * EditableEUR — montant affiché en lecture (format EUR + icône crayon
+ * discrète) qui se transforme, au clic, en champ de saisie avec un bouton de
+ * validation (icône check) pour revenir à la vue de lecture. Le montant
+ * brouillon (`value`) reste piloté par le parent à chaque frappe, pour que
+ * les totaux/réconciliations en direct continuent à se mettre à jour
+ * pendant la saisie — seul l'affichage bascule.
+ */
+function EditableEUR({ value, editing, saving = false, onEdit, onChange, onConfirm, placeholder = 'Non défini', inputStyle }) {
+ const num = value === '' || value === undefined || value === null ? null : Number(value);
+ const confirm = () => { if (!saving) onConfirm(); };
+
+ if (editing) {
+ return (
+
+ {objectifsActifs ? (
+ <>
+ Fixez ici, pour une année donnée, le dépôt global à partager entre les détenteurs, puis la
+ part de chacun sous forme d'enveloppe, puis comment chaque enveloppe se répartit en
+ objectifs de capital investi par plateforme. Chaque montant s'enregistre automatiquement
+ dès sa validation (icône crayon → saisie → check). Les totaux se recalculent en direct pour
+ vous montrer l'impact d'un changement à un niveau sur les autres. Les pages{' '}
+ Dépôts/Retraits et Plateformes affichent ensuite un simple
+ suivi en lecture seule de l'écart par rapport à ce qui est fixé ici.
+ >
+ ) : (
+ <>
+ Cette fonctionnalité permet de fixer, pour une année donnée, un objectif de versement
+ complémentaire par détenteur ainsi qu'un objectif de capital investi par plateforme, puis
+ d'en suivre l'atteinte dans un onglet Objectifs dédié sur les pages{' '}
+ Dépôts/Retraits (suivi des versements) et Plateformes{' '}
+ (suivi du capital investi). Désactivée, elle n'apparaît plus nulle part — vos saisies
+ précédentes sont conservées et réapparaîtront dès la réactivation.
+ >
+ )}
+
+
+
+ {objectifsActifs && (
+ <>
+
+
+
+
+ + Ajouter une année
+
+ (objectifsForYear.length > 0 ? setConfirmInitYear(true) : initFromActual())}
+ disabled={initializing}
+ title="Enregistre immédiatement les objectifs de l'année avec le réalisé constaté (dépôts et capital investi à date) — un point de départ à 100 % que vous pouvez ensuite ajuster ; chaque ajustement se sauvegarde lui-même."
+ style={{
+ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 6,
+ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 6,
+ padding: '6px 12px', color: 'var(--primary)', fontSize: 'var(--fs-sm)',
+ cursor: initializing ? 'wait' : 'pointer', opacity: initializing ? 0.6 : 1,
+ }}
+ >
+ {initializing ? 'Initialisation…' : `Initialiser ${selectedYear} avec l'actuel`}
+
+ setConfirmDeleteYear(true)}
+ disabled={objectifsForYear.length === 0}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 6,
+ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 6,
+ padding: '6px 12px', color: 'var(--danger)', fontSize: 'var(--fs-sm)',
+ cursor: objectifsForYear.length === 0 ? 'default' : 'pointer',
+ opacity: objectifsForYear.length === 0 ? 0.45 : 1,
+ }}
+ >
+ Supprimer les objectifs {selectedYear}
+
+ {isRemovableFutureYear && (
+ 0}
+ title={objectifsForYear.length > 0
+ ? 'Supprimez d\'abord les objectifs de cette année avant de pouvoir retirer l\'année elle-même.'
+ : `Retire ${selectedYear} de la liste des années (année ajoutée manuellement, sans objectif fixé).`}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 6,
+ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 6,
+ padding: '6px 12px', color: 'var(--danger)', fontSize: 'var(--fs-sm)',
+ cursor: objectifsForYear.length > 0 ? 'default' : 'pointer',
+ opacity: objectifsForYear.length > 0 ? 0.45 : 1,
+ }}
+ >
+ Supprimer l'année {selectedYear}
+
+ )}
+
+
+ {err &&
{err}
}
+ {okMsg &&
{okMsg}
}
+
+ {/* ── Récap global multi détenteurs ── */}
+ {multiDetenteur && (
+
+
+ Avertissement : le total calculé des versements des détenteurs ({fmtEUR(sumEnvelopes)}) dépasse de {fmtEUR(-globalReste)} le
+ total des versements complémentaires souhaité ({fmtEUR(globalTarget)}). Voulez-vous automatiquement le corriger ?
+
+
+ {savingGlobal ? 'Correction…' : 'Corriger ce montant'}
+
+