Implémentation de la feature "suivi des objectifs"

This commit is contained in:
ocroguennec committed 2026-08-17 17:15:50 +02:00
1 parent 6e03195d3a
commit 1060bfe2be
14 files changed
+1830 -178

No files matched your search

+46 -5
View File
@@ -2227,23 +2227,64 @@ console.log('[DB] Migrations 2FA OK');
} }
// ── Migration : table objectifs ────────────────────────────────────────── // ── Migration : table objectifs ──────────────────────────────────────────
// Objectifs annuels par investisseur. `type` permet de réutiliser la table // Objectifs annuels. `type` permet plusieurs natures d'objectifs dans la même
// pour d'autres natures d'objectifs plus tard (ex: 'rendement_annuel') — // table : 'versement_annuel' (enveloppe globale de dépôts, par investisseur,
// pour l'instant seul 'versement_annuel' (objectif de dépôts nets) est utilisé. // 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(` db.exec(`
CREATE TABLE IF NOT EXISTS objectifs ( CREATE TABLE IF NOT EXISTS objectifs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE, 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', type TEXT NOT NULL DEFAULT 'versement_annuel',
annee INTEGER NOT NULL, annee INTEGER NOT NULL,
montant REAL NOT NULL, montant REAL NOT NULL,
notes TEXT, notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
UNIQUE(investisseur_id, type, annee)
) )
`); `);
// 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_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" ── // ── Migration : suivi anti-doublon des notifications "données incomplètes" ──
// Stocke la signature (liste triée des champs essentiels manquants) au moment // Stocke la signature (liste triée des champs essentiels manquants) au moment
+57 -17
View File
@@ -6,11 +6,22 @@ const router = Router();
const TYPE_DEFAUT = 'versement_annuel'; 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) { function parseBody(body) {
const { investisseur_id, type = TYPE_DEFAUT, annee, montant, notes } = body; const { investisseur_id, plateforme_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 (!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) { if (!Number.isInteger(Number(annee)) || Number(annee) < 2000 || Number(annee) > 2100) {
throw new HttpError(400, 'annee invalide'); throw new HttpError(400, 'annee invalide');
} }
@@ -18,24 +29,26 @@ function parseBody(body) {
throw new HttpError(400, 'montant doit être un nombre positif'); throw new HttpError(400, 'montant doit être un nombre positif');
} }
return { return {
investisseur_id: Number(investisseur_id), investisseur_id: investisseur_id ? Number(investisseur_id) : null,
type: type.trim(), plateforme_id: plateforme_id ? Number(plateforme_id) : null,
type: trimmedType,
annee: Number(annee), annee: Number(annee),
montant: Number(montant), montant: Number(montant),
notes: notes?.trim() || null, 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é Retourne tous les objectifs des investisseurs de l'utilisateur connecté
(toutes plateformes / tout le portefeuille — pas de notion de scope=all (toutes plateformes / tout le portefeuille — pas de notion de scope=all
vs single ici, un objectif appartient toujours à un investisseur donné). */ vs single ici, un objectif appartient toujours à un investisseur donné). */
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { type, annee } = req.query; const { type, annee, plateforme_id } = req.query;
const conds = ['i.user_id = ?']; const conds = ['i.user_id = ?'];
const args = [req.user.id]; const args = [req.user.id];
if (type) { conds.push('o.type = ?'); args.push(type); } if (type) { conds.push('o.type = ?'); args.push(type); }
if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); } 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(` const rows = db.prepare(`
SELECT o.*, i.nom AS investisseur_nom SELECT o.*, i.nom AS investisseur_nom
@@ -47,26 +60,53 @@ router.get('/', (req, res) => {
res.json(rows); 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) => { router.post('/', (req, res, next) => {
try { try {
const data = parseBody(req.body); const data = parseBody(req.body);
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 = ?') const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(data.investisseur_id, req.user.id); .get(investisseurId, req.user.id);
if (!inv) throw new HttpError(404, 'Investisseur introuvable'); if (!inv) throw new HttpError(404, 'Investisseur introuvable');
}
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(` db.prepare(`
INSERT INTO objectifs (investisseur_id, type, annee, montant, notes) INSERT INTO objectifs (investisseur_id, plateforme_id, type, annee, montant, notes)
VALUES (?,?,?,?,?) VALUES (?,?,?,?,?,?)
ON CONFLICT(investisseur_id, type, annee) `).run(investisseurId, data.plateforme_id, data.type, data.annee, data.montant, data.notes);
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(` const saved = data.plateforme_id
? db.prepare(`
SELECT o.*, i.nom AS investisseur_nom SELECT o.*, i.nom AS investisseur_nom
FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id
WHERE o.investisseur_id = ? AND o.type = ? AND o.annee = ? WHERE o.plateforme_id = ? AND o.type = ? AND o.annee = ?
`).get(data.investisseur_id, data.type, data.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); res.status(201).json(saved);
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
+1
View File
@@ -9,6 +9,7 @@ const ALLOWED_KEYS = new Set([
'chart_capital', 'chart_capital',
'chart_cashback', 'chart_cashback',
'pfo_assujetti', 'pfo_assujetti',
'objectifs_actifs',
// Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…) // Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…)
]); ]);
@@ -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 <span style={{ color: 'var(--text-muted)' }}>—</span>;
// É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 <span style={{ color: 'var(--success)', fontWeight: 600 }}>Conforme à l'objectif</span>;
return ecart >= 0
? <span style={{ color: overColor, fontWeight: 600 }}>+{fmtEUR(ecart)} au-delà de l'objectif</span>
: <span style={{ color: 'var(--warning)', fontWeight: 600 }}>{fmtEUR(-ecart)} en dessous de l'objectif</span>;
}
// 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 <span style={{ color: 'var(--text-muted)' }}>—</span>;
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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 100 }}>
<div style={{ flex: 1, height: 6, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${barPct}%`, height: '100%', background: color, borderRadius: 3 }} />
</div>
<span style={{ color, fontWeight: 700, fontSize: 'var(--fs-xs)', minWidth: 38, textAlign: 'right' }}>
{Math.round(pct)} %
</span>
</div>
);
}
+45 -138
View File
@@ -1,15 +1,18 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js'; 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 * Depuis la mise en place de la section Paramétrage > Fixation des objectifs,
* fournir des lignes de mouvements bruts (avec investisseur_id, type, montant, * ce composant n'est plus éditable : les objectifs se définissent exclusivement
* date) et la liste des investisseurs concernés. La table `objectifs` * dans les Paramétrages (cf. settings/ObjectifsSection.jsx), qui montre la
* (backend) porte un champ `type` qui permet de stocker d'autres natures * relation entre l'enveloppe globale de versement et son allocation par
* d'objectifs plus tard sans nouvelle table. * 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 : * Props :
* - rows : mouvements bruts [{ investisseur_id, type: 'depot'|'retrait', montant, date_operation }] * - rows : mouvements bruts [{ investisseur_id, type: 'depot'|'retrait', montant, date_operation }]
@@ -26,31 +29,21 @@ export default function SuiviObjectifs({
objectifType = 'versement_annuel', objectifType = 'versement_annuel',
title = 'Suivi des objectifs de versement', title = 'Suivi des objectifs de versement',
}) { }) {
const navigate = useNavigate();
const [objectifs, setObjectifs] = useState([]); const [objectifs, setObjectifs] = useState([]);
const [loading, setLoading] = useState(false); 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); setLoading(true);
api.get('/objectifs', { type: objectifType }) api.get('/objectifs', { type: objectifType })
.then(rows => setObjectifs(Array.isArray(rows) ? rows : [])) .then(rows => setObjectifs(Array.isArray(rows) ? rows : []))
.catch(() => setObjectifs([])) .catch(() => setObjectifs([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; }, [objectifType]);
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 ── */ /* ── Années à afficher : celles avec mouvements + celles avec objectif + année courante ── */
const years = useMemo(() => { const years = useMemo(() => {
const set = new Set(extraYears); const set = new Set();
for (const r of rows) { for (const r of rows) {
if (scopeInvestisseurIds.includes(r.investisseur_id) && r.date_operation) { if (scopeInvestisseurIds.includes(r.investisseur_id) && r.date_operation) {
set.add(Number(r.date_operation.slice(0, 4))); set.add(Number(r.date_operation.slice(0, 4)));
@@ -61,7 +54,7 @@ export default function SuiviObjectifs({
} }
set.add(new Date().getFullYear()); set.add(new Date().getFullYear());
return [...set].sort((a, b) => b - a); return [...set].sort((a, b) => b - a);
}, [rows, objectifs, scopeInvestisseurIds, extraYears]); }, [rows, objectifs, scopeInvestisseurIds]);
/* ── Totaux dépôts/retraits par année (scope courant) ── */ /* ── Totaux dépôts/retraits par année (scope courant) ── */
const totalsByYear = useMemo(() => { const totalsByYear = useMemo(() => {
@@ -88,53 +81,17 @@ export default function SuiviObjectifs({
return map; return map;
}, [objectifs, scopeInvestisseurIds]); }, [objectifs, scopeInvestisseurIds]);
const openEditor = (year) => { /* ── Ligne Total : somme des colonnes sur les années affichées ──
const existing = objectifsByYear[year] || []; `diffAvecObjectif` ne cumule le versement net que sur les années où un
const d = {}; objectif est fixé — comparer le taux d'atteinte / l'écart à `diff` (qui
for (const inv of scopedInvestisseurs) { inclut aussi les années "Non défini") gonflerait artificiellement le
const found = existing.find(o => o.investisseur_id === inv.id); réalisé par rapport à l'objectif et ferait passer l'indicateur au rouge
d[inv.id] = found ? String(found.montant) : ''; à tort alors que chaque année avec objectif y est individuellement
} conforme. */
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(() => { const overallTotals = useMemo(() => {
let depots = 0, retraits = 0; let depots = 0, retraits = 0;
let objectifSum = 0, hasObjectif = false; let objectifSum = 0, hasObjectif = false;
let ecartSum = 0, hasEcart = false; let diffAvecObjectif = 0, ecartSum = 0, hasEcart = false;
for (const year of years) { for (const year of years) {
const t = totalsByYear[year] || { depots: 0, retraits: 0 }; const t = totalsByYear[year] || { depots: 0, retraits: 0 };
depots += t.depots; depots += t.depots;
@@ -144,12 +101,14 @@ export default function SuiviObjectifs({
const objectifTotal = objs.reduce((s, o) => s + o.montant, 0); const objectifTotal = objs.reduce((s, o) => s + o.montant, 0);
objectifSum += objectifTotal; objectifSum += objectifTotal;
hasObjectif = true; hasObjectif = true;
diffAvecObjectif += t.depots - t.retraits;
ecartSum += (t.depots - t.retraits) - objectifTotal; ecartSum += (t.depots - t.retraits) - objectifTotal;
hasEcart = true; hasEcart = true;
} }
} }
return { return {
depots, retraits, diff: depots - retraits, depots, retraits, diff: depots - retraits,
diffAvecObjectif: hasObjectif ? diffAvecObjectif : null,
objectifTotal: hasObjectif ? objectifSum : null, objectifTotal: hasObjectif ? objectifSum : null,
ecart: hasEcart ? ecartSum : null, ecart: hasEcart ? ecartSum : null,
}; };
@@ -159,8 +118,13 @@ export default function SuiviObjectifs({
<div className="card" style={{ marginTop: 20 }}> <div className="card" style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>{title}</h3> <h3 style={{ margin: 0 }}>{title}</h3>
<button type="button" className="btn-outline" onClick={addYear} style={{ fontSize: 'var(--fs-sm)' }}> <button
+ Ajouter une année type="button"
className="btn-outline"
onClick={() => navigate('/settings?section=objectifs')}
style={{ fontSize: 'var(--fs-sm)' }}
>
Définir dans Paramétrage →
</button> </button>
</div> </div>
@@ -170,9 +134,10 @@ export default function SuiviObjectifs({
<th style={{ padding: '8px 10px' }}>Année</th> <th style={{ padding: '8px 10px' }}>Année</th>
<th style={{ padding: '8px 10px' }}>Dépôts</th> <th style={{ padding: '8px 10px' }}>Dépôts</th>
<th style={{ padding: '8px 10px' }}>Retraits</th> <th style={{ padding: '8px 10px' }}>Retraits</th>
<th style={{ padding: '8px 10px' }}>Différence</th> <th style={{ padding: '8px 10px' }}>Versement net</th>
<th style={{ padding: '8px 10px' }}>Objectif annuel</th> <th style={{ padding: '8px 10px' }}>Objectifs (Versement)</th>
<th style={{ padding: '8px 10px' }}>Écart</th> <th style={{ padding: '8px 10px' }}>Atteinte</th>
<th style={{ padding: '8px 10px' }}>Écart de versement</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -182,69 +147,18 @@ export default function SuiviObjectifs({
const objs = objectifsByYear[year] || []; const objs = objectifsByYear[year] || [];
const objectifTotal = objs.length > 0 ? objs.reduce((s, o) => s + o.montant, 0) : null; const objectifTotal = objs.length > 0 ? objs.reduce((s, o) => s + o.montant, 0) : null;
const ecart = objectifTotal != null ? diff - objectifTotal : null; const ecart = objectifTotal != null ? diff - objectifTotal : null;
const isEditing = editingYear === year;
return ( return (
<tr key={year} style={{ borderTop: '1px solid var(--border)' }}> <tr key={year} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px', fontWeight: 600 }}>{year}</td> <td style={{ padding: '8px 10px', fontWeight: 600 }}>{year}</td>
<td style={{ padding: '8px 10px', color: 'var(--success)' }}>{fmtEUR(t.depots)}</td> <td style={{ padding: '8px 10px', color: 'var(--success)' }}>{fmtEUR(t.depots)}</td>
<td style={{ padding: '8px 10px', color: 'var(--danger)' }}>{fmtEUR(t.retraits)}</td> <td style={{ padding: '8px 10px', color: 'var(--danger)' }}>{fmtRetrait(t.retraits)}</td>
<td style={{ padding: '8px 10px', fontWeight: 600 }}>{fmtEUR(diff)}</td> <td style={{ padding: '8px 10px', color: 'var(--text-muted)' }}>{fmtEUR(diff)}</td>
<td style={{ padding: '8px 10px' }}> <td style={{ padding: '8px 10px', color: objectifTotal != null ? 'var(--text)' : 'var(--text-muted)' }}>
{isEditing ? ( {objectifTotal != null ? fmtEUR(objectifTotal) : 'Non défini'}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{scopedInvestisseurs.map(inv => (
<div key={inv.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{scopedInvestisseurs.length > 1 && (
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', minWidth: 90 }}>
{memberLabel(inv)}
</span>
)}
<input
type="number"
min="0"
step="1"
placeholder="—"
value={draft[inv.id] ?? ''}
onChange={e => setDraft(d => ({ ...d, [inv.id]: e.target.value }))}
style={{ width: 110, padding: '4px 6px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface)' }}
/>
</div>
))}
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
<button type="button" disabled={saving} onClick={() => saveEditor(year)} style={{ fontSize: 'var(--fs-xs)' }}>
Enregistrer
</button>
<button type="button" className="ghost" disabled={saving} onClick={cancelEditor} style={{ fontSize: 'var(--fs-xs)' }}>
Annuler
</button>
</div>
</div>
) : (
<button
type="button"
className="ghost"
onClick={() => openEditor(year)}
style={{ padding: '2px 6px', fontSize: 'var(--fs-sm)', color: objectifTotal != null ? 'var(--text)' : 'var(--text-muted)' }}
title="Cliquer pour définir l'objectif"
>
{objectifTotal != null ? fmtEUR(objectifTotal) : '+ Définir'}
</button>
)}
</td>
<td style={{ padding: '8px 10px' }}>
{ecart == null ? (
<span style={{ color: 'var(--text-muted)' }}>—</span>
) : ecart >= 0 ? (
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
+{fmtEUR(ecart)} au-delà de l'objectif
</span>
) : (
<span style={{ color: 'var(--warning)', fontWeight: 600 }}>
Reste {fmtEUR(-ecart)}
</span>
)}
</td> </td>
<td style={{ padding: '8px 10px' }}><TauxAtteinte realise={diff} objectif={objectifTotal} overColor="var(--danger)" /></td>
<td style={{ padding: '8px 10px' }}><EcartCell ecart={ecart} overColor="var(--danger)" /></td>
</tr> </tr>
); );
})} })}
@@ -253,20 +167,13 @@ export default function SuiviObjectifs({
<tr style={{ borderTop: '2px solid var(--border)' }}> <tr style={{ borderTop: '2px solid var(--border)' }}>
<td style={{ padding: '8px 10px', fontWeight: 700 }}>Total</td> <td style={{ padding: '8px 10px', fontWeight: 700 }}>Total</td>
<td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--success)' }}>{fmtEUR(overallTotals.depots)}</td> <td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--success)' }}>{fmtEUR(overallTotals.depots)}</td>
<td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--danger)' }}>{fmtEUR(overallTotals.retraits)}</td> <td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--danger)' }}>{fmtRetrait(overallTotals.retraits)}</td>
<td style={{ padding: '8px 10px', fontWeight: 700 }}>{fmtEUR(overallTotals.diff)}</td> <td style={{ padding: '8px 10px', fontWeight: 700 }}>{fmtEUR(overallTotals.diff)}</td>
<td style={{ padding: '8px 10px', fontWeight: 700 }}> <td style={{ padding: '8px 10px', fontWeight: 700 }}>
{overallTotals.objectifTotal != null ? fmtEUR(overallTotals.objectifTotal) : <span style={{ color: 'var(--text-muted)' }}>—</span>} {overallTotals.objectifTotal != null ? fmtEUR(overallTotals.objectifTotal) : <span style={{ color: 'var(--text-muted)' }}>—</span>}
</td> </td>
<td style={{ padding: '8px 10px', fontWeight: 700 }}> <td style={{ padding: '8px 10px', fontWeight: 700 }}><TauxAtteinte realise={overallTotals.diffAvecObjectif} objectif={overallTotals.objectifTotal} overColor="var(--danger)" /></td>
{overallTotals.ecart == null ? ( <td style={{ padding: '8px 10px', fontWeight: 700 }}><EcartCell ecart={overallTotals.ecart} overColor="var(--danger)" /></td>
<span style={{ color: 'var(--text-muted)' }}>—</span>
) : overallTotals.ecart >= 0 ? (
<span style={{ color: 'var(--success)' }}>+{fmtEUR(overallTotals.ecart)} au-delà de l'objectif</span>
) : (
<span style={{ color: 'var(--warning)' }}>Reste {fmtEUR(-overallTotals.ecart)}</span>
)}
</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -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 (
<div className="card" style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<button
type="button"
className="btn-outline"
onClick={() => navigate('/settings?section=objectifs')}
style={{ fontSize: 'var(--fs-sm)' }}
>
Définir dans Paramétrage →
</button>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)' }}>
<th style={{ padding: '8px 10px' }}>Année</th>
<th style={{ padding: '8px 10px' }}>Capital investi au 01/01</th>
<th style={{ padding: '8px 10px' }}>Capital investi</th>
<th style={{ padding: '8px 10px' }}>Objectifs (Capital)</th>
<th style={{ padding: '8px 10px' }}>Atteinte</th>
<th style={{ padding: '8px 10px' }}>Écart de capital investi</th>
</tr>
</thead>
<tbody>
{rows.map(({ year, capitalDebut, capitalLabel, capitalPourEcart, objectif, ecart }) => {
return (
<tr key={year} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px', fontWeight: 600 }}>
{year}{year === currentYear && <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}> (en cours)</span>}
</td>
<td style={{ padding: '8px 10px', color: 'var(--text-muted)' }}>{fmtEUR(capitalDebut)}</td>
<td style={{ padding: '8px 10px', color: 'var(--text-muted)' }}>{capitalLabel}</td>
<td style={{ padding: '8px 10px', color: objectif != null ? 'var(--text)' : 'var(--text-muted)' }}>
{objectif != null ? fmtEUR(objectif) : 'Non défini'}
</td>
<td style={{ padding: '8px 10px' }}><TauxAtteinte realise={capitalPourEcart} objectif={objectif ?? null} /></td>
<td style={{ padding: '8px 10px' }}><EcartCell ecart={ecart} /></td>
</tr>
);
})}
</tbody>
</table>
{loading && <div style={{ padding: 8, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Chargement…</div>}
<div style={{ marginTop: 10, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
"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.
</div>
</div>
);
}
+21 -1
View File
@@ -18,6 +18,7 @@ const KEY_CHART_INTERETS = 'cl_chart_interets';
const KEY_CHART_CAPITAL = 'cl_chart_capital'; const KEY_CHART_CAPITAL = 'cl_chart_capital';
const KEY_CHART_CASHBACK = 'cl_chart_cashback'; const KEY_CHART_CASHBACK = 'cl_chart_cashback';
const KEY_PFO_ASSUJETTI = 'cl_pfo_assujetti'; const KEY_PFO_ASSUJETTI = 'cl_pfo_assujetti';
const KEY_OBJECTIFS_ACTIFS = 'cl_objectifs_actifs';
const FONT_SIZES = ['large', 'medium', 'compact']; const FONT_SIZES = ['large', 'medium', 'compact'];
const LANGUES = ['fr', 'en']; const LANGUES = ['fr', 'en'];
@@ -70,6 +71,14 @@ export function UiProvider({ children }) {
localStorage.getItem(KEY_PFO_ASSUJETTI) === '1' 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 ────────────────────────── // ── Sync DB → localStorage au montage ──────────────────────────
// La DB fait foi : ses valeurs écrasent le localStorage si présentes. // La DB fait foi : ses valeurs écrasent le localStorage si présentes.
useEffect(() => { useEffect(() => {
@@ -91,6 +100,11 @@ export function UiProvider({ children }) {
localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0'); localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0');
setPfoAssujettiState(val); setPfoAssujettiState(val);
} }
if (prefs.objectifs_actifs !== undefined) {
const val = prefs.objectifs_actifs === '1';
localStorage.setItem(KEY_OBJECTIFS_ACTIFS, val ? '1' : '0');
setObjectifsActifsState(val);
}
}).catch(() => { }).catch(() => {
// Silencieux : token absent au premier rendu, on garde le localStorage // 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('pfo_assujetti', val ? '1' : '0');
}, [persistPref]); }, [persistPref]);
const setObjectifsActifs = useCallback((val) => {
setObjectifsActifsState(val);
localStorage.setItem(KEY_OBJECTIFS_ACTIFS, val ? '1' : '0');
persistPref('objectifs_actifs', val ? '1' : '0');
}, [persistPref]);
return ( return (
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti }}> <UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs }}>
{children} {children}
</UiCtx.Provider> </UiCtx.Provider>
); );
+20 -7
View File
@@ -6,6 +6,7 @@ import EmptyState from '../components/EmptyState.jsx';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import Modal from '../components/Modal.jsx'; import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx';
import SoldeChart from '../components/SoldeChart.jsx'; import SoldeChart from '../components/SoldeChart.jsx';
@@ -94,6 +95,7 @@ function platToJSON(rows) {
/* ── Composant principal ─────────────────────────────────────── */ /* ── Composant principal ─────────────────────────────────────── */
export default function DepotsRetraits() { export default function DepotsRetraits() {
const { activeId, activeView, investisseurs } = useInvestisseur(); const { activeId, activeView, investisseurs } = useInvestisseur();
const { objectifsActifs } = useUi();
const { search } = useLocation(); const { search } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('plateformes'); const [activeTab, setActiveTab] = useState('plateformes');
@@ -453,6 +455,12 @@ export default function DepotsRetraits() {
totalPages: drTotalPages, totalItems: drTotalItems, PAGE_SIZES, totalPages: drTotalPages, totalItems: drTotalItems, PAGE_SIZES,
} = usePagination(tableRows, 'cl_pagesize_dr', [filter]); } = 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 // 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) // (déclaré après `tableRows` pour éviter la temporal dead zone)
useEffect(() => { useEffect(() => {
@@ -575,6 +583,7 @@ export default function DepotsRetraits() {
Toujours calculé sur le portefeuille entier (ignore le filtre plateforme), 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"). */ 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 scopeInvestisseurIdsKpi = activeView === 'all' ? investisseurs.map(i => i.id) : [activeId];
const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear()); const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear());
const kpiObjectifTotal = (() => { const kpiObjectifTotal = (() => {
const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id)); const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id));
@@ -904,14 +913,16 @@ export default function DepotsRetraits() {
<TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} /> <TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />
</div> </div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div> <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>
{kpiEcartObjectif != null && ( {objectifsActifs && kpiEcartObjectif != null && (
<div style={{ <div style={{
fontSize: '0.8em', marginTop: 4, fontWeight: 600, fontSize: '0.8em', marginTop: 4, fontWeight: 600,
color: kpiEcartObjectif >= 0 ? 'var(--success)' : 'var(--warning)', color: Math.abs(kpiEcartObjectif) < 0.01 ? 'var(--success)' : kpiEcartObjectif >= 0 ? 'var(--danger)' : 'var(--warning)',
}}> }}>
{kpiEcartObjectif >= 0 {Math.abs(kpiEcartObjectif) < 0.01
? `Conforme à l'objectif ${kpiObjectifYear}`
: kpiEcartObjectif >= 0
? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}` ? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}`
: `Reste ${fmtEUR(-kpiEcartObjectif)} pour l'objectif ${kpiObjectifYear}`} : `${fmtEUR(-kpiEcartObjectif)} en dessous de l'objectif ${kpiObjectifYear}`}
</div> </div>
)} )}
</div> </div>
@@ -929,16 +940,18 @@ export default function DepotsRetraits() {
{!listFocused && <div className="dr-tabs"> {!listFocused && <div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button> onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-annuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-annuelle')}>Vision annuelle</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button> onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
onClick={() => { setActiveTab('mouvements'); setSelectedRow(r => r ?? (rows[0] || null)); }}>Mouvements</button> onClick={() => { setActiveTab('mouvements'); setSelectedRow(r => r ?? (rows[0] || null)); }}>Mouvements</button>
{objectifsActifs && (
<button className={`dr-tab${activeTab === 'vision-annuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-annuelle')}>Objectifs</button>
)}
</div>} </div>}
{/* ====== ONGLET VISION ANNUELLE ====== */} {/* ====== ONGLET VISION ANNUELLE ====== */}
{activeTab === 'vision-annuelle' && ( {objectifsActifs && activeTab === 'vision-annuelle' && (
<div style={{ padding: '0 24px' }}> <div style={{ padding: '0 24px' }}>
<SuiviObjectifs <SuiviObjectifs
rows={allRows} rows={allRows}
+127 -10
View File
@@ -7,7 +7,7 @@ import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx'; import EmptyState from '../components/EmptyState.jsx';
import InvChart from '../components/InvChart.jsx'; import InvChart from '../components/InvChart.jsx';
import InvMensuelTable from '../components/InvMensuelTable.jsx'; import InvMensuelTable from '../components/InvMensuelTable.jsx';
import SuiviObjectifs from '../components/SuiviObjectifs.jsx'; import SuiviObjectifsCapital from '../components/SuiviObjectifsCapital.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx'; import DrillCellPanel from '../components/DrillCellPanel.jsx';
import RembFormModal from '../components/RembFormModal.jsx'; import RembFormModal from '../components/RembFormModal.jsx';
import { InteretsChartProvider } from '../context/InteretsChartContext.jsx'; import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
@@ -286,7 +286,7 @@ function AnneeSelector({ availableYears, selectedYear, onSelect }) {
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
export default function Plateformes() { export default function Plateformes() {
const { activeId, activeView, investisseurs } = useInvestisseur(); const { activeId, activeView, investisseurs } = useInvestisseur();
const { displayMode, chartInterets, chartCapital, chartCashback } = useUi(); const { displayMode, chartInterets, chartCapital, chartCashback, objectifsActifs } = useUi();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -355,6 +355,14 @@ export default function Plateformes() {
}; };
const [listFocused, setListFocused] = useState(false); const [listFocused, setListFocused] = useState(false);
// Repli sur l'onglet Remboursements si la fixation des objectifs est
// désactivée pendant qu'on est sur l'onglet Objectifs (ex. tab restauré
// depuis l'URL ou le localStorage alors que le paramètre a été désactivé).
useEffect(() => {
if (!objectifsActifs && activeTab === 'objectifs') setActiveTab('remboursements');
// eslint-disable-next-line
}, [objectifsActifs, activeTab]);
/* ── Chargement ── */ /* ── Chargement ── */
const load = async () => { const load = async () => {
if (!activeId && activeView !== 'all') return; 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) ── */ /* ── 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; 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) ── */ /* ── Reset détenteur quand plateforme change (skip restauration initiale) ── */
const isFirstPlatChange = useRef(true); const isFirstPlatChange = useRef(true);
useEffect(() => { 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)); return new Set(matching.filter(p => String(p.investisseur_id) === String(selectedDetenteurId)).map(p => p.id));
}, [plats, selectedPlatName, selectedDetenteurId]); }, [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 ── */ /* ── 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 MOIS_LONG_CELL = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const moisEnCoursCell = useMemo(() => { const moisEnCoursCell = useMemo(() => {
@@ -848,6 +898,23 @@ export default function Plateformes() {
const netMode = displayMode === 'net'; const netMode = displayMode === 'net';
const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1; 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) ── /* ── Solde porte-monnaie (source unique : GET /dashboard[?asOf=], cf. dashboard.js) ──
Snapshot backend filtré sur la sélection plateforme(s)/détenteur (filteredPlatIds). */ Snapshot backend filtré sur la sélection plateforme(s)/détenteur (filteredPlatIds). */
const soldeFromSnapshot = (snap) => { const soldeFromSnapshot = (snap) => {
@@ -1374,6 +1441,18 @@ export default function Plateformes() {
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}> <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(prevTotals.encours)} en {prevYearLabel} {fmtEUR(prevTotals.encours)} en {prevYearLabel}
</div> </div>
{objectifsActifs && kpiEcartCapitalObjectif != null && (
<div style={{
fontSize: '0.8em', marginTop: 4, fontWeight: 600,
color: Math.abs(kpiEcartCapitalObjectif) < 0.01 ? 'var(--success)' : kpiEcartCapitalObjectif >= 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}`}
</div>
)}
</div> </div>
{/* 2 — Investissements à risque */} {/* 2 — Investissements à risque */}
@@ -1502,6 +1581,10 @@ export default function Plateformes() {
onClick={() => setActiveTab('investissements')}>Investissements</button> onClick={() => setActiveTab('investissements')}>Investissements</button>
<button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`}
onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button> onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button>
{objectifsActifs && (
<button className={`dr-tab${activeTab === 'objectifs' ? ' active' : ''}`}
onClick={() => setActiveTab('objectifs')}>Objectifs</button>
)}
</div> </div>
{/* ====== ONGLET REMBOURSEMENTS ====== */} {/* ====== ONGLET REMBOURSEMENTS ====== */}
@@ -2213,14 +2296,6 @@ export default function Plateformes() {
</div> </div>
); );
})()} })()}
{/* Suivi des objectifs de versement — toujours sur le portefeuille entier,
indépendamment du filtre plateforme de cette page (cf. décision produit). */}
<SuiviObjectifs
rows={allDepots}
investisseurs={investisseurs}
scopeInvestisseurIds={activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]}
/>
</div> </div>
</div> </div>
); );
@@ -2685,6 +2760,48 @@ export default function Plateformes() {
</div> </div>
)} )}
{/* ====== ONGLET OBJECTIFS ====== */}
{objectifsActifs && activeTab === 'objectifs' && (
<div style={{ padding: '0 24px' }}>
{/* 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. */}
<SuiviObjectifsCapital
investissements={investissementsForObjectifs}
remboursementsCapital={remboursementsCapitalForObjectifs}
reinvestissements={allReinvests}
simulRembs={allSimulRembs}
scopeInvestisseurIds={activeView === 'all' ? investisseurs.map(i => 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 (
<SuiviObjectifsCapital
key={opt.id}
investissements={allRows.filter(inv => 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}`}
/>
);
})}
</div>
)}
{/* Modal création / édition d'un mouvement (onglet "Mouvements Porte-monnaie") */} {/* Modal création / édition d'un mouvement (onglet "Mouvements Porte-monnaie") */}
<Modal <Modal
open={mvModalOpen} open={mvModalOpen}
+4
View File
@@ -6,6 +6,7 @@ import CategoriesInvSection from './settings/CategoriesInvSection.jsx';
import SecteursInvSection from './settings/SecteursInvSection.jsx'; import SecteursInvSection from './settings/SecteursInvSection.jsx';
import ComptesSection from './settings/ComptesSection.jsx'; import ComptesSection from './settings/ComptesSection.jsx';
import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx'; import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
import ObjectifsSection from './settings/ObjectifsSection.jsx';
import DataCleanupSection from './settings/DataCleanupSection.jsx'; import DataCleanupSection from './settings/DataCleanupSection.jsx';
import ImportsSection from './settings/ImportsSection.jsx'; import ImportsSection from './settings/ImportsSection.jsx';
@@ -18,6 +19,7 @@ function IconTag() { return <svg width="15" height="15" viewBox="0 0 24 24"
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; } function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
function IconGrid() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>; } function IconGrid() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>; }
function IconMyFiscal() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="M16 3H8l-2 4h12l-2-4z"/><line x1="12" y1="12" x2="12" y2="12.01"/></svg>; } function IconMyFiscal() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M20 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="M16 3H8l-2 4h12l-2-4z"/><line x1="12" y1="12" x2="12" y2="12.01"/></svg>; }
function IconTarget() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>; }
function IconBroom() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>; } function IconBroom() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>; }
function IconUpload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; } function IconUpload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; }
@@ -35,6 +37,7 @@ const NAV = [
{ id: 'plateformes', label: 'Mes plateformes', icon: <IconServer /> }, { id: 'plateformes', label: 'Mes plateformes', icon: <IconServer /> },
{ id: 'comptes', label: 'Mes comptes courants', icon: <IconWallet /> }, { id: 'comptes', label: 'Mes comptes courants', icon: <IconWallet /> },
{ id: 'ma-fiscalite', label: 'Ma fiscalité', icon: <IconMyFiscal /> }, { id: 'ma-fiscalite', label: 'Ma fiscalité', icon: <IconMyFiscal /> },
{ id: 'objectifs', label: 'Fixation des objectifs', icon: <IconTarget /> },
], ],
}, },
{ {
@@ -87,6 +90,7 @@ export default function Settings() {
{section === 'plateformes' && <PlateformesSection />} {section === 'plateformes' && <PlateformesSection />}
{section === 'comptes' && <ComptesSection />} {section === 'comptes' && <ComptesSection />}
{section === 'ma-fiscalite' && <MaFiscaliteSection />} {section === 'ma-fiscalite' && <MaFiscaliteSection />}
{section === 'objectifs' && <ObjectifsSection />}
{section === 'categories-inv' && <CategoriesInvSection />} {section === 'categories-inv' && <CategoriesInvSection />}
{section === 'secteurs-inv' && <SecteursInvSection />} {section === 'secteurs-inv' && <SecteursInvSection />}
{section === 'nettoyage' && <DataCleanupSection />} {section === 'nettoyage' && <DataCleanupSection />}
File diff suppressed because it is too large. Load diff
+14
View File
@@ -3277,3 +3277,17 @@ tr:hover td { background: var(--surface-2); }
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.msg-img-wrap:hover .msg-img-hover-overlay { opacity: 1; } .msg-img-wrap:hover .msg-img-hover-overlay { opacity: 1; }
/* Objectifs — encart "Total des versements complémentaires" : pleine largeur
sur petit écran, réduit à 70% et centré à partir de 1100px. */
.objectifs-depot-global-box {
width: 100%;
}
@media (min-width: 1100px) {
.objectifs-depot-global-box {
width: 70%;
margin-left: auto;
margin-right: auto;
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Capital investi (encours) — formule unique réutilisée partout où ce montant
* apparaît : KPI/graphique "Capital investi" (Plateformes.jsx, via
* totals.encours), SuiviObjectifs.jsx (colonne "Capital investi") et
* Settings > Fixation des objectifs. Centralisée ici après un bug où deux
* implémentations avaient divergé (flux de l'année vs solde à une date) —
* ne pas la redupliquer, importer cette fonction.
*
* Capital investi = montant_investi + réinvestissements(≤cutoff) − capital
* remboursé(≤cutoff), borné à 0, uniquement pour les investissements
* souscrits avant le cutoff.
*
* @param {Array} investissements [{ id, investisseur_id, plateforme_id, montant_investi, date_souscription }]
* @param {Array} remboursementsCapital [{ investissement_id, capital, date_remb }] — remboursements de type 'normal'
* @param {Array} reinvestissements [{ investissement_id, montant, date_reinvestissement }]
* @param {string} cutoff 'YYYY-MM-DD'
* @returns {Object} map investissement_id -> capital restant (>= 0)
*/
export function capitalEncoursParInvestissement(investissements, remboursementsCapital, reinvestissements, cutoff) {
const rembByInv = {};
for (const rb of remboursementsCapital) {
if (!rb.investissement_id) continue;
(rembByInv[rb.investissement_id] ??= []).push(rb);
}
const reinvestByInv = {};
for (const rv of reinvestissements) {
if (!rv.investissement_id) continue;
(reinvestByInv[rv.investissement_id] ??= []).push(rv);
}
const map = {};
for (const inv of investissements) {
if (!inv.date_souscription || inv.date_souscription > cutoff) continue;
let capInv = inv.montant_investi || 0;
for (const rv of reinvestByInv[inv.id] ?? []) {
const d = rv.date_reinvestissement?.slice(0, 10);
if (d && d <= cutoff) capInv += rv.montant || 0;
}
let capRemb = 0;
for (const rb of rembByInv[inv.id] ?? []) {
const d = rb.date_remb?.slice(0, 10);
if (d && d <= cutoff) capRemb += rb.capital || 0;
}
map[inv.id] = Math.max(0, capInv - capRemb);
}
return map;
}
/**
* Somme du capital encours (cf. ci-dessus) groupée par une clé au choix
* (par ex. plateforme_id ou investisseur_id), filtrée sur une liste d'ids
* autorisés si fournie.
*
* @param {Array} investissements
* @param {Array} remboursementsCapital
* @param {Array} reinvestissements
* @param {string} cutoff
* @param {'plateforme_id'|'investisseur_id'} groupKey
* @param {Set|null} allowedIds — si fourni, ne somme que pour les investissements dont investisseur_id est dans ce set
*/
export function capitalEncoursParGroupe(investissements, remboursementsCapital, reinvestissements, cutoff, groupKey, allowedIds = null) {
const parInv = capitalEncoursParInvestissement(investissements, remboursementsCapital, reinvestissements, cutoff);
const totals = {};
for (const inv of investissements) {
if (allowedIds && !allowedIds.has(inv.investisseur_id)) continue;
const key = inv[groupKey];
if (key == null) continue;
const montant = parInv[inv.id] ?? 0;
totals[key] = (totals[key] ?? 0) + montant;
}
return totals;
}
+4
View File
@@ -7,6 +7,10 @@ export const fmtEUR = (n) => {
return v.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' }); return v.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' });
}; };
// Affiche un retrait avec un signe négatif (ex. "-232,64 €"), sans jamais produire
// "-0,00 €" pour un montant nul.
export const fmtRetrait = (n) => (n > 0 ? `-${fmtEUR(n)}` : fmtEUR(n));
export const fmtNum = (n, digits = 2) => export const fmtNum = (n, digits = 2) =>
(n == null ? '' : Number(n).toLocaleString('fr-FR', { minimumFractionDigits: digits, maximumFractionDigits: digits })); (n == null ? '' : Number(n).toLocaleString('fr-FR', { minimumFractionDigits: digits, maximumFractionDigits: digits }));