Mise en place des objectifs

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