Mise en place des objectifs
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user