Implementation de la notion d'espace d'investissement

This commit is contained in:
ocroguennec committed 2026-09-17 18:25:42 +02:00
1 parent 6b6cad7cd6
commit 4b7df66862
54 files changed
+6755 -261

No files matched your search

+259
View File
@@ -3069,6 +3069,22 @@ db.exec(`
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces(user_id)');
// ── Migration : couleur de thème par (utilisateur, workspace) (17/09/26) ──
// Permet à l'utilisateur de personnaliser la couleur d'accent par espace de
// travail (demande Olivier) : NULL = hérite de la couleur de l'application
// (personnelle si définie via user_preferences.color_theme, sinon défaut
// admin) — même énumération que color_theme (cf. COLOR_THEME_VALUES,
// routes/preferences.js). Stocké sur user_workspaces (PK user_id+
// workspace_id) plutôt que sur workspaces : c'est un réglage personnel par
// compte, pas un réglage global de l'espace. Cf. AppearanceSection.jsx,
// WorkspaceContext.jsx et UiContext.jsx (workspaceColorOverride).
{
const uwCols = db.prepare('PRAGMA table_info(user_workspaces)').all().map(c => c.name);
if (!uwCols.includes('color_theme')) {
db.exec('ALTER TABLE user_workspaces ADD COLUMN color_theme TEXT');
}
}
// Seed : workspace "crowdlending" = l'app actuelle. Idempotent (slug UNIQUE).
db.prepare(`
INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre)
@@ -3103,4 +3119,247 @@ export function grantDefaultWorkspace(userId) {
}
}
// ── Migration : Workspaces sur le référentiel plateformes (08/09/26) ─────
// Chaque entrée du référentiel (plateformes_referentiel) est désormais
// rattachée à un ou plusieurs workspaces (relation N-N, même patron que
// referentiel_categories_inv/referentiel_secteurs_inv). Demande d'Olivier :
// réaffectation obligatoire vers "crowdlending" pour ce qui existe déjà en
// base, et au moins une affectation exigée à la création (validé côté API
// par le schéma Zod de routes/referentiel.js, min(1) sur workspace_ids).
// Placé ici (après la création de `workspaces` ci-dessus) pour garantir
// que la table existe déjà. Cf. project_workspaces_transformation.md.
db.exec(`
CREATE TABLE IF NOT EXISTS referentiel_workspaces (
referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
PRIMARY KEY (referentiel_id, workspace_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_ref_workspaces_ws ON referentiel_workspaces(workspace_id)');
// Backfill idempotent : toute entrée référentiel sans aucune affectation
// (comptes créés avant cette migration, ou entrées jamais touchées depuis)
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (wsCrowdlending) {
db.prepare(`
INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id)
SELECT pr.id, ?
FROM plateformes_referentiel pr
WHERE NOT EXISTS (SELECT 1 FROM referentiel_workspaces rw WHERE rw.referentiel_id = pr.id)
`).run(wsCrowdlending.id);
}
}
// ── Migration : Workspaces sur les plateformes utilisateur (08/09/26) ────
// Extension du même principe que le référentiel ci-dessus : chaque
// plateforme UTILISATEUR (table `plateformes`, distincte du référentiel
// admin) peut désormais être rattachée à un ou plusieurs workspaces (N-N,
// même patron `xxx_workspaces`). Décision d'Olivier (AskUserQuestion,
// 08/09/26) : un compte sur une plateforme peut servir à la fois au
// crowdlending et à un futur workspace Private Equity — pas de colonne
// unique comme initialement envisagé pour l'Étape 2 du retrofit général.
// Backfill : toute plateforme déjà en base est rattachée à "crowdlending".
// Cf. project_workspaces_transformation.md.
db.exec(`
CREATE TABLE IF NOT EXISTS plateforme_workspaces (
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
PRIMARY KEY (plateforme_id, workspace_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_plat_workspaces_ws ON plateforme_workspaces(workspace_id)');
// Backfill idempotent : toute plateforme utilisateur sans aucune affectation
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (wsCrowdlending) {
db.prepare(`
INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id)
SELECT p.id, ?
FROM plateformes p
WHERE NOT EXISTS (SELECT 1 FROM plateforme_workspaces pw WHERE pw.plateforme_id = p.id)
`).run(wsCrowdlending.id);
}
}
// ── Migration : workspace_id sur depots_retraits (17/09/26) ─────────────
// Contrairement à plateformes/plateformes_referentiel (relation N-N — un compte
// pouvant servir plusieurs workspaces à la fois), Olivier a tranché le 17/09/26
// pour une colonne UNIQUE sur depots_retraits : chaque mouvement de trésorerie
// est "flêché" vers un seul workspace précis. Démarre, table par table, l'Étape 1
// du retrofit workspace_id documenté dans project_workspaces_transformation.md —
// dépôts/retraits en premier, à la demande explicite d'Olivier (remboursements
// ensuite). Colonne nullable, backfill de l'existant vers "crowdlending".
{
const drCols2 = db.prepare('PRAGMA table_info(depots_retraits)').all().map(c => c.name);
if (!drCols2.includes('workspace_id')) {
db.exec('ALTER TABLE depots_retraits ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_depret_workspace ON depots_retraits(workspace_id)');
// Backfill idempotent : tout dépôt/retrait sans workspace (créé avant cette
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (wsCrowdlending2) {
db.prepare('UPDATE depots_retraits SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending2.id);
}
}
// ── Migration : workspace_id sur remboursements (17/09/26) ──────────────
// Suite du retrofit workspace_id (project_workspaces_transformation.md),
// table par table à la demande d'Olivier : après depots_retraits, les
// remboursements. Même principe : colonne unique nullable, backfill de
// l'existant vers "crowdlending".
{
const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols.includes('workspace_id')) {
db.exec('ALTER TABLE remboursements ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_workspace ON remboursements(workspace_id)');
// Backfill idempotent : tout remboursement sans workspace (créé avant cette
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (wsCrowdlending3) {
db.prepare('UPDATE remboursements SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending3.id);
}
}
// ── Investissements Private Equity (17/09/26) ────────────────────────────
// Nouveau modèle de données, volontairement séparé de `investissements`
// (cf. project_workspaces_transformation.md) : un deal PE (ex. Fundora) n'a
// ni échéancier connu à l'avance, ni taux d'intérêt fixe — c'est un montant
// unique investi (même principe que le crowdlending, décision Olivier
// 17/09/26 : le porte-monnaie diminue d'un coup, pas de suivi souscrit/
// décaissé), valorisé périodiquement par le fonds, avec un objectif de
// performance hétérogène selon les deals (multiple "x4,5" ou fourchette de
// taux annuel "9-12 %/an") — on le stocke donc en texte libre plutôt que
// d'essayer de le structurer. Table neuve : workspace_id est NOT NULL dès
// la création, pas besoin du nullable+backfill utilisé pour les tables
// préexistantes (depots_retraits, remboursements).
db.exec(`
CREATE TABLE IF NOT EXISTS investissements_pe (
id INTEGER PRIMARY KEY,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
nom_deal TEXT NOT NULL,
strategie TEXT, -- texte libre : 'Capital développement', 'Capital risque', 'Dette'...
date_souscription TEXT NOT NULL,
duree_mois INTEGER,
montant_investi REAL NOT NULL CHECK(montant_investi > 0),
objectif TEXT, -- texte libre : "x4,5" ou "9 % - 12 %/an"
statut TEXT NOT NULL DEFAULT 'valide'
CHECK(statut IN ('en_attente','valide','cloture','perte_definitive')),
reference TEXT,
source TEXT NOT NULL DEFAULT 'manuel',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_inv ON investissements_pe(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_plat ON investissements_pe(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_invpe_ws ON investissements_pe(workspace_id)');
// ── Migration : frais PE — investissement_pe_id (17/09/26) ─────────────────
// frais_operations était exclusivement rattachée à investissements (crowdlending).
// Pour permettre la traçabilité des frais des deals PE (investissements_pe), on ajoute
// une colonne investissement_pe_id nullable, on desserre investissement_id (désormais
// nullable lui aussi), et on impose par CHECK qu'exactement une des deux références
// soit renseignée. Reconstruction de table (SQLite ne permet pas d'altérer une
// contrainte NOT NULL ni un CHECK en place) — même stratégie que les migrations
// précédentes de frais_operations, cf. plus haut dans ce fichier.
{
const fraisOpColsPe = db.prepare('PRAGMA table_info(frais_operations)').all().map(c => c.name);
if (!fraisOpColsPe.includes('investissement_pe_id')) {
const tempName = '__repair_frais_operations_pe';
const fixedDdl = `
CREATE TABLE "${tempName}" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE,
investissement_pe_id INTEGER REFERENCES investissements_pe(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
remboursement_id INTEGER REFERENCES remboursements(id) ON DELETE CASCADE,
montant REAL NOT NULL,
mode_reglement TEXT NOT NULL DEFAULT 'source'
CHECK(mode_reglement IN ('source','portefeuille','compte_courant','remboursement')),
compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL,
date_operation TEXT NOT NULL,
origine TEXT NOT NULL DEFAULT 'manuel'
CHECK(origine IN ('souscription','remboursement','manuel')),
categorie TEXT NOT NULL DEFAULT 'autre',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK ((investissement_id IS NOT NULL) + (investissement_pe_id IS NOT NULL) = 1)
)
`;
const colDefs = db.prepare('PRAGMA table_info(frais_operations)').all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='frais_operations' AND sql IS NOT NULL"
).all();
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM frais_operations`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec('DROP TABLE frais_operations');
// legacy_alter_table = ON : empêche la réécriture automatique des FK enfants
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO frais_operations`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv ON frais_operations(investissement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_inv_pe ON frais_operations(investissement_pe_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_plat ON frais_operations(plateforme_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_remb ON frais_operations(remboursement_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_operation)');
}
}
// Historique des valorisations (relevés périodiques du fonds) — une ligne par
// date de valorisation, plutôt qu'un champ unique écrasé à chaque mise à jour,
// pour permettre un graphique d'évolution et un TVPI recalculable à toute date
// passée (décision Olivier 17/09/26).
db.exec(`
CREATE TABLE IF NOT EXISTS investissements_pe_valorisations (
id INTEGER PRIMARY KEY,
investissement_pe_id INTEGER NOT NULL REFERENCES investissements_pe(id) ON DELETE CASCADE,
date_valorisation TEXT NOT NULL,
valorisation_nette REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_invpeval_inv ON investissements_pe_valorisations(investissement_pe_id)');
// Lien optionnel remboursement → deal PE (17/09/26) : une distribution (retour
// de capital, plus-value) perçue sur un deal PE est saisie comme un
// remboursement bonus_plateforme/interets_plateforme, comme demandé par
// Olivier ("comme un remboursement, page déjà construite") — ce lien
// supplémentaire permet de rattacher cette distribution à un deal précis
// (indispensable quand plusieurs deals partagent une même plateforme, ex.
// Fundora) afin de calculer un TVPI par deal plutôt que par plateforme.
// Nullable et sans effet sur les remboursements crowdlending existants.
{
const rembCols2 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name);
if (!rembCols2.includes('investissement_pe_id')) {
db.exec('ALTER TABLE remboursements ADD COLUMN investissement_pe_id INTEGER REFERENCES investissements_pe(id)');
}
}
db.exec('CREATE INDEX IF NOT EXISTS idx_remb_invpe ON remboursements(investissement_pe_id)');
export default db;
+37
View File
@@ -0,0 +1,37 @@
import db from '../db/index.js';
/**
* Résout l'ensemble des workspace_id qu'un utilisateur a le droit d'utiliser :
* accordés par l'admin (granted_by_admin) ET activés par l'utilisateur (active_by_user).
* Miroir serveur exact de useWorkspace().activeWorkspaces côté frontend
* (frontend/src/context/WorkspaceContext.jsx). Centralisé ici (17/09/26) car utilisé
* par plusieurs routes (plateformes, depots_retraits, imports, remboursements, ...).
*/
export function getUserActiveWorkspaceIds(userId) {
return db.prepare(`
SELECT w.id
FROM user_workspaces uw
JOIN workspaces w ON w.id = uw.workspace_id
WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND uw.active_by_user = 1 AND w.actif_global = 1
`).all(userId).map(r => r.id);
}
/**
* Résout le workspace_id "actif" à stamper sur une ligne créée côté serveur (dépôt/retrait
* pour l'instant — Étape 2a du retrofit workspace_id, cf. project_workspaces_transformation.md).
* Lu depuis le header X-Workspace-Id, envoyé automatiquement par le frontend
* (frontend/src/api.js, miroir de X-Investisseur-Id) et validé contre les workspaces actifs
* de l'utilisateur. Si le header est absent, invalide, ou non autorisé (ex. appel API v1/MCP
* qui ne l'envoie pas encore), retombe silencieusement sur le workspace "crowdlending" —
* jamais d'échec bloquant, pour rester transparent tant qu'un seul workspace existe réellement.
*/
export function resolveActiveWorkspaceId(req) {
const raw = req.header('X-Workspace-Id');
const id = Number(raw);
if (id) {
const allowed = new Set(getUserActiveWorkspaceIds(req.user.id));
if (allowed.has(id)) return id;
}
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
return cl ? cl.id : null;
}
+7 -1
View File
@@ -184,7 +184,13 @@ router.get('/me', requireAuth, (req, res) => {
JOIN investissements i ON i.id = f.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE inv.user_id = users.id
) AS has_frais
) AS has_frais,
EXISTS(
SELECT 1 FROM frais_operations f
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE inv.user_id = users.id
) AS has_frais_pe
FROM users WHERE id = ?
`)
.get(req.user.id);
+23
View File
@@ -125,6 +125,19 @@ router.get('/', (req, res) => {
GROUP BY i.plateforme_id
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
// Capital investi Private Equity (investissements_pe.montant_investi) — une
// plateforme peut être partagée entre le workspace crowdlending et le
// workspace Private Equity (ex. Fundora) : le porte-monnaie réel chez le
// courtier est UNIQUE, donc le capital englouti dans un deal PE doit aussi
// réduire le porte-monnaie affiché ici, même si cette page est celle du
// workspace crowdlending (17/09/26).
const capitalInvestiPePerPlat = db.prepare(`
SELECT plateforme_id, COALESCE(SUM(montant_investi), 0) AS capital_investi
FROM investissements_pe
WHERE ${invWhere}${asOf ? ' AND date_souscription <= ?' : ''}
GROUP BY plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// Frais d'opération (Étape 3, 31/08/26) : un frais réglé 'source' ou 'portefeuille'
// réduit le solde du porte-monnaie. 'source' s'ajoute à la réduction déjà appliquée via
// capitalInvestiPerPlat (qui soustrait montant_investi déjà net) — cas confirmé par Olivier
@@ -158,6 +171,7 @@ router.get('/', (req, res) => {
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
for (const r of capitalInvestiPePerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
for (const r of fraisPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.total_frais;
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
@@ -358,6 +372,14 @@ router.get('/solde-historique', (req, res) => {
AND i.date_souscription <= ?
`).get(date, platId, ...invParams, date);
// Capital investi Private Equity sur cette même plateforme (cf. GET / —
// capitalInvestiPePerPlat) : même porte-monnaie physique, même correction.
const capitalPe = db.prepare(`
SELECT COALESCE(SUM(montant_investi), 0) AS capital_investi
FROM investissements_pe
WHERE plateforme_id = ? AND ${invWhere} AND date_souscription <= ?
`).get(platId, ...invParams, date);
/* ── 5. Frais d'opération 'source'/'portefeuille' (jusqu'à :date) ─── */
// Cf. GET / ci-dessus (fraisPerPlat) — même logique, même exclusion de compte_courant.
const frais = db.prepare(`
@@ -382,6 +404,7 @@ router.get('/solde-historique', (req, res) => {
+ remb.remb_wallet
+ bonus.bonus_wallet
- capital.capital_investi
- capitalPe.capital_investi
- frais.total_frais
+ corrections.total_correction
) * 100) / 100;
+216
View File
@@ -0,0 +1,216 @@
import { Router } from 'express';
import db from '../db/index.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
const router = Router();
/**
* GET /api/dashboard-pe
*
* Équivalent Private Equity de GET /api/dashboard (dashboard.js).
*
* Deux notions bien séparées ici (17/09/26, suite à un bug signalé par
* Olivier) :
* - cashByPlatform (Dépôts/Retraits/Diff affichés, KPIs du haut de page) :
* borné au workspace actif — c'est une vue "intention" (combien j'ai
* déposé/retiré POUR le Private Equity), légitimement différente de la
* même vue côté crowdlending.
* - solde_portefeuille (le porte-monnaie par plateforme) : agrégé sur TOUS
* les workspaces, sans filtre workspace_id. Une plateforme peut être
* partagée entre crowdlending et PE (ex. Fundora) ; son porte-monnaie
* physique chez le courtier est UNIQUE, donc le calcul doit tenir compte
* des dépôts/retraits/investissements des DEUX côtés, quel que soit le
* workspace consulté. Formule strictement identique à celle de
* GET /api/dashboard (dépôts - retraits manuels + remboursements portefeuille
* + bonus/intérêts plateforme - capital investi crowdlending
* (investissements + réinvestissements) - capital investi PE
* (investissements_pe.montant_investi) - frais - + corrections) : les deux
* endpoints doivent toujours reporter le MÊME porte-monnaie pour une
* plateforme partagée.
*
* ?scope=all → agrège tous les investisseurs de l'utilisateur (sinon filtré par
* l'en-tête X-Investisseur-Id, comme /api/dashboard).
* ?asOf=YYYY-MM-DD → cumule uniquement jusqu'à cette date (snapshot fin d'année,
* même principe que /api/dashboard, pour les comparaisons N/N-1 du frontend).
*/
router.get('/', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
const workspaceId = resolveActiveWorkspaceId(req);
let invWhere, invParams;
if (scopeAll) {
invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)';
invParams = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const invId = Number(raw);
if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invWhere = 'investisseur_id = ?';
invParams = [invId];
}
const asOf = /^\d{4}-\d{2}-\d{2}$/.test(req.query.asOf || '') ? req.query.asOf : null;
/* ── Cash global (dépôts/retraits, workspace actif) ─────────────── */
const cash = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type='depot' THEN montant END),0) AS total_depots,
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END),0) AS total_retraits
FROM depots_retraits
WHERE ${invWhere} AND workspace_id = ?${asOf ? ' AND date_operation <= ?' : ''}
`).get(...invParams, workspaceId, ...(asOf ? [asOf] : []));
/* ── Dépôts/retraits par plateforme (plateformes du workspace actif uniquement) ── */
const cashByPlatform = db.prepare(`
SELECT p.id AS plateforme_id, p.nom,
plat_inv.nom AS detenteur_nom,
COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant END),0) AS depots,
COALESCE(SUM(CASE WHEN dr.type='retrait' THEN dr.montant END),0) AS retraits,
COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant
WHEN dr.type='retrait' THEN -dr.montant END),0) AS solde_net
FROM plateformes p
JOIN plateforme_workspaces pw ON pw.plateforme_id = p.id AND pw.workspace_id = ?
LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id
LEFT JOIN depots_retraits dr ON dr.plateforme_id = p.id AND dr.workspace_id = ?
AND ${invWhere.replace('investisseur_id', 'dr.investisseur_id')}
${asOf ? 'AND dr.date_operation <= ?' : ''}
WHERE p.user_id = ?
GROUP BY p.id, p.nom, plat_inv.nom
ORDER BY p.nom
`).all(workspaceId, workspaceId, ...invParams, ...(asOf ? [asOf] : []), userId);
/* ── Solde porte-monnaie par plateforme ──────────────────────────────
* Une plateforme peut être partagée entre plusieurs workspaces (ex.
* Fundora, à la fois crowdlending et PE) : son porte-monnaie physique
* chez le courtier est UNIQUE, donc ce calcul agrège TOUS les workspaces
* (aucun filtre workspace_id ici, volontairement) plutôt que de se borner
* au seul workspace actif — sinon le porte-monnaie PE ignorerait les
* dépôts/investissements crowdlending sur cette même plateforme, comme
* son miroir GET /api/dashboard ignorait jusqu'ici le capital PE
* (bug signalé par Olivier le 17/09/26). Formule strictement identique à
* celle de GET /api/dashboard (dashboard.js) — seul cashByPlatform
* ci-dessus (Dépôts/Retraits affichés) reste borné au workspace actif,
* lui, car il sert de vue "intention" par workspace, pas le solde réel. */
const drManuelPerPlat = db.prepare(`
SELECT plateforme_id,
COALESCE(SUM(CASE WHEN type='depot' THEN montant ELSE 0 END), 0) AS depots,
COALESCE(SUM(CASE WHEN type='retrait' AND COALESCE(source,'') != 'auto_remboursement'
THEN montant ELSE 0 END), 0) AS retraits_manuels
FROM depots_retraits
WHERE ${invWhere}${asOf ? ' AND date_operation <= ?' : ''}
GROUP BY plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// Remboursements crédités directement au portefeuille (type='normal',
// méthode='portefeuille') — concept crowdlending uniquement (PE ne
// connaît que bonus_plateforme/interets_plateforme), mais peut concerner
// une plateforme partagée : à agréger ici aussi pour la même raison.
const rembWalletPerPlat = db.prepare(`
SELECT i.plateforme_id,
COALESCE(SUM(
CASE p.fiscalite
WHEN 'flat_tax' THEN r.net_recu
ELSE r.capital + r.cashback + r.interets_bruts
END
), 0) AS remb_wallet
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND r.type = 'normal'
AND r.methode_remboursement = 'portefeuille'
${asOf ? 'AND r.date_remb <= ?' : ''}
GROUP BY i.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
const capitalInvestiPePerPlat = db.prepare(`
SELECT plateforme_id, COALESCE(SUM(montant_investi), 0) AS capital_investi
FROM investissements_pe
WHERE ${invWhere}${asOf ? ' AND date_souscription <= ?' : ''}
GROUP BY plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// Capital investi crowdlending (investissements + réinvestissements) —
// même raison de partage de plateforme que ci-dessus.
const capitalInvestiPerPlat = db.prepare(`
SELECT i.plateforme_id,
COALESCE(SUM(
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id${asOf ? ' AND rv.date_reinvestissement <= ?' : ''}), 0)
), 0) AS capital_investi
FROM investissements i
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
${asOf ? 'AND i.date_souscription <= ?' : ''}
GROUP BY i.plateforme_id
`).all(...(asOf ? [asOf] : []), ...invParams, ...(asOf ? [asOf] : []));
const bonusWalletPerPlat = db.prepare(`
SELECT bonus_plateforme_id AS plateforme_id,
COALESCE(SUM(cashback), 0) AS bonus_wallet
FROM remboursements
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
AND type IN ('bonus_parrainage', 'bonus_plateforme')
${asOf ? 'AND date_remb <= ?' : ''}
GROUP BY bonus_plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// Intérêts plateforme : comme les bonus, ils créditent directement le
// porte-monnaie de la plateforme (net_recu tient déjà compte des prélèvements).
const interetsPlateformeWalletPerPlat = db.prepare(`
SELECT bonus_plateforme_id AS plateforme_id,
COALESCE(SUM(net_recu), 0) AS interets_plateforme_wallet
FROM remboursements
WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
AND type = 'interets_plateforme'
${asOf ? 'AND date_remb <= ?' : ''}
GROUP BY bonus_plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
// Frais d'opération et corrections de solde — concepts crowdlending
// uniquement (cf. GET /api/dashboard), mais réduisent/ajustent le même
// porte-monnaie physique sur une plateforme partagée.
const fraisPerPlat = db.prepare(`
SELECT f.plateforme_id,
COALESCE(SUM(f.montant), 0) AS total_frais
FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
AND f.mode_reglement IN ('source', 'portefeuille')
${asOf ? 'AND f.date_operation <= ?' : ''}
GROUP BY f.plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
const correctionsPerPlat = db.prepare(`
SELECT plateforme_id,
COALESCE(SUM(montant), 0) AS total_correction
FROM corrections_solde
WHERE ${invWhere}${asOf ? ' AND date <= ?' : ''}
GROUP BY plateforme_id
`).all(...invParams, ...(asOf ? [asOf] : []));
const walletMap = {};
for (const r of drManuelPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.depots - r.retraits_manuels;
for (const r of rembWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.remb_wallet;
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
for (const r of capitalInvestiPePerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
for (const r of fraisPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.total_frais;
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
const cashByPlatformEnriched = cashByPlatform.map(p => ({
...p,
solde_portefeuille: Math.round((walletMap[p.plateforme_id] || 0) * 100) / 100,
}));
const solde_portefeuille_total = Math.round(
cashByPlatformEnriched.reduce((s, p) => s + p.solde_portefeuille, 0) * 100
) / 100;
res.json({ cash, cashByPlatform: cashByPlatformEnriched, solde_portefeuille_total });
});
export default router;
+13 -3
View File
@@ -3,6 +3,7 @@ import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
const router = Router();
@@ -52,6 +53,12 @@ router.get('/', (req, res) => {
const { from, to, type, plateforme_id } = req.query;
const conds = [invCond];
// Étape 2c du retrofit workspace_id (17/09/26) : la lecture respecte désormais le
// workspace actif (même résolution qu'à l'écriture, cf. workspaceScope.js). Sans
// effet aujourd'hui (100% des lignes sont "crowdlending", seul workspace atteignable
// sur cette page) — devient réellement filtrant dès qu'un 2e workspace existera.
conds.push('dr.workspace_id = ?');
args.push(resolveActiveWorkspaceId(req));
if (from) { conds.push('dr.date_operation >= ?'); args.push(from); }
if (to) { conds.push('dr.date_operation <= ?'); args.push(to); }
if (type) { conds.push('dr.type = ?'); args.push(type); }
@@ -73,13 +80,16 @@ router.post('/', requireInvestisseur, (req, res, next) => {
try {
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
// Étape 2a du retrofit workspace_id (17/09/26) : chaque dépôt/retrait créé
// manuellement est flêché vers le workspace actif au moment de la création.
const workspaceId = resolveActiveWorkspaceId(req);
const r = db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, notes)
VALUES (?,?,?,?,?,?,?, 'manuel', ?)
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, notes, workspace_id)
VALUES (?,?,?,?,?,?,?, 'manuel', ?, ?)
`).run(
investisseurId, body.plateforme_id, body.date_operation, body.type,
body.montant, body.libelle || null, body.reference || null, body.notes || null,
body.montant, body.libelle || null, body.reference || null, body.notes || null, workspaceId,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
+12
View File
@@ -195,6 +195,18 @@ function handleUpload(req, res, next) {
if (!owned) throw new HttpError(404, 'Investissement introuvable');
}
// Équivalent pour les deals Private Equity (chantier "fiche détail PE", 17/09/26) — même
// vérification de propriété, jointure via investissements_pe au lieu de investissements.
if (entityType === 'investissement_pe') {
if (!entityId) throw new HttpError(400, 'entity_id est requis pour la catégorie investissement_pe');
const owned = db.prepare(`
SELECT ipe.id FROM investissements_pe ipe
JOIN investisseurs inv ON inv.id = ipe.investisseur_id AND inv.user_id = ?
WHERE ipe.id = ?
`).get(req.user.id, entityId);
if (!owned) throw new HttpError(404, 'Investissement PE introuvable');
}
const usage = getUsage(req.user.id);
const { quotaBytes, quotaCount } = getQuotaConfig();
if (usage.used_count + 1 > quotaCount) {
+96 -46
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
const router = Router();
@@ -16,14 +17,16 @@ const router = Router();
* Montant stocké : TTC uniquement (pas de détail HT/TVA, non pertinent pour
* un particulier — cf. échange avec Olivier, 30/08/26).
*
* Étape 1 (fondations) : ce module fournit un CRUD complet, mais rien dans
* l'application ne crée encore de frais_operations automatiquement — la
* saisie à la souscription, la modale dédiée et l'intégration aux Mouvements
* viendront dans une prochaine passe.
* Depuis le 17/09/26 (chantier "frais PE"), une ligne se rattache SOIT à un
* investissement crowdlending (investissement_id), SOIT à un deal Private
* Equity (investissement_pe_id) — jamais les deux (CHECK en base). Le corps
* de requête doit donc porter exactement l'un des deux champs ; toutes les
* routes ci-dessous se branchent sur celui qui est renseigné.
*/
const Schema = z.object({
investissement_id: z.number().int().positive(),
investissement_id: z.number().int().positive().nullable().optional(),
investissement_pe_id: z.number().int().positive().nullable().optional(),
remboursement_id: z.number().int().positive().nullable().optional(),
montant: z.number().positive(),
mode_reglement: z.enum(['source', 'portefeuille', 'compte_courant']).default('source'),
@@ -35,7 +38,10 @@ const Schema = z.object({
// "Frais" du formulaire d'investissement (unique par investissement, cf. assertUniqueSouscription).
categorie: z.enum(['souscription', 'gestion', 'distribution', 'autre']).default('autre'),
notes: z.string().optional(),
});
}).refine(
b => (b.investissement_id != null) !== (b.investissement_pe_id != null),
{ message: 'Exactement un de investissement_id ou investissement_pe_id doit être renseigné' },
);
function assertOwnedInvestissement(invId, userId) {
const row = db.prepare(`
@@ -47,10 +53,31 @@ function assertOwnedInvestissement(invId, userId) {
return row;
}
function assertRemboursementBelongs(rembId, invId) {
/** Équivalent pour les deals PE (investissements_pe) — bornée au workspace actif, même
* principe que ownedDeal() dans investissementsPe.js. */
function assertOwnedInvestissementPe(invPeId, req) {
const row = db.prepare(`
SELECT ipe.id, ipe.plateforme_id FROM investissements_pe ipe
JOIN investisseurs inv ON inv.id = ipe.investisseur_id AND inv.user_id = ?
WHERE ipe.id = ? AND ipe.workspace_id = ?
`).get(req.user.id, invPeId, resolveActiveWorkspaceId(req));
if (!row) throw new HttpError(403, 'Investissement PE not in scope');
return row;
}
/** Résout et vérifie la référence portée par le corps de requête (crowdlending ou PE),
* renvoie { id, plateforme_id } de l'investissement/deal correspondant. */
function assertOwnedInvestissementEither(body, req) {
return body.investissement_id
? assertOwnedInvestissement(body.investissement_id, req.user.id)
: assertOwnedInvestissementPe(body.investissement_pe_id, req);
}
function assertRemboursementBelongs(rembId, body) {
if (!rembId) return;
const remb = db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?')
.get(rembId, invId);
const remb = body.investissement_id
? db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_id = ?').get(rembId, body.investissement_id)
: db.prepare('SELECT id FROM remboursements WHERE id = ? AND investissement_pe_id = ?').get(rembId, body.investissement_pe_id);
if (!remb) throw new HttpError(400, "remboursement_id ne correspond pas à cet investissement");
}
@@ -61,30 +88,34 @@ export function assertOwnedCompte(compteId, userId) {
}
/**
* Un investissement ne peut avoir qu'UNE seule entrée de catégorie 'souscription' — c'est
* l'entrée lue/écrite depuis l'étape "Frais" du formulaire de création/modification de
* l'investissement (jamais depuis le bloc "Frais d'opération" de la fiche, qui exclut cette
* catégorie de son sélecteur). `excludeId` : à passer lors d'un PUT pour ne pas se bloquer
* soi-même.
* Un investissement (ou un deal PE) ne peut avoir qu'UNE seule entrée de catégorie
* 'souscription' — c'est l'entrée lue/écrite depuis l'étape "Frais" du formulaire de
* création/modification (jamais depuis le bloc "Frais d'opération"/la page Frais, qui
* excluent cette catégorie de leur sélecteur). `excludeId` : à passer lors d'un PUT pour
* ne pas se bloquer soi-même.
*/
function assertUniqueSouscription(investissementId, categorie, excludeId) {
function assertUniqueSouscription(body, categorie, excludeId) {
if (categorie !== 'souscription') return;
const col = body.investissement_id ? 'investissement_id' : 'investissement_pe_id';
const val = body.investissement_id ?? body.investissement_pe_id;
const existing = excludeId
? db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription' AND id != ?`).get(investissementId, excludeId)
: db.prepare(`SELECT id FROM frais_operations WHERE investissement_id = ? AND categorie = 'souscription'`).get(investissementId);
? db.prepare(`SELECT id FROM frais_operations WHERE ${col} = ? AND categorie = 'souscription' AND id != ?`).get(val, excludeId)
: db.prepare(`SELECT id FROM frais_operations WHERE ${col} = ? AND categorie = 'souscription'`).get(val);
if (existing) throw new HttpError(409, 'Un frais de catégorie "souscription" existe déjà pour cet investissement');
}
/**
* GET /api/frais-operations?investissement_id=123
* Sans investissement_id : tous les frais de l'utilisateur (tous investisseurs), enrichis de
* nom_projet/plateforme_nom/investisseur_nom (vue portefeuille, ex. page dédiée Frais — colonne
* Détenteur et onglet "Investissements", Étape 11, 02/09/26) — le filtre par investissement_id
* n'a pas besoin de cet enrichissement, le contexte est déjà connu côté client.
* GET /api/frais-operations?investissement_id=123 | ?investissement_pe_id=456
* Sans filtre : tous les frais de l'utilisateur pour le workspace ACTIF (résolu via
* resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending ou PE selon le slug
* du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/investisseur_nom (vue
* portefeuille, ex. page dédiée Frais/FraisPe). Le filtre par investissement_id/
* investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu
* côté client.
*/
router.get('/', (req, res, next) => {
try {
const { investissement_id } = req.query;
const { investissement_id, investissement_pe_id } = req.query;
let rows;
if (investissement_id) {
assertOwnedInvestissement(Number(investissement_id), req.user.id);
@@ -95,7 +126,19 @@ router.get('/', (req, res, next) => {
WHERE f.investissement_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(Number(investissement_id));
} else if (investissement_pe_id) {
assertOwnedInvestissementPe(Number(investissement_pe_id), req);
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom
FROM frais_operations f
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE f.investissement_pe_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(Number(investissement_pe_id));
} else {
const workspaceId = resolveActiveWorkspaceId(req);
const ws = db.prepare('SELECT slug FROM workspaces WHERE id = ?').get(workspaceId);
if (!ws || ws.slug === 'crowdlending') {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
FROM frais_operations f
@@ -106,6 +149,18 @@ router.get('/', (req, res, next) => {
WHERE inv.user_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(req.user.id);
} else {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
FROM frais_operations f
JOIN investissements_pe ipe ON ipe.id = f.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
JOIN plateformes p ON p.id = ipe.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id
WHERE inv.user_id = ? AND ipe.workspace_id = ?
ORDER BY f.date_operation DESC, f.id DESC
`).all(req.user.id, workspaceId);
}
}
res.json(rows);
} catch (e) { next(e); }
@@ -114,19 +169,19 @@ router.get('/', (req, res, next) => {
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
const inv = assertOwnedInvestissementEither(body, req);
assertRemboursementBelongs(body.remboursement_id, body);
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
assertUniqueSouscription(body.investissement_id, body.categorie);
assertUniqueSouscription(body, body.categorie);
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
const r = db.prepare(`
INSERT INTO frais_operations
(investissement_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
VALUES (?,?,?,?,?,?,?,?,?,?)
(investissement_id, investissement_pe_id, plateforme_id, remboursement_id, montant, mode_reglement, compte_id, date_operation, origine, categorie, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
body.investissement_id ?? null, body.investissement_pe_id ?? null, inv.plateforme_id, body.remboursement_id ?? null,
body.montant, body.mode_reglement, compteId,
body.date_operation, body.origine, body.categorie, body.notes || null,
);
@@ -137,29 +192,27 @@ router.post('/', (req, res, next) => {
router.put('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = db.prepare(`
SELECT f.id FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE f.id = ? AND inv.user_id = ?
`).get(id, req.user.id);
const existing = db.prepare('SELECT id, investissement_id, investissement_pe_id FROM frais_operations WHERE id = ?').get(id);
if (!existing) throw new HttpError(404, 'Not found');
// Vérifie que l'utilisateur possédait déjà cette ligne, quel que soit son type actuel.
if (existing.investissement_id) assertOwnedInvestissement(existing.investissement_id, req.user.id);
else assertOwnedInvestissementPe(existing.investissement_pe_id, req);
const body = Schema.parse(req.body);
const inv = assertOwnedInvestissement(body.investissement_id, req.user.id);
assertRemboursementBelongs(body.remboursement_id, body.investissement_id);
const inv = assertOwnedInvestissementEither(body, req);
assertRemboursementBelongs(body.remboursement_id, body);
if (body.mode_reglement === 'compte_courant') assertOwnedCompte(body.compte_id, req.user.id);
assertUniqueSouscription(body.investissement_id, body.categorie, id);
assertUniqueSouscription(body, body.categorie, id);
const compteId = body.mode_reglement === 'compte_courant' ? (body.compte_id ?? null) : null;
db.prepare(`
UPDATE frais_operations
SET investissement_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?,
SET investissement_id=?, investissement_pe_id=?, plateforme_id=?, remboursement_id=?, montant=?, mode_reglement=?,
compte_id=?, date_operation=?, origine=?, categorie=?, notes=?, updated_at=datetime('now')
WHERE id=?
`).run(
body.investissement_id, inv.plateforme_id, body.remboursement_id ?? null,
body.investissement_id ?? null, body.investissement_pe_id ?? null, inv.plateforme_id, body.remboursement_id ?? null,
body.montant, body.mode_reglement, compteId,
body.date_operation, body.origine, body.categorie, body.notes || null,
id,
@@ -171,13 +224,10 @@ router.put('/:id', (req, res, next) => {
router.delete('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = db.prepare(`
SELECT f.id FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE f.id = ? AND inv.user_id = ?
`).get(id, req.user.id);
const existing = db.prepare('SELECT id, investissement_id, investissement_pe_id FROM frais_operations WHERE id = ?').get(id);
if (!existing) throw new HttpError(404, 'Not found');
if (existing.investissement_id) assertOwnedInvestissement(existing.investissement_id, req.user.id);
else assertOwnedInvestissementPe(existing.investissement_pe_id, req);
db.prepare('DELETE FROM frais_operations WHERE id = ?').run(id);
res.status(204).end();
+19 -8
View File
@@ -9,6 +9,7 @@ xlsx.set_fs(fs);
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
import { generateSimul, generateSimulWithReinvestissements, adjustSimulForActuals } from '../utils/schedule.js';
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
@@ -359,6 +360,9 @@ router.post('/apply', (req, res, next) => {
const cur = platMinDate.get(plateformeId);
if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
};
// Étape 2a du retrofit workspace_id (17/09/26) : résolu une seule fois pour
// tout le lot importé (même job = même workspace actif).
const workspaceId = resolveActiveWorkspaceId(req);
const tx = db.transaction(() => {
for (let idx = 0; idx < rows.length; idx++) {
@@ -394,8 +398,8 @@ router.post('/apply', (req, res, next) => {
db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source)
VALUES (?,?,?,?,?,?,?,?)
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, workspace_id)
VALUES (?,?,?,?,?,?,?,?,?)
`).run(
req.investisseur.id,
plateformeId,
@@ -405,6 +409,7 @@ router.post('/apply', (req, res, next) => {
v('libelle') || null,
v('reference') || null,
srcLabel,
workspaceId,
);
} else if (module === 'investissements') {
@@ -468,14 +473,15 @@ router.post('/apply', (req, res, next) => {
db.prepare(`
INSERT INTO remboursements
(investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux,
prelev_forfaitaire, interets_nets, net_recu, statut, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
prelev_forfaitaire, interets_nets, net_recu, statut, source, workspace_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
investissementId,
dateRemb,
capital, cashback, bruts, ps, pf, interets_nets, net_recu,
v('statut') || 'paye',
srcLabel,
workspaceId,
);
affectedInvestissementIds.add(investissementId);
@@ -721,6 +727,9 @@ router.post('/dossier/apply', (req, res, next) => {
let action, investissementId;
let docsInserted = 0, docsSkipped = 0;
// Étape 2a du retrofit workspace_id (17/09/26) : résolu une seule fois
// pour tout le dossier importé (même job = même workspace actif).
const dossierWorkspaceId = resolveActiveWorkspaceId(req);
const tx = db.transaction(() => {
/* ── 1. Résoudre / créer la plateforme ─────────────────── */
@@ -768,14 +777,15 @@ router.post('/dossier/apply', (req, res, next) => {
db.prepare(`
INSERT INTO remboursements
(investissement_id, date_remb, capital, cashback, interets_bruts,
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source, workspace_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
`).run(
investissementId, rb.date_remb,
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
rb.interets_nets || 0, rb.net_recu || 0,
rb.statut || 'paye', rb.notes || null,
dossierWorkspaceId,
);
}
@@ -867,14 +877,15 @@ router.post('/dossier/apply', (req, res, next) => {
db.prepare(`
INSERT INTO remboursements
(investissement_id, date_remb, capital, cashback, interets_bruts,
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier')
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source, workspace_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
`).run(
investissementId, rb.date_remb,
rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0,
rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0,
rb.interets_nets || 0, rb.net_recu || 0,
rb.statut || 'paye', rb.notes || null,
dossierWorkspaceId,
);
rembInserted++;
}
+289
View File
@@ -0,0 +1,289 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
const router = Router();
/**
* Investissements Private Equity — table dédiée (17/09/26, cf.
* project_workspaces_transformation.md), distincte de `investissements`
* (crowdlending) : un deal PE n'a ni échéancier connu à l'avance ni taux
* d'intérêt fixe, c'est un montant unique investi, valorisé périodiquement
* par le fonds (investissements_pe_valorisations), avec un objectif de
* performance hétérogène selon les deals (texte libre plutôt que structuré).
*
* workspace_id est NOT NULL dès la création (table neuve, pas de legacy à
* backfiller) — toutes les lignes appartiennent au workspace actif au
* moment de la création, et toute lecture/écriture est bornée au workspace
* actif via resolveActiveWorkspaceId (même principe que depots_retraits et
* remboursements).
*/
const Schema = z.object({
investisseur_id: z.number().int().positive().optional(),
plateforme_id: z.number().int().positive(),
nom_deal: z.string().min(1),
strategie: z.string().optional(),
date_souscription: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
duree_mois: z.number().int().positive().nullable().optional(),
montant_investi: z.number().positive(),
objectif: z.string().optional(),
statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'),
reference: z.string().optional(),
notes: z.string().optional(),
});
const ValorisationSchema = z.object({
date_valorisation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
valorisation_nette: z.number().nonnegative(),
notes: z.string().optional(),
});
/** Résout l'investisseur_id : body en priorité (validé), sinon header (comme depots_retraits.js) */
function resolveInvestisseurId(req, bodyInvestisseurId) {
if (!bodyInvestisseurId) return req.investisseur.id;
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(bodyInvestisseurId, req.user.id);
if (!row) throw new HttpError(403, 'Investisseur non autorisé');
return bodyInvestisseurId;
}
/** Vérifie qu'un deal appartient bien à l'utilisateur courant (et au workspace actif), le renvoie. */
function ownedDeal(req, id) {
const row = db.prepare(`
SELECT ipe.* FROM investissements_pe ipe
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ipe.id = ? AND inv.user_id = ? AND ipe.workspace_id = ?
`).get(id, req.user.id, resolveActiveWorkspaceId(req));
if (!row) throw new HttpError(404, 'Not found');
return row;
}
/**
* GET /api/investissements-pe
* ?scope=all → agrège tous les investisseurs de l'utilisateur (vue "Famille")
* (défaut) → filtre sur l'investisseur donné par X-Investisseur-Id
*/
router.get('/', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invCond, invArgs;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'ipe.investisseur_id = ?';
invArgs = [id];
}
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT ipe.*,
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
(SELECT v.valorisation_nette FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation,
(SELECT v.date_valorisation FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ?
ORDER BY ipe.date_souscription DESC, ipe.id DESC
`).all(...invArgs, workspaceId);
const withTvpi = rows.map(r => ({
...r,
tvpi: r.derniere_valorisation != null
? Math.round(((r.derniere_valorisation + r.distributions_cumulees) / r.montant_investi) * 100) / 100
: null,
}));
res.json(withTvpi);
});
/**
* GET /api/investissements-pe/valorisations
* Historique complet des valorisations, tous deals confondus (workspace
* actif) — nécessaire pour reconstituer une courbe de valorisation du
* portefeuille (page Investissements PE : graphique "Valorisation totale"
* et onglet "Vision mensuelle"), ce que l'historique par deal seul
* (GET /:id/valorisations) ne permet pas sans un aller-retour par deal.
* ?scope=all → agrège tous les investisseurs de l'utilisateur (comme GET /).
*/
router.get('/valorisations', (req, res) => {
const scopeAll = req.query.scope === 'all';
const userId = req.user.id;
let invCond, invArgs;
if (scopeAll) {
invCond = 'inv.user_id = ?';
invArgs = [userId];
} else {
const raw = req.header('X-Investisseur-Id');
const id = Number(raw);
if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' });
const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' });
invCond = 'ipe.investisseur_id = ?';
invArgs = [id];
}
const workspaceId = resolveActiveWorkspaceId(req);
const rows = db.prepare(`
SELECT v.id, v.investissement_pe_id, v.date_valorisation, v.valorisation_nette
FROM investissements_pe_valorisations v
JOIN investissements_pe ipe ON ipe.id = v.investissement_pe_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ${invCond} AND ipe.workspace_id = ?
ORDER BY v.date_valorisation ASC, v.id ASC
`).all(...invArgs, workspaceId);
res.json(rows);
});
router.post('/', requireInvestisseur, (req, res, next) => {
try {
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
const workspaceId = resolveActiveWorkspaceId(req);
const r = db.prepare(`
INSERT INTO investissements_pe
(investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription,
duree_mois, montant_investi, objectif, statut, reference, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null,
body.date_souscription, body.duree_mois ?? null, body.montant_investi, body.objectif || null,
body.statut, body.reference || null, body.notes || null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
/**
* GET /api/investissements-pe/:id — fiche détaillée d'un deal (chantier "fiche détail PE",
* 17/09/26). Même enrichissement que GET / (dernière valorisation, distributions cumulées,
* TVPI) mais pour une seule ligne — évite de renvoyer toute la liste juste pour afficher une
* fiche. requireInvestisseur n'est pas utilisé ici (ownedDeal vérifie déjà la propriété par
* user_id + workspace actif, sans dépendre de l'investisseur sélectionné côté client — la fiche
* doit rester accessible même si l'utilisateur a changé de sélecteur détenteur entre-temps).
*/
router.get('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const row = db.prepare(`
SELECT ipe.*,
p.nom AS plateforme_nom,
inv.nom AS investisseur_nom,
(SELECT v.valorisation_nette FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation,
(SELECT v.date_valorisation FROM investissements_pe_valorisations v
WHERE v.investissement_pe_id = ipe.id
ORDER BY v.date_valorisation DESC, v.id DESC LIMIT 1) AS derniere_valorisation_date,
COALESCE((SELECT SUM(r.net_recu) FROM remboursements r
WHERE r.investissement_pe_id = ipe.id), 0) AS distributions_cumulees
FROM investissements_pe ipe
JOIN plateformes p ON p.id = ipe.plateforme_id
JOIN investisseurs inv ON inv.id = ipe.investisseur_id
WHERE ipe.id = ?
`).get(id);
if (!row) throw new HttpError(404, 'Not found');
row.tvpi = row.derniere_valorisation != null
? Math.round(((row.derniere_valorisation + row.distributions_cumulees) / row.montant_investi) * 100) / 100
: null;
res.json(row);
} catch (e) { next(e); }
});
router.put('/:id', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const body = Schema.parse(req.body);
const investisseurId = resolveInvestisseurId(req, body.investisseur_id);
db.prepare(`
UPDATE investissements_pe
SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?,
duree_mois=?, montant_investi=?, objectif=?, statut=?, reference=?, notes=?,
updated_at=datetime('now')
WHERE id=?
`).run(
investisseurId, body.plateforme_id, body.nom_deal, body.strategie || null, body.date_souscription,
body.duree_mois ?? null, body.montant_investi, body.objectif || null, body.statut,
body.reference || null, body.notes || null, id,
);
res.json({ id, ...body });
} catch (e) { next(e); }
});
router.delete('/:id', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
// Les distributions déjà saisies (remboursements liés) restent en base,
// simplement détachées du deal supprimé — jamais de perte de données
// financières pour libérer une contrainte de clé étrangère.
db.prepare('UPDATE remboursements SET investissement_pe_id = NULL WHERE investissement_pe_id = ?').run(id);
db.prepare('DELETE FROM investissements_pe WHERE id = ?').run(id);
res.status(204).end();
} catch (e) { next(e); }
});
/* ── Historique des valorisations ────────────────────────────── */
router.get('/:id/valorisations', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const rows = db.prepare(`
SELECT * FROM investissements_pe_valorisations
WHERE investissement_pe_id = ?
ORDER BY date_valorisation DESC, id DESC
`).all(id);
res.json(rows);
} catch (e) { next(e); }
});
router.post('/:id/valorisations', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const body = ValorisationSchema.parse(req.body);
const r = db.prepare(`
INSERT INTO investissements_pe_valorisations (investissement_pe_id, date_valorisation, valorisation_nette, notes)
VALUES (?,?,?,?)
`).run(id, body.date_valorisation, body.valorisation_nette, body.notes || null);
res.status(201).json({ id: r.lastInsertRowid, investissement_pe_id: id, ...body });
} catch (e) { next(e); }
});
router.delete('/:id/valorisations/:valId', requireInvestisseur, (req, res, next) => {
try {
const id = Number(req.params.id);
ownedDeal(req, id);
const r = db.prepare(`
DELETE FROM investissements_pe_valorisations WHERE id = ? AND investissement_pe_id = ?
`).run(Number(req.params.valId), id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+110 -3
View File
@@ -4,6 +4,7 @@ import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { createZip, readZip } from '../utils/zip.js';
import { deleteDocumentsForEntity } from './documents.js';
import { getUserActiveWorkspaceIds } from '../middleware/workspaceScope.js';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
@@ -65,6 +66,8 @@ const Schema = z.object({
type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(),
freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(),
referentiel_id: z.number().int().positive().nullable().optional(),
// Espaces de travail auxquels cette plateforme utilisateur est affectée (au moins un requis)
workspace_ids: z.array(z.number().int()).min(1, 'Au moins un espace de travail doit être sélectionné'),
});
// Champs hérités du référentiel (comparés pour calculer overridden_fields)
@@ -223,6 +226,58 @@ function syncCategories(platId, catIds) {
})(platId, catIds);
}
/** Espaces de travail auxquels chaque plateforme UTILISATEUR est affectée */
function attachWorkspaces(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT pw.plateforme_id, w.id AS workspace_id, w.slug, w.nom
FROM plateforme_workspaces pw
JOIN workspaces w ON w.id = pw.workspace_id
WHERE pw.plateforme_id IN (${ids.map(() => '?').join(',')})
ORDER BY w.ordre, w.id
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.plateforme_id]) map[l.plateforme_id] = [];
map[l.plateforme_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
}
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
}
/** Espaces de travail auxquels chaque entrée du RÉFÉRENTIEL est affectée (pour filtrer l'éligibilité) */
function attachReferentielWorkspaces(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rw.referentiel_id, w.id AS workspace_id, w.slug, w.nom
FROM referentiel_workspaces rw
JOIN workspaces w ON w.id = rw.workspace_id
WHERE rw.referentiel_id IN (${ids.map(() => '?').join(',')})
ORDER BY w.ordre, w.id
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.referentiel_id]) map[l.referentiel_id] = [];
map[l.referentiel_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
}
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
}
/** Workspaces que l'utilisateur a le droit d'utiliser : accordés par l'admin ET activés par lui-même
* (Settings > Mes espaces de travail) — seuls ceux-là sont sélectionnables pour une NOUVELLE
* affectation de plateforme. Une affectation déjà existante reste conservée même hors de cet
* ensemble (cf. validation dans PUT /:id). */
// getUserActiveWorkspaceIds : déplacé le 17/09/26 dans middleware/workspaceScope.js
// (réutilisé désormais par depots_retraits/imports/remboursements, cf. import ci-dessus).
function syncWorkspaces(platId, workspaceIds) {
db.prepare('DELETE FROM plateforme_workspaces WHERE plateforme_id = ?').run(platId);
if (!workspaceIds || workspaceIds.length === 0) return;
const ins = db.prepare('INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id) VALUES (?,?)');
for (const id of workspaceIds) ins.run(platId, id);
}
// ── Lecture référentiel (tous users authentifiés) ─────────────────────────
router.get('/referentiel-list', (_req, res) => {
const rows = db.prepare(`
@@ -232,7 +287,9 @@ router.get('/referentiel-list', (_req, res) => {
FROM plateformes_referentiel pr
ORDER BY pr.nom
`).all();
res.json(rows);
// workspaces attachés pour permettre au frontend de filtrer les entrées éligibles
// à l'espace de travail choisi par l'utilisateur lors de l'ajout (PlatPickerModal)
res.json(attachReferentielWorkspaces(rows));
});
router.get('/', (req, res) => {
@@ -261,16 +318,26 @@ router.get('/', (req, res) => {
}));
const withCats = attachCategories(req.user.id, enriched);
const withCatsInv = attachCategoriesInv(withCats);
const withAll = attachSecteursInv(withCatsInv).map(r => {
const withStripped = attachSecteursInv(withCatsInv).map(r => {
const { excluded_categories_inv_ids, excluded_secteurs_inv_ids, ...rest } = r;
return rest;
});
const withAll = attachWorkspaces(withStripped);
res.json(withAll);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
// On ne peut affecter, à la CRÉATION, qu'à des espaces de travail auxquels on a
// accès et qu'on a activés (Settings > Mes espaces de travail).
const allowedWsCreate = new Set(getUserActiveWorkspaceIds(req.user.id));
const invalidWsCreate = body.workspace_ids.filter(id => !allowedWsCreate.has(id));
if (invalidWsCreate.length > 0) {
throw new HttpError(400, "Vous ne pouvez affecter cette plateforme qu'aux espaces de travail auxquels vous avez accès et que vous avez activés.");
}
let fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite;
let taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null;
let typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT';
@@ -305,6 +372,7 @@ router.post('/', (req, res, next) => {
);
const id = r.lastInsertRowid;
syncCategories(id, body.categories);
syncWorkspaces(id, body.workspace_ids);
res.status(201).json({
id, ...body,
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
@@ -335,6 +403,7 @@ router.get('/export', (req, res, next) => {
`).all(req.user.id);
rows = attachCategoriesInv(rows);
rows = attachSecteursInv(rows);
rows = attachWorkspaces(rows);
// Attach legacy categories
const platIds = rows.map(r => r.id);
@@ -377,6 +446,7 @@ router.get('/export', (req, res, next) => {
categories: legacyMap[r.id] || [],
categories_inv: (r.categories_inv || []).map(c => c.nom),
secteurs_inv: (r.secteurs_inv || []).map(s => s.nom),
workspace_slugs: (r.workspaces || []).map(w => w.slug),
}));
entries.push({ name: 'data.json', data: JSON.stringify(dataRows, null, 2) });
@@ -417,7 +487,8 @@ router.get('/:id/export', (req, res, next) => {
if (!p) throw new HttpError(404, 'Plateforme introuvable');
const [withCatsInv] = attachCategoriesInv([p]);
const [withAll] = attachSecteursInv([withCatsInv]);
const [withSects] = attachSecteursInv([withCatsInv]);
const [withAll] = attachWorkspaces([withSects]);
const catsLegacy = db.prepare(`
SELECT c.nom FROM plateforme_categories pc
@@ -443,6 +514,7 @@ router.get('/:id/export', (req, res, next) => {
categories: catsLegacy,
categories_inv: (withAll.categories_inv || []).map(c => c.nom),
secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom),
workspace_slugs: (withAll.workspaces || []).map(w => w.slug),
};
entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) });
@@ -541,6 +613,25 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
return ids;
}
// Résoudre des slugs de workspaces vers leurs IDs — restreint à ceux auxquels
// l'utilisateur important a effectivement accès (accordé + activé). Un slug
// inconnu ou hors d'accès est ignoré ; si la liste résultante est vide (vieux ZIP
// sans workspace_slugs, ou accès différent), fallback sur "crowdlending" si accessible,
// pour ne jamais importer une plateforme sans aucune affectation.
const userAllowedWs = new Set(getUserActiveWorkspaceIds(req.user.id));
function resolveWorkspaceIdsForUser(slugs) {
const ids = [];
for (const slug of (slugs || [])) {
const row = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(slug);
if (row && userAllowedWs.has(row.id)) ids.push(row.id);
}
if (ids.length === 0) {
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (cl && userAllowedWs.has(cl.id)) ids.push(cl.id);
}
return ids;
}
const imageMap = {};
for (const e of zipEntries) {
if (e.name.startsWith('logos/') && e.name.length > 6) imageMap[path.basename(e.name)] = e.data;
@@ -557,6 +648,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv');
const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv');
const legacyCatIds = resolveLegacyCatIds(p.categories);
const wsIds = resolveWorkspaceIdsForUser(p.workspace_slugs);
const existing = db.prepare('SELECT id FROM plateformes WHERE nom = ? AND user_id = ?').get(p.nom, req.user.id);
let platId;
@@ -601,6 +693,8 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
// Sync secteurs_inv
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(platId);
for (const id of sectsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?,?)').run(platId, id);
// Sync workspaces
syncWorkspaces(platId, wsIds);
// Images
for (const fname of [p.logo_filename, p.icone_filename]) {
@@ -627,6 +721,18 @@ router.put('/:id', (req, res, next) => {
.get(req.params.id, req.user.id);
if (!current) throw new HttpError(404, 'Not found');
// Validation des workspaces : on ne peut AJOUTER que des workspaces auxquels on a
// accès et qu'on a activés (Settings > Mes espaces de travail) ; une affectation déjà
// présente reste autorisée même si l'accès a depuis été retiré (jamais retirée
// silencieusement par cette route — seul l'utilisateur peut la décocher lui-même).
const currentWsIds = db.prepare('SELECT workspace_id FROM plateforme_workspaces WHERE plateforme_id = ?')
.all(req.params.id).map(r => r.workspace_id);
const allowedWsEdit = new Set([...getUserActiveWorkspaceIds(req.user.id), ...currentWsIds]);
const invalidWsEdit = body.workspace_ids.filter(id => !allowedWsEdit.has(id));
if (invalidWsEdit.length > 0) {
throw new HttpError(400, "Vous ne pouvez affecter cette plateforme qu'aux espaces de travail auxquels vous avez accès et que vous avez activés.");
}
// Calculer overridden_fields si la plateforme est liée à un référentiel
let overriddenFields = JSON.parse(current.overridden_fields || '[]');
if (current.referentiel_id) {
@@ -664,6 +770,7 @@ router.put('/:id', (req, res, next) => {
);
if (r.changes === 0) throw new HttpError(404, 'Not found');
syncCategories(Number(req.params.id), body.categories);
syncWorkspaces(Number(req.params.id), body.workspace_ids);
res.json({
id: Number(req.params.id), ...body,
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
+1 -1
View File
@@ -20,7 +20,7 @@ const ALLOWED_KEYS = new Set([
// Contrairement aux autres clés de ALLOWED_KEYS, la valeur est validée ici
// (pas seulement la clé) car une valeur invalide casserait silencieusement
// le rendu CSS ([data-color-theme] côté frontend n'a pas de fallback).
const COLOR_THEME_VALUES = new Set([
export const COLOR_THEME_VALUES = new Set([
'classique', 'indigo_teal', 'nuit', 'nature', 'coucher_de_soleil',
'violet', 'frais', 'contraste', 'pastel', 'moderne',
]);
+64 -7
View File
@@ -110,6 +110,8 @@ const Schema = z.object({
// Nouvelles listes gérées globalement (IDs)
categories_inv_ids: z.array(z.number().int()).optional().default([]),
secteurs_inv_ids: z.array(z.number().int()).optional().default([]),
// Espaces de travail auxquels ce référentiel est affecté (au moins un requis)
workspace_ids: z.array(z.number().int()).min(1, 'Au moins un espace de travail doit être sélectionné'),
// Notation : tableau de critères
notation: z.array(z.object({
nom: z.string().min(1),
@@ -181,6 +183,25 @@ function attachSecteursInv(rows) {
return rows.map(r => ({ ...r, secteurs_inv: map[r.id] || [] }));
}
/** Espaces de travail auxquels le référentiel est affecté */
function attachWorkspaces(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rw.referentiel_id, w.id AS workspace_id, w.slug, w.nom
FROM referentiel_workspaces rw
JOIN workspaces w ON w.id = rw.workspace_id
WHERE rw.referentiel_id IN (${ids.map(() => '?').join(',')})
ORDER BY w.ordre, w.id
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.referentiel_id]) map[l.referentiel_id] = [];
map[l.referentiel_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
}
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
}
/** Critères de notation du référentiel */
function attachNotation(rows) {
if (rows.length === 0) return rows;
@@ -213,7 +234,7 @@ function parsePaysOp(rows) {
}
/** Enregistre catégories, secteurs et notation pour un référentiel */
function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteursInvIds) {
function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteursInvIds, workspaceIds) {
tx(() => {
// Legacy string categories
db.prepare('DELETE FROM referentiel_categories WHERE referentiel_id = ?').run(refId);
@@ -238,6 +259,13 @@ function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteu
for (const id of secteursInvIds) ins.run(refId, id);
}
// Espaces de travail (IDs) — au moins un requis, déjà validé par le schéma Zod
db.prepare('DELETE FROM referentiel_workspaces WHERE referentiel_id = ?').run(refId);
if (workspaceIds && workspaceIds.length > 0) {
const ins = db.prepare('INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id) VALUES (?,?)');
for (const id of workspaceIds) ins.run(refId, id);
}
db.prepare('DELETE FROM referentiel_notation WHERE referentiel_id = ?').run(refId);
if (notation.length > 0) {
const ins = db.prepare(`
@@ -267,6 +295,7 @@ router.get('/', (_req, res) => {
rows = attachCats(rows);
rows = attachCatsInv(rows);
rows = attachSecteursInv(rows);
rows = attachWorkspaces(rows);
rows = attachNotation(rows);
rows = parsePaysOp(rows);
res.json(rows);
@@ -288,6 +317,7 @@ router.get('/export', (req, res, next) => {
let rows = db.prepare('SELECT * FROM plateformes_referentiel ORDER BY nom').all();
rows = attachCatsInv(rows);
rows = attachSecteursInv(rows);
rows = attachWorkspaces(rows);
rows = parsePaysOp(rows);
const entries = [];
@@ -353,6 +383,7 @@ router.get('/export', (req, res, next) => {
icone_filename: r.icone_filename,
categories_inv: (r.categories_inv || []).map(c => c.nom),
secteurs_inv: (r.secteurs_inv || []).map(s => s.nom),
workspace_slugs: (r.workspaces || []).map(w => w.slug),
notation: notationByRef[r.id] || [],
}));
@@ -388,7 +419,7 @@ router.get('/:id/export', (req, res, next) => {
try {
const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id);
if (!ref) throw new HttpError(404, 'Référentiel introuvable');
const [enriched] = parsePaysOp(attachSecteursInv(attachCatsInv([ref])));
const [enriched] = parsePaysOp(attachWorkspaces(attachSecteursInv(attachCatsInv([ref]))));
const entries = [];
const manifest = {
@@ -450,6 +481,7 @@ router.get('/:id/export', (req, res, next) => {
icone_filename: enriched.icone_filename,
categories_inv: (enriched.categories_inv || []).map(c => c.nom),
secteurs_inv: (enriched.secteurs_inv || []).map(s => s.nom),
workspace_slugs: (enriched.workspaces || []).map(w => w.slug),
notation,
};
@@ -506,6 +538,25 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
return ids;
}
// Résout des slugs de workspaces vers leurs IDs DANS CE catalogue —
// contrairement aux catégories/secteurs, les workspaces sont un
// catalogue géré par l'admin, jamais créés automatiquement à l'import.
// Si aucun slug ne correspond (vieux ZIP sans workspace_slugs, ou
// catalogue cible différent), on retombe sur "crowdlending" pour ne
// jamais importer une entrée référentiel sans aucune affectation.
function resolveWorkspaceIds(slugs) {
const ids = [];
for (const slug of (slugs || [])) {
const row = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(slug);
if (row) ids.push(row.id);
}
if (ids.length === 0) {
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
if (cl) ids.push(cl.id);
}
return ids;
}
// Build a map of images from the ZIP
const imageMap = {};
for (const e of zipEntries) {
@@ -522,6 +573,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
const catsIds = resolveTagIds(p.categories_inv, 'categories_inv');
const sectsIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv');
const wsIds = resolveWorkspaceIds(p.workspace_slugs);
const paysOp = Array.isArray(p.pays_operation) && p.pays_operation.length ? JSON.stringify(p.pays_operation) : null;
const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(p.nom);
@@ -590,6 +642,11 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
for (const id of sectsIds) {
db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)').run(refId, id);
}
// Sync referentiel_workspaces
db.prepare('DELETE FROM referentiel_workspaces WHERE referentiel_id = ?').run(refId);
for (const id of wsIds) {
db.prepare('INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id) VALUES (?,?)').run(refId, id);
}
// Sync referentiel_notation
if (Array.isArray(p.notation) && p.notation.length) {
@@ -659,7 +716,7 @@ router.get('/:id', (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id);
if (!row) throw new HttpError(404, 'Référentiel introuvable');
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row])))));
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))));
res.json(enriched);
} catch (e) { next(e); }
});
@@ -701,10 +758,10 @@ router.post('/', (req, res, next) => {
const refId = r.lastInsertRowid;
const tx = db.transaction(fn => fn());
saveRelations(tx, refId, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids);
saveRelations(tx, refId, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids, data.workspace_ids);
const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(refId);
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row])))));
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))));
enriched.nb_plateformes_liees = 0;
res.status(201).json(enriched);
} catch (e) { next(e); }
@@ -755,11 +812,11 @@ router.put('/:id', (req, res, next) => {
);
const tx = db.transaction(fn => fn());
saveRelations(tx, row.id, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids);
saveRelations(tx, row.id, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids, data.workspace_ids);
const updated = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(row.id);
const nb = db.prepare('SELECT COUNT(*) AS n FROM plateformes WHERE referentiel_id = ?').get(row.id).n;
const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([updated])))));
const [enriched] = parsePaysOp(attachWorkspaces(attachNotation(attachSecteursInv(attachCatsInv(attachCats([updated]))))));
enriched.nb_plateformes_liees = nb;
res.json(enriched);
} catch (e) { next(e); }
+39 -19
View File
@@ -3,6 +3,7 @@ import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { resolveActiveWorkspaceId } from '../middleware/workspaceScope.js';
import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js';
import { assertOwnedCompte } from './fraisOperations.js';
@@ -13,6 +14,12 @@ const Schema = z.object({
investissement_id: z.number().int().positive().nullable().optional(),
bonus_plateforme_id: z.number().int().positive().nullable().optional(),
bonus_investisseur_id: z.number().int().positive().nullable().optional(),
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
// rattacher une distribution bonus_plateforme/interets_plateforme à un deal
// précis pour calculer son TVPI, quand plusieurs deals partagent une même
// plateforme (ex. Fundora) — cf. project_workspaces_transformation.md.
// Sans effet sur le type 'normal' (crowdlending).
investissement_pe_id: z.number().int().positive().nullable().optional(),
date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
capital: z.number().nonnegative().default(0),
cashback: z.number().nonnegative().default(0),
@@ -89,7 +96,7 @@ function getTaxIndicatif(body) {
* puis en insère un nouveau avec source='auto_remboursement'.
* - Sinon : supprime simplement l'ancien retrait lié.
*/
function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb, req) {
// Suppression du retrait automatique précédent (s'il y en avait un)
db.prepare('DELETE FROM depots_retraits WHERE remboursement_id = ?').run(rembId);
@@ -100,16 +107,22 @@ function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
).get(investissementId);
if (!inv) return;
// Étape 2a du retrofit workspace_id (17/09/26) : le retrait auto généré par un
// remboursement hérite du workspace actif au moment du remboursement (même
// résolution que les dépôts/retraits manuels, cf. workspaceScope.js).
const workspaceId = resolveActiveWorkspaceId(req);
db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant,
libelle, source, remboursement_id)
VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?)
libelle, source, remboursement_id, workspace_id)
VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?, ?)
`).run(
inv.investisseur_id, inv.plateforme_id, dateRemb,
netRecu,
`Remboursement — ${inv.nom_projet}`,
rembId,
workspaceId,
);
}
@@ -201,6 +214,9 @@ router.get('/', (req, res) => {
: `(i.investisseur_id = ?
OR (r.investissement_id IS NULL AND r.bonus_investisseur_id = ?))`;
const invArg = scopeAll ? req.user.id : req.investisseur.id;
// Étape 2c du retrofit workspace_id (17/09/26) : filtre par workspace actif,
// même principe que GET /depots-retraits.
const workspaceId = resolveActiveWorkspaceId(req);
const outerConds = [];
const outerArgs = [];
@@ -228,7 +244,7 @@ router.get('/', (req, res) => {
END AS capital_restant_du
FROM remboursements r
LEFT JOIN investissements i ON i.id = r.investissement_id
WHERE ${invCond}
WHERE ${invCond} AND r.workspace_id = ?
)
SELECT
b.*,
@@ -254,7 +270,7 @@ router.get('/', (req, res) => {
LEFT JOIN comptes c ON c.id = b.compte_id
${outerWhere}
ORDER BY b.date_remb DESC, b.id DESC
`).all(invArg, invArg, ...outerArgs);
`).all(invArg, invArg, workspaceId, ...outerArgs);
res.json(rows);
});
@@ -271,18 +287,19 @@ router.post('/', (req, res, next) => {
INSERT INTO remboursements
(type, investissement_id, date_remb, capital, cashback,
interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc, prelev_sociaux,
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id)
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?)
prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id, workspace_id)
VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?, ?)
`).run(
body.investissement_id, body.date_remb, body.capital, body.cashback,
body.interets_bruts_avant_local, body.taxe_locale,
body.interets_bruts, body.frais_ttc, body.prelev_sociaux, body.prelev_forfaitaire,
interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement,
body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null,
resolveActiveWorkspaceId(req),
);
syncInvestissementStatut(body.investissement_id);
adjustSimulForActuals(db, body.investissement_id);
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb, req);
{
const invPlat = db.prepare('SELECT plateforme_id FROM investissements WHERE id = ?').get(body.investissement_id);
syncFraisDistribution(
@@ -320,13 +337,14 @@ router.post('/', (req, res, next) => {
INSERT INTO remboursements
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts, frais_ttc,
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes)
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?)
prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?,?, ?,?, ?, 'manuel', ?, ?, ?)
`).run(
body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
body.prelev_sociaux, body.prelev_forfaitaire,
interets_nets, net_recu, body.statut, body.notes || null,
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
);
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
}
@@ -335,11 +353,12 @@ router.post('/', (req, res, next) => {
INSERT INTO remboursements
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire,
interets_nets, net_recu, statut, source, notes)
VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?)
interets_nets, net_recu, statut, source, notes, workspace_id, investissement_pe_id)
VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?, ?, ?)
`).run(
body.type, body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
body.cashback, body.cashback, body.statut, body.notes || null,
resolveActiveWorkspaceId(req), body.investissement_pe_id ?? null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets: 0, net_recu: body.cashback });
} catch (e) { next(e); }
@@ -481,7 +500,7 @@ router.put('/:id', (req, res, next) => {
);
syncInvestissementStatut(body.investissement_id);
adjustSimulForActuals(db, body.investissement_id);
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb, req);
{
const invPlat = db.prepare('SELECT plateforme_id FROM investissements WHERE id = ?').get(body.investissement_id);
syncFraisDistribution(
@@ -493,20 +512,21 @@ router.put('/:id', (req, res, next) => {
db.prepare(`
UPDATE remboursements
SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, frais_ttc=?,
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?
prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?,
investissement_pe_id=?
WHERE id=?
`).run(
body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, body.frais_ttc,
body.prelev_sociaux, body.prelev_forfaitaire,
interets_nets, net_recu, body.statut, body.notes || null, id,
interets_nets, net_recu, body.statut, body.notes || null, body.investissement_pe_id ?? null, id,
);
} else {
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
db.prepare(`
UPDATE remboursements
SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=?
SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=?, investissement_pe_id=?
WHERE id=?
`).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, id);
`).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, body.investissement_pe_id ?? null, id);
}
res.json({ id, ...body, interets_nets, net_recu });
@@ -526,7 +546,7 @@ router.delete('/:id', (req, res, next) => {
`).get(id, req.user.id);
if (!existing) throw new HttpError(404, 'Not found');
syncAutoRetrait(id, 'portefeuille', 0, null, null); // supprime le retrait auto si présent
syncAutoRetrait(id, 'portefeuille', 0, null, null, req); // supprime le retrait auto si présent
db.prepare('DELETE FROM remboursements WHERE id=?').run(id);
if (existing.investissement_id) {
syncInvestissementStatut(existing.investissement_id);
@@ -545,7 +565,7 @@ router.delete('/', (req, res, next) => {
const rembs = db.prepare('SELECT id FROM remboursements WHERE investissement_id=?').all(investissement_id);
for (const r of rembs) {
syncAutoRetrait(r.id, 'portefeuille', 0, null, null);
syncAutoRetrait(r.id, 'portefeuille', 0, null, null, req);
}
db.prepare('DELETE FROM remboursements WHERE investissement_id=?').run(investissement_id);
syncInvestissementStatut(investissement_id);
+26 -2
View File
@@ -10,8 +10,17 @@ const router = Router();
* summary: Liste des mouvements de cash (dépôts / retraits)
* tags: [Dépôts / Retraits]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: workspace
* schema: { type: string }
* description: >
* Slug du workspace à filtrer (ex. "crowdlending"). Absent par défaut :
* renvoie tous les workspaces confondus (les clés API restent globales,
* cf. project_workspaces_transformation.md) — 400 si le slug est inconnu.
* responses:
* 200: { description: Liste des mouvements }
* 400: { description: Slug de workspace inconnu }
*/
router.get('/', (req, res) => {
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
@@ -21,14 +30,29 @@ router.get('/', (req, res) => {
: 'dr.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const conds = [invCond];
const args = [invParam];
// Filtre optionnel par workspace (17/09/26, décision explicite d'Olivier) :
// absent par défaut → comportement inchangé, tous workspaces confondus (les
// clés API/MCP restent globales, décision du 08/09/26) ; ?workspace=<slug>
// permet à un outil MCP de cibler un workspace précis — slug plutôt qu'un id
// brut, portable entre installations comme pour l'export/import ZIP.
if (req.query.workspace) {
const ws = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
conds.push('dr.workspace_id = ?');
args.push(ws.id);
}
const rows = db.prepare(`
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
dr.montant, dr.libelle
FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id
WHERE ${invCond}
WHERE ${conds.join(' AND ')}
ORDER BY dr.date_operation DESC
`).all(invParam);
`).all(...args);
res.json(rows);
});
+17
View File
@@ -23,8 +23,16 @@ const router = Router();
* - in: query
* name: date_fin
* schema: { type: string, format: date }
* - in: query
* name: workspace
* schema: { type: string }
* description: >
* Slug du workspace à filtrer (ex. "crowdlending"). Absent par défaut :
* renvoie tous les workspaces confondus (les clés API restent globales,
* cf. project_workspaces_transformation.md) — 400 si le slug est inconnu.
* responses:
* 200: { description: Liste des remboursements }
* 400: { description: Slug de workspace inconnu }
*/
router.get('/', (req, res) => {
const { date_debut, date_fin } = req.query;
@@ -37,6 +45,15 @@ router.get('/', (req, res) => {
if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
// Filtre optionnel par workspace (17/09/26, même principe que /depots-retraits) :
// absent par défaut → comportement inchangé, tous workspaces confondus.
if (req.query.workspace) {
const ws = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(String(req.query.workspace));
if (!ws) return res.status(400).json({ error: `Workspace inconnu : "${req.query.workspace}"` });
conds.push('r.workspace_id = ?');
args.push(ws.id);
}
const rows = db.prepare(`
SELECT r.id, r.date_remb, i.nom_projet, p.nom AS plateforme_nom,
r.capital, r.interets_bruts, r.frais_ttc,
+32 -1
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { COLOR_THEME_VALUES } from './preferences.js';
/* ── Espaces de travail de l'utilisateur connecté ─────────────────────────
* Liste les workspaces accordés par l'admin (granted_by_admin=1) avec leur
@@ -14,7 +15,7 @@ const router = Router();
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user, uw.color_theme
FROM user_workspaces uw
JOIN workspaces w ON w.id = uw.workspace_id
WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND w.actif_global = 1
@@ -43,4 +44,34 @@ router.patch('/:id/active', (req, res, next) => {
} catch (e) { next(e); }
});
// Couleur d'accent personnelle pour cet espace de travail (17/09/26) — cf.
// project_workspaces_transformation.md et migration user_workspaces.color_theme
// (db/index.js). null = hérite de la couleur de l'application (UiContext.jsx,
// colorTheme || adminColorTheme). Même schéma de validation que PATCH
// /preferences (color_theme) : valeur vérifiée ici (pas seulement la clé),
// une valeur invalide casserait silencieusement le rendu CSS.
const ColorSchema = z.object({ color_theme: z.string().nullable() });
router.patch('/:id/color', (req, res, next) => {
try {
const { color_theme } = ColorSchema.parse(req.body);
const workspaceId = Number(req.params.id);
if (color_theme !== null && !COLOR_THEME_VALUES.has(color_theme)) {
throw new HttpError(400, `Valeur invalide pour color_theme : ${color_theme}`);
}
const grant = db.prepare(
'SELECT 1 FROM user_workspaces WHERE user_id = ? AND workspace_id = ? AND granted_by_admin = 1'
).get(req.user.id, workspaceId);
if (!grant) throw new HttpError(403, "Vous n'avez pas accès à cet espace de travail");
db.prepare(
'UPDATE user_workspaces SET color_theme = ? WHERE user_id = ? AND workspace_id = ?'
).run(color_theme, req.user.id, workspaceId);
res.json({ id: workspaceId, color_theme });
} catch (e) { next(e); }
});
export default router;
+4
View File
@@ -15,9 +15,11 @@ import plateformesRouter from './routes/plateformes.js';
import categoriesRouter from './routes/categories.js';
import depotsRetraitsRouter from './routes/depotsRetraits.js';
import investissementsRouter from './routes/investissements.js';
import investissementsPeRouter from './routes/investissementsPe.js';
import remboursementsRouter from './routes/remboursements.js';
import simulRouter from './routes/simul.js';
import dashboardRouter from './routes/dashboard.js';
import dashboardPeRouter from './routes/dashboardPe.js';
import taxreportRouter from './routes/taxreport.js';
import platefomeTaxRouter from './routes/plateforme-tax.js';
import importsRouter from './routes/imports.js';
@@ -135,9 +137,11 @@ app.use('/api/plateformes', requireAuth, plateformesRouter);
app.use('/api/categories', requireAuth, categoriesRouter);
app.use('/api/depots-retraits', requireAuth, depotsRetraitsRouter);
app.use('/api/investissements', requireAuth, investissementsRouter);
app.use('/api/investissements-pe', requireAuth, investissementsPeRouter);
app.use('/api/remboursements', requireAuth, remboursementsRouter);
app.use('/api/simul', requireAuth, simulRouter);
app.use('/api/dashboard', requireAuth, dashboardRouter);
app.use('/api/dashboard-pe', requireAuth, dashboardPeRouter);
app.use('/api/taxreport', requireAuth, taxreportRouter);
app.use('/api/plateforme-tax', requireAuth, platefomeTaxRouter);
app.use('/api/imports', requireAuth, importsRouter);
+84 -21
View File
@@ -10,11 +10,17 @@ import ResetPassword from './pages/ResetPassword.jsx';
import VerifyEmail from './pages/VerifyEmail.jsx';
import Layout from './components/Layout.jsx';
import Dashboard from './pages/Dashboard.jsx';
import DashboardPe from './pages/DashboardPe.jsx';
import DepotsRetraits from './pages/DepotsRetraits.jsx';
import DepotsRetraitsSimple from './pages/DepotsRetraitsSimple.jsx';
import Investissements from './pages/Investissements.jsx';
import InvestissementsPe from './pages/InvestissementsPe.jsx';
import InvestissementDetail from './pages/InvestissementDetail.jsx';
import InvestissementPeDetail from './pages/InvestissementPeDetail.jsx';
import Remboursements from './pages/Remboursements.jsx';
import RemboursementsSimple from './pages/RemboursementsSimple.jsx';
import Frais from './pages/Frais.jsx';
import FraisPe from './pages/FraisPe.jsx';
import SimulRemboursements from './pages/SimulRemboursements.jsx';
import TaxReport from './pages/TaxReport.jsx';
import Settings from './pages/Settings.jsx';
@@ -54,6 +60,16 @@ function FraisOnly({ children }) {
return children;
}
// Équivalent PE (chantier "frais PE", 17/09/26) — visible seulement après déclaration d'au
// moins un frais sur un deal Private Equity (hasFraisPe, cf. AuthContext.jsx / GET /auth/me).
// Même mécanisme non réactif que FraisOnly.
function FraisPeOnly({ children }) {
const { hasFraisPe, loading } = useAuth();
if (loading) return <div style={{ padding: 32 }}>Chargement…</div>;
if (!hasFraisPe) return <Navigate to="/" replace />;
return children;
}
// ── Gating de navigation par workspace (Phase 4, étape 0 — 08/09/26) ────
// cf. project_workspaces_transformation.md (mémoire projet). N'agit
// aujourd'hui sur rien de visible tant qu'un seul workspace (crowdlending)
@@ -67,25 +83,72 @@ function RequireWorkspace({ slug, children }) {
return children;
}
// Page d'accueil sensible au workspace actif : le Dashboard crowdlending
// tant que ce workspace est actif (comportement actuel, inchangé), sinon un
// simple message d'attente — appelé à être remplacé par le vrai contenu du
// workspace concerné au fur et à mesure de sa construction (Phase 4+).
// Page d'accueil sensible au workspace actif : le Dashboard crowdlending tant
// que ce workspace est actif (comportement actuel, inchangé), sinon le
// tableau de bord dédié PE (DashboardPe.jsx, chantier "dashboard PE",
// 17/09/26) — première version structurelle (KPI + graphiques de
// portefeuille), à enrichir ensuite.
function WorkspaceHome() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') {
return (
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
<h2 style={{ margin: '0 0 8px', color: 'var(--text)' }}>{active.nom}</h2>
<p style={{ margin: 0 }}>
{active.description || "Cet espace de travail n'est pas encore disponible."}
</p>
</div>
);
}
if (active && active.slug !== 'crowdlending') return <DashboardPe />;
return <Dashboard />;
}
// Route /depots-retraits : partagée entre tous les workspaces (17/09/26,
// demande explicite d'Olivier de réutiliser le code de la page crowdlending
// plutôt que d'en construire une séparée à chaque nouveau workspace). Bascule
// sur la version simplifiée (pages/DepotsRetraitsSimple.jsx) pour tout
// workspace autre que crowdlending — jamais lié en dur à un slug précis
// (ex. "private_equity") pour rester valable si un 3e workspace apparaît un
// jour. Fail-open vers la page crowdlending tant que `active` n'est pas
// encore chargé, comme RequireWorkspace ci-dessus.
function DepotsRetraitsRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <DepotsRetraitsSimple />;
return <DepotsRetraits />;
}
// Même principe que DepotsRetraitsRouter ci-dessus (17/09/26) : la page
// Remboursements simplifiée (RemboursementsSimple.jsx, limitée aux types
// bonus/intérêts plateforme — pas de type "normal" lié à un investissement,
// investissements n'ayant pas encore de workspace_id) sert tout workspace
// autre que crowdlending, générique par slug pour rester valable si un 3e
// workspace apparaît un jour.
function RemboursementsRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <RemboursementsSimple />;
return <Remboursements />;
}
// Même principe (17/09/26) : la page Investissements bascule sur la table
// dédiée investissements_pe (InvestissementsPe.jsx) pour tout workspace
// autre que crowdlending — modèle de données volontairement différent
// (montant unique investi + valorisations périodiques, pas d'échéancier),
// cf. project_workspaces_transformation.md.
function InvestissementsRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <InvestissementsPe />;
return <Investissements />;
}
// Fiche détaillée /investissements/:id (chantier "fiche détail PE", 17/09/26) :
// structure encore minimale côté PE (résumé, valorisations, remboursements,
// frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx).
function InvestissementDetailRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <InvestissementPeDetail />;
return <InvestissementDetail />;
}
// Même principe (chantier "frais PE", 17/09/26) : la page Frais dédiée existe désormais pour
// les deux workspaces (FraisPe.jsx pour tout workspace non-crowdlending), chacune gardée par
// son propre indicateur "au moins un frais déclaré" (FraisOnly / FraisPeOnly ci-dessus).
function FraisRouter() {
const { active } = useWorkspace() || {};
if (active && active.slug !== 'crowdlending') return <FraisPeOnly><FraisPe /></FraisPeOnly>;
return <FraisOnly><Frais /></FraisOnly>;
}
export default function App() {
const [allowRegistration, setAllowRegistration] = useState(true);
@@ -131,11 +194,11 @@ export default function App() {
<Route element={<Protected><Layout /></Protected>}>
<Route index element={<WorkspaceHome />} />
<Route path="plateformes" element={<RequireWorkspace slug="crowdlending"><Plateformes /></RequireWorkspace>} />
<Route path="depots-retraits" element={<RequireWorkspace slug="crowdlending"><DepotsRetraits /></RequireWorkspace>} />
<Route path="investissements" element={<RequireWorkspace slug="crowdlending"><Investissements /></RequireWorkspace>} />
<Route path="investissements/:id" element={<RequireWorkspace slug="crowdlending"><InvestissementDetail /></RequireWorkspace>} />
<Route path="remboursements" element={<RequireWorkspace slug="crowdlending"><Remboursements /></RequireWorkspace>} />
<Route path="frais" element={<RequireWorkspace slug="crowdlending"><FraisOnly><Frais /></FraisOnly></RequireWorkspace>} />
<Route path="depots-retraits" element={<DepotsRetraitsRouter />} />
<Route path="investissements" element={<InvestissementsRouter />} />
<Route path="investissements/:id" element={<InvestissementDetailRouter />} />
<Route path="remboursements" element={<RemboursementsRouter />} />
<Route path="frais" element={<FraisRouter />} />
<Route path="simul" element={<RequireWorkspace slug="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
<Route path="taxreport" element={<RequireWorkspace slug="crowdlending"><TaxReport /></RequireWorkspace>} />
<Route path="assistant-ia" element={<RequireWorkspace slug="crowdlending"><AssistantIA /></RequireWorkspace>} />
@@ -145,8 +208,8 @@ export default function App() {
<Route path="preferences" element={<Navigate to="/settings?section=apparence" replace />} />
<Route path="compte" element={<MonCompte />} />
<Route path="admin" element={<AdminOnly><Admin /></AdminOnly>} />
<Route path="admin/plateformes" element={<RequireWorkspace slug="crowdlending"><AdminOnly><AdminPlateformes /></AdminOnly></RequireWorkspace>} />
<Route path="admin/fiscalite" element={<RequireWorkspace slug="crowdlending"><AdminOnly><AdminFiscalite /></AdminOnly></RequireWorkspace>} />
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
<Route path="aide" element={<Aide />} />
<Route path="notifications" element={<Notifications />} />
+5
View File
@@ -6,10 +6,15 @@ function authHeaders() {
const token = localStorage.getItem('cl_token');
const investisseurId = localStorage.getItem('cl_investisseur_id');
const deviceToken = localStorage.getItem('cl_device_token');
// Même clé que WorkspaceContext.jsx (KEY_ACTIVE) — miroir de X-Investisseur-Id,
// sert à flêcher côté serveur les nouvelles lignes vers le workspace actif
// (Étape 2a du retrofit workspace_id, cf. workspaceScope.js).
const workspaceId = localStorage.getItem('cl_active_workspace_id');
const h = {};
if (token) h['Authorization'] = `Bearer ${token}`;
if (investisseurId) h['X-Investisseur-Id'] = investisseurId;
if (deviceToken) h['X-Device-Token'] = deviceToken;
if (workspaceId) h['X-Workspace-Id'] = workspaceId;
return h;
}
+14 -3
View File
@@ -1,4 +1,5 @@
import Modal from './Modal.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
/**
* Modale de confirmation générique.
@@ -7,8 +8,15 @@ import Modal from './Modal.jsx';
* title — titre de la modale (défaut : "Confirmer la suppression")
* message — texte explicatif
* confirmLabel — libellé du bouton de confirmation (défaut : "Supprimer")
* onConfirm — callback appelé au clic "Confirmer"
* onConfirm — callback appelé au clic "Confirmer" (peut être async)
* onCancel — callback appelé au clic "Annuler" ou ✕
*
* Protégée nativement contre le double-clic (cf. useAsyncAction) : "Confirmer" se
* désactive dès le premier clic (retour visuel via le style global button:disabled)
* et bloque aussi "Annuler"/✕ tant que la requête est en cours — évite de déclencher
* deux fois la même action (suppression, etc.) sur une connexion lente. Le libellé du
* bouton n'est pas réécrit ici : certains appelants font déjà varier `confirmLabel`
* eux-mêmes pendant l'opération (ex. "Suppression…") et gardent la main dessus.
*/
export default function ConfirmModal({
open,
@@ -18,16 +26,19 @@ export default function ConfirmModal({
onConfirm,
onCancel,
}) {
const { run, pending } = useAsyncAction();
return (
<Modal
open={open}
title={title}
onClose={onCancel}
width={440}
busy={pending}
footer={
<>
<button className="ghost" type="button" onClick={onCancel}>Annuler</button>
<button className="danger" type="button" onClick={onConfirm}>{confirmLabel}</button>
<button className="ghost" type="button" disabled={pending} onClick={onCancel}>Annuler</button>
<button className="danger" type="button" disabled={pending} onClick={() => run(onConfirm)}>{confirmLabel}</button>
</>
}
>
+9 -5
View File
@@ -44,6 +44,7 @@ export default function FraisFormModal({
locked = false,
investissementOptions = [],
confirmingDelete, setConfirmingDelete,
submitting = false,
}) {
const modalTitle = editingId ? 'Modifier le frais' : 'Ajouter un frais';
@@ -53,12 +54,13 @@ export default function FraisFormModal({
title={modalTitle}
onClose={onClose}
width={locked ? 480 : 560}
busy={submitting}
footer={
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<div style={{ flex: 1 }}>
{editingId && onDelete && (
!confirmingDelete ? (
<button className="danger" onClick={() => setConfirmingDelete(true)}>
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
Supprimer
</button>
) : (
@@ -66,14 +68,16 @@ export default function FraisFormModal({
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
Supprimer définitivement ?
</span>
<button onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
</span>
)
)}
</div>
<button className="ghost" onClick={onClose}>Annuler</button>
<button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Ajouter'}</button>
<button className="ghost" disabled={submitting} onClick={onClose}>Annuler</button>
<button className="primary" disabled={submitting} onClick={onSubmit}>
{submitting ? (editingId ? 'Enregistrement…' : 'Ajout…') : (editingId ? 'Enregistrer' : 'Ajouter')}
</button>
</div>
}
>
@@ -73,6 +73,7 @@ export default function InvestissementFormModal({
dateCibleRevisionWarning = null,
restructurationBlock = null,
width = 720,
submitting = false,
}) {
// Vestige côté données : mémorise la dernière souscription valide saisie.
// N'est lu nulle part dans le code actuel — conservé tel quel (écriture sans effet)
@@ -285,12 +286,13 @@ export default function InvestissementFormModal({
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
onClose={onClose}
width={width}
busy={submitting}
footer={
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<div style={{ flex: 1 }}>
{editingId && onDelete && (
!confirmingDelete ? (
<button className="danger" onClick={() => setConfirmingDelete(true)}>
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
Supprimer
</button>
) : (
@@ -298,16 +300,20 @@ export default function InvestissementFormModal({
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
Supprimer définitivement (documents inclus) ?
</span>
<button onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
</span>
)
)}
</div>
{step === 1 && <button onClick={onClose}>Annuler</button>}
{step > 1 && <button onClick={goPrev}>Précédent</button>}
{step < totalSteps && <button className="primary" onClick={goNext}>Suivant</button>}
{step === totalSteps && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
{step === 1 && <button disabled={submitting} onClick={onClose}>Annuler</button>}
{step > 1 && <button disabled={submitting} onClick={goPrev}>Précédent</button>}
{step < totalSteps && <button className="primary" disabled={submitting} onClick={goNext}>Suivant</button>}
{step === totalSteps && (
<button className="primary" disabled={submitting} onClick={onSubmit}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
)}
</div>
}
>
@@ -0,0 +1,246 @@
import { useEffect, useRef, useState } from 'react';
import Modal from './Modal.jsx';
/**
* Modale d'ajout/modification d'un deal Private Equity, PAR ÉTAPES (chantier "frais PE",
* 17/09/26 — demande Olivier : "adapte la modale d'investissement, un peu sur le style de la
* modal crowdlending (par étape) pour pouvoir aussi rentrer les frais").
*
* Adaptation de InvestissementFormModal.jsx (crowdlending) : Étape 1 "Infos du deal" toujours
* présente, Étape 2 "Frais" conditionnelle — uniquement si la plateforme sélectionnée facture
* des frais (référentiel frais_plateforme ≠ 'aucun', configuré dans Settings > Plateformes,
* déjà générique/non spécifique à un workspace).
*
* Contrairement à crowdlending (une seule ligne "frais de souscription"), l'étape Frais capture
* ICI DEUX lignes indépendantes — Frais d'entrée et Frais de gestion — conformément aux
* captures Fundora montrées par Olivier (3 deals, chacun distinguant les deux montants). Chaque
* ligne journalise sa propre entrée frais_operations : Frais d'entrée → catégorie
* 'souscription' (unique par deal, cf. assertUniqueSouscription côté backend) ; Frais de
* gestion → catégorie 'gestion', origine 'souscription' (ce qui la distingue, côté
* pré-remplissage, d'un frais de gestion ajouté ultérieurement depuis la page Frais PE ou
* depuis la liste des investissements — ceux-là restent en origine 'manuel').
*
* Composant "bête" : tout le state du formulaire appartient au parent (InvestissementsPe.jsx,
* mêmes conventions que Investissements.jsx / InvestissementFormModal.jsx côté crowdlending) —
* cette modale ne possède que l'état d'étape courante (`step`).
*/
export default function InvestissementPeFormModal({
open, editingId, onClose,
form, setForm, plats, investisseurs, multiDetenteur, comptesInvestisseur,
err, submitting, onSubmit, onDelete, investisseurForPlat,
}) {
const [step, setStep] = useState(1);
const formRef = useRef(null);
useEffect(() => { if (open) setStep(1); }, [open]);
const platFrais = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
const showFraisStep = !!platFrais && platFrais !== 'aucun';
const totalSteps = showFraisStep ? 2 : 1;
const stepTitles = showFraisStep ? ['Infos du deal', 'Frais'] : ['Infos du deal'];
const goNext = () => {
if (formRef.current && !formRef.current.reportValidity()) return;
setStep(s => Math.min(totalSteps, s + 1));
};
const goPrev = () => setStep(s => Math.max(1, s - 1));
const setField = (key, value) => setForm(f => ({ ...f, [key]: value }));
const handleFraisReglementChange = (prefix, mode) => {
setForm(f => ({
...f,
[`${prefix}_mode_reglement`]: mode,
[`${prefix}_compte_id`]: mode === 'compte_courant' ? f[`${prefix}_compte_id`] : '',
}));
};
return (
<Modal
open={open}
title={editingId ? "Modifier l'investissement" : 'Nouvel investissement'}
onClose={onClose}
busy={submitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div>
{editingId && step === 1 && (
<button className="danger" disabled={submitting} onClick={onDelete}>Supprimer</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{step === 1 && <button disabled={submitting} onClick={onClose}>Annuler</button>}
{step > 1 && <button disabled={submitting} onClick={goPrev}>Précédent</button>}
{step < totalSteps && <button className="primary" disabled={submitting} onClick={goNext}>Suivant</button>}
{step === totalSteps && (
<button className="primary" disabled={submitting} onClick={onSubmit}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
)}
</div>
</div>
}
>
{totalSteps > 1 && (
<>
<div style={{ display: 'flex', gap: 4, marginBottom: 10 }}>
{Array.from({ length: totalSteps }).map((_, i) => (
<div key={i} style={{
flex: 1, height: 4, borderRadius: 2,
background: i < step ? 'var(--primary)' : 'var(--border)',
}} />
))}
</div>
<div className="text-muted" style={{ fontSize: 13, marginBottom: 14 }}>
Étape {step}/{totalSteps} — {stepTitles[step - 1]}
</div>
</>
)}
<form ref={formRef}>
{err && <div className="error">{err}</div>}
{step === 1 && (
<div className="form-grid">
<div style={{ gridColumn: '1 / -1' }}>
<label>Nom du deal *</label>
<input required value={form.nom_deal} onChange={e => setField('nom_deal', e.target.value)} placeholder="Ex. Cornerstone Growth" />
</div>
<div>
<label>Plateforme *</label>
<select required value={form.plateforme_id} onChange={e => {
const platId = e.target.value;
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId) }));
}}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
<div>
<label>Stratégie</label>
<input value={form.strategie} onChange={e => setField('strategie', e.target.value)} placeholder="Ex. Capital risque" />
</div>
<div>
<label>Date de souscription *</label>
<input type="date" required value={form.date_souscription} onChange={e => setField('date_souscription', e.target.value)} />
</div>
<div>
<label>Durée (années)</label>
<input type="number" step="0.5" min="0" value={form.duree_annees} onChange={e => setField('duree_annees', e.target.value)} />
</div>
<div>
<label>Montant investi (€) *</label>
<input type="number" step="0.01" min="0" required value={form.montant_investi} onChange={e => setField('montant_investi', e.target.value)} />
</div>
<div>
<label>Objectif</label>
<input value={form.objectif} onChange={e => setField('objectif', e.target.value)} placeholder="Ex. x4,5 ou 9% - 12%/an" />
</div>
<div>
<label>Statut</label>
<select value={form.statut} onChange={e => setField('statut', e.target.value)}>
<option value="en_attente">En attente</option>
<option value="valide">Validé</option>
<option value="cloture">Clôturé</option>
<option value="perte_definitive">Perte définitive</option>
</select>
</div>
<div>
<label>Référence</label>
<input value={form.reference} onChange={e => setField('reference', e.target.value)} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.investisseur_id} onChange={e => setField('investisseur_id', e.target.value)}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={form.notes} onChange={e => setField('notes', e.target.value)} />
</div>
</div>
)}
{step === 2 && showFraisStep && (
<>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0, marginBottom: 14 }}>
Cette plateforme facture des frais (entrée, gestion…). Indiquez les montants TTC
constatés pour cette souscription, ou laissez un montant à 0/vide si le frais
correspondant ne s'applique pas cette fois-ci.
</p>
<FraisBlock prefix="frais_entree" label="Frais d'entrée" form={form} setField={setField}
handleFraisReglementChange={handleFraisReglementChange} comptesInvestisseur={comptesInvestisseur} />
<FraisBlock prefix="frais_gestion" label="Frais de gestion" form={form} setField={setField}
handleFraisReglementChange={handleFraisReglementChange} comptesInvestisseur={comptesInvestisseur} />
</>
)}
</form>
</Modal>
);
}
function FraisBlock({ prefix, label, form, setField, handleFraisReglementChange, comptesInvestisseur }) {
const modeSaisie = form[`${prefix}_mode_saisie`];
const valeur = form[`${prefix}_valeur`];
const modeReglement = form[`${prefix}_mode_reglement`];
const compteId = form[`${prefix}_compte_id`];
return (
<div style={{ marginBottom: 18, padding: 12, border: '1px solid var(--border)', borderRadius: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 10 }}>{label}</div>
<div className="form-grid">
<div>
<label>Mode de saisie</label>
<select value={modeSaisie} onChange={e => setField(`${prefix}_mode_saisie`, e.target.value)}>
<option value="montant">Montant (€)</option>
<option value="pourcentage">% du montant investi</option>
</select>
</div>
<div>
<label>{modeSaisie === 'pourcentage' ? `${label} (% du montant investi)` : `${label} TTC (€)`}</label>
<input type="number" step="0.01" min="0" value={valeur} onChange={e => setField(`${prefix}_valeur`, e.target.value)} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Prélèvement</label>
<select value={modeReglement} onChange={e => handleFraisReglementChange(prefix, e.target.value)}>
<option value="source">À la source (sans mouvement complémentaire)</option>
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
</select>
</div>
{modeReglement === 'compte_courant' && (
<div style={{ gridColumn: '1 / -1' }}>
<label>Compte de règlement *</label>
{comptesInvestisseur.length > 0 ? (
<select required value={compteId} onChange={e => setField(`${prefix}_compte_id`, e.target.value)}>
<option value="">— Choisir un compte —</option>
{comptesInvestisseur.map(c => (
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
))}
</select>
) : (
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
</p>
)}
</div>
)}
</div>
</div>
);
}
+30 -5
View File
@@ -252,12 +252,23 @@ const IconBurger = () => (
);
export default function Layout() {
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
const { hasFrais } = useAuth();
const { active: activeWorkspace } = useWorkspace() || {};
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode, setWorkspaceColorOverride } = useUi();
const { hasFrais, hasFraisPe } = useAuth();
const { active: activeWorkspace, hasMultipleWorkspaces } = useWorkspace() || {};
// Couleur d'accent par espace de travail (17/09/26, cf. AppearanceSection.jsx) :
// à chaque changement d'espace actif, applique sa couleur personnalisée
// (ou repli sur la couleur de l'application si non définie) — seulement
// pertinent quand plusieurs espaces sont actifs (sinon rien à distinguer).
useEffect(() => {
setWorkspaceColorOverride(hasMultipleWorkspaces ? (activeWorkspace?.color_theme || null) : null);
}, [activeWorkspace, hasMultipleWorkspaces, setWorkspaceColorOverride]);
// Gating de nav par workspace (Phase 4, étape 0) : fail-open tant que
// activeWorkspace n'est pas chargé, cf. App.jsx (RequireWorkspace).
const isCrowdlendingActive = !activeWorkspace || activeWorkspace.slug === 'crowdlending';
// Lien "Frais" (chantier "frais PE", 17/09/26) : visible dans les deux workspaces, chacun
// avec son propre indicateur "au moins un frais déclaré" (hasFrais / hasFraisPe).
const showFrais = isCrowdlendingActive ? hasFrais : hasFraisPe;
const navigate = useNavigate();
const [navIcons, setNavIcons] = useState({});
const [aiEnabled, setAiEnabled] = useState(false);
@@ -332,11 +343,23 @@ export default function Layout() {
<NavLink to="/" end title="Tableau de bord">
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
</NavLink>
{/* Plateformes reste propre au crowdlending pour l'instant (pas
d'équivalent PE — les plateformes PE se gèrent depuis Mes
plateformes, settings). Investissements, Dépôts/Retraits et
Remboursements (17/09/26) sont en revanche visibles quel que soit
le workspace actif — la route bascule automatiquement sur la
version adaptée (InvestissementsRouter / DepotsRetraitsRouter /
RemboursementsRouter, App.jsx). Chaque lien est conditionné
individuellement plutôt que regroupé dans un seul bloc
isCrowdlendingActive, pour conserver l'ordre historique du menu
crowdlending (Plateformes, Investissements, Dépôts/Retraits,
Remboursements, Frais...) au lieu de faire remonter ces liens en
tête — cf. capture d'Olivier, 17/09/26. */}
{isCrowdlendingActive && (
<>
<NavLink to="/plateformes" title="Plateformes">
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
</NavLink>
)}
<NavLink to="/investissements" title="Investissements">
<NavIcon libFilename={navIcons.investissement} Fallback={IconInvestments} /><span className="nav-label">Investissements</span>
</NavLink>
@@ -346,11 +369,13 @@ export default function Layout() {
<NavLink to="/remboursements" title="Remboursements">
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
</NavLink>
{hasFrais && (
{showFrais && (
<NavLink to="/frais" title="Frais">
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
</NavLink>
)}
{isCrowdlendingActive && (
<>
<NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink>
+6 -2
View File
@@ -1,5 +1,9 @@
export default function Modal({ open, title, onClose, children, footer, width = 600 }) {
export default function Modal({ open, title, onClose, children, footer, width = 600, busy = false }) {
if (!open) return null;
// busy : une soumission est en cours (cf. useAsyncAction) — on désactive la fermeture
// par le ✕ pour éviter de quitter la modale au milieu d'une requête (l'utilisateur perdrait
// le suivi de son état et pourrait être tenté de rouvrir/ressaisir).
const handleClose = busy ? undefined : onClose;
return (
<div className="modal-backdrop">
<div
@@ -9,7 +13,7 @@ export default function Modal({ open, title, onClose, children, footer, width =
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<button className="ghost" onClick={onClose}>✕</button>
<button className="ghost" onClick={handleClose} disabled={busy} aria-disabled={busy}>✕</button>
</div>
{children}
{footer && <div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>{footer}</div>}
+8 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import * as XLSX from 'xlsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
/* ── Éléments partagés entre l'onglet "Mouvements" de DepotsRetraits.jsx et l'onglet
"Mouvements Porte-monnaie" de Plateformes.jsx. Toute évolution ici s'applique aux deux
@@ -695,9 +696,10 @@ export function CorrectionModal({ open, onClose, onConfirm,
const diff = Math.round((Number(declared) - computedBalance) * 100) / 100;
const hasDiff = Math.abs(diff) >= 0.005;
const handleConfirm = () => {
onConfirm(hasDiff ? diff : 0);
};
// Anti-double-soumission (cf. useAsyncAction) : évite de créer deux fois la même correction
// sur un double-clic / une connexion lente, avant que la modale ne se ferme.
const { run, pending } = useAsyncAction();
const handleConfirm = () => run(() => onConfirm(hasDiff ? diff : 0));
if (!open) return null;
return (
@@ -773,9 +775,9 @@ export function CorrectionModal({ open, onClose, onConfirm,
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button onClick={onClose}>Passer</button>
<button className="primary" onClick={handleConfirm}>
{hasDiff ? 'Créer la correction' : 'OK'}
<button disabled={pending} onClick={onClose}>Passer</button>
<button className="primary" disabled={pending} onClick={handleConfirm}>
{pending ? '…' : (hasDiff ? 'Créer la correction' : 'OK')}
</button>
</div>
</div>
+9 -5
View File
@@ -71,6 +71,7 @@ export default function RembFormModal({
investissementOptions = [],
multiDetenteur = false,
confirmingDelete, setConfirmingDelete,
submitting = false,
}) {
const isBonus = !locked && BONUS_VALUES.includes(form.investissement_id);
const isInteretsPlateforme = !locked && form.investissement_id === INTERETS_PLATEFORME_VALUE;
@@ -128,12 +129,13 @@ export default function RembFormModal({
title={modalTitle}
onClose={onClose}
width={900}
busy={submitting}
footer={
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<div style={{ flex: 1 }}>
{editingId && onDelete && (
!confirmingDelete ? (
<button className="danger" onClick={() => setConfirmingDelete(true)}>
<button className="danger" disabled={submitting} onClick={() => setConfirmingDelete(true)}>
Supprimer
</button>
) : (
@@ -141,14 +143,16 @@ export default function RembFormModal({
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
Supprimer définitivement ?
</span>
<button onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
<button disabled={submitting} onClick={() => setConfirmingDelete(false)}>Non</button>
<button className="danger" disabled={submitting} onClick={onDelete}>{submitting ? 'Suppression…' : 'Oui, supprimer'}</button>
</span>
)
)}
</div>
<button onClick={onClose}>Annuler</button>
<button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
<button disabled={submitting} onClick={onClose}>Annuler</button>
<button className="primary" disabled={submitting} onClick={onSubmit}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
}
>
+52 -18
View File
@@ -136,6 +136,7 @@ export default function UserMenu() {
const popupRef = useRef(null);
const subRef = useRef(null);
const closeTimer = useRef(null);
const activeRowRef = useRef(null); // bouton (ligne) qui a ouvert le sous-panneau actif
const famille = investisseurs.filter(i => i.type !== 'entreprise');
const entreprises = investisseurs.filter(i => i.type === 'entreprise');
@@ -143,7 +144,7 @@ export default function UserMenu() {
const POPUP_W = 250;
const SUB_W = 230;
/* ── Calcul position (fixed = échappe overflow:hidden) ────── */
/* ── Calcul position du popup principal (fixed = échappe overflow:hidden) ── */
const computePosition = useCallback(() => {
if (!triggerRef.current) return;
const r = triggerRef.current.getBoundingClientRect();
@@ -165,16 +166,30 @@ export default function UserMenu() {
position: 'fixed', left: mainLeft, bottom: mainBottom,
width: mainWidth, top: 'auto', right: 'auto',
});
setSubStyle({
position: 'fixed',
left: mainLeft + (sidebarCollapsed ? POPUP_W : mainWidth) + 6,
bottom: mainBottom,
width: SUB_W, top: 'auto', right: 'auto',
});
}, [sidebarCollapsed]);
// Position du sous-panneau (Espace de travail / Vue du profil) : ancrée sur
// le bas de la LIGNE cliquée (rowEl), pas sur le popup entier — sinon le
// sous-panneau apparaît décalé en bas de la fenêtre dès que la ligne
// cliquée n'est pas tout en bas du popup principal (ex. "Espace de
// travail", proche du haut). Affiché à droite du popup, à la même hauteur
// que la ligne cliquée.
const computeSubPosition = useCallback((rowEl) => {
if (!rowEl || !popupRef.current) return;
const rowRect = rowEl.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
setSubStyle({
position: 'fixed',
left: popupRect.right + 6,
bottom: vh - rowRect.bottom,
width: SUB_W, top: 'auto', right: 'auto',
});
}, []);
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
const closeMenu = useCallback(() => { setOpen(false); setActiveSubPanel(null); }, []);
const closeMenu = useCallback(() => { setOpen(false); setActiveSubPanel(null); activeRowRef.current = null; }, []);
const clearClose = () => clearTimeout(closeTimer.current);
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
@@ -192,20 +207,29 @@ export default function UserMenu() {
return () => document.removeEventListener('mousedown', onDown);
}, [open, closeMenu]);
/* Recalcul si sidebar change ou si visualViewport change (Safari mobile) */
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]);
/* Recalcul si sidebar change ou si visualViewport change (Safari mobile) —
* y compris le sous-panneau, ré-ancré sur sa ligne d'origine (activeRowRef). */
useEffect(() => {
if (!open) return;
computePosition();
if (activeRowRef.current) computeSubPosition(activeRowRef.current);
}, [sidebarCollapsed, open, computePosition, computeSubPosition]);
useEffect(() => {
if (!open) return;
const vp = window.visualViewport;
if (!vp) return;
vp.addEventListener('resize', computePosition);
vp.addEventListener('scroll', computePosition);
return () => {
vp.removeEventListener('resize', computePosition);
vp.removeEventListener('scroll', computePosition);
const recompute = () => {
computePosition();
if (activeRowRef.current) computeSubPosition(activeRowRef.current);
};
}, [open, computePosition]);
vp.addEventListener('resize', recompute);
vp.addEventListener('scroll', recompute);
return () => {
vp.removeEventListener('resize', recompute);
vp.removeEventListener('scroll', recompute);
};
}, [open, computePosition, computeSubPosition]);
/* Handlers ouverture */
const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } };
@@ -281,7 +305,12 @@ export default function UserMenu() {
<button
className={`user-menu-item user-menu-vue-row${activeSubPanel === 'workspace' ? ' active' : ''}`}
role="menuitem"
onClick={() => setActiveSubPanel(p => p === 'workspace' ? null : 'workspace')}
onClick={(e) => {
const next = activeSubPanel === 'workspace' ? null : 'workspace';
setActiveSubPanel(next);
activeRowRef.current = next ? e.currentTarget : null;
if (next) computeSubPosition(e.currentTarget);
}}
>
<WorkspaceBadge size={24} />
<span className="user-menu-vue-name">{activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'Espace de travail'}</span>
@@ -297,7 +326,12 @@ export default function UserMenu() {
<button
className={`user-menu-item user-menu-vue-row${activeSubPanel === 'profile' ? ' active' : ''}`}
role="menuitem"
onClick={() => setActiveSubPanel(p => p === 'profile' ? null : 'profile')}
onClick={(e) => {
const next = activeSubPanel === 'profile' ? null : 'profile';
setActiveSubPanel(next);
activeRowRef.current = next ? e.currentTarget : null;
if (next) computeSubPosition(e.currentTarget);
}}
>
{activeView === 'all'
? <TeamBadge size={24} />
@@ -0,0 +1,237 @@
import { useMemo, useState } from 'react';
import { fmtEUR } from '../utils/format.js';
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
function endOfMonth(Y, M) {
const d = new Date(Y, M, 0);
return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
/**
* Vision mensuelle — Private Equity (17/09/26). Équivalent PE de
* CapitalMensuelTable.jsx (crowdlending), mais avec une sémantique
* différente (décision Olivier) : chaque cellule est la VALORISATION
* connue en fin de mois (dernière valorisation saisie à cette date, ou
* montant_investi si aucune valorisation n'a encore été saisie), et non
* un capital en cours qui décroît avec des remboursements — un deal PE
* peut donc voir sa valeur monter ou descendre d'un mois à l'autre.
*
* `rows` : réponse de GET /investissements-pe (id, plateforme_id,
* plateforme_nom, montant_investi, date_souscription, statut…).
* `valorisations` : réponse de GET /investissements-pe/valorisations
* (investissement_pe_id, date_valorisation, valorisation_nette), tous
* deals confondus.
*/
export default function ValorisationMensuelleTable({ rows, valorisations, plats, expandButton }) {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const [annee, setAnnee] = useState(currentYear);
const availableYears = useMemo(() => {
const set = new Set(rows.map(r => r.date_souscription?.slice(0,4)).filter(Boolean));
set.add(String(currentYear));
return [...set].map(Number).sort((a,b) => a - b);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
/* ── Historique de valorisations trié, par deal ── */
const valorisationsByDeal = useMemo(() => {
const map = {};
for (const v of (valorisations || [])) {
const d = v.date_valorisation?.slice(0, 10);
if (!d || v.valorisation_nette == null) continue;
if (!map[v.investissement_pe_id]) map[v.investissement_pe_id] = [];
map[v.investissement_pe_id].push({ date: d, value: v.valorisation_nette });
}
for (const k in map) map[k].sort((a, b) => a.date.localeCompare(b.date));
return map;
}, [valorisations]);
const getValorisationAtEndOfMonth = (deal, Y, M) => {
const endM = endOfMonth(Y, M);
if (deal.date_souscription > endM) return 0;
let val = deal.montant_investi;
for (const v of (valorisationsByDeal[deal.id] || [])) {
if (v.date <= endM) val = v.value; else break;
}
return val;
};
/* ── Agrégat par plateforme ── */
const { grid, multiDetenteur } = useMemo(() => {
if (!rows.length) return { grid: null, multiDetenteur: false };
const platMap = {};
for (const p of plats) platMap[p.id] = p;
const byPlat = {};
for (const deal of rows) {
const pid = deal.plateforme_id;
if (!byPlat[pid]) {
const p = platMap[pid] || {};
byPlat[pid] = {
id: pid,
nom: deal.plateforme_nom || p.nom || '—',
investisseur_id: p.investisseur_id ?? null,
detenteur_nom: deal.investisseur_nom || null,
months: Array(12).fill(0),
};
}
const row = byPlat[pid];
for (let m = 1; m <= 12; m++) row.months[m-1] += getValorisationAtEndOfMonth(deal, annee, m);
}
const allPlats = Object.values(byPlat).filter(p => p.months.some(v => v > 0));
const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1;
const grid = allPlats.sort((a,b) => b.months.reduce((s,v) => s+v, 0) - a.months.reduce((s,v) => s+v, 0));
return { grid, multiDetenteur: multi };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, annee, valorisationsByDeal, plats]);
const stats = useMemo(() => {
if (!grid) return null;
const monthTotals = Array.from({ length: 12 }, (_, i) => grid.reduce((s, row) => s + row.months[i], 0));
const platMoyennes = grid.map(row => {
const nonZero = row.months.filter(v => v > 0);
return nonZero.length ? nonZero.reduce((s,v) => s+v, 0) / nonZero.length : 0;
});
const totalMoyenne = platMoyennes.reduce((s,v) => s+v, 0);
const platPoids = platMoyennes.map(m => totalMoyenne > 0 ? (m / totalMoyenne) * 100 : 0);
const nonZeroMonthTotals = monthTotals.filter(v => v > 0);
const globalMoyenne = nonZeroMonthTotals.length
? nonZeroMonthTotals.reduce((s,v) => s+v, 0) / nonZeroMonthTotals.length
: 0;
return { monthTotals, platMoyennes, platPoids, globalMoyenne };
}, [grid]);
const [windowStart, setWindowStart] = useState(() => {
const idx = availableYears.indexOf(currentYear);
return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), (idx >= 0 ? idx : availableYears.length - 1) - 1));
});
const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee];
const canPrev = windowStart > 0;
const canNext = windowStart + 3 < availableYears.length;
return (
<div className="solde-chart-wrap" style={{ padding: '24px 24px 16px', marginBottom: 24 }}>
{/* ── Header ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div style={{ display:'flex', alignItems:'center', gap:5, marginBottom:2 }}>
<span style={{ fontSize:13, color:'var(--text-muted)' }}>{`Valorisation · ${annee}`}</span>
</div>
<div className="solde-chart-value">
{stats ? fmtEUR(stats.globalMoyenne) : '—'}
<span style={{ fontSize:14, fontWeight:400, color:'var(--text-muted)', marginLeft:8 }}>moy. mensuelle</span>
</div>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.max(0, w-1))}
disabled={!canPrev} style={{ opacity: canPrev ? 1 : 0.3 }}>‹</button>
{visibleYears.map(y => (
<button key={y}
className={`solde-range-btn${annee === y ? ' active' : ''}`}
onClick={() => setAnnee(y)}>
{y}
</button>
))}
<button className="solde-range-btn"
onClick={() => setWindowStart(w => Math.min(Math.max(0, availableYears.length - 3), w+1))}
disabled={!canNext} style={{ opacity: canNext ? 1 : 0.3 }}>›</button>
<button className={`solde-range-btn${annee === currentYear ? ' active' : ''}`}
onClick={() => setAnnee(currentYear)}>
TOUT
</button>
{expandButton}
</div>
</div>
</div>
{/* ── Table ── */}
{!grid || grid.length === 0 ? (
<div style={{ marginTop: 20, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', padding: '24px 0', textAlign: 'center' }}>
Aucune valorisation pour {annee}.
</div>
) : (
<div style={{ overflowX: 'auto', marginTop: 20, position: 'relative', zIndex: 0 }}>
<table className="tip-table">
<thead>
<tr>
<th className="tip-th-empty" />
<th className="tip-th-year" colSpan={12}>{annee}</th>
<th className="tip-th-empty" />
<th className="tip-th-empty" />
</tr>
<tr>
<th className="tip-th-name tip-th-name-amber">Plateforme</th>
{MOIS_LONG.map((m, i) => (
<th key={m}
className={`tip-th-month${annee === currentYear && i === currentMonth - 1 ? ' tip-th-month-current' : ''}`}>
{m}
</th>
))}
<th className="tip-th-total">Moyenne</th>
<th className="tip-th-avg">Poids</th>
</tr>
</thead>
<tbody>
{grid.map((plat, pi) => (
<tr key={plat.id} className="tip-row-plat">
<td className="tip-td-name">
{plat.nom}
{multiDetenteur && plat.detenteur_nom && (
<span style={{ marginLeft: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', fontWeight: 400 }}>
{plat.detenteur_nom}
</span>
)}
</td>
{plat.months.map((v, mi) => (
<td key={mi}
className={`tip-td-num${annee === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
</td>
))}
<td className="tip-td-total">
{stats.platMoyennes[pi] > 0 ? fmtEUR(stats.platMoyennes[pi]) : <span className="tip-dash">—</span>}
</td>
<td className="tip-td-avg">
{stats.platPoids[pi] > 0 ? (
<div style={{ display:'flex', alignItems:'center', gap:5, justifyContent:'flex-end' }}>
<div style={{ width:36, height:4, borderRadius:2, background:'var(--surface-2)', overflow:'hidden' }}>
<div style={{ width:`${Math.min(100,stats.platPoids[pi])}%`, height:'100%', background:'var(--primary)', borderRadius:2 }} />
</div>
<span style={{ minWidth:38, textAlign:'right' }}>{stats.platPoids[pi].toFixed(1)} %</span>
</div>
) : <span className="tip-dash">—</span>}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="tip-footer-total">
<td className="tip-td-name">Toutes les plateformes</td>
{stats.monthTotals.map((v, i) => (
<td key={i}
className={`tip-td-num${annee === currentYear && i === currentMonth - 1 ? ' tip-col-current' : ''}`}>
{v > 0 ? fmtEUR(v) : <span className="tip-dash">—</span>}
</td>
))}
<td className="tip-td-total">{fmtEUR(stats.globalMoyenne)}</td>
<td className="tip-td-void" />
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,333 @@
import { useMemo, useState, useRef } from 'react';
/* ── Constantes ─────────────────────────────────────────────── */
const BLUE = 'var(--primary)';
const BG = 'var(--surface)';
const GRID = 'var(--border)';
const LABEL = 'var(--text-muted)';
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
const W = 900, H = 260;
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
/* ── Helpers ────────────────────────────────────────────────── */
function fmtK(v) {
if (v === 0) return '0 €';
const abs = Math.abs(v);
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €';
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
}
function fmtAxisDate(dateStr, range) {
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
const mon = MOIS_COURT[d.getMonth()];
const yr = d.getFullYear();
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
if (range === '1M' || range === '3M') return `${day} ${mon}`;
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
}
function fmtValueDisplay(v) {
return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €';
}
/**
* Graphique "Valorisation totale du portefeuille" — équivalent Private
* Equity de InvChart.jsx (crowdlending), mais avec une sémantique
* radicalement différente (décision Olivier du 17/09/26) : là où InvChart
* cumule des deltas (capital en cours = investi − remboursé), ce composant
* agrège pour chaque deal sa DERNIÈRE valeur connue à une date donnée
* (montant_investi à la souscription, puis chaque valorisation saisie la
* remplace — jamais un delta additif), et somme ces dernières valeurs
* connues sur l'ensemble des deals. La courbe peut donc monter ET
* descendre au fil des valorisations, contrairement au capital en cours
* qui ne fait que décroître.
*/
export default function ValorisationPeChart({ rows, valorisations, platYear }) {
const [range, setRange] = useState('TOUT');
const [hover, setHover] = useState(null);
const svgRef = useRef(null);
const wrapRef = useRef(null);
const todayStr = new Date().toISOString().slice(0, 10);
const currentYear = String(new Date().getFullYear());
const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr;
const dataCutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : null;
/* ── 1. Courbe valorisation totale = somme, par deal, de la dernière
* valeur connue (montant_investi à la souscription, puis remplacée par
* chaque valorisation saisie) ── */
const allPoints = useMemo(() => {
if (!rows?.length) return [];
const rowIds = new Set(rows.map(r => r.id));
// Événements : chaque événement FIXE la valeur connue du deal (et non
// un delta cumulé comme InvChart) — une valorisation remplace la
// précédente valeur connue, elle ne s'y ajoute pas.
const events = [];
for (const r of rows) {
const d = r.date_souscription?.slice(0, 10);
if (!d || (dataCutoffStr && d > dataCutoffStr)) continue;
events.push({ date: d, dealId: r.id, value: r.montant_investi ?? 0 });
}
if (valorisations?.length) {
for (const v of valorisations) {
if (!rowIds.has(v.investissement_pe_id)) continue;
const d = v.date_valorisation?.slice(0, 10);
if (!d || v.valorisation_nette == null || (dataCutoffStr && d > dataCutoffStr)) continue;
events.push({ date: d, dealId: v.investissement_pe_id, value: v.valorisation_nette });
}
}
events.sort((a, b) => a.date.localeCompare(b.date) || a.dealId - b.dealId);
const lastValueByDeal = {};
let cum = 0;
const cumulated = [];
let i = 0;
while (i < events.length) {
const d = events[i].date;
while (i < events.length && events[i].date === d) {
const ev = events[i];
cum += ev.value - (lastValueByDeal[ev.dealId] ?? 0);
lastValueByDeal[ev.dealId] = ev.value;
i++;
}
cumulated.push({ date: d, value: Math.max(0, cum) });
}
// Un point dont la date dépasse aujourd'hui est regroupé sur la date
// du jour pour l'affichage (identique à InvChart).
const clamped = [];
for (const p of cumulated) {
const d = p.date > todayStr ? todayStr : p.date;
if (clamped.length && clamped[clamped.length - 1].date === d) {
clamped[clamped.length - 1] = { date: d, value: p.value };
} else {
clamped.push({ date: d, value: p.value });
}
}
return clamped;
}, [rows, valorisations, dataCutoffStr, todayStr]);
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
/* ── 2. Filtrage par plage ── */
const points = useMemo(() => {
if (!allPoints.length) return [];
if (range === 'TOUT') {
const pts = [...allPoints];
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
return pts;
}
const now = new Date(cutoffStr + 'T00:00:00');
let fromDate = new Date(now);
switch (range) {
case '1J': fromDate.setDate(now.getDate() - 1); break;
case '7J': fromDate.setDate(now.getDate() - 7); break;
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
}
const fromStr = fromDate.toISOString().slice(0, 10);
const before = allPoints.filter(p => p.date < fromStr);
const startV = before.length ? before[before.length - 1].value : 0;
const after = allPoints.filter(p => p.date >= fromStr);
const pts = [{ date: fromStr, value: startV }, ...after];
if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value });
return pts;
}, [allPoints, range, cutoffStr]);
/* ── 3. Échelles ── */
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
if (points.length < 2) return {};
const vals = points.map(p => p.value);
const dataMin = Math.min(...vals);
const dataMax = Math.max(...vals);
const lo = Math.min(0, dataMin);
const hi = Math.max(0, dataMax);
const pad = (hi - lo) * 0.1 || 10;
const scaleLo = lo - (lo < 0 ? pad : 0);
const scaleHi = hi + pad;
const valRange = scaleHi - scaleLo || 1;
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
const minDt = ts[0];
const maxDt = ts[ts.length - 1];
const dtRange = maxDt - minDt || 1;
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
const step = (scaleHi - scaleLo) / 4;
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
const nX = Math.min(8, points.length);
const xTicks = Array.from({ length: nX }, (_, i) => {
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
return points[idx];
});
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
}, [points]);
/* ── 4. Chemins SVG ── */
const { linePath, areaPath } = useMemo(() => {
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
for (let i = 1; i < points.length; i++) {
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
}
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
return { linePath: line, areaPath: area };
}, [points, xScale, yScale, yZero]);
/* ── 5. Hover ── */
const handleMouseMove = (e) => {
if (!svgRef.current || !xScale || points.length < 2) return;
const rect = svgRef.current.getBoundingClientRect();
const svgX = ((e.clientX - rect.left) / rect.width) * W;
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
let nearest = points[0], minDiff = Infinity;
for (const p of points) {
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
if (diff < minDiff) { minDiff = diff; nearest = p; }
}
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
};
/* ── Tooltip ── */
const tooltipStyle = useMemo(() => {
if (!hover) return null;
const xPct = (hover.x / W) * 100;
const yPct = (hover.y / H) * 100;
const anchorRight = xPct > 65;
return {
position: 'absolute',
top: `calc(${yPct}% - 64px)`,
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
transform: 'none',
pointerEvents: 'none',
};
}, [hover]);
if (!allPoints.length) return null;
const displayLabel = platYear ? `31/12/${platYear}` : "Aujourd'hui";
const displayDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
: displayLabel;
const displayValue = fmtValueDisplay(hover ? hover.value : currentValue);
const tooltipDate = hover
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
: null;
return (
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div className="solde-chart-date">Valorisation totale · {displayDate}</div>
<div className="solde-chart-value">{displayValue}</div>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
{RANGES.map(r => (
<button key={r}
className={`solde-range-btn${range === r ? ' active' : ''}`}
onClick={() => { setRange(r); setHover(null); }}>
{r}
</button>
))}
</div>
</div>
</div>
{/* ── SVG ── */}
{xScale && (
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="pe-val-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BLUE} stopOpacity="0.22" />
<stop offset="70%" stopColor={BLUE} stopOpacity="0.06" />
<stop offset="100%" stopColor={BLUE} stopOpacity="0" />
</linearGradient>
<filter id="pe-val-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Grille horizontale */}
{yTicks.map(({ v, y }) => (
<g key={v}>
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
{fmtK(v)}
</text>
</g>
))}
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
stroke={GRID} strokeWidth="1" />
)}
{/* Aire dégradée + courbe */}
<path d={areaPath} fill="url(#pe-val-fill)" />
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
filter="url(#pe-val-glow)" strokeLinejoin="round" />
{/* Labels axe X */}
{xTicks.map((p, i) => {
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
return (
<text key={i} x={xScale(p.date)} y={H - 8}
textAnchor={anchor} fill={LABEL} fontSize="10"
fontFamily="system-ui,sans-serif">
{fmtAxisDate(p.date, range)}
</text>
);
})}
{/* Hover : ligne verticale + point */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke={GRID} strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={BLUE} stroke={BG} strokeWidth="2" />
</g>
)}
</svg>
)}
{/* ── Tooltip flottant ── */}
{hover && tooltipStyle && (
<div style={tooltipStyle}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">{tooltipDate}</span>
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
</div>
</div>
)}
</div>
);
}
+5 -1
View File
@@ -78,9 +78,13 @@ export function AuthProvider({ children }) {
// simple assumé : n'apparaît qu'après rechargement de session suivant la toute première
// déclaration de frais, pas de mise à jour réactive en direct.
const hasFrais = !!user?.has_frais;
// Équivalent PE (chantier "frais PE", 17/09/26) — vrai dès qu'au moins un frais a été
// déclaré sur un deal Private Equity de l'utilisateur (calculé côté backend, cf.
// GET /auth/me, colonne has_frais_pe). Même mécanisme non réactif que hasFrais.
const hasFraisPe = !!user?.has_frais_pe;
return (
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin, hasFrais }}>
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin, hasFrais, hasFraisPe }}>
{children}
</AuthCtx.Provider>
);
+17 -4
View File
@@ -104,6 +104,15 @@ export function UiProvider({ children }) {
localStorage.getItem(KEY_ADMIN_COLOR_THEME) || 'classique'
);
// Couleur d'accent propre à l'espace de travail actif (17/09/26, cf.
// AppearanceSection.jsx / WorkspaceContext.jsx) : null si l'utilisateur n'a
// qu'un seul espace actif, ou si l'espace actif hérite de la couleur de
// l'application. Non persistée ici (c'est user_workspaces.color_theme, via
// WorkspaceContext, qui fait foi) — juste reflétée dans l'attribut
// data-color-theme par Layout.jsx à chaque changement d'espace actif.
// Prime sur colorTheme/adminColorTheme quand elle est définie.
const [workspaceColorOverride, setWorkspaceColorOverride] = useState(null);
// ── Thème par défaut (admin) : chargé une fois au montage ───────
// Endpoint public (pas d'auth requise) : reste valable sur la page de
// connexion, comme dans App.jsx.
@@ -116,10 +125,14 @@ export function UiProvider({ children }) {
}, []);
// ── Applique le thème de couleur effectif sur <html> ────────────
// Un choix personnel (colorTheme) prime toujours sur le défaut admin.
// Ordre de priorité : couleur du workspace actif (si définie) > choix
// personnel (colorTheme) > défaut admin.
useEffect(() => {
document.documentElement.setAttribute('data-color-theme', colorTheme || adminColorTheme);
}, [colorTheme, adminColorTheme]);
document.documentElement.setAttribute(
'data-color-theme',
workspaceColorOverride || colorTheme || adminColorTheme
);
}, [colorTheme, adminColorTheme, workspaceColorOverride]);
// ── Sync DB → localStorage au montage ──────────────────────────
// La DB fait foi : ses valeurs écrasent le localStorage si présentes.
@@ -253,7 +266,7 @@ export function UiProvider({ children }) {
}, [persistPref]);
return (
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs, colorTheme, adminColorTheme, setColorTheme }}>
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti, objectifsActifs, setObjectifsActifs, colorTheme, adminColorTheme, setColorTheme, workspaceColorOverride, setWorkspaceColorOverride }}>
{children}
</UiCtx.Provider>
);
+10 -1
View File
@@ -63,6 +63,15 @@ export function WorkspaceProvider({ children }) {
api.patch('/preferences', { active_workspace_id: String(id) }).catch(() => {});
}, []);
// Couleur d'accent personnelle par workspace (17/09/26, cf. AppearanceSection.jsx) —
// null = hérite de la couleur de l'application (UiContext.jsx). Mise à jour
// optimiste locale + persistance en base (user_workspaces.color_theme,
// routes/workspaces.js PATCH /:id/color).
const setWorkspaceColor = useCallback((workspaceId, value) => {
setWorkspaces(ws => ws.map(w => (w.id === workspaceId ? { ...w, color_theme: value } : w)));
api.patch(`/workspaces/${workspaceId}/color`, { color_theme: value }).catch(() => {});
}, []);
// Seuls les workspaces activés personnellement (active_by_user) comptent
// pour l'affichage — un workspace accordé par l'admin mais désactivé par
// l'utilisateur (Settings > Mes espaces de travail) reste invisible.
@@ -76,7 +85,7 @@ export function WorkspaceProvider({ children }) {
return (
<WorkspaceCtx.Provider value={{
workspaces, activeWorkspaces, active, activeId,
setActiveWorkspace, reload, loading,
setActiveWorkspace, setWorkspaceColor, reload, loading,
hasMultipleWorkspaces: activeWorkspaces.length > 1,
}}>
{children}
+50
View File
@@ -0,0 +1,50 @@
import { useCallback, useRef, useState } from 'react';
/**
* useAsyncAction — garde anti-double-exécution pour un handler asynchrone
* (soumission de formulaire, suppression, action d'un bouton…).
*
* Contexte : sur une connexion lente vers le serveur (VPN, 4G…), rien
* n'indiquait visuellement qu'une requête était en cours. L'utilisateur,
* ne voyant pas de retour, cliquait une seconde fois sur "Créer"/"Enregistrer"
* avant la fermeture de la modale (qui n'intervient qu'après le succès de la
* requête) — ce qui déclenchait un second POST et doublait la saisie en base.
*
* `run` protège contre ce phénomène à deux niveaux :
* 1. Une ref (donc synchrone, pas soumise au re-render React) bloque tout
* appel concurrent tant qu'un premier est en cours — même un double-clic
* très rapproché, avant que le bouton n'ait eu le temps de se désactiver
* visuellement, est ignoré.
* 2. `pending` (état React) permet en plus de désactiver l'UI (bouton,
* fermeture de la modale) et d'afficher un indicateur de chargement,
* pour que l'utilisateur sache que sa requête est bien partie.
*
* Usage :
* const { run, pending } = useAsyncAction();
* ...
* <button disabled={pending} onClick={() => run(submit)}>
* {pending ? 'Enregistrement…' : 'Enregistrer'}
* </button>
*
* `run` transmet ses arguments au handler (utile pour un onSubmit de <form>
* qui reçoit l'événement) et retourne sa valeur de résolution. Un appel
* ignoré (déjà en cours) résout silencieusement sur `undefined`.
*/
export function useAsyncAction() {
const runningRef = useRef(false);
const [pending, setPending] = useState(false);
const run = useCallback(async (handler, ...args) => {
if (runningRef.current) return undefined;
runningRef.current = true;
setPending(true);
try {
return await handler(...args);
} finally {
runningRef.current = false;
setPending(false);
}
}, []);
return { run, pending };
}
+1 -1
View File
@@ -397,7 +397,7 @@ function TauxCreditImpotSection() {
)}
{editing && <TciModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onClose={() => setDeleting(null)} />}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
</div>
);
}
+67 -19
View File
@@ -46,13 +46,14 @@ const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code
const FISCALITE_LABELS = { flat_tax: 'Flat tax (30%)', sans_fiscalite_locale: 'Sans fiscalité locale', avec_fiscalite_locale: 'Avec fiscalité locale' };
const CATEGORIE_PLATEFORME_LABELS = { psfp: 'Prestataire de Services de Financement Participatif', psan: 'Prestataire de Services sur Actifs Numériques' };
const FRAIS_PLATEFORME_LABELS = { aucun: 'Aucun frais', entree_gestion_directe: "Frais d'entrée + Frais de gestion (Prélèvement direct)" };
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [] };
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [], workspace_ids: [] };
/* ── Modal création / édition référentiel ─────────────────────── */
function RefModal({ row, onClose, onSaved }) {
const isNew = !row?.id;
const [catsInv, setCatsInv] = useState([]);
const [secteursInv, setSecteursInv] = useState([]);
const [workspaces, setWorkspaces] = useState([]);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
@@ -70,15 +71,30 @@ function RefModal({ row, onClose, onSaved }) {
categories: [],
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
workspace_ids: (row.workspaces || []).map(w => w.id),
});
useEffect(() => {
Promise.all([
api.get('/ref-categories'),
api.get('/ref-secteurs'),
]).then(([cats, sects]) => {
api.get('/admin/workspaces'),
]).then(([cats, sects, ws]) => {
setCatsInv(cats);
setSecteursInv(sects);
setWorkspaces(ws);
// À la création, présélectionne le workspace "crowdlending" (seul
// workspace réel aujourd'hui) — l'utilisateur reste libre d'ajuster ;
// au moins une affectation reste exigée à l'enregistrement.
if (isNew) {
setForm(f => (f.workspace_ids && f.workspace_ids.length > 0) ? f : {
...f,
workspace_ids: (() => {
const cl = ws.find(w => w.slug === 'crowdlending');
return cl ? [cl.id] : ws.map(w => w.id);
})(),
});
}
}).catch(() => {});
}, []); // eslint-disable-line
@@ -88,6 +104,7 @@ function RefModal({ row, onClose, onSaved }) {
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
if (!form.workspace_ids || form.workspace_ids.length === 0) { setErr('Sélectionnez au moins un espace de travail.'); return; }
setSaving(true); setErr(null);
try {
// Champs gérés par RefModal
@@ -105,6 +122,7 @@ function RefModal({ row, onClose, onSaved }) {
description: form.description.trim() || null,
categories_inv_ids: form.categories_inv_ids || [],
secteurs_inv_ids: form.secteurs_inv_ids || [],
workspace_ids: form.workspace_ids || [],
};
if (isNew) {
@@ -165,6 +183,26 @@ function RefModal({ row, onClose, onSaved }) {
<option value="psan">{CATEGORIE_PLATEFORME_LABELS.psan}</option>
</select>
</div>
<div>
<label>Espaces de travail *</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{workspaces.map(w => {
const checked = (form.workspace_ids || []).includes(w.id);
return (
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => set('workspace_ids', e.target.checked
? [...(form.workspace_ids || []), w.id]
: (form.workspace_ids || []).filter(id => id !== w.id))} />
<span className="chip-cat" style={{ fontSize: 11 }}>{w.nom}</span>
</label>
);
})}
</div>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
Cette entrée du référentiel ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).
</div>
</div>
<div><label>Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
style={{ minHeight: 180, resize: 'vertical' }} placeholder="Informations générales sur la plateforme…" /></div>
@@ -327,6 +365,7 @@ function ReferentielSection() {
const { search: urlSearch } = useLocation();
const [search, setSearch] = useState('');
const [filterDomicil, setFilterDomicil] = useState('');
const [filterWorkspace, setFilterWorkspace] = useState('');
const [filterCategorie, setFilterCategorie] = useState(
() => new URLSearchParams(urlSearch).get('filterCat') || ''
);
@@ -337,6 +376,12 @@ function ReferentielSection() {
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [rows]);
const allWorkspaces = useMemo(() => {
const map = new Map();
rows.forEach(r => (r.workspaces || []).forEach(w => map.set(w.id, w.nom)));
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'fr'));
}, [rows]);
const allDomiciliations = useMemo(() => {
const set = new Set();
rows.forEach(r => { if (r.domiciliation) set.add(r.domiciliation); });
@@ -346,16 +391,17 @@ function ReferentielSection() {
const filteredRows = useMemo(() => {
let r = rows;
if (filterDomicil) r = r.filter(x => x.domiciliation === filterDomicil);
if (filterWorkspace) r = r.filter(x => (x.workspaces || []).some(w => String(w.id) === filterWorkspace));
if (filterCategorie) r = r.filter(x => (x.categories_inv || []).some(c => c.nom === filterCategorie));
if (search.trim()) {
const q = search.trim().toLowerCase();
r = r.filter(x => x.nom.toLowerCase().includes(q));
}
return r;
}, [rows, search, filterDomicil, filterCategorie]);
}, [rows, search, filterDomicil, filterWorkspace, filterCategorie]);
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterCategorie]);
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterWorkspace, filterCategorie]);
const load = useCallback(async () => {
try {
@@ -486,6 +532,16 @@ function ReferentielSection() {
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 14px', fontSize: 14, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<select
value={filterWorkspace}
onChange={e => setFilterWorkspace(e.target.value)}
style={{ flex: 1, padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
>
<option value="">Tous les espaces de travail</option>
{allWorkspaces.map(([id, nom]) => (
<option key={id} value={String(id)}>{nom}</option>
))}
</select>
<select
value={filterDomicil}
onChange={e => setFilterDomicil(e.target.value)}
@@ -504,9 +560,9 @@ function ReferentielSection() {
<option value="">Toutes les catégories</option>
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{(search || filterDomicil || filterCategorie) && (
{(search || filterDomicil || filterWorkspace || filterCategorie) && (
<button
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterCategorie(''); }}
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterWorkspace(''); setFilterCategorie(''); }}
style={{ padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}
>Effacer les filtres</button>
)}
@@ -537,15 +593,14 @@ function ReferentielSection() {
<th style={{ padding: '10px 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Héritage</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
{rows.length === 0
? 'Aucune entrée dans le référentiel. Créez-en une ou importez une plateforme orpheline.'
: 'Aucun résultat pour ces critères.'}
@@ -573,16 +628,9 @@ function ReferentielSection() {
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}><span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={row.domiciliation} size={15} />{countryLabel(row.domiciliation)}</span></td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.categories_inv || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{(row.categories_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories_inv.length - 2}</span>}
{(row.categories_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.secteurs_inv || []).slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{(row.secteurs_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.secteurs_inv.length - 2}</span>}
{(row.secteurs_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
{(row.workspaces || []).slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
{(row.workspaces || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.workspaces.length - 2}</span>}
{(row.workspaces || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
+331
View File
@@ -0,0 +1,331 @@
import { useEffect, useMemo, useState } from 'react';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import ValorisationPeChart from '../components/ValorisationPeChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import { fmtEUR, fmtPct } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
/**
* Tableau de bord Private Equity (chantier "dashboard PE", 17/09/26) — première
* version structurelle demandée par Olivier ("un premier jet avec tout") : KPI
* de portefeuille (porte-monnaie, capital investi, NAV, TVPI/DPI/RVPI, TRI,
* frais cumulés, fraîcheur des valorisations) + graphiques (courbe de
* valorisation globale, répartitions par plateforme/stratégie, frais par
* catégorie) + classement des deals par performance. Réutilise
* ValorisationPeChart (déjà utilisé sur la page Investissements PE) pour la
* courbe, et GET /api/dashboard-pe (déjà monté côté backend, jusqu'ici
* consommé uniquement par DepotsRetraitsSimple.jsx) pour le solde du
* porte-monnaie — même snapshot que le Dashboard crowdlending. Les
* répartitions passent par un petit composant local BarBreakdown plutôt que
* FraisDistributionChart, dont l'en-tête affiche le mot "Frais" en dur —
* trompeur pour une répartition de capital investi.
*/
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
const PALETTE = [
'#1e40af', '#9333ea', '#0d9488', '#f59e0b',
'#e8547a', '#10b981', '#f97316', '#06b6d4',
];
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
/* ── Carte KPI ──────────────────────────────────────────────── */
function KpiCard({ title, value, note, color }) {
return (
<div className="kpi">
<div className="label">{title}</div>
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4, color: color || 'var(--text)' }}>{value}</div>
{note && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{note}</div>}
</div>
);
}
/* ── Répartition en barres horizontales (générique, léger) ─────
* items : [{ label, value }] — trié par valeur décroissante, largeur des
* barres proportionnelle au maximum. Volontairement plus simple qu'un
* treemap SVG (DistributionChart / FraisDistributionChart) : ce composant
* reste neutre (pas de titre métier en dur) pour être réutilisé sur
* plusieurs répartitions différentes sans être trompeur. */
function BarBreakdown({ title, items }) {
const data = (items || []).filter(i => i.value > 0).sort((a, b) => b.value - a.value);
const max = data.length ? data[0].value : 0;
const total = data.reduce((s, i) => s + i.value, 0);
return (
<div className="card">
<h3 style={{ margin: '0 0 14px' }}>{title}</h3>
{data.length === 0 && (
<div className="text-muted" style={{ textAlign: 'center', padding: '20px 0' }}>Aucune donnée</div>
)}
{data.map((d, i) => (
<div key={d.label} style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85em', marginBottom: 3 }}>
<span>{d.label}</span>
<span className="text-muted">{fmtEUR(d.value)} · {total > 0 ? Math.round(d.value / total * 100) : 0}%</span>
</div>
<div style={{ background: 'var(--surface-2)', borderRadius: 6, height: 8, overflow: 'hidden' }}>
<div style={{
width: `${max > 0 ? (d.value / max * 100) : 0}%`, height: '100%',
background: PALETTE[i % PALETTE.length], borderRadius: 6,
}} />
</div>
</div>
))}
</div>
);
}
/* ── Mini-jauge TVPI (classement par deal) — échelle visuelle 0 → x3 (au-delà,
* la barre sature à 100% : l'essentiel est de voir d'un coup d'œil qui est
* au-dessus/en-dessous de x1, pas d'avoir une échelle linéaire fidèle à un
* multiple hors norme). ── */
function TvpiBar({ value }) {
if (value == null) return <span className="text-muted">—</span>;
const pct = Math.min(value, 3) / 3 * 100;
const color = value >= 1 ? 'var(--success)' : 'var(--danger)';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ position: 'relative', width: 90, height: 8, background: 'var(--surface-2)', borderRadius: 4, flexShrink: 0 }}>
<div style={{ position: 'absolute', inset: 0, width: `${pct}%`, background: color, borderRadius: 4 }} />
<div title="x1 (seuil de rentabilité)" style={{ position: 'absolute', left: `${(1 / 3) * 100}%`, top: -2, width: 1, height: 12, background: 'var(--text-muted)' }} />
</div>
<span style={{ fontWeight: 600, color }}>{fmtMultiple(value)}</span>
</div>
);
}
export default function DashboardPe() {
const { activeId: investisseurId } = useInvestisseur();
const { active } = useWorkspace() || {};
const [rows, setRows] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [fraisOps, setFraisOps] = useState([]);
const [rembs, setRembs] = useState([]);
const [wallet, setWallet] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
useEffect(() => {
if (!investisseurId) return;
setLoading(true);
setErr(null);
Promise.all([
api.get('/investissements-pe'),
api.get('/investissements-pe/valorisations'),
api.get('/frais-operations'),
api.get('/remboursements'),
api.get('/dashboard-pe'),
]).then(([inv, val, frais, remb, dash]) => {
setRows(inv);
setValorisations(val);
setFraisOps(frais);
setRembs(remb);
setWallet(dash);
}).catch(e => setErr(e.message))
.finally(() => setLoading(false));
}, [investisseurId, active?.id]);
/* ── Agrégats KPI ── */
const totals = useMemo(() => {
let investi = 0, nav = 0, distributions = 0, fraisCumules = 0, actifs = 0, soldes = 0;
const staleDeals = [];
const staleThreshold = new Date();
staleThreshold.setMonth(staleThreshold.getMonth() - 6);
const staleThresholdStr = staleThreshold.toISOString().slice(0, 10);
for (const r of rows) {
investi += r.montant_investi;
nav += (r.derniere_valorisation ?? r.montant_investi);
distributions += r.distributions_cumulees || 0;
const enCours = r.statut === 'valide' || r.statut === 'en_attente';
if (enCours) {
actifs++;
if (!r.derniere_valorisation_date || r.derniere_valorisation_date < staleThresholdStr) {
staleDeals.push(r.nom_deal);
}
} else {
soldes++;
}
}
for (const f of fraisOps) fraisCumules += f.montant || 0;
return {
investi, nav, distributions, fraisCumules, actifs, soldes,
staleCount: staleDeals.length, staleDeals,
tvpi: investi > 0 ? (nav + distributions) / investi : null,
dpi: investi > 0 ? distributions / investi : null,
rvpi: investi > 0 ? nav / investi : null,
};
}, [rows, fraisOps]);
/* ── TRI (XIRR) global du portefeuille — même méthode que le XIRR
* crowdlending (Dashboard.jsx) : flux réels datés + valeur résiduelle des
* deals encore actifs, valorisée "comme vendue aujourd'hui". ── */
const xirrData = useMemo(() => {
const cutoff = new Date().toISOString().slice(0, 10);
const flows = [];
for (const r of rows) {
if (!r.date_souscription) continue;
flows.push({ amount: -r.montant_investi, date: r.date_souscription });
}
for (const rb of rembs) {
if (rb.investissement_pe_id == null) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || d > cutoff) continue;
flows.push({ amount: rb.net_recu || 0, date: d });
}
// Frais réglés hors "source" (vraie sortie de trésorerie du détenteur) —
// même logique que le XIRR net crowdlending.
for (const fo of fraisOps) {
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
const d = fo.date_operation?.slice(0, 10);
if (!d || d > cutoff) continue;
flows.push({ amount: -(fo.montant || 0), date: d });
}
let estime = false;
for (const r of rows) {
if (r.statut !== 'valide' && r.statut !== 'en_attente') continue;
const val = r.derniere_valorisation ?? r.montant_investi;
if (val > 0.01) { estime = true; flows.push({ amount: val, date: cutoff }); }
}
flows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
return { rate: xirr(flows), estime };
}, [rows, rembs, fraisOps]);
/* ── Répartitions ── */
const capitalParPlateforme = useMemo(() => {
const map = {};
for (const r of rows) map[r.plateforme_nom] = (map[r.plateforme_nom] || 0) + r.montant_investi;
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [rows]);
const capitalParStrategie = useMemo(() => {
const map = {};
for (const r of rows) {
const k = r.strategie || 'Non renseignée';
map[k] = (map[k] || 0) + r.montant_investi;
}
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [rows]);
const fraisParCategorie = useMemo(() => {
const map = {};
for (const f of fraisOps) {
const k = FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie || 'Autre';
map[k] = (map[k] || 0) + (f.montant || 0);
}
return Object.entries(map).map(([label, value]) => ({ label, value }));
}, [fraisOps]);
/* ── Classement des deals par TVPI ── */
const ranking = useMemo(() => [...rows].sort((a, b) => {
if (a.tvpi == null && b.tvpi == null) return 0;
if (a.tvpi == null) return 1;
if (b.tvpi == null) return -1;
return b.tvpi - a.tvpi;
}), [rows]);
const xirrDisplay = xirrData.rate != null ? fmtPct(xirrData.rate * 100) : 'Non calculable';
const xirrColor = xirrData.rate != null ? (xirrData.rate >= 0 ? 'var(--success)' : 'var(--danger)') : undefined;
const xirrNote = xirrData.rate == null
? 'Pas assez de flux datés pour calculer un taux'
: (xirrData.estime ? 'Estimé — deals actifs valorisés à date du jour' : 'Taux définitif — tous les deals sont soldés');
const staleNote = totals.staleCount === 0
? 'Toutes les valorisations actives sont à jour'
: totals.staleDeals.slice(0, 3).join(', ') + (totals.staleDeals.length > 3 ? '…' : '');
return (
<>
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {active?.nom || 'Private Equity'}</h2></div>
{loading && <div className="text-muted" style={{ padding: '24px 0' }}>Chargement…</div>}
{err && <div className="error">{err}</div>}
{!loading && !err && rows.length === 0 && (
<EmptyState
icon="📈"
title="Aucun investissement enregistré"
message="Ajoutez votre premier investissement Private Equity pour voir apparaître les indicateurs et graphiques du portefeuille."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
)}
{!loading && !err && rows.length > 0 && (
<>
<div className="kpi-grid">
<KpiCard title="Capital investi total" value={fmtEUR(totals.investi)} />
<KpiCard
title="Porte-monnaie"
value={wallet ? fmtEUR(wallet.solde_portefeuille_total) : '…'}
note="Cash disponible sur les plateformes, hors compte courant"
/>
<KpiCard title="Valorisation nette (NAV)" value={fmtEUR(totals.nav)} />
<KpiCard title="Distributions cumulées" value={fmtEUR(totals.distributions)} />
<KpiCard title="TVPI portefeuille" value={fmtMultiple(totals.tvpi)} note="Valeur totale / capital investi" />
<KpiCard title="DPI (réalisé)" value={fmtMultiple(totals.dpi)} note="Distributions / capital investi" />
<KpiCard title="RVPI (non réalisé)" value={fmtMultiple(totals.rvpi)} note="Valorisation résiduelle / capital investi" />
<KpiCard title="TRI (XIRR) global" value={xirrDisplay} color={xirrColor} note={xirrNote} />
<KpiCard title="Frais cumulés" value={fmtEUR(totals.fraisCumules)} />
<KpiCard title="Deals" value={`${totals.actifs} actif${totals.actifs > 1 ? 's' : ''}`} note={`${totals.soldes} soldé${totals.soldes > 1 ? 's' : ''}`} />
<KpiCard
title="Valorisations à rafraîchir"
value={String(totals.staleCount)}
color={totals.staleCount > 0 ? 'var(--warning)' : undefined}
note={staleNote}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '5fr 3fr', gap: 16, marginTop: 8, marginBottom: 16 }}>
<ValorisationPeChart rows={rows} valorisations={valorisations} platYear={null} />
<BarBreakdown title="Capital investi par plateforme" items={capitalParPlateforme} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<BarBreakdown title="Capital investi par stratégie" items={capitalParStrategie} />
<BarBreakdown title="Frais cumulés par catégorie" items={fraisParCategorie} />
</div>
<div className="card">
<h3 style={{ margin: '0 0 14px' }}>Performance par deal</h3>
<table>
<thead>
<tr>
<th>Deal</th><th>Plateforme</th><th>Statut</th>
<th className="num">Capital investi</th>
<th className="num">Distributions</th>
<th className="num">Valorisation actuelle</th>
<th>TVPI</th>
</tr>
</thead>
<tbody>
{ranking.map(r => (
<tr key={r.id}>
<td>{r.nom_deal}</td>
<td className="text-muted">{r.plateforme_nom}</td>
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
<td className="num">{fmtEUR(r.montant_investi)}</td>
<td className="num">{fmtEUR(r.distributions_cumulees || 0)}</td>
<td className="num">{fmtEUR(r.derniere_valorisation ?? r.montant_investi)}</td>
<td><TvpiBar value={r.tvpi} /></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
);
}
+10 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
@@ -115,6 +116,7 @@ export default function DepotsRetraits() {
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyMouvementForm);
const [err, setErr] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
const [drPlatYear, setDrPlatYear] = useState('');
@@ -1679,20 +1681,23 @@ export default function DepotsRetraits() {
open={modalOpen}
title={editingId ? 'Modifier le mouvement' : 'Nouveau mouvement'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', gap: 8 }}>
{editingId && <button className="danger" onClick={onDelete}>Supprimer</button>}
{editingId && <button onClick={() => openCorrection(null)}>Correction de solde</button>}
{editingId && <button className="danger" disabled={submitting} onClick={onDelete}>Supprimer</button>}
{editingId && <button disabled={submitting} onClick={() => openCorrection(null)}>Correction de solde</button>}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={close}>Annuler</button>
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
</div>
}
>
<form onSubmit={submit}>
<form onSubmit={e => runSubmit(submit, e)}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
+676
View File
@@ -0,0 +1,676 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
import SoldeChart from '../components/SoldeChart.jsx';
import DistributionChart from '../components/DistributionChart.jsx';
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, rangeFromDate } from '../utils/format.js';
import { emptyMouvementForm } from '../components/MouvementsShared.jsx';
/**
* Dépôts / Retraits — version simplifiée, utilisée par tout workspace autre
* que crowdlending (Private Equity pour l'instant — cf. DepotsRetraitsRouter
* dans App.jsx, jamais lié en dur à un slug précis).
*
* Depuis le 17/09/26 (demande d'Olivier : "adapter la page dépôt/retrait pour
* qu'elle ressemble à la page dépôt/retrait de Crowdlending mais avec les
* données de Private Equity"), cette page reprend le graphique de solde
* cumulé, la Distribution par plateforme, les KPIs (Dépôts/Retraits/Diff/
* Porte-monnaie) et les onglets Plateformes + Vision mensuelle de
* pages/DepotsRetraits.jsx (crowdlending).
*
* Volontairement absents pour cette itération (décisions Olivier du 17/09/26) :
* - L'onglet "Mouvements" façon crowdlending (journal unifié dépôts/retraits +
* corrections + remboursements + souscriptions + frais/prélèvements fiscaux,
* cf. MouvementsShared.jsx) : trop couplé à la fiscalité crowdlending, différé.
* L'onglet "Mouvements" ci-dessous reste la version simple préexistante
* (depots_retraits uniquement), avec les mêmes filtres type/plateforme/année
* que les autres onglets.
* - L'onglet "Objectifs" (table `objectifs`, pas encore rattachée à un
* workspace_id) : hors scope pour l'instant.
* - La Distribution est groupée par Plateforme comme côté Crowdlending (le
* composant DistributionChart groupe en dur sur plateforme_nom, choix
* confirmé par Olivier plutôt qu'un regroupement par deal).
*
* Le porte-monnaie par plateforme et les KPIs viennent du nouveau endpoint
* GET /api/dashboard-pe (backend/src/routes/dashboardPe.js) — équivalent PE de
* GET /api/dashboard, basé sur investissements_pe.montant_investi au lieu de
* investissements, sans corrections/réinvestissements/frais_operations.
*/
export default function DepotsRetraitsSimple() {
const { activeId: investisseurId, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [allPlats, setAllPlats] = useState([]);
const [dashData, setDashData] = useState(null);
const [dashDataAsOf, setDashDataAsOf] = useState({});
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyMouvementForm);
const [deleteTarget, setDeleteTarget] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [activeTab, setActiveTab] = useState('plateformes');
// Défaut 'TOUT' aligné sur DepotsRetraits.jsx (crowdlending) — évite qu'un
// jeu de données encore clairsemé (peu de mouvements, potentiellement plus
// vieux qu'1 an) fasse disparaître silencieusement la Distribution.
const [chartRange, setChartRange] = useState('TOUT');
const [filter, setFilter] = useState({ type: '', plateforme_ids: [] });
const [platYear, setPlatYear] = useState('');
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces).
const plats = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
const load = async () => {
setLoading(true);
try {
const [dr, pl, dash] = await Promise.all([
api.get('/depots-retraits'),
api.get('/plateformes'),
api.get('/dashboard-pe'),
]);
setRows(dr);
setAllPlats(pl);
setDashData(dash);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (investisseurId) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
const years = useMemo(
() => [...new Set(rows.map(r => r.date_operation.slice(0, 4)))].sort().reverse(),
[rows],
);
// Snapshots fin d'année (année sélectionnée + année précédente, pour le
// TrendBadge) — chargés à la demande, mêmes principes que DepotsRetraits.jsx.
const prevYear = String(Number(platYear || new Date().getFullYear()) - 1);
useEffect(() => {
if (!platYear) return;
[platYear, prevYear].forEach(y => {
const key = `${y}-12-31`;
if (dashDataAsOf[key]) return;
api.get('/dashboard-pe', { asOf: key })
.then(snap => setDashDataAsOf(prev => ({ ...prev, [key]: snap })))
.catch(() => {});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platYear, investisseurId, activeWorkspace?.id]);
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
};
const openAdd = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
};
// Ouverture auto depuis le bouton global "Nouveau dépôt/retrait" (topbar/burger,
// cf. Layout.jsx — même ?new=1 que la page crowdlending). Le paramètre est
// retiré de l'URL dès sa lecture, indépendamment du chargement des
// plateformes ; un ref différé ouvre la modale une fois `plats` disponible,
// pour ne jamais perdre la demande si elle arrive avant le premier chargement.
const pendingNew = useRef(false);
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('new') !== '1') return;
navigate('/depots-retraits', { replace: true });
if (plats.length > 0) {
openAdd();
} else {
pendingNew.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
useEffect(() => {
if (pendingNew.current && plats.length > 0) {
pendingNew.current = false;
openAdd();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plats.length]);
const openEdit = (r) => {
setEditingId(r.id);
setForm({
investisseur_id: String(r.investisseur_id || ''),
plateforme_id: String(r.plateforme_id || ''),
date_operation: r.date_operation,
type: r.type,
montant: String(r.montant ?? ''),
libelle: r.libelle || '',
reference: r.reference || '',
notes: r.notes || '',
});
setErr(null);
setModalOpen(true);
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); };
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
const payload = {
investisseur_id: Number(form.investisseur_id) || undefined,
plateforme_id: Number(form.plateforme_id),
date_operation: form.date_operation,
type: form.type,
montant: Number(form.montant),
libelle: form.libelle || undefined,
reference: form.reference || undefined,
notes: form.notes || undefined,
};
try {
if (editingId) await api.put(`/depots-retraits/${editingId}`, payload);
else await api.post('/depots-retraits', payload);
close();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
if (!deleteTarget) return;
try {
await api.del(`/depots-retraits/${deleteTarget.id}`);
setDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
};
/* ── Graphique / Distribution / KPIs (mêmes principes que DepotsRetraits.jsx,
* sans le filtre mois — pas d'onglet Mouvements multi-source ici) ── */
const kpiRows = rows.filter(r => {
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
if (platYear && r.date_operation.slice(0, 4) !== platYear) return false;
return true;
});
const chartRows = kpiRows.filter(r => !filter.type || r.type === filter.type);
const distributionRows = useMemo(() => {
const from = rangeFromDate(chartRange);
if (!from) return chartRows;
return chartRows.filter(r => r.date_operation.slice(0, 10) >= from);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartRows, chartRange]);
const totals = kpiRows.reduce((acc, r) => {
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
return acc;
}, { depots: 0, retraits: 0 });
const soldeFromSnapshot = (snap) => {
if (!snap) return null;
if (filter.plateforme_ids.length > 0) {
return (snap.cashByPlatform ?? [])
.filter(c => filter.plateforme_ids.includes(String(c.plateforme_id)))
.reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0);
}
return snap.solde_portefeuille_total ?? null;
};
const kpiSoldePortefeuille = (() => {
if (!platYear) {
if (filter.plateforme_ids.length === 1 && dashData) {
const p = dashData.cashByPlatform?.find(c => String(c.plateforme_id) === filter.plateforme_ids[0]);
return p?.solde_portefeuille ?? null;
}
return dashData?.solde_portefeuille_total ?? null;
}
return soldeFromSnapshot(dashDataAsOf[`${platYear}-12-31`]);
})();
const prevKpiSoldePortefeuille = platYear ? soldeFromSnapshot(dashDataAsOf[`${prevYear}-12-31`]) : null;
const prevTotals = useMemo(() => {
if (!platYear) return { depots: 0, retraits: 0 };
return rows.filter(r => {
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
return r.date_operation.slice(0, 4) === prevYear;
}).reduce((acc, r) => {
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
return acc;
}, { depots: 0, retraits: 0 });
}, [rows, filter.plateforme_ids, platYear, prevYear]);
/* ── Données de l'onglet Plateformes (directement depuis dashboard-pe,
* pas de recalcul client contrairement à DepotsRetraits.jsx — le endpoint
* PE supporte déjà ?asOf nativement) ── */
const platData = useMemo(() => {
const src = platYear
? (dashDataAsOf[`${platYear}-12-31`]?.cashByPlatform ?? [])
: (dashData?.cashByPlatform ?? []);
return [...src].sort((a, b) =>
a.nom.localeCompare(b.nom, 'fr') || (a.detenteur_nom ?? '').localeCompare(b.detenteur_nom ?? '', 'fr'));
}, [dashData, dashDataAsOf, platYear]);
const platTotals = useMemo(() => platData.reduce((acc, p) => {
acc.depots += p.depots; acc.retraits += p.retraits;
acc.solde_net += p.solde_net; acc.solde_portefeuille += p.solde_portefeuille ?? 0;
return acc;
}, { depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }), [platData]);
/* ── Onglet Mouvements : liste simple depots_retraits, avec les mêmes
* filtres type/plateforme/année que les autres onglets ── */
const mouvementsRows = useMemo(
() => [...kpiRows]
.filter(r => !filter.type || r.type === filter.type)
.sort((a, b) => b.date_operation.localeCompare(a.date_operation) || b.id - a.id),
[kpiRows, filter.type],
);
const hasActiveFilter = filter.type || filter.plateforme_ids.length > 0 || platYear;
const clearFilters = () => { setFilter({ type: '', plateforme_ids: [] }); setPlatYear(''); };
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2></div>
<EmptyState
title="Aucune plateforme dans cet espace de travail"
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes pour pouvoir y enregistrer des dépôts et retraits.`}
cta="Configurer mes plateformes"
to="/settings?section=plateformes&openAdd=1"
/>
</>
);
if (!loading && !modalOpen && rows.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2></div>
<EmptyState
icon="💶"
title="Aucun dépôt enregistré"
message="Vos plateformes sont configurées, mais aucun dépôt n'a encore été enregistré."
cta="Ajouter un dépôt"
to="/depots-retraits?new=1"
/>
<Modal
open={modalOpen}
title={editingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? 'Création…' : 'Créer'}
</button>
</div>
}
>
<DepotsRetraitsForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} investisseurForPlat={investisseurForPlat} />
</Modal>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2>
{hasActiveFilter && (
<button className="dr-clear-btn" onClick={clearFilters}>✕ Effacer les filtres</button>
)}
</div>
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
{/* ── Graphiques ── */}
<div className="charts-row">
<SoldeChart rows={chartRows} range={chartRange} onRangeChange={setChartRange} />
<DistributionChart rows={distributionRows} />
</div>
{/* ── KPIs ── */}
<div className="dr-kpi-row">
<div
className={`kpi dr-kpi-clickable${filter.type === 'depot' ? ' dr-kpi-active dr-kpi-active-success' : ''}`}
onClick={() => setFilter(f => ({ ...f, type: f.type === 'depot' ? '' : 'depot' }))}
title="Filtrer sur les dépôts"
>
<div className="label">Dépôts</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots)}</span>
{platYear && <TrendBadge current={totals.depots} prev={prevTotals.depots} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots)} en {prevYear}</div>}
</div>
<div
className={`kpi dr-kpi-clickable${filter.type === 'retrait' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
onClick={() => setFilter(f => ({ ...f, type: f.type === 'retrait' ? '' : 'retrait' }))}
title="Filtrer sur les retraits"
>
<div className="label">Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.retraits)}</span>
{platYear && <TrendBadge current={totals.retraits} prev={prevTotals.retraits} invert={true} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>}
</div>
<div
className={`kpi${hasActiveFilter ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
onClick={hasActiveFilter ? clearFilters : undefined}
title={hasActiveFilter ? 'Effacer les filtres' : undefined}
style={hasActiveFilter ? { cursor: 'pointer' } : {}}
>
<div className="label">Diff. Dépôts vs Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots - totals.retraits)}</span>
{platYear && <TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>}
</div>
<div className="kpi" title="Cash disponible sur les porte-monnaie des plateformes">
<div className="label">Porte-monnaie</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'}</span>
{platYear && kpiSoldePortefeuille !== null && <TrendBadge current={kpiSoldePortefeuille} prev={prevKpiSoldePortefeuille} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}</div>}
</div>
</div>
{/* ── Onglets ── */}
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
onClick={() => setActiveTab('mouvements')}>Mouvements</button>
</div>
{/* ====== ONGLET PLATEFORMES ====== */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par plateforme
{platYear && <span style={{ marginLeft: 8 }}>pour l'année {platYear}</span>}
</h3>
<select value={platYear} onChange={e => setPlatYear(e.target.value)}
style={{
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-muted)',
cursor: 'pointer', outline: 'none',
}}>
<option value="">Toutes les années</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{!dashData ? (
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
) : (
<table>
<thead>
<tr><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
</thead>
<tbody>
{platData.length === 0 && (
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
)}
{(() => {
const totalSolde = platTotals.solde_net;
return platData.map(p => {
const isActive = filter.plateforme_ids.includes(String(p.plateforme_id));
const poids = totalSolde !== 0 ? (p.solde_net / totalSolde) * 100 : 0;
return (
<tr key={p.plateforme_id}
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setFilter(f => ({
...f,
plateforme_ids: isActive
? f.plateforme_ids.filter(id => id !== String(p.plateforme_id))
: [...f.plateforme_ids, String(p.plateforme_id)]
}))}>
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{fmtEUR(p.depots)}</td>
<td className="num">{fmtEUR(p.retraits)}</td>
<td className="num">{fmtEUR(p.solde_net)}</td>
<td className="num">{fmtEUR(p.solde_portefeuille ?? 0)}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{poids.toFixed(1)} %</span>
</div>
</td>
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isActive ? '✕' : ''}</td>
</tr>
);
});
})()}
</tbody>
{platData.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {platData.length} plateformes</td>
{multiDetenteur && <td />}
<td className="num">{fmtEUR(platTotals.depots)}</td>
<td className="num">{fmtEUR(platTotals.retraits)}</td>
<td className="num">{fmtEUR(platTotals.solde_net)}</td>
<td className="num">{fmtEUR(platTotals.solde_portefeuille)}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
<td />
</tr>
</tfoot>
)}
</table>
)}
</div>
</div>
)}
{/* ====== ONGLET VISION MENSUELLE ====== */}
{activeTab === 'vision-mensuelle' && (
<div style={{ padding: '0 24px' }}>
<DepotsMensuelTable allRows={rows} plats={plats} />
</div>
)}
{/* ====== ONGLET MOUVEMENTS ====== */}
{activeTab === 'mouvements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Mouvements</h3>
<button className="primary" onClick={openAdd}>+ Nouveau dépôt/retrait</button>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
<th>Type</th><th className="num">Montant</th><th>Libellé</th><th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{mouvementsRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
)}
{mouvementsRows.map(r => (
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openEdit(r)}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</td>}
<td>{r.type === 'depot' ? 'Dépôt' : 'Retrait'}</td>
<td className="num">{fmtEUR(r.montant)}</td>
<td className="text-muted">{r.libelle || '—'}</td>
<td onClick={e => e.stopPropagation()}>
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<Modal
open={modalOpen}
title={editingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div>
{editingId && (
<button className="danger" disabled={submitting}
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
Supprimer
</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
</div>
}
>
<DepotsRetraitsForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} investisseurForPlat={investisseurForPlat} />
</Modal>
<ConfirmModal
open={!!deleteTarget}
title="Supprimer ce mouvement ?"
message="Cette action est irréversible."
confirmLabel="Supprimer"
onConfirm={onDelete}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}
/* ── Indicateur de progression (copié de pages/DepotsRetraits.jsx — pas
* exporté par MouvementsShared.jsx) ── */
function TrendBadge({ current, prev, invert = false }) {
if (prev == null || Math.abs(prev) < 0.005) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
* points d'entrée (page avec mouvements existants, et état vide). Champs
* strictement alignés sur le Schema de backend/src/routes/depotsRetraits.js. */
function DepotsRetraitsForm({ form, setForm, plats, investisseurs, multiDetenteur, err, runSubmit, submit, investisseurForPlat }) {
return (
<form onSubmit={e => runSubmit(submit, e)}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Plateforme *</label>
<select required value={form.plateforme_id} onChange={e => {
const platId = e.target.value;
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId) }));
}}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
<div>
<label>Date *</label>
<input type="date" required value={form.date_operation} onChange={e => setForm({ ...form, date_operation: e.target.value })} />
</div>
<div>
<label>Type *</label>
<select required value={form.type} onChange={e => setForm({ ...form, type: e.target.value })}>
<option value="depot">Dépôt</option>
<option value="retrait">Retrait</option>
</select>
</div>
<div>
<label>Montant (€) *</label>
<input type="number" step="0.01" min="0" required value={form.montant} onChange={e => setForm({ ...form, montant: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Libellé</label>
<input value={form.libelle} onChange={e => setForm({ ...form, libelle: e.target.value })} placeholder="Ex. Appel de fonds" />
</div>
<div>
<label>Référence</label>
<input value={form.reference} onChange={e => setForm({ ...form, reference: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.investisseur_id} onChange={e => setForm({ ...form, investisseur_id: e.target.value })}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
</div>
</form>
);
}
+5 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
@@ -198,6 +199,7 @@ export default function Frais() {
};
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
const { run: runSubmit, pending: submitting } = useAsyncAction();
// Callback générique (onFieldChange) — mêmes effets de bord que le formulaire d'origine
// (InvestissementDetail.jsx) : changer mode_reglement réinitialise compte_id sauf en passant
@@ -540,8 +542,9 @@ export default function Frais() {
form={form}
onFieldChange={setField}
err={err}
onSubmit={submit}
onDelete={onDelete}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
comptesInvestisseur={comptesInvestisseur}
investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete}
+565
View File
@@ -0,0 +1,565 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import FraisCumulChart from '../components/FraisCumulChart.jsx';
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
/**
* Page dédiée "Frais" pour le workspace Private Equity (chantier "frais PE", 17/09/26 —
* demande Olivier : "il faut une page sur les frais"). Adaptation à l'identique de Frais.jsx
* (crowdlending) — même structure (graphiques, KPI, onglets Plateformes / Typologie /
* Investissements / Opérations), parité complète demandée explicitement par Olivier — avec
* les différences suivantes :
* - source des données : /investissements-pe (deals) au lieu de /investissements, et
* GET /frais-operations sans filtre se borne déjà au workspace ACTIF côté backend
* (resolveActiveWorkspaceId), donc renvoie naturellement les frais PE ici ;
* - libellé "deal" / nom_deal au lieu de "projet" / nom_projet ;
* - gating d'accès via hasFraisPe (cf. AuthContext.jsx) plutôt que hasFrais, cf. FraisPeOnly
* dans App.jsx.
*
* Le composant FraisFormModal (partagé, inchangé) attend en interne un champ de formulaire
* nommé `investissement_id` pour son sélecteur "Investissement" — ce nom est conservé tel
* quel dans l'état local de cette page (setField / emptyForm ci-dessous) et traduit en
* `investissement_pe_id` uniquement au moment de construire le payload envoyé à l'API
* (submit ci-dessous), pour ne rien modifier au composant partagé avec crowdlending.
*/
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
compte_courant: 'Compte courant',
remboursement: 'Retenu sur remboursement',
};
function emptyForm() {
return { investissement_id: '', montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), notes: '', categorie: 'autre' };
}
export default function FraisPe() {
const { activeId, activeView } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const [allFraisOps, setAllFraisOps] = useState([]);
const [deals, setDeals] = useState([]);
const [allPlats, setAllPlats] = useState([]);
const [platLoaded, setPlatLoaded] = useState(false);
const [loading, setLoading] = useState(false);
/* Onglets (parité avec Frais.jsx) */
const [activeTab, setActiveTab] = useState('plateformes');
/* Modale ajout/édition */
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm());
const [err, setErr] = useState(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
// Plateformes éligibles au workspace actif uniquement (GET /plateformes n'est pas filtré
// côté serveur — seulement enrichi de row.workspaces), même patron que InvestissementsPe.jsx.
const plateformes = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
const load = async () => {
if (!activeId && activeView !== 'all') return;
setLoading(true);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [f, d] = await Promise.all([
api.get('/frais-operations'),
api.get('/investissements-pe', scopeParams),
]);
setAllFraisOps(f);
setDeals(d);
} finally {
setLoading(false);
}
};
useEffect(() => {
api.get('/plateformes').then(d => { setAllPlats(d); setPlatLoaded(true); }).catch(() => setPlatLoaded(true));
}, []);
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView, activeWorkspace?.id]);
useEffect(() => {
const deal = deals.find(d => String(d.id) === String(form.investissement_id));
if (!modalOpen || !deal?.investisseur_id) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${deal.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalOpen, form.investissement_id]);
const fraisFiltered = allFraisOps;
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
const fraisMoyen = fraisFiltered.length ? fraisTotal / fraisFiltered.length : 0;
const fraisSouscriptionTotal = useMemo(() =>
fraisFiltered.filter(f => f.categorie === 'souscription').reduce((s, f) => s + (f.montant || 0), 0),
[fraisFiltered]
);
const fraisDistributionTotal = useMemo(() =>
fraisFiltered.filter(f => f.categorie === 'distribution').reduce((s, f) => s + (f.montant || 0), 0),
[fraisFiltered]
);
/* ── Répartition par catégorie (onglet "Typologie") ── */
const byCategorie = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.categorie || 'autre';
if (!map[key]) map[key] = { key, label: FRAIS_CATEGORIE_LABELS[key] || key, montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Répartition par plateforme (onglet "Plateformes") ── */
const byPlateforme = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.plateforme_id;
if (!map[key]) map[key] = { key, label: f.plateforme_nom || '—', detenteur: f.investisseur_nom || '—', montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Frais par deal (onglet "Investissements") — uniquement les deals concernés par au
moins un frais ── */
const byInvestissement = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.investissement_pe_id;
if (!map[key]) map[key] = {
key,
plateforme: f.plateforme_nom || '—',
detenteur: f.investisseur_nom || '—',
projet: f.nom_deal || '—',
souscription: 0,
distribution: 0,
total: 0,
};
const cat = f.categorie || 'autre';
if (cat === 'souscription') map[key].souscription += f.montant || 0;
if (cat === 'distribution') map[key].distribution += f.montant || 0;
map[key].total += f.montant || 0;
}
return Object.values(map).sort((a, b) => {
const pa = a.plateforme.toLowerCase(), pb = b.plateforme.toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return a.projet.localeCompare(b.projet, 'fr');
});
}, [fraisFiltered]);
/* ── Tableau détaillé paginé (onglet "Opérations") ── */
const {
pagedItems: pagedFrais, page, setPage, pageSize, setPageSize,
totalPages, totalItems, PAGE_SIZES,
} = usePagination(fraisFiltered, 'pe_pagesize_frais', []);
/* ── Deals pour le sélecteur de la modale (triés plateforme puis deal) — mappés sur les
mêmes clés que FraisFormModal attend (plateforme_nom / nom_projet), cf. doc en tête. ── */
const investissementOptions = useMemo(() =>
deals.slice()
.sort((a, b) => {
const pa = (a.plateforme_nom || '').toLowerCase();
const pb = (b.plateforme_nom || '').toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return (a.nom_deal || '').localeCompare(b.nom_deal || '', 'fr');
})
.map(d => ({ id: d.id, plateforme_nom: d.plateforme_nom, nom_projet: d.nom_deal })),
[deals]
);
/* ── CRUD ── */
const openAdd = () => {
setEditingId(null);
setForm(emptyForm());
setErr(null);
setConfirmingDelete(false);
setModalOpen(true);
};
const openEdit = (f) => {
setEditingId(f.id);
setForm({
investissement_id: String(f.investissement_pe_id),
montant: f.montant,
mode_reglement: f.mode_reglement,
compte_id: f.compte_id || '',
date_operation: f.date_operation,
notes: f.notes || '',
categorie: f.categorie || 'autre',
});
setErr(null);
setConfirmingDelete(false);
setModalOpen(true);
};
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
const { run: runSubmit, pending: submitting } = useAsyncAction();
const setField = (key, value) => {
setForm(f => {
if (key === 'mode_reglement') return { ...f, mode_reglement: value, compte_id: value === 'compte_courant' ? f.compte_id : '' };
if (key === 'investissement_id') return { ...f, investissement_id: value, compte_id: '' };
return { ...f, [key]: value };
});
};
const submit = async (e) => {
e?.preventDefault?.();
setErr(null);
try {
const payload = {
investissement_pe_id: Number(form.investissement_id),
montant: Number(form.montant),
mode_reglement: form.mode_reglement,
compte_id: form.mode_reglement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
date_operation: form.date_operation,
origine: 'manuel',
categorie: form.categorie || 'autre',
notes: form.notes || undefined,
};
if (editingId) await api.put(`/frais-operations/${editingId}`, payload);
else await api.post('/frais-operations', payload);
closeModal();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
setErr(null);
try {
await api.del(`/frais-operations/${editingId}`);
closeModal();
await load();
} catch (e) { setErr(e.message); }
};
/* ── Rendu ── */
if (platLoaded && !loading && plateformes.length === 0) return (
<>
<div className="topbar"><h2>Frais</h2></div>
<EmptyState />
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="frais" />Frais</h2>
<button className="primary" onClick={openAdd}>+ Ajouter un frais</button>
</div>
{/* ── Graphiques : courbe du cumul des frais + répartition par plateforme ── */}
<div className="charts-row">
<FraisCumulChart rows={fraisFiltered} />
<FraisDistributionChart rows={fraisFiltered} groupBy={f => f.plateforme_nom || '—'} label="par Plateforme" />
</div>
{/* ── KPIs ── */}
<div className="dr-kpi-row">
<div className="kpi">
<div className="label">Frais payés</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais de souscription</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisSouscriptionTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais de distribution</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisDistributionTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Nombre d'opérations</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fraisFiltered.length}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais moyen / opération</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisMoyen)}</span>
</div>
</div>
</div>
{/* ── Onglets ── */}
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'typologie' ? ' active' : ''}`}
onClick={() => setActiveTab('typologie')}>Typologie</button>
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
onClick={() => setActiveTab('investissements')}>Investissements</button>
<button className={`dr-tab${activeTab === 'operations' ? ' active' : ''}`}
onClick={() => setActiveTab('operations')}>Opérations</button>
</div>
{/* ══ ONGLET PLATEFORMES ══ */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Répartition par plateforme</h3>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byPlateforme.length === 0 && (
<tr><td colSpan={multiDetenteur ? 5 : 4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byPlateforme.map(p => {
const poids = fraisTotal !== 0 ? (p.montant / fraisTotal) * 100 : 0;
return (
<tr key={p.key}>
<td>{p.label}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur}</td>}
<td className="num">{fmtEUR(p.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byPlateforme.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td colSpan={multiDetenteur ? 2 : 1}>Total — {byPlateforme.length} plateformes</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET TYPOLOGIE ══ */}
{activeTab === 'typologie' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Répartition par catégorie</h3>
</div>
<table>
<thead>
<tr>
<th>Catégorie</th>
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byCategorie.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byCategorie.map(c => {
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
return (
<tr key={c.key}>
<td>{c.label}</td>
<td className="num">{fmtEUR(c.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byCategorie.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byCategorie.length} catégories</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET INVESTISSEMENTS — liste des frais par deal ══ */}
{activeTab === 'investissements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Frais par investissement</h3>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th>Deal</th>
<th className="num">Souscription</th>
<th className="num">Distribution</th>
<th className="num">Total Frais</th>
</tr>
</thead>
<tbody>
{byInvestissement.length === 0 && (
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byInvestissement.map(d => (
<tr key={d.key}>
<td>{d.plateforme}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{d.detenteur}</td>}
<td>{d.projet}</td>
<td className="num">{fmtEUR(d.souscription)}</td>
<td className="num">{fmtEUR(d.distribution)}</td>
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(d.total)}</td>
</tr>
))}
</tbody>
{byInvestissement.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td colSpan={multiDetenteur ? 3 : 2}>Total — {byInvestissement.length} investissements</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.souscription, 0))}</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.distribution, 0))}</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.total, 0))}</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET OPÉRATIONS — liste détaillée (CRUD) ══ */}
{activeTab === 'operations' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Détail des opérations</h3>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th><th>Deal</th><th className="num">Montant</th>
<th>Catégorie</th><th>Prélèvement</th><th>Compte</th><th>Notes</th>
<th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{pagedFrais.length === 0 && (
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{pagedFrais.map(f => (
<tr key={f.id} style={{ cursor: 'pointer' }} title="Modifier ce frais" onClick={() => openEdit(f)}>
<td>{fmtDate(f.date_operation)}</td>
<td>{f.plateforme_nom}</td>
<td>{f.nom_deal}</td>
<td className="num">{fmtEUR(f.montant)}</td>
<td>{FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}</td>
<td style={{ fontSize: 'var(--fs-sm)' }}>{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.compte_nom || '—'}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.notes || '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
title="Modifier"
onClick={() => openEdit(f)}>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={page} setPage={setPage}
pageSize={pageSize} setPageSize={setPageSize}
totalPages={totalPages} totalItems={totalItems}
PAGE_SIZES={PAGE_SIZES}
/>
</div>
</div>
)}
<FraisFormModal
open={modalOpen}
onClose={closeModal}
editingId={editingId}
form={form}
onFieldChange={setField}
err={err}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
comptesInvestisseur={comptesInvestisseur}
investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
/>
</>
);
}
+33 -16
View File
@@ -13,6 +13,7 @@ import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
import { dayOfMonth } from '../utils/dateEcheances.js';
import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
const emptyForm = {
investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '',
@@ -110,6 +111,12 @@ export default function InvestissementDetail() {
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [err, setErr] = useState(null);
// Gardes anti-double-soumission (cf. useAsyncAction) — une par modale indépendante, pour ne
// désactiver que les boutons de la modale concernée pendant sa propre requête.
const { run: runSubmit, pending: submitting } = useAsyncAction();
const { run: runSubmitRemb, pending: submittingRemb } = useAsyncAction();
const { run: runSubmitFrais, pending: submittingFrais } = useAsyncAction();
const { run: runSubmitReinv, pending: submittingReinv } = useAsyncAction();
// Mémorise la dernière souscription valide (utile pour isAutoCalc malgré les onChange "" intermédiaires)
const lastValidSouscriptionRef = useRef('');
@@ -2534,9 +2541,10 @@ export default function InvestissementDetail() {
title={revisionEditId ? 'Corriger la révision' : 'Réviser les conditions du prêt'}
onClose={closeRevisionModal}
width={520}
busy={revisionSaving}
footer={
<>
<button className="ghost" onClick={closeRevisionModal}>Annuler</button>
<button className="ghost" disabled={revisionSaving} onClick={closeRevisionModal}>Annuler</button>
<button onClick={submitRevision} disabled={revisionSaving}>
{revisionSaving ? 'Enregistrement…' : (revisionEditId ? 'Enregistrer la correction' : 'Enregistrer la révision')}
</button>
@@ -2634,8 +2642,9 @@ export default function InvestissementDetail() {
open={modalOpen}
editingId={id}
onClose={close}
onSubmit={submit}
onDelete={onDelete}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
confirmingDelete={confirmingInvDelete}
setConfirmingDelete={setConfirmingInvDelete}
form={form}
@@ -2717,8 +2726,9 @@ export default function InvestissementDetail() {
open={rembModalOpen}
editingId={editingRembId}
onClose={closeRembModal}
onSubmit={submitRemb}
onDelete={deleteRemb}
onSubmit={e => runSubmitRemb(submitRemb, e)}
onDelete={() => runSubmitRemb(deleteRemb)}
submitting={submittingRemb}
confirmingDelete={confirmingRembDelete}
setConfirmingDelete={setConfirmingRembDelete}
err={rembErr}
@@ -2738,27 +2748,30 @@ export default function InvestissementDetail() {
title="Ajouter un réinvestissement"
onClose={closeReinvModal}
width={480}
busy={submittingReinv}
footer={
reinvTab === 'manuel' ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={closeReinvModal}>Annuler</button>
<button className="primary" onClick={submitReinv}>Enregistrer</button>
<button disabled={submittingReinv} onClick={closeReinvModal}>Annuler</button>
<button className="primary" disabled={submittingReinv} onClick={() => runSubmitReinv(submitReinv)}>
{submittingReinv ? 'Enregistrement…' : 'Enregistrer'}
</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={closeReinvModal}>Annuler</button>
<button disabled={submittingReinv} onClick={closeReinvModal}>Annuler</button>
{!autoReinvActive ? (
<button className="primary" onClick={async () => {
<button className="primary" disabled={submittingReinv} onClick={() => runSubmitReinv(async () => {
await api.put(`/investissements/${id}/auto-reinvest`, { active: true });
await load();
closeReinvModal();
}}>Activer</button>
})}>{submittingReinv ? '…' : 'Activer'}</button>
) : (
<button className="danger" onClick={async () => {
<button className="danger" disabled={submittingReinv} onClick={() => runSubmitReinv(async () => {
await api.put(`/investissements/${id}/auto-reinvest`, { active: false });
await load();
closeReinvModal();
}}>Désactiver</button>
})}>{submittingReinv ? '…' : 'Désactiver'}</button>
)}
</div>
)
@@ -2781,7 +2794,7 @@ export default function InvestissementDetail() {
</div>
{reinvTab === 'manuel' ? (
<form onSubmit={submitReinv}>
<form onSubmit={e => runSubmitReinv(submitReinv, e)}>
<p className="text-muted" style={{ marginTop: 0, marginBottom: 16, fontSize: 13 }}>
Un réinvestissement ajoute du capital à ce prêt à une date donnée. La projection
des intérêts sera recalculée à partir de cette date.
@@ -2854,9 +2867,10 @@ export default function InvestissementDetail() {
title="Modifier la date de la 1ère échéance"
onClose={() => setEditDpeModal(false)}
width={420}
busy={editDpeSaving}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setEditDpeModal(false)}>Annuler</button>
<button disabled={editDpeSaving} onClick={() => setEditDpeModal(false)}>Annuler</button>
<button className="primary" onClick={saveEditDpe} disabled={editDpeSaving}>
{editDpeSaving ? 'Enregistrement…' : 'Enregistrer'}
</button>
@@ -2886,6 +2900,7 @@ export default function InvestissementDetail() {
title="Traitement en masse des remboursements"
onClose={() => { if (!bulkRembProcessing) setBulkRembModal(false); }}
width={560}
busy={bulkRembProcessing}
footer={(() => {
const selectedCount = bulkRembItems.filter(it => it.selected).length;
return bulkRembDone ? (
@@ -3279,7 +3294,8 @@ export default function InvestissementDetail() {
form={fraisForm}
onFieldChange={setFraisField}
err={fraisErr}
onSubmit={submitFrais}
onSubmit={e => runSubmitFrais(submitFrais, e)}
submitting={submittingFrais}
comptesInvestisseur={comptesFraisInvestisseur}
locked
/>
@@ -3297,9 +3313,10 @@ export default function InvestissementDetail() {
title="Déclarer une perte définitive"
onClose={closePerteModal}
width={520}
busy={perteSaving}
footer={
<>
<button className="ghost" onClick={closePerteModal}>Annuler</button>
<button className="ghost" disabled={perteSaving} onClick={closePerteModal}>Annuler</button>
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
</button>
@@ -0,0 +1,624 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import PageIcon from '../components/PageIcon.jsx';
import InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, fmtOctets, today } from '../utils/format.js';
import { emptyDealForm, AUTO_VAL_NOTE } from './InvestissementsPe.jsx';
/**
* Fiche détaillée d'un deal Private Equity (chantier "fiche détail PE", 17/09/26 — demande
* Olivier : "une page investissement détail adapté qui reprend un peu l'esprit de crowdlending
* ... il faut que je vois tous les remboursements, tous les frais associés et que j'aie la
* possibilité de stocker des documents"). Première passe volontairement sobre (structure posée,
* à enrichir ensuite) — pas encore de reprise des notions Déploiement / TVPI du sous-jacent /
* Obligation / Valeur totale cible visibles sur les captures Fundora d'Olivier, toujours hors
* périmètre comme convenu lors du chantier précédent.
*
* Route : /investissements/:id, branchée ici pour tout workspace non-crowdlending par
* InvestissementDetailRouter (App.jsx) — miroir de InvestissementDetail.jsx (crowdlending), en
* beaucoup plus court : pas d'échéancier, de XIRR, de révisions de prêt ni de réinvestissements
* (notions sans équivalent PE à ce stade).
*
* Modifier l'investissement réutilise directement InvestissementPeFormModal (le même assistant
* par étapes que la liste), y compris son étape "Frais" (souscription/gestion réglés à la
* source) — le bloc "Frais d'opération" ci-dessous gère lui les frais complémentaires
* (gestion/distribution/autre), exactement comme côté crowdlending : les lignes 'souscription'
* et 'gestion'-à-l'origine-souscription y apparaissent en lecture seule, redirigeant vers
* "Modifier l'investissement" plutôt que vers la modale FraisFormModal (qui n'propose pas la
* catégorie 'souscription' dans son sélecteur).
*/
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
compte_courant: 'Compte courant',
remboursement: 'Retenu sur remboursement',
};
const emptyValForm = { date_valorisation: today(), valorisation_nette: '', notes: '' };
const emptyFraisForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
export default function InvestissementPeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const [deal, setDeal] = useState(null);
const [allPlats, setAllPlats] = useState([]);
const [remboursements, setRemboursements] = useState([]);
const [fraisOps, setFraisOps] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [documents, setDocuments] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const plats = allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id));
const load = async () => {
setLoading(true);
try {
const [d, pl, remb, frais, val, docs] = await Promise.all([
api.get(`/investissements-pe/${id}`),
api.get('/plateformes'),
api.get('/remboursements'),
api.get('/frais-operations', { investissement_pe_id: id }),
api.get(`/investissements-pe/${id}/valorisations`),
api.get('/documents', { entity_type: 'investissement_pe', entity_id: id }),
]);
setDeal(d);
setAllPlats(pl);
setRemboursements(remb.filter(r => String(r.investissement_pe_id) === String(id)));
setFraisOps(frais);
setValorisations(val);
setDocuments(docs);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, [id]);
/* ── Modifier l'investissement (réutilise le même assistant que la liste) ── */
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState(emptyDealForm);
const [formErr, setFormErr] = useState(null);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [deleteConfirm, setDeleteConfirm] = useState(false);
useEffect(() => {
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
}, [form.investisseur_id, modalOpen]);
const openEditDeal = () => {
if (!deal) return;
const entree = fraisOps.find(f => f.categorie === 'souscription');
const gestion = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
const autoVal = valorisations.find(v => v.notes === AUTO_VAL_NOTE);
setForm({
...emptyDealForm,
plateforme_id: String(deal.plateforme_id || ''),
investisseur_id: String(deal.investisseur_id || ''),
nom_deal: deal.nom_deal,
strategie: deal.strategie || '',
date_souscription: deal.date_souscription,
duree_annees: deal.duree_mois ? String(Math.round((deal.duree_mois / 12) * 10) / 10) : '',
montant_investi: String(deal.montant_investi ?? ''),
objectif: deal.objectif || '',
statut: deal.statut,
reference: deal.reference || '',
notes: deal.notes || '',
valorisation_initiale_id: autoVal ? autoVal.id : null,
...(entree ? {
frais_entree_valeur: String(entree.montant), frais_entree_mode_reglement: entree.mode_reglement,
frais_entree_compte_id: entree.compte_id ? String(entree.compte_id) : '', frais_entree_id: entree.id,
} : {}),
...(gestion ? {
frais_gestion_valeur: String(gestion.montant), frais_gestion_mode_reglement: gestion.mode_reglement,
frais_gestion_compte_id: gestion.compte_id ? String(gestion.compte_id) : '', frais_gestion_id: gestion.id,
} : {}),
});
setFormErr(null);
setModalOpen(true);
};
const closeEditDeal = () => { setModalOpen(false); setFormErr(null); };
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(deal?.investisseur_id || '');
};
const computeFraisTTC = (modeSaisie, valeur) => {
if (valeur === '' || Number(valeur) <= 0) return 0;
return Math.round((modeSaisie === 'pourcentage'
? Number(form.montant_investi) * Number(valeur) / 100
: Number(valeur)) * 100) / 100;
};
const upsertFrais = async (has, ttc, categorie, modeReglement, compteId, fraisId) => {
try {
if (has) {
const payload = {
investissement_pe_id: Number(id),
montant: ttc,
mode_reglement: modeReglement,
compte_id: modeReglement === 'compte_courant' && compteId ? Number(compteId) : null,
date_operation: form.date_souscription,
origine: 'souscription',
categorie,
};
if (fraisId) await api.put(`/frais-operations/${fraisId}`, payload);
else await api.post('/frais-operations', payload);
} else if (fraisId) {
await api.del(`/frais-operations/${fraisId}`);
}
} catch { /* non bloquant */ }
};
const syncValorisationInitiale = async (totalFraisSource, valorisationInitialeId) => {
try {
if (valorisationInitialeId) await api.del(`/investissements-pe/${id}/valorisations/${valorisationInitialeId}`);
if (totalFraisSource > 0) {
const initiale = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
await api.post(`/investissements-pe/${id}/valorisations`, {
date_valorisation: form.date_souscription, valorisation_nette: initiale, notes: AUTO_VAL_NOTE,
});
}
} catch { /* non bloquant */ }
};
const submitDeal = async (e) => {
e?.preventDefault?.(); setFormErr(null);
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
const fraisApplicable = !!platFraisType && platFraisType !== 'aucun';
const hasFraisEntree = fraisApplicable && form.frais_entree_valeur !== '' && Number(form.frais_entree_valeur) > 0;
const hasFraisGestion = fraisApplicable && form.frais_gestion_valeur !== '' && Number(form.frais_gestion_valeur) > 0;
const fraisEntreeTTC = hasFraisEntree ? computeFraisTTC(form.frais_entree_mode_saisie, form.frais_entree_valeur) : 0;
const fraisGestionTTC = hasFraisGestion ? computeFraisTTC(form.frais_gestion_mode_saisie, form.frais_gestion_valeur) : 0;
const totalFraisSource =
(hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) +
(hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0);
const payload = {
plateforme_id: Number(form.plateforme_id),
investisseur_id: Number(form.investisseur_id) || undefined,
nom_deal: form.nom_deal,
strategie: form.strategie || undefined,
date_souscription: form.date_souscription,
duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined,
montant_investi: Number(form.montant_investi),
objectif: form.objectif || undefined,
statut: form.statut,
reference: form.reference || undefined,
notes: form.notes || undefined,
};
try {
await api.put(`/investissements-pe/${id}`, payload);
await upsertFrais(hasFraisEntree, fraisEntreeTTC, 'souscription', form.frais_entree_mode_reglement, form.frais_entree_compte_id, form.frais_entree_id);
await upsertFrais(hasFraisGestion, fraisGestionTTC, 'gestion', form.frais_gestion_mode_reglement, form.frais_gestion_compte_id, form.frais_gestion_id);
await syncValorisationInitiale(totalFraisSource, form.valorisation_initiale_id);
closeEditDeal();
await load();
} catch (e) { setFormErr(e.message); }
};
const onDeleteDeal = async () => {
try {
await api.del(`/investissements-pe/${id}`);
navigate('/investissements');
} catch (e) { setErr(e.message); }
};
/* ── Valorisations ── */
const [valForm, setValForm] = useState(emptyValForm);
const [valErr, setValErr] = useState(null);
const [valDeleteTarget, setValDeleteTarget] = useState(null);
const { run: runValSubmit, pending: valSubmitting } = useAsyncAction();
const submitVal = async (e) => {
e?.preventDefault?.(); setValErr(null);
try {
await api.post(`/investissements-pe/${id}/valorisations`, {
date_valorisation: valForm.date_valorisation,
valorisation_nette: Number(valForm.valorisation_nette),
notes: valForm.notes || undefined,
});
setValForm(emptyValForm);
await load();
} catch (e) { setValErr(e.message); }
};
const onDeleteVal = async () => {
if (!valDeleteTarget) return;
try {
await api.del(`/investissements-pe/${id}/valorisations/${valDeleteTarget.id}`);
setValDeleteTarget(null);
await load();
} catch (e) { setValErr(e.message); }
};
/* ── Frais d'opération complémentaires (gestion/distribution/autre) ── */
const [fraisModalOpen, setFraisModalOpen] = useState(false);
const [fraisEditingId, setFraisEditingId] = useState(null);
const [fraisForm, setFraisForm] = useState(emptyFraisForm);
const [fraisErr, setFraisErr] = useState(null);
const [fraisConfirmingDelete, setFraisConfirmingDelete] = useState(false);
const { run: runFraisSubmit, pending: fraisSubmitting } = useAsyncAction();
const openAddFrais = () => {
setFraisEditingId(null);
setFraisForm({ ...emptyFraisForm, date_operation: today() });
setFraisErr(null);
setFraisConfirmingDelete(false);
setFraisModalOpen(true);
};
const openEditFrais = (f) => {
setFraisEditingId(f.id);
setFraisForm({
montant: f.montant, mode_reglement: f.mode_reglement, compte_id: f.compte_id || '',
date_operation: f.date_operation, categorie: f.categorie || 'autre', notes: f.notes || '',
});
setFraisErr(null);
setFraisConfirmingDelete(false);
setFraisModalOpen(true);
};
const closeFraisModal = () => { setFraisModalOpen(false); setFraisErr(null); setFraisConfirmingDelete(false); };
const submitFrais = async (e) => {
e?.preventDefault?.(); setFraisErr(null);
try {
const payload = {
investissement_pe_id: Number(id),
montant: Number(fraisForm.montant),
mode_reglement: fraisForm.mode_reglement,
compte_id: fraisForm.mode_reglement === 'compte_courant' && fraisForm.compte_id ? Number(fraisForm.compte_id) : null,
date_operation: fraisForm.date_operation,
origine: 'manuel',
categorie: fraisForm.categorie || 'autre',
notes: fraisForm.notes || undefined,
};
if (fraisEditingId) await api.put(`/frais-operations/${fraisEditingId}`, payload);
else await api.post('/frais-operations', payload);
closeFraisModal();
await load();
} catch (e) { setFraisErr(e.message); }
};
const onDeleteFrais = async () => {
try {
await api.del(`/frais-operations/${fraisEditingId}`);
closeFraisModal();
await load();
} catch (e) { setFraisErr(e.message); }
};
/* ── Documents (réutilise le système générique /documents, entity_type 'investissement_pe',
* porté depuis InvestissementDetail.jsx en version simplifiée : bouton "+ Ajouter" direct
* plutôt que menu ⋮, pas d'action "tout supprimer" pour cette première passe) ── */
const [docsErr, setDocsErr] = useState(null);
const [uploadingDoc, setUploadingDoc] = useState(false);
const [renamingDocId, setRenamingDocId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [docDeleteTarget, setDocDeleteTarget] = useState(null);
const docFileInputRef = useRef(null);
const refreshDocuments = async () => {
try { setDocuments(await api.get('/documents', { entity_type: 'investissement_pe', entity_id: id })); }
catch { /* la fiche reste utilisable même si le rafraîchissement échoue */ }
};
const handleDocFileChange = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploadingDoc(true); setDocsErr(null);
try {
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'investissement_pe');
fd.append('entity_id', id);
fd.append('categorie', 'investissement_pe');
fd.append('nom_affichage', file.name.replace(/\.[^./\\]+$/, ''));
await api.postForm('/documents', fd);
await refreshDocuments();
} catch (e) { setDocsErr(e.message); }
finally { setUploadingDoc(false); }
};
const handleDocRenameStart = (doc) => { setRenamingDocId(doc.id); setRenameValue(doc.nom_affichage); };
const handleDocRenameCommit = async (docId) => {
const value = renameValue.trim();
setRenamingDocId(null);
const current = documents.find(d => d.id === docId);
if (!value || !current || value === current.nom_affichage) return;
try { await api.put(`/documents/${docId}`, { nom_affichage: value }); await refreshDocuments(); }
catch (e) { setDocsErr(e.message); }
};
const handleDocDownload = async (doc) => {
try {
const blob = await api.blob(`/documents/${doc.id}/download`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${doc.nom_affichage}.${doc.extension}`; a.click();
URL.revokeObjectURL(url);
} catch (e) { setDocsErr(e.message); }
};
const handleDocDelete = async () => {
if (!docDeleteTarget) return;
try { await api.del(`/documents/${docDeleteTarget.id}`); setDocDeleteTarget(null); await refreshDocuments(); }
catch (e) { setDocsErr(e.message); }
};
if (loading) return <div style={{ padding: 32 }}>Chargement…</div>;
if (!deal) return (
<>
<div className="topbar"><h2>Investissement introuvable</h2></div>
{err && <div className="error" style={{ margin: '0 24px' }}>{err}</div>}
</>
);
const totalFrais = fraisOps.reduce((s, f) => s + (f.montant || 0), 0);
return (
<>
<div className="topbar">
<button onClick={() => navigate('/investissements')} style={{ marginRight: 12 }}>← Retour</button>
<h2><PageIcon name="investissement" />{deal.nom_deal}</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={openEditDeal}>Modifier</button>
<button className="danger" onClick={() => setDeleteConfirm(true)}>Supprimer</button>
</div>
</div>
{err && <div className="error" style={{ margin: '0 24px 16px' }}>{err}</div>}
<div style={{ padding: '0 24px' }}>
{/* ── Résumé ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div className="dr-kpi-row" style={{ marginBottom: 0 }}>
<div className="kpi"><div className="label">Montant investi</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.montant_investi)}</div></div>
<div className="kpi"><div className="label">Valorisation actuelle</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}</div></div>
<div className="kpi"><div className="label">TVPI</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(deal.tvpi)}</div></div>
<div className="kpi"><div className="label">Distributions cumulées</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.distributions_cumulees)}</div></div>
<div className="kpi"><div className="label">Statut</div>
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{STATUT_LABELS[deal.statut] || deal.statut}</div></div>
</div>
<div className="text-muted" style={{ marginTop: 14, fontSize: 13 }}>
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
{deal.strategie ? ` · ${deal.strategie}` : ''}
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
</div>
{deal.notes && <div className="text-muted" style={{ marginTop: 8, fontSize: 13 }}>{deal.notes}</div>}
</div>
{/* ── Valorisations ── */}
<div className="card" style={{ marginBottom: 16 }}>
<h3 style={{ marginTop: 0, marginBottom: 14 }}>Valorisations</h3>
{valErr && <div className="error">{valErr}</div>}
<form onSubmit={e => runValSubmit(submitVal, e)} className="form-grid" style={{ marginBottom: 16 }}>
<div>
<label>Date *</label>
<input type="date" required value={valForm.date_valorisation} onChange={e => setValForm({ ...valForm, date_valorisation: e.target.value })} />
</div>
<div>
<label>Valorisation nette (€) *</label>
<input type="number" step="0.01" min="0" required value={valForm.valorisation_nette} onChange={e => setValForm({ ...valForm, valorisation_nette: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={valForm.notes} onChange={e => setValForm({ ...valForm, notes: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<button type="submit" className="primary" disabled={valSubmitting}>
{valSubmitting ? 'Ajout…' : '+ Ajouter une valorisation'}
</button>
</div>
</form>
<table>
<thead><tr><th>Date</th><th className="num">Valorisation nette</th><th>Notes</th><th style={{ width: 40 }}></th></tr></thead>
<tbody>
{valorisations.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center' }}>Aucune valorisation enregistrée</td></tr>
)}
{valorisations.map(v => (
<tr key={v.id}>
<td>{fmtDate(v.date_valorisation)}</td>
<td className="num">{fmtEUR(v.valorisation_nette)}</td>
<td className="text-muted">{v.notes || '—'}</td>
<td><button className="icon-btn" title="Supprimer" onClick={() => setValDeleteTarget(v)}>✕</button></td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Remboursements ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Remboursements</h3>
<button onClick={() => navigate('/remboursements?new=1')}>+ Nouveau remboursement</button>
</div>
<table>
<thead><tr><th>Date</th><th className="num">Montant versé</th><th>Notes</th></tr></thead>
<tbody>
{remboursements.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center' }}>Aucun remboursement enregistré pour ce deal</td></tr>
)}
{remboursements.map(r => (
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => navigate('/remboursements')} title="Voir dans Remboursements">
<td>{fmtDate(r.date_remb)}</td>
<td className="num">{fmtEUR(r.net_recu)}</td>
<td className="text-muted">{r.notes || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Frais d'opération ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Frais d'opération
{fraisOps.length > 0 && <span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>Total : {fmtEUR(totalFrais)}</span>}
</h3>
<button onClick={openAddFrais}>+ Ajouter un frais</button>
</div>
{fraisOps.length === 0 ? (
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun frais journalisé pour l'instant.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fraisOps.map(f => {
const isManaged = f.categorie === 'souscription' || (f.categorie === 'gestion' && f.origine === 'souscription');
return (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0, cursor: isManaged ? 'pointer' : 'pointer' }}
onClick={() => isManaged ? openEditDeal() : openEditFrais(f)}
title={isManaged ? "Modifiable depuis « Modifier l'investissement »" : 'Cliquer pour modifier'}>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)' }}>
{fmtEUR(f.montant)}
<span className="text-muted" style={{ fontWeight: 400, marginLeft: 8 }}>
{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}
{f.mode_reglement === 'compte_courant' && f.compte_nom ? ` (${f.compte_nom})` : ''}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{fmtDate(f.date_operation)} · {FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}
{f.notes ? ` · ${f.notes}` : ''}
</div>
</div>
{!isManaged && (
<button className="icon-btn" title="Supprimer" onClick={() => { setFraisEditingId(f.id); onDeleteFrais(); }}>✕</button>
)}
</div>
);
})}
</div>
)}
</div>
{/* ── Documents ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Mes documents</h3>
<button onClick={() => docFileInputRef.current?.click()} disabled={uploadingDoc}>
{uploadingDoc ? 'Envoi…' : '+ Ajouter un document'}
</button>
</div>
{docsErr && <div className="error">{docsErr}</div>}
{documents.length === 0 ? (
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun document pour l'instant.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{documents.map(doc => (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{renamingDocId === doc.id ? (
<input autoFocus value={renameValue} onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleDocRenameCommit(doc.id); if (e.key === 'Escape') setRenamingDocId(null); }}
onBlur={() => handleDocRenameCommit(doc.id)} style={{ width: '100%', fontSize: 'var(--fs-sm)' }} />
) : (
<div onClick={() => handleDocRenameStart(doc)} title="Cliquer pour renommer"
style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}>
{doc.nom_affichage}.{doc.extension}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{fmtOctets(doc.taille_octets)} · {fmtDate(doc.created_at)}</div>
</div>
<button className="icon-btn" title="Télécharger" onClick={() => handleDocDownload(doc)}>⬇</button>
<button className="icon-btn" title="Supprimer" onClick={() => setDocDeleteTarget(doc)}>✕</button>
</div>
))}
</div>
)}
<input ref={docFileInputRef} type="file" onChange={handleDocFileChange} style={{ display: 'none' }} />
</div>
</div>
<InvestissementPeFormModal
open={modalOpen}
editingId={id}
onClose={closeEditDeal}
form={form}
setForm={setForm}
plats={plats}
investisseurs={investisseurs}
multiDetenteur={new Set(plats.map(p => p.investisseur_id)).size > 1}
comptesInvestisseur={comptesInvestisseur}
err={formErr}
submitting={submitting}
onSubmit={() => runSubmit(submitDeal)}
onDelete={() => { closeEditDeal(); setDeleteConfirm(true); }}
investisseurForPlat={investisseurForPlat}
/>
<ConfirmModal
open={deleteConfirm}
title="Supprimer cet investissement ?"
message="Cette action est irréversible. Les distributions déjà saisies (Remboursements) sont conservées mais détachées de ce deal."
confirmLabel="Supprimer"
onConfirm={onDeleteDeal}
onCancel={() => setDeleteConfirm(false)}
/>
<ConfirmModal
open={!!valDeleteTarget}
title="Supprimer cette valorisation ?"
message="Cette action est irréversible."
confirmLabel="Supprimer"
onConfirm={onDeleteVal}
onCancel={() => setValDeleteTarget(null)}
/>
<ConfirmModal
open={!!docDeleteTarget}
title="Supprimer ce document ?"
message={docDeleteTarget ? `"${docDeleteTarget.nom_affichage}.${docDeleteTarget.extension}" sera définitivement supprimé.` : ''}
confirmLabel="Supprimer"
onConfirm={handleDocDelete}
onCancel={() => setDocDeleteTarget(null)}
/>
<FraisFormModal
open={fraisModalOpen}
onClose={closeFraisModal}
editingId={fraisEditingId}
form={fraisForm}
onFieldChange={(k, v) => setFraisForm(f => ({ ...f, [k]: v }))}
err={fraisErr}
onSubmit={e => runFraisSubmit(submitFrais, e)}
onDelete={() => runFraisSubmit(onDeleteFrais)}
submitting={fraisSubmitting}
comptesInvestisseur={comptesInvestisseur}
locked
investissementOptions={[]}
confirmingDelete={fraisConfirmingDelete}
setConfirmingDelete={setFraisConfirmingDelete}
/>
</>
);
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
@@ -296,6 +297,7 @@ export default function Investissements() {
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [openMenu, setOpenMenu] = useState(null);
// Proposition de dépôt après création d'un investissement si le porte-monnaie ne
@@ -1437,7 +1439,8 @@ export default function Investissements() {
open={modalOpen}
editingId={editingId}
onClose={close}
onSubmit={submit}
onSubmit={e => runSubmit(submit, e)}
submitting={submitting}
form={form}
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
+938
View File
@@ -0,0 +1,938 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
import ValorisationPeChart from '../components/ValorisationPeChart.jsx';
import DistributionChart from '../components/DistributionChart.jsx';
import ValorisationMensuelleTable from '../components/ValorisationMensuelleTable.jsx';
import InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
export const emptyDealForm = {
plateforme_id: '',
investisseur_id: '',
nom_deal: '',
strategie: '',
date_souscription: today(),
duree_annees: '',
montant_investi: '',
objectif: '',
statut: 'valide',
reference: '',
notes: '',
// Étape "Frais" de la modale (chantier 17/09/26, demande Olivier — captures Fundora) : deux
// lignes indépendantes journalisées comme deux entrées frais_operations distinctes. Frais
// d'entrée → catégorie 'souscription' (unique par deal) ; Frais de gestion → catégorie
// 'gestion', origine 'souscription' (distingue cette ligne d'un frais de gestion ajouté plus
// tard depuis la page Frais PE ou la liste des investissements).
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
// Id de la valorisation "initiale" auto-générée à la date de souscription pour refléter les
// frais réglés "à la source" (cf. AUTO_VAL_NOTE / submit ci-dessous) — null si aucune n'existe
// encore pour ce deal. Décision Olivier 17/09/26 : le montant investi (souscrit) ne bouge
// JAMAIS, c'est la valorisation qui encaisse immédiatement les frais d'entrée/de gestion à la
// date de souscription — jusqu'à ce qu'une vraie valorisation du fonds vienne la remplacer.
valorisation_initiale_id: null,
};
// Marqueur de la valorisation "initiale" auto-générée par l'étape "Frais" de la modale (cf.
// submit ci-dessous) — permet de la retrouver/remplacer sans toucher aux valorisations saisies
// manuellement par ailleurs (modale "Valorisations" annexe).
export const AUTO_VAL_NOTE = 'Valorisation initiale (frais de souscription)';
// Accès rapide "+ frais" depuis une ligne de la liste des investissements (chantier "frais PE",
// 17/09/26, demande Olivier) — permet d'ajouter un frais complémentaire (gestion récurrente,
// distribution, autre) sans passer par la page Frais PE. Catégorie par défaut 'gestion' :
// 'souscription' reste exclusivement gérée par l'étape "Frais" de la modale d'investissement
// (cf. FraisFormModal.jsx, qui exclut déjà cette catégorie de son sélecteur).
const emptyFraisAddForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
/* ── Indicateur de progression (copié de pages/Investissements.jsx —
* pas exporté par un composant partagé) ── */
function TrendBadge({ current, prev, invert = false }) {
if (prev == null || Math.abs(prev) < 0.005) return null;
const diff = current - prev;
const pct = (diff / prev) * 100;
const up = diff > 0;
const neutral = diff === 0;
const good = neutral ? null : (invert ? !up : up);
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
const arrow = neutral ? '→' : up ? '↗' : '↘';
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 8px', borderRadius: 20,
background: bg, color, fontSize: '0.76em', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{arrow} {label}
</span>
);
}
/**
* Investissements — Private Equity (17/09/26, cf.
* project_workspaces_transformation.md). Sert tout workspace autre que
* crowdlending (cf. InvestissementsRouter dans App.jsx, jamais lié en dur à
* un slug précis), sur une table dédiée investissements_pe : un deal PE n'a
* ni échéancier connu à l'avance ni taux d'intérêt fixe — c'est un montant
* unique investi (le porte-monnaie diminue d'un coup, comme en crowdlending,
* décision Olivier), valorisé périodiquement par le fonds. Les distributions
* (retour de capital, plus-value) se saisissent depuis la page Remboursements
* (types bonus_plateforme / intérêts_plateforme), avec un lien optionnel vers
* le deal pour calculer son TVPI.
*
* Depuis le 17/09/26 (demande d'Olivier : "modifier la page investissements
* de PE pour qu'elle ressemble à celle de crowdlending"), cette page reprend
* le graphique cumulé, la Distribution par plateforme, 5 KPIs et les onglets
* Plateformes / Vision mensuelle / Investissements de pages/Investissements.jsx
* (crowdlending) — MAIS avec une sémantique adaptée au modèle PE (décisions
* Olivier du 17/09/26, cf. les 4 questions posées avant l'implémentation) :
* - Le graphique et la 1ère carte KPI n'affichent pas un "capital en cours"
* (qui décroît avec les remboursements, notion sans équivalent en PE
* puisque montant_investi n'est jamais décrémenté) mais la VALORISATION
* TOTALE du portefeuille : pour chaque deal, sa dernière valeur connue
* (montant_investi à la souscription, puis chaque valorisation saisie la
* remplace), sommée sur tous les deals — cf. ValorisationPeChart.jsx.
* - La 2e carte "Investissements à risque" devient "Pertes définitives"
* (somme du montant investi des deals au statut perte_definitive) — PE n'a
* pas de notion de retard/défaut comparable au crowdlending.
* - Les cartes 4 et 5 ("Investissements remboursés" / "Intérêts perçus" en
* crowdlending, deux notions distinctes) deviennent "Distributions
* cumulées" (retour de cash du fonds, capital et plus-value confondus —
* pas de séparation en PE) et "TVPI moyen" (multiple pondéré du
* portefeuille : (valorisation + distributions) / capital investi).
* - L'onglet "Vision mensuelle" devient un tableau de VALORISATION mensuelle
* par plateforme (ValorisationMensuelleTable.jsx), et non plus un capital
* en cours basé sur des remboursements qui n'existent pas pour ces deals.
*
* Approximation assumée pour les comparaisons N/N-1 (TrendBadge) sur les
* cartes Distributions et TVPI : distributions_cumulees (renvoyé par
* GET /investissements-pe) est une valeur globale, non bornée à une date —
* la comparaison restreint seulement l'ENSEMBLE des deals pris en compte
* (souscrits avant la coupure), pas le montant des distributions elles-
* mêmes à cette date. Seule la valorisation (dont l'historique daté est
* connu via GET /investissements-pe/valorisations) est bornée exactement à
* la coupure. Même principe d'approximation que "Investissements à risque"
* côté crowdlending, qui utilise déjà le statut ACTUEL plutôt qu'un statut
* historique à la date de coupure.
*/
export default function InvestissementsPe() {
const { activeId: investisseurId, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [allPlats, setAllPlats] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyDealForm);
const [deleteTarget, setDeleteTarget] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
// Comptes du détenteur sélectionné dans le formulaire — nécessaires pour l'étape "Frais"
// (option de règlement "compte courant du détenteur"), même mécanisme que Frais.jsx /
// Investissements.jsx côté crowdlending (route déjà générique, indépendante du workspace).
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
// Modale annexe "+ frais" (accès rapide depuis une ligne de la liste des investissements)
const [fraisAddDeal, setFraisAddDeal] = useState(null);
const [fraisAddForm, setFraisAddForm] = useState(emptyFraisAddForm);
const [fraisAddErr, setFraisAddErr] = useState(null);
const [fraisAddComptes, setFraisAddComptes] = useState([]);
const { run: runFraisAddSubmit, pending: fraisAddSubmitting } = useAsyncAction();
const [activeTab, setActiveTab] = useState('plateformes');
const [filter, setFilter] = useState({ statut: '', plateforme_id: '' });
const [platYear, setPlatYear] = useState('');
const [listFocused, setListFocused] = useState(false);
// Valorisations : petite modale annexe, ouverte depuis une ligne du tableau.
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces),
// même patron que DepotsRetraitsSimple / RemboursementsSimple.
const plats = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
const load = async () => {
setLoading(true);
try {
const [inv, pl, val] = await Promise.all([
api.get('/investissements-pe'),
api.get('/plateformes'),
api.get('/investissements-pe/valorisations'),
]);
setRows(inv);
setAllPlats(pl);
setValorisations(val);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (investisseurId) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
useEffect(() => {
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.investisseur_id, modalOpen]);
useEffect(() => {
if (!fraisAddDeal?.investisseur_id) { setFraisAddComptes([]); return; }
api.get(`/investissements/comptes-par-investisseur/${fraisAddDeal.investisseur_id}`)
.then(setFraisAddComptes)
.catch(() => setFraisAddComptes([]));
}, [fraisAddDeal?.investisseur_id]);
const openFraisAdd = (deal) => {
setFraisAddDeal(deal);
setFraisAddForm({ ...emptyFraisAddForm, date_operation: today() });
setFraisAddErr(null);
};
const closeFraisAdd = () => { setFraisAddDeal(null); setFraisAddErr(null); };
const submitFraisAdd = async (e) => {
e?.preventDefault?.(); setFraisAddErr(null);
try {
await api.post('/frais-operations', {
investissement_pe_id: fraisAddDeal.id,
montant: Number(fraisAddForm.montant),
mode_reglement: fraisAddForm.mode_reglement,
compte_id: fraisAddForm.mode_reglement === 'compte_courant' && fraisAddForm.compte_id ? Number(fraisAddForm.compte_id) : null,
date_operation: fraisAddForm.date_operation,
origine: 'manuel',
categorie: fraisAddForm.categorie,
notes: fraisAddForm.notes || undefined,
});
closeFraisAdd();
} catch (e) { setFraisAddErr(e.message); }
};
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
};
const openAdd = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...emptyDealForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
};
// Ouverture auto depuis le bouton global "Ajout Investissement" (topbar/burger,
// cf. Layout.jsx — même ?new=1 que les pages Dépôts/Retraits et Remboursements).
const pendingNew = useRef(false);
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('new') !== '1') return;
navigate('/investissements', { replace: true });
if (plats.length > 0) {
openAdd();
} else {
pendingNew.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
useEffect(() => {
if (pendingNew.current && plats.length > 0) {
pendingNew.current = false;
openAdd();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plats.length]);
const openEdit = async (r) => {
setEditingId(r.id);
setForm({
plateforme_id: String(r.plateforme_id || ''),
investisseur_id: String(r.investisseur_id || ''),
nom_deal: r.nom_deal,
strategie: r.strategie || '',
date_souscription: r.date_souscription,
duree_annees: r.duree_mois ? String(Math.round((r.duree_mois / 12) * 10) / 10) : '',
montant_investi: String(r.montant_investi ?? ''),
objectif: r.objectif || '',
statut: r.statut,
reference: r.reference || '',
notes: r.notes || '',
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
});
setErr(null);
setModalOpen(true);
// Pré-remplissage best-effort des frais déjà journalisés + de la valorisation "initiale"
// auto-générée (cf. AUTO_VAL_NOTE/submit) pour ce deal — n'empêche jamais l'ouverture de la
// modale si l'appel échoue.
try {
const [fraisOps, valRows] = await Promise.all([
api.get('/frais-operations', { investissement_pe_id: r.id }),
api.get(`/investissements-pe/${r.id}/valorisations`),
]);
const entree = fraisOps.find(f => f.categorie === 'souscription');
const gestion = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
const autoVal = valRows.find(v => v.notes === AUTO_VAL_NOTE);
setForm(f => ({
...f,
valorisation_initiale_id: autoVal ? autoVal.id : null,
...(entree ? {
frais_entree_valeur: String(entree.montant), frais_entree_mode_reglement: entree.mode_reglement,
frais_entree_compte_id: entree.compte_id ? String(entree.compte_id) : '', frais_entree_id: entree.id,
} : {}),
...(gestion ? {
frais_gestion_valeur: String(gestion.montant), frais_gestion_mode_reglement: gestion.mode_reglement,
frais_gestion_compte_id: gestion.compte_id ? String(gestion.compte_id) : '', frais_gestion_id: gestion.id,
} : {}),
}));
} catch { /* non bloquant — rattrapable depuis la page Frais PE / la modale Valorisations */ }
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyDealForm); setErr(null); };
// Calcule le TTC d'une ligne de frais depuis son mode de saisie (montant fixe ou % du
// montant investi) — même formule que Investissements.jsx (crowdlending).
const computeFraisTTC = (modeSaisie, valeur) => {
if (valeur === '' || Number(valeur) <= 0) return 0;
return Math.round((modeSaisie === 'pourcentage'
? Number(form.montant_investi) * Number(valeur) / 100
: Number(valeur)) * 100) / 100;
};
// Upsert best-effort d'une ligne frais_operations (PUT si déjà chargée via *_id, POST sinon ;
// DELETE si le montant a été vidé alors qu'une entrée existait) — n'empêche jamais
// l'enregistrement principal du deal, rattrapable a posteriori depuis la page Frais PE.
const upsertFrais = async (dealId, has, ttc, categorie, modeReglement, compteId, fraisId) => {
try {
if (has) {
const fraisPayload = {
investissement_pe_id: dealId,
montant: ttc,
mode_reglement: modeReglement,
compte_id: modeReglement === 'compte_courant' && compteId ? Number(compteId) : null,
date_operation: form.date_souscription,
origine: 'souscription',
categorie,
};
if (fraisId) await api.put(`/frais-operations/${fraisId}`, fraisPayload);
else await api.post('/frais-operations', fraisPayload);
} else if (fraisId) {
await api.del(`/frais-operations/${fraisId}`);
}
} catch { /* non bloquant */ }
};
// Gère la valorisation "initiale" auto-générée à la date de souscription (cf. AUTO_VAL_NOTE) :
// le montant investi (souscrit) ne bouge jamais, c'est cette valorisation qui encaisse
// immédiatement les frais réglés "à la source" — décision Olivier 17/09/26. Comme l'API ne
// propose pas de PUT sur une valorisation, on supprime l'éventuelle ligne existante puis on
// recrée à neuf (couvre aussi le cas où la date de souscription a changé). Best-effort, jamais
// bloquant pour l'enregistrement principal du deal.
const syncValorisationInitiale = async (dealId, totalFraisSource, valorisationInitialeId) => {
try {
if (valorisationInitialeId) {
await api.del(`/investissements-pe/${dealId}/valorisations/${valorisationInitialeId}`);
}
if (totalFraisSource > 0) {
const initiale = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
await api.post(`/investissements-pe/${dealId}/valorisations`, {
date_valorisation: form.date_souscription,
valorisation_nette: initiale,
notes: AUTO_VAL_NOTE,
});
}
} catch { /* non bloquant — rattrapable depuis la modale Valorisations */ }
};
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
const fraisApplicable = !!platFraisType && platFraisType !== 'aucun';
const hasFraisEntree = fraisApplicable && form.frais_entree_valeur !== '' && Number(form.frais_entree_valeur) > 0;
const hasFraisGestion = fraisApplicable && form.frais_gestion_valeur !== '' && Number(form.frais_gestion_valeur) > 0;
const fraisEntreeTTC = hasFraisEntree ? computeFraisTTC(form.frais_entree_mode_saisie, form.frais_entree_valeur) : 0;
const fraisGestionTTC = hasFraisGestion ? computeFraisTTC(form.frais_gestion_mode_saisie, form.frais_gestion_valeur) : 0;
// Total des frais réglés "à la source" (aucun mouvement de trésorerie réel) — c'est ce
// montant qui vient réduire la VALORISATION à la date de souscription (cf.
// syncValorisationInitiale), jamais le montant investi lui-même (décision Olivier 17/09/26).
const totalFraisSource =
(hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) +
(hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0);
const payload = {
plateforme_id: Number(form.plateforme_id),
investisseur_id: Number(form.investisseur_id) || undefined,
nom_deal: form.nom_deal,
strategie: form.strategie || undefined,
date_souscription: form.date_souscription,
duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined,
montant_investi: Number(form.montant_investi),
objectif: form.objectif || undefined,
statut: form.statut,
reference: form.reference || undefined,
notes: form.notes || undefined,
};
try {
let dealId = editingId;
if (editingId) {
await api.put(`/investissements-pe/${editingId}`, payload);
} else {
const created = await api.post('/investissements-pe', payload);
dealId = created.id;
}
await upsertFrais(dealId, hasFraisEntree, fraisEntreeTTC, 'souscription', form.frais_entree_mode_reglement, form.frais_entree_compte_id, form.frais_entree_id);
await upsertFrais(dealId, hasFraisGestion, fraisGestionTTC, 'gestion', form.frais_gestion_mode_reglement, form.frais_gestion_compte_id, form.frais_gestion_id);
await syncValorisationInitiale(dealId, totalFraisSource, form.valorisation_initiale_id);
close();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
if (!deleteTarget) return;
try {
await api.del(`/investissements-pe/${deleteTarget.id}`);
setDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
};
const sorted = useMemo(
() => [...rows].sort((a, b) => b.date_souscription.localeCompare(a.date_souscription) || b.id - a.id),
[rows],
);
/* ── Années disponibles (sélecteur Plateformes) ── */
const years = useMemo(
() => [...new Set(rows.map(r => r.date_souscription?.slice(0, 4)).filter(Boolean))].sort().reverse(),
[rows],
);
/* ── Historique de valorisations trié, par deal (pour bornage à une
* coupure — TrendBadge N/N-1 et graphique) ── */
const valorisationsByDeal = useMemo(() => {
const map = {};
for (const v of valorisations) {
const d = v.date_valorisation?.slice(0, 10);
if (!d || v.valorisation_nette == null) continue;
if (!map[v.investissement_pe_id]) map[v.investissement_pe_id] = [];
map[v.investissement_pe_id].push({ date: d, value: v.valorisation_nette });
}
for (const k in map) map[k].sort((a, b) => a.date.localeCompare(b.date));
return map;
}, [valorisations]);
/* Valorisation d'un deal à une coupure donnée (ou dernière connue si
* cutoff est null) — jamais < montant_investi tant qu'aucune valorisation
* n'a été saisie. */
const valorisationAtCutoff = (deal, cutoff) => {
if (!cutoff) return deal.derniere_valorisation ?? deal.montant_investi;
let val = deal.montant_investi;
for (const v of (valorisationsByDeal[deal.id] || [])) {
if (v.date <= cutoff) val = v.value; else break;
}
return val;
};
/* ── Totaux KPI (plateforme + année sélectionnées) ── */
const kpiRows = useMemo(() => rows.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (platYear && r.date_souscription > `${platYear}-12-31`) return false;
return true;
}), [rows, filter.plateforme_id, platYear]);
const cutoff = platYear ? `${platYear}-12-31` : null;
const totals = useMemo(() => kpiRows.reduce((acc, r) => {
acc.investi += r.montant_investi;
acc.valorisation += valorisationAtCutoff(r, cutoff);
acc.distributions += r.distributions_cumulees || 0;
if (r.statut === 'perte_definitive') acc.pertes += r.montant_investi;
return acc;
}, { investi: 0, valorisation: 0, distributions: 0, pertes: 0 }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[kpiRows, cutoff, valorisationsByDeal]);
totals.tvpi = totals.investi > 0 ? (totals.valorisation + totals.distributions) / totals.investi : null;
const prevYear = platYear ? String(Number(platYear) - 1) : null;
const prevCutoff = prevYear ? `${prevYear}-12-31` : null;
const prevRows = useMemo(() => {
if (!platYear) return [];
return rows.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
return r.date_souscription <= prevCutoff;
});
}, [rows, filter.plateforme_id, platYear, prevCutoff]);
const prevTotals = useMemo(() => prevRows.reduce((acc, r) => {
acc.investi += r.montant_investi;
acc.valorisation += valorisationAtCutoff(r, prevCutoff);
acc.distributions += r.distributions_cumulees || 0;
if (r.statut === 'perte_definitive') acc.pertes += r.montant_investi;
return acc;
}, { investi: 0, valorisation: 0, distributions: 0, pertes: 0 }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[prevRows, prevCutoff, valorisationsByDeal]);
prevTotals.tvpi = prevTotals.investi > 0 ? (prevTotals.valorisation + prevTotals.distributions) / prevTotals.investi : null;
/* ── Données graphique / Distribution : filtrées par plateforme + année,
* sans le filtre statut (comme Investissements.jsx côté crowdlending) ── */
const chartRows = useMemo(() => rows.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (platYear && r.date_souscription > `${platYear}-12-31`) return false;
return true;
}), [rows, filter.plateforme_id, platYear]);
const distRows = useMemo(() => chartRows
.map(r => ({ plateforme_nom: r.plateforme_nom, montant: valorisationAtCutoff(r, cutoff) }))
.filter(r => r.montant > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[chartRows, cutoff, valorisationsByDeal]);
/* ── Onglet Plateformes : indépendant du filtre plateforme (comme
* Investissements.jsx), seulement borné par l'année sélectionnée ── */
const yearMembers = useMemo(() => rows.filter(r => !platYear || r.date_souscription <= `${platYear}-12-31`), [rows, platYear]);
const platData = useMemo(() => {
const totalInvesti = yearMembers.reduce((s, r) => s + r.montant_investi, 0);
const byPlat = {};
for (const r of yearMembers) {
if (!byPlat[r.plateforme_id]) {
byPlat[r.plateforme_id] = {
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
detenteur_nom: r.investisseur_nom || null,
count: 0, investi: 0, valorisation: 0, distributions: 0, pertes: 0,
};
}
const p = byPlat[r.plateforme_id];
p.count++;
p.investi += r.montant_investi;
p.valorisation += valorisationAtCutoff(r, cutoff);
p.distributions += r.distributions_cumulees || 0;
if (r.statut === 'perte_definitive') p.pertes += r.montant_investi;
}
return Object.values(byPlat)
.sort((a, b) => b.investi - a.investi)
.map(p => ({
...p,
tvpi: p.investi > 0 ? (p.valorisation + p.distributions) / p.investi : null,
poids: totalInvesti > 0 ? (p.investi / totalInvesti) * 100 : 0,
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [yearMembers, cutoff, valorisationsByDeal]);
const platTotals = useMemo(() => platData.reduce((acc, p) => {
acc.count += p.count; acc.investi += p.investi;
acc.valorisation += p.valorisation; acc.distributions += p.distributions;
return acc;
}, { count: 0, investi: 0, valorisation: 0, distributions: 0 }), [platData]);
platTotals.tvpi = platTotals.investi > 0 ? (platTotals.valorisation + platTotals.distributions) / platTotals.investi : null;
/* ── Onglet Investissements : liste des deals, filtrée plateforme + statut ── */
const filteredRows = useMemo(() => sorted.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (filter.statut && r.statut !== filter.statut) return false;
return true;
}), [sorted, filter]);
const hasFilter = !!(filter.statut || filter.plateforme_id);
const clearFilter = () => setFilter({ statut: '', plateforme_id: '' });
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="investissement" />Investissements</h2></div>
<EmptyState
title="Aucune plateforme dans cet espace de travail"
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes pour pouvoir y enregistrer des investissements.`}
cta="Configurer mes plateformes"
to="/settings?section=plateformes&openAdd=1"
/>
</>
);
if (!loading && !modalOpen && sorted.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="investissement" />Investissements</h2></div>
<EmptyState
icon="📈"
title="Aucun investissement enregistré"
message="Vos plateformes sont configurées, mais aucun investissement n'a encore été enregistré."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
<InvestissementPeFormModal
open={modalOpen}
editingId={editingId}
onClose={close}
form={form}
setForm={setForm}
plats={plats}
investisseurs={investisseurs}
multiDetenteur={multiDetenteur}
comptesInvestisseur={comptesInvestisseur}
err={err}
submitting={submitting}
onSubmit={() => runSubmit(submit)}
onDelete={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}
investisseurForPlat={investisseurForPlat}
/>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="investissement" />Investissements</h2>
{hasFilter && (
<button className="dr-clear-btn" onClick={clearFilter}>✕ Effacer les filtres</button>
)}
</div>
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
{/* ── Graphiques ── */}
{!listFocused && <div className="charts-row">
<ValorisationPeChart rows={chartRows} valorisations={valorisations} platYear={platYear} />
<DistributionChart rows={distRows} />
</div>}
{/* ── KPIs ── */}
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
{/* 1 — Valorisation totale */}
<div className="kpi">
<div className="label">Valorisation totale</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.valorisation)}</span>
{platYear && <TrendBadge current={totals.valorisation} prev={prevTotals.valorisation} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.valorisation)} en {prevYear}</div>}
</div>
{/* 2 — Pertes définitives */}
<div
className={`kpi${totals.pertes > 0 ? ' dr-kpi-clickable' : ''}${filter.statut === 'perte_definitive' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
onClick={() => {
if (totals.pertes === 0) return;
setFilter(f => ({ ...f, statut: f.statut === 'perte_definitive' ? '' : 'perte_definitive' }));
setActiveTab('investissements');
}}
title={totals.pertes > 0 ? 'Filtrer les deals en perte définitive' : undefined}
style={totals.pertes > 0 ? { cursor: 'pointer' } : {}}
>
<div className="label">Pertes définitives</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.pertes > 0 ? 'danger' : ''}>{fmtEUR(totals.pertes)}</span>
{platYear && totals.pertes > 0 && <TrendBadge current={totals.pertes} prev={prevTotals.pertes} invert={true} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.pertes)} en {prevYear}</div>}
</div>
{/* 3 — Investissements depuis le début */}
<div
className={`kpi${hasFilter ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
onClick={() => hasFilter && clearFilter()}
title={hasFilter ? 'Effacer les filtres' : undefined}
style={hasFilter ? { cursor: 'pointer' } : {}}
>
<div className="label">Investissements depuis le début</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span>
{platYear && <TrendBadge current={totals.investi} prev={prevTotals.investi} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.investi)} en {prevYear}</div>}
</div>
{/* 4 — Distributions cumulées */}
<div className="kpi">
<div className="label">Distributions cumulées</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.distributions)}</span>
{platYear && <TrendBadge current={totals.distributions} prev={prevTotals.distributions} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.distributions)} en {prevYear}</div>}
</div>
{/* 5 — TVPI moyen */}
<div className="kpi" title="Multiple pondéré du portefeuille : (valorisation + distributions) / capital investi">
<div className="label">TVPI moyen</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtMultiple(totals.tvpi)}</span>
{platYear && totals.tvpi != null && <TrendBadge current={totals.tvpi} prev={prevTotals.tvpi} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtMultiple(prevTotals.tvpi)} en {prevYear}</div>}
</div>
</div>}
{/* ── Onglets ── */}
{!listFocused && <div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
onClick={() => setActiveTab('investissements')}>Investissements</button>
</div>}
{/* ====== ONGLET PLATEFORMES ====== */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par plateforme
{platYear && <span style={{ marginLeft: 8 }}>pour l'année {platYear}</span>}
</h3>
<select value={platYear} onChange={e => setPlatYear(e.target.value)}
style={{
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-muted)',
cursor: 'pointer', outline: 'none',
}}>
<option value="">Toutes les années</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{!rows.length && loading ? (
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
) : (
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Deals</th>
<th className="num">Montant investi</th>
<th className="num">Valorisation actuelle</th>
<th className="num">Distributions</th>
<th className="num">TVPI</th>
<th className="num">Poids</th>
<th style={{ width: 28 }}></th>
</tr>
</thead>
<tbody>
{platData.length === 0 && (
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
Aucun investissement
</td></tr>
)}
{platData.map(p => {
const isActive = String(filter.plateforme_id) === String(p.plateforme_id);
return (
<tr key={p.plateforme_id}
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => {
setFilter(f => ({ ...f, plateforme_id: isActive ? '' : String(p.plateforme_id) }));
setActiveTab('investissements');
}}>
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{p.count}</td>
<td className="num">{fmtEUR(p.investi)}</td>
<td className="num">{fmtEUR(p.valorisation)}</td>
<td className="num">{fmtEUR(p.distributions)}</td>
<td className="num">{fmtMultiple(p.tvpi)}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, p.poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{p.poids.toFixed(1)} %</span>
</div>
</td>
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isActive ? '✕' : ''}</td>
</tr>
);
})}
</tbody>
{platData.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {platData.length} plateformes</td>
{multiDetenteur && <td />}
<td className="num">{platTotals.count}</td>
<td className="num">{fmtEUR(platTotals.investi)}</td>
<td className="num">{fmtEUR(platTotals.valorisation)}</td>
<td className="num">{fmtEUR(platTotals.distributions)}</td>
<td className="num">{fmtMultiple(platTotals.tvpi)}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
<td />
</tr>
</tfoot>
)}
</table>
)}
</div>
</div>
)}
{/* ====== ONGLET VISION MENSUELLE ====== */}
{activeTab === 'vision-mensuelle' && (
<div style={{ padding: '0 24px' }}>
<ValorisationMensuelleTable rows={rows} valorisations={valorisations} plats={plats} />
</div>
)}
{/* ====== ONGLET INVESTISSEMENTS ====== */}
{activeTab === 'investissements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Liste des investissements
{filteredRows.length !== sorted.length && (
<span style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', fontWeight: 400, color: 'var(--text-muted)' }}>
{filteredRows.length} / {sorted.length}
</span>
)}
</h3>
<button className="primary" onClick={openAdd}>+ Nouvel investissement</button>
</div>
{/* Filtres */}
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
<div style={{ flex: 1, minWidth: 130 }}>
<label>Statut</label>
<select value={filter.statut} onChange={e => setFilter(f => ({ ...f, statut: e.target.value }))}>
<option value="">Tous</option>
<option value="en_attente">En attente</option>
<option value="valide">Validé</option>
<option value="cloture">Clôturé</option>
<option value="perte_definitive">Perte définitive</option>
</select>
</div>
<div style={{ flex: 1, minWidth: 130 }}>
<label>Plateforme</label>
<select value={filter.plateforme_id} onChange={e => setFilter(f => ({ ...f, plateforme_id: e.target.value }))}>
<option value="">Toutes</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
</div>
<table>
<thead>
<tr>
<th>Deal</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
<th>Stratégie</th><th>Date</th><th className="num">Montant investi</th>
<th className="num">Dernière valorisation</th><th className="num">TVPI</th>
<th>Objectif</th><th>Statut</th><th style={{ width: 70 }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 11 : 10} className="text-muted" style={{ textAlign: 'center' }}>Aucun investissement</td></tr>
)}
{filteredRows.map(r => (
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => navigate(`/investissements/${r.id}`)}>
<td>{r.nom_deal}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted">{r.investisseur_nom || '—'}</td>}
<td className="text-muted">{r.strategie || '—'}</td>
<td>{fmtDate(r.date_souscription)}</td>
<td className="num">{fmtEUR(r.montant_investi)}</td>
<td className="num">{r.derniere_valorisation != null ? fmtEUR(r.derniere_valorisation) : '—'}</td>
<td className="num">{fmtMultiple(r.tvpi)}</td>
<td className="text-muted">{r.objectif || '—'}</td>
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
<td onClick={e => e.stopPropagation()}>
<button className="icon-btn" title="Ajouter un frais" onClick={() => openFraisAdd(r)}>€</button>
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<InvestissementPeFormModal
open={modalOpen}
editingId={editingId}
onClose={close}
form={form}
setForm={setForm}
plats={plats}
investisseurs={investisseurs}
multiDetenteur={multiDetenteur}
comptesInvestisseur={comptesInvestisseur}
err={err}
submitting={submitting}
onSubmit={() => runSubmit(submit)}
onDelete={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}
investisseurForPlat={investisseurForPlat}
/>
<ConfirmModal
open={!!deleteTarget}
title="Supprimer cet investissement ?"
message="Cette action est irréversible. Les distributions déjà saisies (Remboursements) sont conservées mais détachées de ce deal."
confirmLabel="Supprimer"
onConfirm={onDelete}
onCancel={() => setDeleteTarget(null)}
/>
{/* ── Modale annexe : "+ frais" rapide depuis une ligne de la liste ── */}
<FraisFormModal
open={!!fraisAddDeal}
onClose={closeFraisAdd}
editingId={null}
form={fraisAddForm}
onFieldChange={(k, v) => setFraisAddForm(f => ({ ...f, [k]: v }))}
err={fraisAddErr}
onSubmit={e => runFraisAddSubmit(submitFraisAdd, e)}
onDelete={null}
comptesInvestisseur={fraisAddComptes}
locked
investissementOptions={[]}
confirmingDelete={false}
setConfirmingDelete={() => {}}
submitting={fraisAddSubmitting}
/>
</>
);
}
+14 -7
View File
@@ -14,6 +14,7 @@ import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
@@ -435,6 +436,7 @@ export default function Plateformes() {
const [rembInv, setRembInv] = useState(null); // investissement lié au remb en cours d'édition
const [rembForm, setRembForm] = useState({});
const [rembErr, setRembErr] = useState(null);
const { run: runSubmitRemb, pending: submittingRemb } = useAsyncAction();
const [rembConfirmingDelete, setRembConfirmingDelete] = useState(false);
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
@@ -1082,6 +1084,7 @@ export default function Plateformes() {
const [mvEditingId, setMvEditingId] = useState(null);
const [mvForm, setMvForm] = useState(emptyMouvementForm);
const [mvErr, setMvErr] = useState(null);
const { run: runMvSubmit, pending: mvSubmitting } = useAsyncAction();
const [mvSelectedRow, setMvSelectedRow] = useState(null);
const [mvOpenMenu, setMvOpenMenu] = useState(null); // { row, x, y }
const [mvDeleteConfirm, setMvDeleteConfirm] = useState(null);
@@ -3100,20 +3103,23 @@ export default function Plateformes() {
open={mvModalOpen}
title="Modifier le mouvement"
onClose={mvCloseEdit}
busy={mvSubmitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button className="danger" onClick={mvOnDelete}>Supprimer</button>
<button onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
<button className="danger" disabled={mvSubmitting} onClick={mvOnDelete}>Supprimer</button>
<button disabled={mvSubmitting} onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={mvCloseEdit}>Annuler</button>
<button className="primary" onClick={mvSubmit}>Enregistrer</button>
<button disabled={mvSubmitting} onClick={mvCloseEdit}>Annuler</button>
<button className="primary" disabled={mvSubmitting} onClick={() => runMvSubmit(mvSubmit)}>
{mvSubmitting ? 'Enregistrement…' : 'Enregistrer'}
</button>
</div>
</div>
}
>
<form onSubmit={mvSubmit}>
<form onSubmit={e => runMvSubmit(mvSubmit, e)}>
{mvErr && <div className="error">{mvErr}</div>}
<div className="form-grid">
<div>
@@ -3260,8 +3266,9 @@ export default function Plateformes() {
open={rembModalOpen}
editingId={rembEditingId}
onClose={closeRembModal}
onSubmit={submitRemb}
onDelete={onDeleteRemb}
onSubmit={e => runSubmitRemb(submitRemb, e)}
onDelete={() => runSubmitRemb(onDeleteRemb)}
submitting={submittingRemb}
confirmingDelete={rembConfirmingDelete}
setConfirmingDelete={setRembConfirmingDelete}
err={rembErr}
+5 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
@@ -245,6 +246,7 @@ export default function Remboursements() {
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [corrDeleteConfirm, setCorrDeleteConfirm] = useState(null);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
@@ -1543,8 +1545,9 @@ export default function Remboursements() {
open={modalOpen}
editingId={editingId}
onClose={close}
onSubmit={submit}
onDelete={onDelete}
onSubmit={e => runSubmit(submit, e)}
onDelete={() => runSubmit(onDelete)}
submitting={submitting}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
err={err}
+911
View File
@@ -0,0 +1,911 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useWorkspace } from '../context/WorkspaceContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import EmptyState from '../components/EmptyState.jsx';
import PageIcon from '../components/PageIcon.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
import InteretsChart from '../components/InteretsChart.jsx';
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
import * as XLSX from 'xlsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
const TYPE_LABELS = {
bonus_plateforme: 'Bonus plateforme',
bonus_parrainage: 'Bonus parrainage',
interets_plateforme: 'Intérêts plateforme',
};
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc'];
/* ── Export (Excel/CSV/JSON) — même patron que Remboursements.jsx (crowdlending,
* chantier "remboursements PE", 17/09/26, à la demande d'Olivier), colonnes
* adaptées au modèle PE (pas de capital, lien vers un deal optionnel). ── */
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function rembPeRow(r, dealNameById) {
return {
date_remb: r.date_remb, plateforme_nom: r.plateforme_nom || '',
detenteur_nom: r.plateforme_detenteur_nom || '',
type: TYPE_LABELS[r.type] || r.type,
deal: r.investissement_pe_id ? (dealNameById[r.investissement_pe_id] || '') : '',
cashback: r.cashback || 0, interets_bruts: r.interets_bruts || 0, frais_ttc: r.frais_ttc || 0,
prelev_sociaux: r.prelev_sociaux || 0, prelev_forfaitaire: r.prelev_forfaitaire || 0,
interets_nets: r.interets_nets || 0, net_recu: r.net_recu || 0,
statut: r.statut,
};
}
function rembPeToCSV(rows, dealNameById) {
const BOM = '\ufeff'; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Date','Plateforme','Détenteur','Type','Investissement','Cashback (€)','Intérêts bruts (€)','Frais TTC (€)','Prélèv. sociaux (€)','Prélèvement forfaitaire (€)','Intérêts nets (€)','Net reçu (€)','Statut'];
const data = rows.map(r => {
const d = rembPeRow(r, dealNameById);
return [
d.date_remb, d.plateforme_nom, d.detenteur_nom, d.type, d.deal,
String(d.cashback).replace('.', ','), String(d.interets_bruts).replace('.', ','), String(d.frais_ttc).replace('.', ','),
String(d.prelev_sociaux).replace('.', ','), String(d.prelev_forfaitaire).replace('.', ','),
String(d.interets_nets).replace('.', ','), String(d.net_recu).replace('.', ','), d.statut,
];
});
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function rembPeToXLS(rows, dealNameById) {
const data = rows.map(r => {
const d = rembPeRow(r, dealNameById);
return {
'Date': d.date_remb, 'Plateforme': d.plateforme_nom, 'Détenteur': d.detenteur_nom, 'Type': d.type,
'Investissement': d.deal, 'Cashback (€)': d.cashback, 'Intérêts bruts (€)': d.interets_bruts,
'Frais TTC (€)': d.frais_ttc, 'Prélèv. sociaux (€)': d.prelev_sociaux, 'Prélèvement forfaitaire (€)': d.prelev_forfaitaire,
'Intérêts nets (€)': d.interets_nets, 'Net reçu (€)': d.net_recu, 'Statut': d.statut,
};
});
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Remboursements');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function rembPeToJSON(rows, dealNameById) {
return JSON.stringify(rows.map(r => rembPeRow(r, dealNameById)), null, 2);
}
/* ── Menu déroulant d'export (CSV/Excel/JSON) — identique au composant de
* Remboursements.jsx : rien ici n'est spécifique crowdlending. ── */
function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled} onClick={() => setOpen(o => !o)} title="Exporter">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
</svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
const emptyRembForm = {
type: 'bonus_plateforme',
bonus_plateforme_id: '',
bonus_investisseur_id: '',
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
// rattacher une distribution à un investissement précis pour calculer son
// TVPI, quand plusieurs deals partagent une même plateforme (ex. Fundora).
investissement_pe_id: '',
date_remb: today(),
statut: 'paye',
notes: '',
// Bonus (parrainage / plateforme)
cashback: '',
// Intérêts plateforme
interets_bruts_avant_local: '',
taxe_locale: '',
interets_bruts: '',
frais_ttc: '',
prelev_sociaux: '',
prelev_forfaitaire: '',
};
/**
* Remboursements — version simplifiée, utilisée par tout workspace autre que
* crowdlending (Private Equity pour l'instant — cf. RemboursementsRouter dans
* App.jsx, jamais lié en dur à un slug précis), même patron que
* DepotsRetraitsSimple.jsx.
*
* Un remboursement "normal" (type par défaut de pages/Remboursements.jsx) est
* obligatoirement rattaché à un investissement (investissement_id), or la
* table investissements n'a pas encore de workspace_id et sa page reste
* réservée à crowdlending (cf. project_workspaces_transformation.md, Étape 2
* à venir table par table). Cette page se limite donc, à la demande
* d'Olivier (17/09/26), aux 3 types de remboursement qui ne dépendent que
* d'une plateforme + d'un détenteur : bonus parrainage, bonus plateforme,
* intérêts plateforme — déjà pleinement utilisables puisque plateformes
* supporte le multi-workspace (N-N) depuis une étape précédente.
*
* L'endpoint (/remboursements) est directement partagé avec la page
* crowdlending, déjà flêché par workspace côté serveur via l'en-tête
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
*/
export default function RemboursementsSimple() {
const { activeId: investisseurId, investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
const location = useLocation();
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [allPlats, setAllPlats] = useState([]);
const [deals, setDeals] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyRembForm);
const [deleteTarget, setDeleteTarget] = useState(null);
const { run: runSubmit, pending: submitting } = useAsyncAction();
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces),
// même patron que DepotsRetraitsSimple.
const plats = useMemo(
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
[allPlats, activeWorkspace],
);
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
// ── Onglets + filtres (chantier "remboursements PE", 17/09/26 — rapprochement de
// la page Remboursements crowdlending, à la demande d'Olivier) : Plateformes
// (graphiques + répartition), Vision mensuelle, Remboursements (liste filtrable,
// paginée, exportable). Pas d'onglets "Mois en cours"/"Projections" : ils reposent
// sur un échéancier prévisionnel qui n'existe pas pour un deal PE.
const [activeTab, setActiveTab] = useState('plateformes');
const [rembPlatYear, setRembPlatYear] = useState('');
const [vmYear, setVmYear] = useState('');
const [tableFilter, setTableFilter] = useState({ plateforme_id: '', type: '', investissement_pe_id: '', year: '', month: '' });
const load = async () => {
setLoading(true);
try {
const [rb, pl, dl] = await Promise.all([
api.get('/remboursements'),
api.get('/plateformes'),
api.get('/investissements-pe'),
]);
setRows(rb);
setAllPlats(pl);
setDeals(dl);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (investisseurId) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [investisseurId, activeWorkspace?.id]);
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
};
const openAdd = () => {
setEditingId(null);
const firstId = plats[0]?.id || '';
setForm({ ...emptyRembForm, bonus_plateforme_id: firstId, bonus_investisseur_id: investisseurForPlat(firstId) });
setErr(null);
setModalOpen(true);
};
// Ouverture auto depuis le bouton global "Nouveau remboursement" (topbar/burger,
// cf. Layout.jsx — même ?new=1 que la page crowdlending et que Dépôts/Retraits).
const pendingNew = useRef(false);
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('new') !== '1') return;
navigate('/remboursements', { replace: true });
if (plats.length > 0) {
openAdd();
} else {
pendingNew.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
useEffect(() => {
if (pendingNew.current && plats.length > 0) {
pendingNew.current = false;
openAdd();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plats.length]);
// Le type n'est pas modifiable en édition : le backend choisit sa branche
// d'UPDATE (et donc les colonnes réellement écrites) d'après form.type
// soumis, pas d'après le type déjà stocké en base — changer de type en
// cours d'édition désynchroniserait la ligne. On fige donc le type choisi
// à la création.
const openEdit = (r) => {
setEditingId(r.id);
setForm({
type: r.type,
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
date_remb: r.date_remb,
statut: r.statut,
notes: r.notes || '',
cashback: String(r.cashback ?? ''),
interets_bruts_avant_local: String(r.interets_bruts_avant_local ?? ''),
taxe_locale: String(r.taxe_locale ?? ''),
interets_bruts: String(r.interets_bruts ?? ''),
frais_ttc: String(r.frais_ttc ?? ''),
prelev_sociaux: String(r.prelev_sociaux ?? ''),
prelev_forfaitaire: String(r.prelev_forfaitaire ?? ''),
});
setErr(null);
setModalOpen(true);
};
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyRembForm); setErr(null); };
const submit = async (e) => {
e?.preventDefault?.(); setErr(null);
const base = {
type: form.type,
bonus_plateforme_id: Number(form.bonus_plateforme_id),
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
investissement_pe_id: Number(form.investissement_pe_id) || undefined,
date_remb: form.date_remb,
statut: form.statut,
notes: form.notes || undefined,
};
const payload = form.type === 'interets_plateforme'
? {
...base,
interets_bruts_avant_local: Number(form.interets_bruts_avant_local) || 0,
taxe_locale: Number(form.taxe_locale) || 0,
interets_bruts: Number(form.interets_bruts) || 0,
frais_ttc: Number(form.frais_ttc) || 0,
prelev_sociaux: Number(form.prelev_sociaux) || 0,
prelev_forfaitaire: Number(form.prelev_forfaitaire) || 0,
}
: {
...base,
cashback: Number(form.cashback) || 0,
};
try {
if (editingId) await api.put(`/remboursements/${editingId}`, payload);
else await api.post('/remboursements', payload);
close();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
if (!deleteTarget) return;
try {
await api.del(`/remboursements/${deleteTarget.id}`);
setDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
};
const sorted = useMemo(
() => [...rows].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id),
[rows],
);
const statutLabel = (s) => ({ paye: 'Payé', retard: 'En retard', partiel: 'Partiel', impaye: 'Impayé' }[s] || s);
const years = useMemo(
() => [...new Set(rows.map(r => r.date_remb.slice(0, 4)))].sort().reverse(),
[rows],
);
const dealNameById = useMemo(() => {
const map = {};
for (const d of deals) map[d.id] = d.nom_deal;
return map;
}, [deals]);
/* ── Onglet Plateformes : graphiques + répartition ──────────────────────
* InteretsChart/InteretsDistributionChart (crowdlending) ne lisent que
* date_remb + interets_nets/interets_bruts — génériques, réutilisés tels
* quels. Le modèle PE n'a pas de bascule brut/net au niveau agrégat (pas
* de fiscalité à la source recalculée ici) : on aligne les deux champs sur
* net_recu (cashback + intérêts nets déjà calculés côté backend) pour que
* la courbe/le treemap représentent le total réellement perçu. */
const netMode = true;
const chartRows = useMemo(() => {
const src = rembPlatYear ? rows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear) : rows;
return src.map(r => ({ ...r, interets_nets: r.net_recu, interets_bruts: r.net_recu }));
}, [rows, rembPlatYear]);
const byPlatform = useMemo(() => {
const src = rembPlatYear ? rows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear) : rows;
const map = {};
for (const r of src) {
const key = String(r.plateforme_id);
if (!map[key]) map[key] = {
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
detenteur_nom: r.plateforme_detenteur_nom || null,
cashback: 0, interets_nets: 0, net_recu: 0, count: 0,
};
map[key].cashback += r.cashback || 0;
map[key].interets_nets += r.interets_nets || 0;
map[key].net_recu += r.net_recu || 0;
map[key].count++;
}
return Object.values(map).sort((a, b) => b.net_recu - a.net_recu);
}, [rows, rembPlatYear]);
const byPlatformTotals = useMemo(() => byPlatform.reduce((acc, p) => {
acc.cashback += p.cashback; acc.interets_nets += p.interets_nets;
acc.net_recu += p.net_recu; acc.count += p.count;
return acc;
}, { cashback: 0, interets_nets: 0, net_recu: 0, count: 0 }), [byPlatform]);
const totalNetRecu = byPlatformTotals.net_recu;
/* ── Onglet Vision mensuelle : grille Plateforme × mois, année sélectionnée.
* Pas de composant réutilisable côté crowdlending (TableauInteretsPlateforme
* dépend d'un endpoint + contexte bâtis autour d'un échéancier réel/projeté
* qui n'existe pas pour un deal PE) — grille reconstruite ici, client-side,
* à partir des lignes déjà chargées. ── */
const effectiveVmYear = vmYear || years[0] || String(new Date().getFullYear());
const vmData = useMemo(() => {
const map = {};
for (const r of rows) {
if (r.date_remb.slice(0, 4) !== effectiveVmYear) continue;
const key = String(r.plateforme_id);
if (!map[key]) map[key] = { plateforme_id: r.plateforme_id, nom: r.plateforme_nom, months: Array(12).fill(0), total: 0 };
const m = Number(r.date_remb.slice(5, 7)) - 1;
map[key].months[m] += r.net_recu || 0;
map[key].total += r.net_recu || 0;
}
return Object.values(map).sort((a, b) => b.total - a.total);
}, [rows, effectiveVmYear]);
const vmMonthTotals = useMemo(() => {
const totals = Array(12).fill(0);
for (const p of vmData) for (let i = 0; i < 12; i++) totals[i] += p.months[i];
return totals;
}, [vmData]);
const vmGrandTotal = vmMonthTotals.reduce((s, v) => s + v, 0);
/* ── Onglet Remboursements : liste filtrable, paginée, exportable ── */
const tableRows = useMemo(() => sorted.filter(r => {
if (tableFilter.plateforme_id && String(r.plateforme_id) !== tableFilter.plateforme_id) return false;
if (tableFilter.type && r.type !== tableFilter.type) return false;
if (tableFilter.investissement_pe_id && String(r.investissement_pe_id || '') !== tableFilter.investissement_pe_id) return false;
if (tableFilter.year && r.date_remb.slice(0, 4) !== tableFilter.year) return false;
if (tableFilter.month && r.date_remb.slice(5, 7) !== tableFilter.month) return false;
return true;
}), [sorted, tableFilter]);
const {
pagedItems: pagedTableRows, page: rembPage, setPage: setRembPage,
pageSize: rembPageSize, setPageSize: setRembPageSize,
totalPages: rembTotalPages, totalItems: rembTotalItems, PAGE_SIZES,
} = usePagination(tableRows, 'cl_pagesize_remb_pe', [tableFilter]);
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="remboursement" />Remboursements</h2></div>
<EmptyState
title="Aucune plateforme dans cet espace de travail"
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes pour pouvoir y enregistrer des remboursements.`}
cta="Configurer mes plateformes"
to="/settings?section=plateformes&openAdd=1"
/>
</>
);
if (!loading && !modalOpen && sorted.length === 0) return (
<>
<div className="topbar"><h2><PageIcon name="remboursement" />Remboursements</h2></div>
<EmptyState
icon="💰"
title="Aucun remboursement enregistré"
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré."
cta="Ajouter un remboursement"
to="/remboursements?new=1"
/>
<Modal
open={modalOpen}
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? 'Création…' : 'Créer'}
</button>
</div>
}
>
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
investisseurForPlat={investisseurForPlat} editingId={editingId} />
</Modal>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="remboursement" />Remboursements</h2>
<button className="primary" onClick={openAdd}>+ Nouveau remboursement</button>
</div>
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
<div className="charts-row">
<InteretsChart rows={chartRows} netMode={netMode} />
<InteretsDistributionChart rows={chartRows} netMode={netMode} />
</div>
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`}
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
</div>
{/* ══ ONGLET PLATEFORMES ══ */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par plateforme
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select value={rembPlatYear} onChange={e => setRembPlatYear(e.target.value)}
style={{ fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', outline: 'none' }}>
<option value="">Toutes les années</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<ExportDropdown
disabled={byPlatform.length === 0}
onCSV={() => dlBlob(rembPeToCSV(rows, dealNameById), 'remboursements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(rembPeToXLS(rows, dealNameById), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(rembPeToJSON(rows, dealNameById), 'remboursements.json', 'application/json')}
/>
</div>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Cashback</th>
<th className="num">Intérêts (nets)</th>
<th className="num">Total net perçu</th>
<th className="num">Mouvements</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byPlatform.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun remboursement'}
</td></tr>
)}
{byPlatform.map(p => {
const poids = totalNetRecu !== 0 ? (p.net_recu / totalNetRecu) * 100 : 0;
return (
<tr key={p.plateforme_id} className="dr-row" style={{ cursor: 'pointer' }}
onClick={() => { setTableFilter(f => ({ ...f, plateforme_id: String(p.plateforme_id) })); setActiveTab('remboursements'); }}
title="Cliquer pour voir le détail dans l'onglet Remboursements"
>
<td>{p.nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{p.cashback > 0 ? fmtEUR(p.cashback) : '—'}</td>
<td className="num">{p.interets_nets > 0 ? fmtEUR(p.interets_nets) : '—'}</td>
<td className="num">{fmtEUR(p.net_recu)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{poids.toFixed(1)} %</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byPlatform.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byPlatform.length} plateformes</td>
{multiDetenteur && <td />}
<td className="num">{byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'}</td>
<td className="num">{byPlatformTotals.interets_nets > 0 ? fmtEUR(byPlatformTotals.interets_nets) : '—'}</td>
<td className="num">{fmtEUR(byPlatformTotals.net_recu)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{byPlatformTotals.count}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET VISION MENSUELLE ══ */}
{activeTab === 'vision-mensuelle' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Vision mensuelle — {effectiveVmYear}</h3>
<select value={effectiveVmYear} onChange={e => setVmYear(e.target.value)}
style={{ fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', outline: 'none' }}>
{(years.length ? years : [effectiveVmYear]).map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>
<th>Plateforme</th>
{MOIS_ABBR.map(m => <th key={m} className="num">{m}</th>)}
<th className="num">Total</th>
</tr>
</thead>
<tbody>
{vmData.length === 0 && (
<tr><td colSpan={14} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : `Aucun remboursement en ${effectiveVmYear}`}
</td></tr>
)}
{vmData.map(p => (
<tr key={p.plateforme_id}>
<td>{p.nom}</td>
{p.months.map((v, i) => <td key={i} className="num">{v > 0 ? fmtEUR(v) : '—'}</td>)}
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(p.total)}</td>
</tr>
))}
</tbody>
{vmData.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total</td>
{vmMonthTotals.map((v, i) => <td key={i} className="num">{v > 0 ? fmtEUR(v) : '—'}</td>)}
<td className="num">{fmtEUR(vmGrandTotal)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
</div>
)}
{/* ══ ONGLET REMBOURSEMENTS ══ */}
{activeTab === 'remboursements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Remboursements</h3>
<ExportDropdown
disabled={tableRows.length === 0}
onCSV={() => dlBlob(rembPeToCSV(tableRows, dealNameById), 'remboursements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(rembPeToXLS(tableRows, dealNameById), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(rembPeToJSON(tableRows, dealNameById), 'remboursements.json', 'application/json')}
/>
</div>
<div className="row" style={{ marginBottom: 12 }}>
<div>
<label>Plateforme</label>
<select value={tableFilter.plateforme_id} onChange={e => setTableFilter({ ...tableFilter, plateforme_id: e.target.value })}>
<option value="">Toutes</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
<div>
<label>Type</label>
<select value={tableFilter.type} onChange={e => setTableFilter({ ...tableFilter, type: e.target.value })}>
<option value="">Tous</option>
<option value="bonus_plateforme">Bonus plateforme</option>
<option value="bonus_parrainage">Bonus parrainage</option>
<option value="interets_plateforme">Intérêts plateforme</option>
</select>
</div>
<div>
<label>Investissement</label>
<select value={tableFilter.investissement_pe_id} onChange={e => setTableFilter({ ...tableFilter, investissement_pe_id: e.target.value })}>
<option value="">Tous</option>
{deals.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
</select>
</div>
<div>
<label>Année</label>
<select value={tableFilter.year} onChange={e => setTableFilter({ ...tableFilter, year: e.target.value })}>
<option value="">Toutes</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div>
<label>Mois</label>
<select value={tableFilter.month} onChange={e => setTableFilter({ ...tableFilter, month: e.target.value })}>
<option value="">Tous</option>
{MOIS_FR.map((m, i) => <option key={i + 1} value={String(i + 1).padStart(2, '0')}>{m}</option>)}
</select>
</div>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
<th>Type</th><th className="num">Net perçu</th><th>Statut</th><th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{pagedTableRows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
)}
{pagedTableRows.map(r => (
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openEdit(r)}>
<td>{fmtDate(r.date_remb)}</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</td>}
<td>{TYPE_LABELS[r.type] || r.type}</td>
<td className="num">{fmtEUR(r.net_recu)}</td>
<td className="text-muted">{statutLabel(r.statut)}</td>
<td onClick={e => e.stopPropagation()}>
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination page={rembPage} setPage={setRembPage} pageSize={rembPageSize} setPageSize={setRembPageSize}
totalPages={rembTotalPages} totalItems={rembTotalItems} PAGE_SIZES={PAGE_SIZES} />
</div>
</div>
)}
<Modal
open={modalOpen}
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
onClose={close}
busy={submitting}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<div>
{editingId && (
<button className="danger" disabled={submitting}
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
Supprimer
</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={submitting} onClick={close}>Annuler</button>
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
</button>
</div>
</div>
}
>
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
investisseurForPlat={investisseurForPlat} editingId={editingId} />
</Modal>
<ConfirmModal
open={!!deleteTarget}
title="Supprimer ce remboursement ?"
message="Cette action est irréversible."
confirmLabel="Supprimer"
onConfirm={onDelete}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
* points d'entrée (page avec mouvements existants, et état vide). Champs
* strictement alignés sur le Schema de backend/src/routes/remboursements.js,
* limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme
* (le type "normal", lié à un investissement, reste crowdlending uniquement). */
function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDetenteur, err, runSubmit, submit, investisseurForPlat, editingId }) {
const isInterets = form.type === 'interets_plateforme';
// Deals PE de la plateforme sélectionnée (un remboursement bonus/intérêts
// peut être rattaché à un deal précis, pour le calcul de son TVPI — cf.
// pages/InvestissementsPe.jsx). Optionnel : plusieurs plateformes n'ont
// qu'un seul deal, ou aucun deal PE suivi pour l'instant.
const dealsForPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id));
const previewNet = () => {
if (isInterets) {
const bruts = Number(form.interets_bruts) || 0;
const frais = Number(form.frais_ttc) || 0;
const ps = Number(form.prelev_sociaux) || 0;
const pf = Number(form.prelev_forfaitaire) || 0;
return Math.round((bruts - frais - ps - pf) * 100) / 100;
}
return Number(form.cashback) || 0;
};
return (
<form onSubmit={e => runSubmit(submit, e)}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Type *</label>
<select required value={form.type} disabled={!!editingId}
title={editingId ? 'Le type ne peut pas être modifié après création' : undefined}
onChange={e => setForm({ ...form, type: e.target.value })}>
<option value="bonus_plateforme">Bonus plateforme</option>
<option value="bonus_parrainage">Bonus parrainage</option>
<option value="interets_plateforme">Intérêts plateforme</option>
</select>
</div>
<div>
<label>Plateforme *</label>
<select required value={form.bonus_plateforme_id} onChange={e => {
const platId = e.target.value;
setForm(f => ({ ...f, bonus_plateforme_id: platId, bonus_investisseur_id: investisseurForPlat(platId), investissement_pe_id: '' }));
}}>
<option value="">—</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
{dealsForPlat.length > 0 && (
<div>
<label>Investissement (optionnel)</label>
<select value={form.investissement_pe_id} onChange={e => setForm({ ...form, investissement_pe_id: e.target.value })}>
<option value="">— Aucun (non rattaché à un deal) —</option>
{dealsForPlat.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
</select>
</div>
)}
<div>
<label>Date *</label>
<input type="date" required value={form.date_remb} onChange={e => setForm({ ...form, date_remb: e.target.value })} />
</div>
{!isInterets && (
<div>
<label>Montant reçu (€) *</label>
<input type="number" step="0.01" min="0" required value={form.cashback} onChange={e => setForm({ ...form, cashback: e.target.value })} />
</div>
)}
{isInterets && (
<>
<div>
<label>Intérêts bruts (€) *</label>
<input type="number" step="0.01" min="0" required value={form.interets_bruts} onChange={e => setForm({ ...form, interets_bruts: e.target.value })} />
</div>
<div>
<label>Intérêts bruts avant taxe locale (€)</label>
<input type="number" step="0.01" min="0" value={form.interets_bruts_avant_local} onChange={e => setForm({ ...form, interets_bruts_avant_local: e.target.value })} />
</div>
<div>
<label>Taxe locale (€)</label>
<input type="number" step="0.01" min="0" value={form.taxe_locale} onChange={e => setForm({ ...form, taxe_locale: e.target.value })} />
</div>
<div>
<label>Frais TTC (€)</label>
<input type="number" step="0.01" min="0" value={form.frais_ttc} onChange={e => setForm({ ...form, frais_ttc: e.target.value })} />
</div>
<div>
<label>Prélèvements sociaux (€)</label>
<input type="number" step="0.01" min="0" value={form.prelev_sociaux} onChange={e => setForm({ ...form, prelev_sociaux: e.target.value })} />
</div>
<div>
<label>Prélèvement forfaitaire (€)</label>
<input type="number" step="0.01" min="0" value={form.prelev_forfaitaire} onChange={e => setForm({ ...form, prelev_forfaitaire: e.target.value })} />
</div>
</>
)}
<div>
<label>Statut</label>
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
<option value="paye">Payé</option>
<option value="retard">En retard</option>
<option value="partiel">Partiel</option>
<option value="impaye">Impayé</option>
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Détenteur *</label>
<select required value={form.bonus_investisseur_id} onChange={e => setForm({ ...form, bonus_investisseur_id: e.target.value })}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 13 }}>
Net perçu estimé : {fmtEUR(previewNet())}
</div>
</div>
</form>
);
}
@@ -4,6 +4,7 @@ import { useUi } from '../../context/UiContext.jsx';
import { api } from '../../api.js';
import { getSuggestedPalettes } from '../../utils/chartPalettes.js';
import { COLOR_THEMES, colorThemeSwatch } from '../../utils/colorThemes.js';
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
import ThemeSelect from '../../components/ThemeSelect.jsx';
@@ -281,6 +282,7 @@ function ChartColorPicker({ value, onChange }) {
export default function AppearanceSection() {
const { mode, setMode } = useTheme();
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, colorTheme, adminColorTheme, setColorTheme } = useUi();
const { activeWorkspaces, hasMultipleWorkspaces, setWorkspaceColor } = useWorkspace() || {};
const [libIcons, setLibIcons] = useState({});
const suggestedPalettes = useMemo(() => getSuggestedPalettes(), []);
// "Automatique" (value '') suit le thème par défaut de l'administrateur —
@@ -335,6 +337,40 @@ export default function AppearanceSection() {
/>
</div>
</div>
{/* Couleur par espace de travail (17/09/26) : visible seulement si
plusieurs espaces sont actifs (rien à distinguer sinon). Par
défaut chaque espace hérite de la couleur de l'application
ci-dessus ; l'utilisateur peut la personnaliser par espace, et
elle s'applique automatiquement en basculant dessus (cf.
Layout.jsx / UiContext.jsx workspaceColorOverride). */}
{hasMultipleWorkspaces && (
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid var(--border)' }}>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>Couleur des espaces de travail</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, marginBottom: 14, maxWidth: 420 }}>
Attribuez une couleur à chaque espace : elle remplace automatiquement la couleur
de l'application quand vous basculez dessus. Par défaut, un espace hérite de la
couleur de l'application ci-dessus.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{activeWorkspaces.map((w) => (
<div key={w.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<span style={{ fontSize: 'var(--fs-sm)' }}>{w.libelle_menu || w.nom}</span>
<div style={{ width: 260, flexShrink: 0 }}>
<ThemeSelect
value={w.color_theme || ''}
onChange={(v) => setWorkspaceColor(w.id, v || null)}
options={[
{ value: '', label: "Hérite de la couleur de l'application", swatch: colorThemeSwatch(colorTheme || adminColorTheme) },
...COLOR_THEMES,
]}
/>
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="card">
+12 -7
View File
@@ -3,6 +3,7 @@ import { api } from '../../api.js';
import { memberLabel } from '../../utils/format.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import Modal from '../../components/Modal.jsx';
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
const EXONERATION_LABELS = {
aucune: 'Aucune',
@@ -85,6 +86,8 @@ export default function ComptesSection() {
const [editCompte, setEditCompte] = useState(null);
const [err, setErr] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const { run: runAddCompte, pending: addingCompte } = useAsyncAction();
const { run: runSaveEdit, pending: savingEdit } = useAsyncAction();
const openNew = () => { setNewCompte(EMPTY_COMPTE); setErr(null); setShowNew(true); };
@@ -204,14 +207,15 @@ export default function ComptesSection() {
open={showNew}
title="Nouveau compte"
onClose={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}
busy={addingCompte}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
<button type="button" onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
<button className="primary" form="form-new-compte" type="submit">Créer</button>
<button type="button" disabled={addingCompte} onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
<button className="primary" form="form-new-compte" type="submit" disabled={addingCompte}>{addingCompte ? 'Création…' : 'Créer'}</button>
</div>
}
>
<form id="form-new-compte" onSubmit={addCompte} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<form id="form-new-compte" onSubmit={e => runAddCompte(addCompte, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
</form>
@@ -222,19 +226,20 @@ export default function ComptesSection() {
open={!!editCompte}
title="Modifier le compte"
onClose={() => { setEditCompte(null); setErr(null); }}
busy={savingEdit}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button className="danger" type="button" onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
<button className="danger" type="button" disabled={savingEdit} onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
Supprimer
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-edit-compte" type="submit">Enregistrer</button>
<button type="button" disabled={savingEdit} onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-edit-compte" type="submit" disabled={savingEdit}>{savingEdit ? 'Enregistrement…' : 'Enregistrer'}</button>
</div>
</div>
}
>
<form id="form-edit-compte" onSubmit={saveEdit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<form id="form-edit-compte" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
</form>
@@ -8,7 +8,9 @@ import ConfirmModal from '../../components/ConfirmModal.jsx';
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
import { memberLabel, fmtDate } from '../../utils/format.js';
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
@@ -54,7 +56,7 @@ function fmtFiscalite(p) {
return base;
}
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null, workspace_ids: [] };
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
@@ -230,6 +232,12 @@ function PlatDetailPanel({ plat, onEdit }) {
label: "Secteurs d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
},
{
label: 'Espaces de travail',
value: (plat.workspaces || []).length > 0
? <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{plat.workspaces.map(w => <span key={w.id} className="chip-workspace">{w.nom}</span>)}</div>
: '—',
},
plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut },
plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut },
{
@@ -290,20 +298,25 @@ function PlatDetailPanel({ plat, onEdit }) {
/* ── Picker référentiel ──────────────────────────────────────── */
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }) {
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err, activeWorkspaces = [] }) {
const [search, setSearch] = useState('');
const [domFilter, setDomFilter] = useState('all'); // 'all' | 'fr' | 'etr'
const [confirming, setConfirming] = useState(null); // ref en cours de confirmation
const [dateOuverture, setDateOuverture] = useState('');
const [chosenWorkspace, setChosenWorkspace] = useState(null); // workspace choisi (si l'utilisateur en a plusieurs)
useEffect(() => {
if (open) { setConfirming(null); setDateOuverture(''); }
}, [open]);
if (open) {
setConfirming(null); setDateOuverture('');
setChosenWorkspace(activeWorkspaces.length === 1 ? activeWorkspaces[0].id : null);
}
}, [open]); // eslint-disable-line
if (!open) return null;
const q = search.trim().toLowerCase();
const filtered = referentiel.filter(r => {
if (chosenWorkspace && !(r.workspaces || []).some(w => w.id === chosenWorkspace)) return false;
if (domFilter === 'fr' && r.domiciliation !== 'FR') return false;
if (domFilter === 'etr' && r.domiciliation === 'FR') return false;
if (q && !r.nom.toLowerCase().includes(q)) return false;
@@ -315,6 +328,34 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
return f ? (LOGO_BASE + f) : null;
};
/* ── Étape de choix de l'espace de travail (si plusieurs disponibles) ── */
if (activeWorkspaces.length > 1 && chosenWorkspace === null) {
return (
<Modal open={open} title="Ajouter une plateforme" onClose={onClose} width={480}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', lineHeight: 1.5 }}>
Sur quel espace de travail voulez-vous ajouter cette plateforme ? Seules les plateformes
du référentiel éligibles à cet espace vous seront proposées.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{activeWorkspaces.map(w => (
<button key={w.id} type="button" onClick={() => setChosenWorkspace(w.id)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px', borderRadius: 8, border: '1.5px solid var(--border)',
background: 'var(--surface)', cursor: 'pointer', textAlign: 'left',
fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)',
}}>
{w.libelle_menu || w.nom}
<span style={{ opacity: 0.5 }}>→</span>
</button>
))}
</div>
</div>
</Modal>
);
}
/* ── Étape de confirmation : date d'ouverture du compte ── */
if (confirming) {
const img = imgFor(confirming);
@@ -327,7 +368,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button type="button" className="ghost" onClick={() => setConfirming(null)}>← Retour</button>
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture)}>
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture, chosenWorkspace)}>
Ajouter
</button>
</div>
@@ -384,6 +425,15 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
Les informations (domiciliation, fiscalité…) seront pré-remplies automatiquement.
</p>
{activeWorkspaces.length > 1 && (
<button type="button" onClick={() => setChosenWorkspace(null)}
style={{ alignSelf: 'flex-start', background: 'none', border: 'none', padding: 0,
color: 'var(--primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
← Espace de travail : {activeWorkspaces.find(w => w.id === chosenWorkspace)?.libelle_menu
|| activeWorkspaces.find(w => w.id === chosenWorkspace)?.nom || ''} (changer)
</button>
)}
{/* Barre de recherche + chips filtre */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
@@ -494,7 +544,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
/* ── Formulaire plateforme partagé (création + édition) ─────────── */
function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPreview,
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo }) {
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo, activeWorkspaces = [] }) {
const isFrance = state.domiciliation === 'FR';
@@ -575,6 +625,27 @@ function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setL
<input type="date" value={state.date_ouverture || ''} onChange={e => setter(s => ({ ...s, date_ouverture: e.target.value }))}
style={{ width: '100%', boxSizing: 'border-box' }} />
))}
{/* Espaces de travail (uniquement affiché si plusieurs disponibles) */}
{activeWorkspaces.length > 1 && row('Espaces de travail *', (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{activeWorkspaces.map(w => {
const checked = (state.workspace_ids || []).includes(w.id);
return (
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => setter(s => ({
...s,
workspace_ids: e.target.checked
? [...(s.workspace_ids || []), w.id]
: (s.workspace_ids || []).filter(id => id !== w.id),
}))} />
<span className="chip-workspace" style={{ fontSize: 11 }}>{w.libelle_menu || w.nom}</span>
</label>
);
})}
</div>
), "Cette plateforme ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).")}
</div>
);
@@ -741,6 +812,7 @@ function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setL
export default function PlateformesSection() {
const [searchParams, setSearchParams] = useSearchParams();
const { activeWorkspaces } = useWorkspace();
// ── State ──────────────────────────────────────────────────────
const [plats, setPlats] = useState([]);
@@ -779,6 +851,8 @@ export default function PlateformesSection() {
const [err, setErr] = useState(null);
const { run: runAddPlat, pending: addingPlat } = useAsyncAction();
const { run: runSaveEditPlat, pending: savingEditPlat } = useAsyncAction();
const [msg, setMsg] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const platImportRef = useRef(null);
@@ -886,8 +960,9 @@ export default function PlateformesSection() {
};
/* ── Ajouter depuis le référentiel ──────────────────────────── */
const addPlatFromRef = async (ref, dateOuverture) => {
const addPlatFromRef = async (ref, dateOuverture, workspaceId) => {
try {
const wsId = workspaceId ?? activeWorkspaces[0]?.id;
await api.post('/plateformes', {
nom: ref.nom,
url: ref.url || '',
@@ -902,6 +977,7 @@ export default function PlateformesSection() {
freq_interets_defaut: ref.freq_interets_defaut || null,
referentiel_id: ref.id,
date_ouverture: dateOuverture || null,
workspace_ids: wsId ? [wsId] : [],
});
setShowAddPicker(false);
await load();
@@ -911,6 +987,10 @@ export default function PlateformesSection() {
/* ── Plateformes ─────────────────────────────────────────────── */
const addPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
if (activeWorkspaces.length > 1 && (!newPlat.workspace_ids || newPlat.workspace_ids.length === 0)) {
setErr('Sélectionnez au moins un espace de travail.');
return;
}
try {
const created = await api.post('/plateformes', {
...newPlat,
@@ -922,6 +1002,9 @@ export default function PlateformesSection() {
type_pret_defaut: newPlat.type_pret_defaut || null,
freq_interets_defaut: newPlat.freq_interets_defaut || null,
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
workspace_ids: (newPlat.workspace_ids && newPlat.workspace_ids.length > 0)
? newPlat.workspace_ids
: (activeWorkspaces[0] ? [activeWorkspaces[0].id] : []),
});
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
setNewPlat(EMPTY_PLAT);
@@ -997,6 +1080,7 @@ export default function PlateformesSection() {
secteurs_inv_ids: platSectsInv.map(s => s.id),
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
workspace_ids: (p.workspaces || []).map(w => w.id),
domiciliation: p.domiciliation || 'france',
fiscalite: p.fiscalite || 'flat_tax',
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
@@ -1017,6 +1101,10 @@ export default function PlateformesSection() {
const saveEditPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
if (activeWorkspaces.length > 1 && (!editPlat.workspace_ids || editPlat.workspace_ids.length === 0)) {
setErr('Sélectionnez au moins un espace de travail.');
return;
}
try {
await api.put(`/plateformes/${editPlat.id}`, {
nom: editPlat.nom, url: editPlat.url || '',
@@ -1032,6 +1120,9 @@ export default function PlateformesSection() {
date_ouverture: editPlat.date_ouverture || null,
type_pret_defaut: editPlat.type_pret_defaut || null,
freq_interets_defaut: editPlat.freq_interets_defaut || null,
workspace_ids: (editPlat.workspace_ids && editPlat.workspace_ids.length > 0)
? editPlat.workspace_ids
: (activeWorkspaces[0] ? [activeWorkspaces[0].id] : []),
});
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
// Sauvegarder les associations catégories/secteurs d'investissement
@@ -1114,8 +1205,7 @@ export default function PlateformesSection() {
<th style={{ padding: '10px 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
{multiDetenteur && <th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>}
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
<th style={{ padding: '10px 8px', width: 40 }}></th>
@@ -1123,13 +1213,12 @@ export default function PlateformesSection() {
</thead>
<tbody>
{plats.length === 0 && (
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
)}
{plats.map((p, i) => {
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
const inv = platInvestisseur(p);
const cats = p.categories_inv || [];
const sects = p.secteurs_inv || [];
const wsList = p.workspaces || [];
return (
<tr key={p.id}
onClick={() => setSelectedPlat(selectedPlat?.id === p.id ? null : p)}
@@ -1158,16 +1247,9 @@ export default function PlateformesSection() {
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
{cats.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
{wsList.slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
{wsList.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{wsList.length - 2}</span>}
{wsList.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
{multiDetenteur && (
@@ -1245,6 +1327,7 @@ export default function PlateformesSection() {
onSelect={addPlatFromRef}
onManual={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}
err={err}
activeWorkspaces={activeWorkspaces}
/>
{/* ── Modal : Ajouter une plateforme ── */}
@@ -1255,12 +1338,14 @@ export default function PlateformesSection() {
logoPreview: newPlatLogoPreview, setLogoPreview: setNewPlatLogoPreview,
investisseurs, referentiel,
categoriesInv: [], secteursInv: [],
activeWorkspaces,
});
return (
<PlateformeFormModal
title="Nouvelle plateforme"
onClose={() => { setShowNewPlat(false); setNewPlat(EMPTY_PLAT); setNewPlatLogoFile(null); setNewPlatLogoPreview(null); setErr(null); }}
onSubmit={addPlat}
onSubmit={e => runAddPlat(addPlat, e)}
saving={addingPlat}
err={err}
submitLabel="Créer"
width={600}
@@ -1279,12 +1364,14 @@ export default function PlateformesSection() {
categoriesInv, secteursInv,
isEdit: true,
onDelLogo: () => delLogoPlat(editPlat.id),
activeWorkspaces,
});
return (
<PlateformeFormModal
title="Modifier la plateforme"
onClose={() => { setEditPlat(null); setEditPlatLogoFile(null); setEditPlatLogoPreview(null); setErr(null); }}
onSubmit={saveEditPlat}
onSubmit={e => runSaveEditPlat(saveEditPlat, e)}
saving={savingEditPlat}
err={err}
submitLabel="Enregistrer"
width={600}
+5
View File
@@ -1015,6 +1015,11 @@ tr:hover td { background: var(--surface-2); }
font-weight: 500; background: rgba(16,185,129,.12); color: var(--success);
border: 1px solid rgba(16,185,129,.25);
}
.chip-workspace {
display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px;
font-weight: 500; background: rgba(245,158,11,.12); color: #d97706;
border: 1px solid rgba(245,158,11,.25);
}
/* ── Filtre par catégorie — bloc "Mes documents" (Mon compte) ── */
.doc-cat-tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
+15 -8
View File
@@ -152,8 +152,9 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
title: withLabel('Liste des remboursements'),
description: withSource(
"Liste les remboursements réels perçus (toutes plateformes confondues), " +
"avec le nom du projet et de la plateforme. Filtrable par période. " +
"Chaque ligne inclut frais_ttc (montant de frais saisi sur ce " +
"avec le nom du projet et de la plateforme. Filtrable par période et par " +
"workspace (paramètre workspace ; sans ce paramètre, tous workspaces " +
"confondus). Chaque ligne inclut frais_ttc (montant de frais saisi sur ce " +
"remboursement) et frais_mode_reglement : le frais ne réduit " +
"interets_nets/net_recu que si frais_mode_reglement vaut " +
"\"remboursement\" — avec \"portefeuille\" ou \"compte_courant\", le frais " +
@@ -164,11 +165,13 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
.describe('Date de début au format YYYY-MM-DD (incluse)'),
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
workspace: z.string().optional()
.describe('Slug du workspace à filtrer (ex. "crowdlending"). Absent : tous workspaces confondus.'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ date_debut, date_fin }) => {
try { return toolResult(await apiGet('/remboursements', { date_debut, date_fin })); }
async ({ date_debut, date_fin, workspace }) => {
try { return toolResult(await apiGet('/remboursements', { date_debut, date_fin, workspace })); }
catch (e) { return toolError(e); }
},
);
@@ -214,12 +217,16 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
title: withLabel('Liste des dépôts / retraits'),
description: withSource(
"Liste les mouvements de cash (dépôts et retraits) sur les plateformes " +
"de crowdlending, du plus récent au plus ancien."),
inputSchema: {},
"de crowdlending, du plus récent au plus ancien. Filtrable par workspace " +
"(paramètre workspace) ; sans ce paramètre, tous workspaces confondus."),
inputSchema: {
workspace: z.string().optional()
.describe('Slug du workspace à filtrer (ex. "crowdlending"). Absent : tous workspaces confondus.'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async () => {
try { return toolResult(await apiGet('/depots-retraits')); }
async ({ workspace }) => {
try { return toolResult(await apiGet('/depots-retraits', { workspace })); }
catch (e) { return toolError(e); }
},
);