From 49fab06012d5f84bb3dce47e9bd75fb234fca9c7 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sat, 19 Sep 2026 19:00:12 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9liorations=20diverses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/index.js | 12 + backend/src/routes/fraisOperations.js | 40 +- backend/src/routes/investissementsPe.js | 34 +- frontend/src/App.jsx | 13 +- .../components/InvestissementPeFormModal.jsx | 8 + frontend/src/components/Layout.jsx | 27 +- frontend/src/components/PlatSelector.jsx | 120 ++++ frontend/src/pages/DepotsRetraitsSimple.jsx | 18 +- frontend/src/pages/Frais.jsx | 2 +- frontend/src/pages/FraisPe.jsx | 11 +- frontend/src/pages/InvestissementPeDetail.jsx | 7 + frontend/src/pages/InvestissementsPe.jsx | 15 +- frontend/src/pages/Plateformes.jsx | 90 +-- frontend/src/pages/PlateformesPe.jsx | 646 ++++++++++++++++++ frontend/src/pages/RemboursementsSimple.jsx | 12 +- 15 files changed, 908 insertions(+), 147 deletions(-) create mode 100644 frontend/src/components/PlatSelector.jsx create mode 100644 frontend/src/pages/PlateformesPe.jsx diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 2ccf3f5..c0dbafe 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -3445,6 +3445,18 @@ if (!invPeCols.includes('capital_investissable')) { `); } +// Mode de détention du deal PE (19/09/26, demande Olivier) — prépare le terrain pour un futur +// calcul fiscal PE sans le construire maintenant (cf. project_workspaces_transformation.md) : +// 'direct' (compte-titres classique, flat tax au moment du versement, ex. Fundora) | 'pea_pme' +// (enveloppe PEA/PEA-PME, ex. Baltis) | 'assurance_vie'. Même principe que fiscalite_override +// côté crowdlending (cf. project_fiscalite_override.md), mais en énumération à 3 valeurs plutôt +// qu'un booléen "exonéré ou pas" : PEA-PME et assurance-vie ont des régimes fiscaux différents +// l'un de l'autre, pas juste "exonéré ou pas". Informatif pour l'instant, aucun calcul fiscal +// n'en dépend encore. +if (!invPeCols.includes('mode_detention')) { + db.exec("ALTER TABLE investissements_pe ADD COLUMN mode_detention TEXT NOT NULL DEFAULT 'direct'"); +} + // 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 diff --git a/backend/src/routes/fraisOperations.js b/backend/src/routes/fraisOperations.js index 9fa9f16..8903ba6 100644 --- a/backend/src/routes/fraisOperations.js +++ b/backend/src/routes/fraisOperations.js @@ -106,12 +106,18 @@ function assertUniqueSouscription(body, categorie, excludeId) { /** * 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/ + * Sans filtre : tous les frais du détenteur actif (header X-Investisseur-Id) pour le + * workspace ACTIF (résolu via resolveActiveWorkspaceId, header X-Workspace-Id) — crowdlending + * ou PE selon le type du workspace, enrichis de nom_projet/nom_deal + plateforme_nom/ + * investisseur_nom (vue portefeuille, ex. page dédiée Frais/FraisPe). ?scope=all agrège tous + * les détenteurs de l'utilisateur (vue "Famille et entreprises"), même mécanisme que GET + * /investissements-pe, /depots-retraits et /remboursements (19/09/26 — jusqu'ici cette route + * ignorait totalement le détenteur actif et renvoyait toujours tout le foyer, contrairement à + * ces trois autres routes ; bug remonté par Olivier : la page Frais ne filtrait jamais par + * détenteur, même un détenteur précis sélectionné). Le filtre par investissement_id/ * investissement_pe_id n'a pas besoin de cet enrichissement, le contexte est déjà connu - * côté client. + * côté client, et reste volontairement family-wide (propriété déjà vérifiée par + * assertOwnedInvestissement(Pe) sur le deal précis demandé). */ router.get('/', (req, res, next) => { try { @@ -138,6 +144,22 @@ router.get('/', (req, res, next) => { } else { const workspaceId = resolveActiveWorkspaceId(req); const ws = db.prepare('SELECT type FROM workspaces WHERE id = ?').get(workspaceId); + + const scopeAll = req.query.scope === 'all'; + let invCond, invArgs; + if (scopeAll) { + invCond = 'inv.user_id = ?'; + invArgs = [req.user.id]; + } 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, req.user.id); + if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); + invCond = 'inv.id = ?'; + invArgs = [id]; + } + if (!ws || ws.type === 'crowdlending') { rows = db.prepare(` SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom @@ -146,9 +168,9 @@ router.get('/', (req, res, next) => { 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 = ? + WHERE ${invCond} ORDER BY f.date_operation DESC, f.id DESC - `).all(req.user.id); + `).all(...invArgs); } else { rows = db.prepare(` SELECT f.*, c.nom AS compte_nom, ipe.nom_deal, p.nom AS plateforme_nom, inv.nom AS investisseur_nom @@ -157,9 +179,9 @@ router.get('/', (req, res, next) => { 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 = ? + WHERE ${invCond} AND ipe.workspace_id = ? ORDER BY f.date_operation DESC, f.id DESC - `).all(req.user.id, workspaceId); + `).all(...invArgs, workspaceId); } } res.json(rows); diff --git a/backend/src/routes/investissementsPe.js b/backend/src/routes/investissementsPe.js index 54aab8e..96c1f7f 100644 --- a/backend/src/routes/investissementsPe.js +++ b/backend/src/routes/investissementsPe.js @@ -44,6 +44,8 @@ const Schema = z.object({ statut: z.enum(['en_attente', 'valide', 'cloture', 'perte_definitive']).default('valide'), reference: z.string().optional(), notes: z.string().optional(), + // Mode de détention (19/09/26) — cf. migration mode_detention dans db/index.js. + mode_detention: z.enum(['direct', 'pea_pme', 'assurance_vie']).default('direct'), }); const ValorisationSchema = z.object({ @@ -102,6 +104,11 @@ router.get('/', (req, res) => { } const workspaceId = resolveActiveWorkspaceId(req); + // Filtre optionnel par plateforme (19/09/26, page Plateformes PE) — mirroir du filtre + // plateforme_id déjà supporté par GET /depots-retraits et GET /remboursements. + const { plateforme_id } = req.query; + const platCond = plateforme_id ? 'AND ipe.plateforme_id = ?' : ''; + const platArgs = plateforme_id ? [Number(plateforme_id)] : []; const rows = db.prepare(` SELECT ipe.*, @@ -126,9 +133,9 @@ router.get('/', (req, res) => { 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 = ? + WHERE ${invCond} AND ipe.workspace_id = ? ${platCond} ORDER BY ipe.date_souscription DESC, ipe.id DESC - `).all(...invArgs, workspaceId); + `).all(...invArgs, workspaceId, ...platArgs); const withTvpi = rows.map(r => ({ ...r, @@ -252,7 +259,15 @@ router.get('/mouvements-portefeuille', (req, res) => { // (crowdlending) qui appelle cet endpoint. Pour depots_retraits en revanche (table partagée // entre workspaces), il FAUT exclure explicitement le workspace appelant, pour ne pas // dupliquer ce que le frontend a déjà récupéré via son propre GET /depots-retraits scopé. + // + // ?native=1 (19/09/26, page Plateformes PE) : bascule ce même endpoint pour un appel fait + // DEPUIS le workspace PE lui-même (plutôt que depuis crowdlending) — dans ce sens, les + // depots_retraits à renvoyer sont ceux DU workspace appelant (workspace_id = callerWorkspaceId), + // pas ceux d'un autre workspace. Les souscriptions/distributions/frais PE restent inchangées + // (déjà natives, sans filtre workspace_id — limite connue si plusieurs workspaces PE existent + // un jour, cf. commentaire ci-dessus, non résolue ici). const callerWorkspaceId = resolveActiveWorkspaceId(req); + const native = req.query.native === '1'; const souscriptions = db.prepare(` SELECT ipe.id AS deal_id, ipe.plateforme_id, p.nom AS plateforme_nom, @@ -292,6 +307,7 @@ router.get('/mouvements-portefeuille', (req, res) => { WHERE ${invCond} `).all(...invArgs); + const drWorkspaceOp = native ? '=' : '!='; const depots = db.prepare(` SELECT dr.id AS dr_id, dr.plateforme_id, p.nom AS plateforme_nom, plat_inv.nom AS plateforme_detenteur_nom, @@ -301,7 +317,7 @@ router.get('/mouvements-portefeuille', (req, res) => { JOIN plateformes p ON p.id = dr.plateforme_id LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id JOIN investisseurs inv ON inv.id = dr.investisseur_id - WHERE dr.workspace_id != ? AND ${invCondDr} + WHERE dr.workspace_id ${drWorkspaceOp} ? AND ${invCondDr} `).all(callerWorkspaceId, ...invArgsDr); const rows = [ @@ -342,13 +358,14 @@ router.post('/', requireInvestisseur, (req, res, next) => { const r = db.prepare(` INSERT INTO investissements_pe (investisseur_id, plateforme_id, workspace_id, nom_deal, strategie, date_souscription, - duree_mois, montant_investi, capital_investissable, objectif, statut, reference, notes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + duree_mois, montant_investi, capital_investissable, objectif, statut, reference, notes, + mode_detention) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( investisseurId, body.plateforme_id, workspaceId, body.nom_deal, body.strategie || null, body.date_souscription, body.duree_mois ?? null, body.montant_investi, body.capital_investissable ?? null, body.objectif || null, - body.statut, body.reference || null, body.notes || null, + body.statut, body.reference || null, body.notes || null, body.mode_detention, ); res.status(201).json({ id: r.lastInsertRowid, ...body }); } catch (e) { next(e); } @@ -505,12 +522,13 @@ router.put('/:id', requireInvestisseur, (req, res, next) => { UPDATE investissements_pe SET investisseur_id=?, plateforme_id=?, nom_deal=?, strategie=?, date_souscription=?, duree_mois=?, montant_investi=?, capital_investissable=?, objectif=?, statut=?, - reference=?, notes=?, updated_at=datetime('now') + reference=?, notes=?, mode_detention=?, 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.capital_investissable ?? null, - body.objectif || null, body.statut, body.reference || null, body.notes || null, id, + body.objectif || null, body.statut, body.reference || null, body.notes || null, + body.mode_detention, id, ); res.json({ id, ...body }); } catch (e) { next(e); } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3e57318..af70788 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -32,6 +32,7 @@ import AdminIA from './pages/AdminIA.jsx'; import Aide from './pages/Aide.jsx'; import PlatformeProfile from './pages/PlatformeProfile.jsx'; import Plateformes from './pages/Plateformes.jsx'; +import PlateformesPe from './pages/PlateformesPe.jsx'; import Notifications from './pages/Notifications.jsx'; import Communication from './pages/Communication.jsx'; import AssistantIA from './pages/AssistantIA.jsx'; @@ -153,6 +154,16 @@ function FraisRouter() { return ; } +// Même principe (page Plateformes PE, 19/09/26, demande Olivier) : la page Plateformes +// bascule sur PlateformesPe.jsx pour tout workspace autre que crowdlending — plus de +// RequireWorkspace en dur sur cette route (elle était jusqu'ici réservée au crowdlending, +// cf. project_workspaces_transformation.md). +function PlateformesRouter() { + const { active } = useWorkspace() || {}; + if (active && active.type !== 'crowdlending') return ; + return ; +} + export default function App() { const [allowRegistration, setAllowRegistration] = useState(true); @@ -197,7 +208,7 @@ export default function App() { } /> }> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/InvestissementPeFormModal.jsx b/frontend/src/components/InvestissementPeFormModal.jsx index 9359166..cb40815 100644 --- a/frontend/src/components/InvestissementPeFormModal.jsx +++ b/frontend/src/components/InvestissementPeFormModal.jsx @@ -149,6 +149,14 @@ export default function InvestissementPeFormModal({ setField('reference', e.target.value)} /> +
+ + +
setDrForm({ ...drForm, date_operation: e.target.value })} /> +
+
+ + +
+
+ + setDrForm({ ...drForm, montant: e.target.value })} /> +
+
+ + setDrForm({ ...drForm, libelle: e.target.value })} placeholder="Ex. Appel de fonds" /> +
+
+ + setDrForm({ ...drForm, reference: e.target.value })} /> +
+ {multiDetenteur && ( +
+ + +
+ )} +
+ + setDrForm({ ...drForm, notes: e.target.value })} /> +
+ + + + + setDrDeleteTarget(null)} + /> + + ); +} diff --git a/frontend/src/pages/RemboursementsSimple.jsx b/frontend/src/pages/RemboursementsSimple.jsx index a5e1ca8..60cfebd 100644 --- a/frontend/src/pages/RemboursementsSimple.jsx +++ b/frontend/src/pages/RemboursementsSimple.jsx @@ -209,7 +209,7 @@ const emptyRembForm = { * X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26). */ export default function RemboursementsSimple() { - const { activeId: investisseurId } = useInvestisseur(); + const { activeId: investisseurId, activeView } = useInvestisseur(); const { active: activeWorkspace } = useWorkspace() || {}; const location = useLocation(); const navigate = useNavigate(); @@ -258,10 +258,11 @@ export default function RemboursementsSimple() { const load = async () => { setLoading(true); try { + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; const [rb, pl, dl, pfu] = await Promise.all([ - api.get('/remboursements'), + api.get('/remboursements', scopeParams), api.get('/plateformes'), - api.get('/investissements-pe'), + api.get('/investissements-pe', scopeParams), api.get('/pfu'), ]); setRows(rb); @@ -276,9 +277,10 @@ export default function RemboursementsSimple() { }; useEffect(() => { - if (investisseurId) load(); + if (!investisseurId && activeView !== 'all') return; + load(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [investisseurId, activeWorkspace?.id]); + }, [investisseurId, activeView, activeWorkspace?.id]); const investisseurForPlat = (platId) => { const plat = plats.find(p => String(p.id) === String(platId));