diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 61d49af..e8dbba6 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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; diff --git a/backend/src/middleware/workspaceScope.js b/backend/src/middleware/workspaceScope.js new file mode 100644 index 0000000..933cfad --- /dev/null +++ b/backend/src/middleware/workspaceScope.js @@ -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; +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 86fbb15..cfba5a5 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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); diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index f5a1963..c45df87 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -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; diff --git a/backend/src/routes/dashboardPe.js b/backend/src/routes/dashboardPe.js new file mode 100644 index 0000000..dd871e3 --- /dev/null +++ b/backend/src/routes/dashboardPe.js @@ -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; diff --git a/backend/src/routes/depotsRetraits.js b/backend/src/routes/depotsRetraits.js index 6f4b32c..09a84e7 100644 --- a/backend/src/routes/depotsRetraits.js +++ b/backend/src/routes/depotsRetraits.js @@ -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); } diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js index 9759a74..9b5a550 100644 --- a/backend/src/routes/documents.js +++ b/backend/src/routes/documents.js @@ -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) { diff --git a/backend/src/routes/fraisOperations.js b/backend/src/routes/fraisOperations.js index e8acef9..4b984c9 100644 --- a/backend/src/routes/fraisOperations.js +++ b/backend/src/routes/fraisOperations.js @@ -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,17 +126,41 @@ router.get('/', (req, res, next) => { WHERE f.investissement_id = ? ORDER BY f.date_operation DESC, f.id DESC `).all(Number(investissement_id)); - } else { + } else if (investissement_pe_id) { + assertOwnedInvestissementPe(Number(investissement_pe_id), req); rows = db.prepare(` - SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom + SELECT f.*, c.nom AS compte_nom FROM frais_operations f - JOIN investissements i ON i.id = f.investissement_id - JOIN investisseurs inv ON inv.id = i.investisseur_id - JOIN plateformes p ON p.id = i.plateforme_id - LEFT JOIN comptes c ON c.id = f.compte_id - WHERE inv.user_id = ? + 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(req.user.id); + `).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 + JOIN investissements i ON i.id = f.investissement_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + JOIN plateformes p ON p.id = i.plateforme_id + LEFT JOIN comptes c ON c.id = f.compte_id + 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(); diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index 6bda458..ff02445 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -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++; } diff --git a/backend/src/routes/investissementsPe.js b/backend/src/routes/investissementsPe.js new file mode 100644 index 0000000..7ea0418 --- /dev/null +++ b/backend/src/routes/investissementsPe.js @@ -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; diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index de9fb98..603fb1c 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -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, diff --git a/backend/src/routes/preferences.js b/backend/src/routes/preferences.js index 868d627..6ce54ee 100644 --- a/backend/src/routes/preferences.js +++ b/backend/src/routes/preferences.js @@ -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', ]); diff --git a/backend/src/routes/referentiel.js b/backend/src/routes/referentiel.js index 4816633..2540c7e 100644 --- a/backend/src/routes/referentiel.js +++ b/backend/src/routes/referentiel.js @@ -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); } diff --git a/backend/src/routes/remboursements.js b/backend/src/routes/remboursements.js index 2125f29..aeeac46 100644 --- a/backend/src/routes/remboursements.js +++ b/backend/src/routes/remboursements.js @@ -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); diff --git a/backend/src/routes/v1/depotsRetraits.js b/backend/src/routes/v1/depotsRetraits.js index 95b12d8..7b18c61 100644 --- a/backend/src/routes/v1/depotsRetraits.js +++ b/backend/src/routes/v1/depotsRetraits.js @@ -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= + // 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); }); diff --git a/backend/src/routes/v1/remboursements.js b/backend/src/routes/v1/remboursements.js index f083d50..0ec0a61 100644 --- a/backend/src/routes/v1/remboursements.js +++ b/backend/src/routes/v1/remboursements.js @@ -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, diff --git a/backend/src/routes/workspaces.js b/backend/src/routes/workspaces.js index c3b49c5..4c6a2ef 100644 --- a/backend/src/routes/workspaces.js +++ b/backend/src/routes/workspaces.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 0b24ef0..d4e35eb 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 09cf1a3..8921de0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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
Chargement…
; + if (!hasFraisPe) return ; + 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 ( -
-

{active.nom}

-

- {active.description || "Cet espace de travail n'est pas encore disponible."} -

-
- ); - } + if (active && active.slug !== 'crowdlending') return ; return ; } +// 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 ; + return ; +} + +// 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 ; + return ; +} + +// 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 ; + return ; +} + +// 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 ; + return ; +} + +// 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 ; + return ; +} + export default function App() { const [allowRegistration, setAllowRegistration] = useState(true); @@ -131,11 +194,11 @@ export default function App() { }> } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -145,8 +208,8 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 8539c8c..d6642fc 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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; } diff --git a/frontend/src/components/ConfirmModal.jsx b/frontend/src/components/ConfirmModal.jsx index 87e2f20..8fc24a2 100644 --- a/frontend/src/components/ConfirmModal.jsx +++ b/frontend/src/components/ConfirmModal.jsx @@ -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 ( - - + + } > diff --git a/frontend/src/components/FraisFormModal.jsx b/frontend/src/components/FraisFormModal.jsx index 0cc7108..9113b93 100644 --- a/frontend/src/components/FraisFormModal.jsx +++ b/frontend/src/components/FraisFormModal.jsx @@ -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={
{editingId && onDelete && ( !confirmingDelete ? ( - ) : ( @@ -66,14 +68,16 @@ export default function FraisFormModal({ Supprimer définitivement ? - - + + ) )}
- - + +
} > diff --git a/frontend/src/components/InvestissementFormModal.jsx b/frontend/src/components/InvestissementFormModal.jsx index 219459f..9c90b8c 100644 --- a/frontend/src/components/InvestissementFormModal.jsx +++ b/frontend/src/components/InvestissementFormModal.jsx @@ -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={
{editingId && onDelete && ( !confirmingDelete ? ( - ) : ( @@ -298,16 +300,20 @@ export default function InvestissementFormModal({ Supprimer définitivement (documents inclus) ? - - + + ) )}
- {step === 1 && } - {step > 1 && } - {step < totalSteps && } - {step === totalSteps && } + {step === 1 && } + {step > 1 && } + {step < totalSteps && } + {step === totalSteps && ( + + )}
} > diff --git a/frontend/src/components/InvestissementPeFormModal.jsx b/frontend/src/components/InvestissementPeFormModal.jsx new file mode 100644 index 0000000..18e1b6e --- /dev/null +++ b/frontend/src/components/InvestissementPeFormModal.jsx @@ -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 ( + +
+ {editingId && step === 1 && ( + + )} +
+
+ {step === 1 && } + {step > 1 && } + {step < totalSteps && } + {step === totalSteps && ( + + )} +
+ + } + > + {totalSteps > 1 && ( + <> +
+ {Array.from({ length: totalSteps }).map((_, i) => ( +
+ ))} +
+
+ Étape {step}/{totalSteps} — {stepTitles[step - 1]} +
+ + )} + +
+ {err &&
{err}
} + + {step === 1 && ( +
+
+ + setField('nom_deal', e.target.value)} placeholder="Ex. Cornerstone Growth" /> +
+
+ + +
+
+ + setField('strategie', e.target.value)} placeholder="Ex. Capital risque" /> +
+
+ + setField('date_souscription', e.target.value)} /> +
+
+ + setField('duree_annees', e.target.value)} /> +
+
+ + setField('montant_investi', e.target.value)} /> +
+
+ + setField('objectif', e.target.value)} placeholder="Ex. x4,5 ou 9% - 12%/an" /> +
+
+ + +
+
+ + setField('reference', e.target.value)} /> +
+
+ + +
+
+ + setField('notes', e.target.value)} /> +
+
+ )} + + {step === 2 && showFraisStep && ( + <> +

+ 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. +

+ + + + )} + + + ); +} + +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 ( +
+
{label}
+
+
+ + +
+
+ + setField(`${prefix}_valeur`, e.target.value)} /> +
+
+ + +
+ {modeReglement === 'compte_courant' && ( +
+ + {comptesInvestisseur.length > 0 ? ( + + ) : ( +

+ Aucun compte défini pour ce détenteur. Créer un compte → +

+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index bd0f33d..5f2008c 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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,25 +343,39 @@ export default function Layout() { Tableau de bord + {/* 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 && ( + + Plateformes + + )} + + Investissements + + + Dépôts / Retraits + + + Remboursements + + {showFrais && ( + + Frais + + )} {isCrowdlendingActive && ( <> - - Plateformes - - - Investissements - - - Dépôts / Retraits - - - Remboursements - - {hasFrais && ( - - Frais - - )} Fiscalité diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx index f56e782..84d8595 100644 --- a/frontend/src/components/Modal.jsx +++ b/frontend/src/components/Modal.jsx @@ -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 (

{title}

- +
{children} {footer &&
{footer}
} diff --git a/frontend/src/components/MouvementsShared.jsx b/frontend/src/components/MouvementsShared.jsx index ce85e48..2e3ce98 100644 --- a/frontend/src/components/MouvementsShared.jsx +++ b/frontend/src/components/MouvementsShared.jsx @@ -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, )}
- - +
diff --git a/frontend/src/components/RembFormModal.jsx b/frontend/src/components/RembFormModal.jsx index 5352288..a8a205d 100644 --- a/frontend/src/components/RembFormModal.jsx +++ b/frontend/src/components/RembFormModal.jsx @@ -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={
{editingId && onDelete && ( !confirmingDelete ? ( - ) : ( @@ -141,14 +143,16 @@ export default function RembFormModal({ Supprimer définitivement ? - - + + ) )}
- - + +
} > diff --git a/frontend/src/components/UserMenu.jsx b/frontend/src/components/UserMenu.jsx index bf15781..fff211b 100644 --- a/frontend/src/components/UserMenu.jsx +++ b/frontend/src/components/UserMenu.jsx @@ -132,10 +132,11 @@ export default function UserMenu() { const [popupStyle, setPopupStyle] = useState({}); const [subStyle, setSubStyle] = useState({}); - const triggerRef = useRef(null); - const popupRef = useRef(null); - const subRef = useRef(null); - const closeTimer = useRef(null); + const triggerRef = useRef(null); + 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() { + {visibleYears.map(y => ( + + ))} + + + {expandButton} +
+
+ + + {/* ── Table ── */} + {!grid || grid.length === 0 ? ( +
+ Aucune valorisation pour {annee}. +
+ ) : ( +
+ + + + + + + + {MOIS_LONG.map((m, i) => ( + + ))} + + + + + + + {grid.map((plat, pi) => ( + + + {plat.months.map((v, mi) => ( + + ))} + + + + ))} + + + + + + {stats.monthTotals.map((v, i) => ( + + ))} + + + +
+ {annee} + +
Plateforme + {m} + MoyennePoids
+ {plat.nom} + {multiDetenteur && plat.detenteur_nom && ( + + {plat.detenteur_nom} + + )} + + {v > 0 ? fmtEUR(v) : —} + + {stats.platMoyennes[pi] > 0 ? fmtEUR(stats.platMoyennes[pi]) : —} + + {stats.platPoids[pi] > 0 ? ( +
+
+
+
+ {stats.platPoids[pi].toFixed(1)} % +
+ ) : —} +
Toutes les plateformes + {v > 0 ? fmtEUR(v) : —} + {fmtEUR(stats.globalMoyenne)} +
+
+ )} + + ); +} diff --git a/frontend/src/components/ValorisationPeChart.jsx b/frontend/src/components/ValorisationPeChart.jsx new file mode 100644 index 0000000..fb85d7f --- /dev/null +++ b/frontend/src/components/ValorisationPeChart.jsx @@ -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 ( +
+ + {/* ── En-tête ── */} +
+
+
Valorisation totale · {displayDate}
+
{displayValue}
+
+
+
+ {RANGES.map(r => ( + + ))} +
+
+
+ + {/* ── SVG ── */} + {xScale && ( + setHover(null)} + > + + + + + + + + + + + + + {/* Grille horizontale */} + {yTicks.map(({ v, y }) => ( + + + + {fmtK(v)} + + + ))} + + {/* Ligne zéro */} + {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + + )} + + {/* Aire dégradée + courbe */} + + + + {/* Labels axe X */} + {xTicks.map((p, i) => { + const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'; + return ( + + {fmtAxisDate(p.date, range)} + + ); + })} + + {/* Hover : ligne verticale + point */} + {hover && ( + + + + + )} + + )} + + {/* ── Tooltip flottant ── */} + {hover && tooltipStyle && ( +
+
+ {tooltipDate} + {fmtValueDisplay(hover.value)} +
+
+ )} +
+ ); +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 94bd5fd..c18b436 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( - + {children} ); diff --git a/frontend/src/context/UiContext.jsx b/frontend/src/context/UiContext.jsx index d82b515..0184804 100644 --- a/frontend/src/context/UiContext.jsx +++ b/frontend/src/context/UiContext.jsx @@ -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 ──────────── - // 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 ( - + {children} ); diff --git a/frontend/src/context/WorkspaceContext.jsx b/frontend/src/context/WorkspaceContext.jsx index 40eace7..0fed089 100644 --- a/frontend/src/context/WorkspaceContext.jsx +++ b/frontend/src/context/WorkspaceContext.jsx @@ -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 ( 1, }}> {children} diff --git a/frontend/src/hooks/useAsyncAction.js b/frontend/src/hooks/useAsyncAction.js new file mode 100644 index 0000000..4047775 --- /dev/null +++ b/frontend/src/hooks/useAsyncAction.js @@ -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(); + * ... + * + * + * `run` transmet ses arguments au handler (utile pour un onSubmit de
+ * 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 }; +} diff --git a/frontend/src/pages/AdminFiscalite.jsx b/frontend/src/pages/AdminFiscalite.jsx index 8273267..49a217b 100644 --- a/frontend/src/pages/AdminFiscalite.jsx +++ b/frontend/src/pages/AdminFiscalite.jsx @@ -397,7 +397,7 @@ function TauxCreditImpotSection() { )} {editing && setEditing(null)} onSaved={load} />} - {deleting && setDeleting(null)} />} + {deleting && setDeleting(null)} />} ); } diff --git a/frontend/src/pages/AdminPlateformes.jsx b/frontend/src/pages/AdminPlateformes.jsx index ca4a472..2dd7287 100644 --- a/frontend/src/pages/AdminPlateformes.jsx +++ b/frontend/src/pages/AdminPlateformes.jsx @@ -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 }) { +
+ +
+ {workspaces.map(w => { + const checked = (form.workspace_ids || []).includes(w.id); + return ( + + ); + })} +
+
+ Cette entrée du référentiel ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s). +
+