Implémentation de la feature "suivi des objectifs"
This commit is contained in:
1 parent
6e03195d3a
commit
1060bfe2be
14 files changed
+1830
-178
No files matched your search
+46
-5
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user