From 6bba34396c5cda00aa3e80a9352d11847883a007 Mon Sep 17 00:00:00 2001 From: Olivier Date: Fri, 18 Sep 2026 17:09:31 +0200 Subject: [PATCH] MAJ --- backend/src/routes/investissementsPe.js | 102 +++++++ frontend/src/pages/InvestissementPeDetail.jsx | 250 +++++++++++++++--- frontend/src/pages/InvestissementsPe.jsx | 2 +- 3 files changed, 313 insertions(+), 41 deletions(-) diff --git a/backend/src/routes/investissementsPe.js b/backend/src/routes/investissementsPe.js index 7a01fad..3227af4 100644 --- a/backend/src/routes/investissementsPe.js +++ b/backend/src/routes/investissementsPe.js @@ -1,9 +1,13 @@ import { Router } from 'express'; import { z } from 'zod'; +import path from 'node:path'; +import fs from 'node: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 { createZip, sanitizeZipPart } from '../utils/zip.js'; +import { docsDir } from './documents.js'; const router = Router(); @@ -102,6 +106,7 @@ router.get('/', (req, res) => { const rows = db.prepare(` SELECT ipe.*, p.nom AS plateforme_nom, + p.logo_filename AS plateforme_logo, inv.nom AS investisseur_nom, (SELECT v.valorisation_nette FROM investissements_pe_valorisations v WHERE v.investissement_pe_id = ipe.id @@ -211,6 +216,7 @@ router.get('/:id', (req, res, next) => { const row = db.prepare(` SELECT ipe.*, p.nom AS plateforme_nom, + p.logo_filename AS plateforme_logo, inv.nom AS investisseur_nom, (SELECT v.valorisation_nette FROM investissements_pe_valorisations v WHERE v.investissement_pe_id = ipe.id @@ -240,6 +246,102 @@ router.get('/:id', (req, res, next) => { } catch (e) { next(e); } }); +/** + * GET /api/investissements-pe/:id/export — dossier ZIP d'un deal PE (18/09/26, demande + * Olivier — menu "⋮" de la fiche détail, inspiré du même menu côté crowdlending). Même + * principe que GET /investissements/:id/export (manifest.json + documents/), mais le + * contenu du manifeste reflète le modèle de données PE, plus réduit : pas d'échéancier + * prévisionnel, pas d'historique de modifications, pas de révisions de conditions, pas de + * pertes ni réinvestissements pour cette table (concepts qui n'existent pas côté PE) — en + * repli, valorisations + remboursements (distributions) + frais_operations + documents. + */ +router.get('/:id/export', (req, res, next) => { + try { + const id = Number(req.params.id); + ownedDeal(req, id); + const meta = db.prepare(` + SELECT ipe.*, p.nom AS plateforme_nom, inv.nom AS investisseur_nom + 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); + + const valorisations = db.prepare( + 'SELECT date_valorisation, valorisation_nette, capital_deploye, plus_value_latente, notes FROM investissements_pe_valorisations WHERE investissement_pe_id = ? ORDER BY date_valorisation ASC, id ASC' + ).all(id); + const remboursements = db.prepare( + 'SELECT date_remb, capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, distribution_nature, notes FROM remboursements WHERE investissement_pe_id = ? ORDER BY date_remb ASC' + ).all(id); + const frais = db.prepare( + 'SELECT date_operation, montant, mode_reglement, origine, categorie, notes FROM frais_operations WHERE investissement_pe_id = ? ORDER BY date_operation ASC' + ).all(id); + const documents = db.prepare( + "SELECT * FROM documents WHERE user_id = ? AND entity_type = 'investissement_pe' AND entity_id = ? ORDER BY created_at ASC" + ).all(req.user.id, id); + + // Fichiers du zip + métadonnées correspondantes dans le manifeste — même logique de + // dédoublonnage des noms que l'export crowdlending (cf. investissements.js). + const usedNames = new Set(); + const documentsMeta = []; + const entries = []; + for (const doc of documents) { + const baseLabel = sanitizeZipPart(doc.nom_affichage); + let finalName = `${baseLabel}.${doc.extension}`; + let n = 2; + while (usedNames.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; } + usedNames.add(finalName); + const zipPath = `documents/${finalName}`; + + documentsMeta.push({ + nom_affichage: doc.nom_affichage, + nom_original: doc.nom_original, + extension: doc.extension, + mime_type: doc.mime_type, + taille_octets: doc.taille_octets, + categorie: doc.categorie, + zip_path: zipPath, + }); + + const filePath = path.join(docsDir, doc.filename); + if (fs.existsSync(filePath)) entries.push({ name: zipPath, data: fs.readFileSync(filePath) }); + } + + const manifest = { + version: '1.0', + type: 'dossier_investissement_pe', + exported_at: new Date().toISOString(), + investissement_pe: { + nom_deal: meta.nom_deal, + strategie: meta.strategie, + date_souscription: meta.date_souscription, + duree_mois: meta.duree_mois, + montant_investi: meta.montant_investi, + capital_investissable: meta.capital_investissable, + objectif: meta.objectif, + statut: meta.statut, + reference: meta.reference, + notes: meta.notes, + }, + plateforme: { nom: meta.plateforme_nom }, + investisseur: { nom: meta.investisseur_nom }, + valorisations, + remboursements, + frais_operations: frais, + documents: documentsMeta, + }; + + entries.unshift({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }); + + const zipBuf = createZip(entries); + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `Dossier_PE_${id}_${ts}.zip`; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(zipBuf); + } catch (e) { next(e); } +}); + router.put('/:id', requireInvestisseur, (req, res, next) => { try { const id = Number(req.params.id); diff --git a/frontend/src/pages/InvestissementPeDetail.jsx b/frontend/src/pages/InvestissementPeDetail.jsx index 5ad9c6e..bb47f8e 100644 --- a/frontend/src/pages/InvestissementPeDetail.jsx +++ b/frontend/src/pages/InvestissementPeDetail.jsx @@ -1,11 +1,10 @@ import { useEffect, useRef, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { api } from '../api.js'; import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import { useWorkspace } from '../context/WorkspaceContext.jsx'; import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; -import PageIcon from '../components/PageIcon.jsx'; import InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx'; import FraisFormModal from '../components/FraisFormModal.jsx'; import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx'; @@ -83,12 +82,79 @@ function StatRow({ label, value, caption, valueColor }) { ); } +// ── Menus contextuels "⋮" (18/09/26, demande Olivier — même esprit sur tous les blocs de la +// fiche : topbar, Valorisations, Remboursements, Frais d'opération, Mes documents). onOpen +// reçoit {x, y} = coin bas-droit du bouton déclencheur ; CardMenu se positionne en +// position:fixed avec transform translateX(-100%) pour s'aligner sur ce coin. +function MenuTrigger({ onOpen, size = 30 }) { + return ( + + ); +} + +function CardMenu({ pos, onClose, items, minWidth = 190 }) { + if (!pos) return null; + return ( + <> +
+
+ {items.filter(Boolean).map(({ label, icon, action, danger, separatorBefore, disabled }) => ( +
+ {separatorBefore &&
} + +
+ ))} +
+ + ); +} + export default function InvestissementPeDetail() { const { id } = useParams(); const navigate = useNavigate(); + const location = useLocation(); + const navOrigin = location.state?.from ?? null; const { investisseurs } = useInvestisseur(); const { active: activeWorkspace } = useWorkspace() || {}; + // Menus "⋮" d'actions de la fiche (18/09/26, demande Olivier — inspiré du menu "⋮" côté + // crowdlending, InvestissementDetail.jsx / cardMenu ; puis étendu le 18/09/26 "dans le même + // esprit" à chaque bloc de la page) : {x, y} = coin bas-droit du bouton déclencheur, cf. + // MenuTrigger/CardMenu ci-dessus. + const [cardMenu, setCardMenu] = useState(null); // topbar (Modifier/Exporter/Supprimer) + const [rembMenu, setRembMenu] = useState(null); // bloc Remboursements + const [fraisMenu, setFraisMenu] = useState(null); // bloc Frais d'opération + const [docsMenu, setDocsMenu] = useState(null); // bloc Mes documents + const [deal, setDeal] = useState(null); const [allPlats, setAllPlats] = useState([]); const [remboursements, setRemboursements] = useState([]); @@ -260,6 +326,23 @@ export default function InvestissementPeDetail() { } catch (e) { setFormErr(e.message); } }; + // Export du dossier PE en ZIP (18/09/26, demande Olivier — menu "⋮", pendant PE de + // exportDossier() côté crowdlending) : le backend construit un manifest.json (deal, + // valorisations, distributions, frais) + les documents joints (le binaire des fichiers + // n'est connu que du serveur). + const exportDeal = async () => { + try { + const blob = await api.blob(`/investissements-pe/${id}/export`); + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `Dossier_PE_${id}_${ts}.zip`; a.click(); + URL.revokeObjectURL(url); + } catch (e) { + window.alert(`Échec de l'export du dossier : ${e.message}`); + } + }; + const onDeleteDeal = async () => { try { await api.del(`/investissements-pe/${id}`); @@ -271,6 +354,8 @@ export default function InvestissementPeDetail() { const [valForm, setValForm] = useState(emptyValForm); const [valErr, setValErr] = useState(null); const [valDeleteTarget, setValDeleteTarget] = useState(null); + const [valModalOpen, setValModalOpen] = useState(false); + const [valMenu, setValMenu] = useState(null); const { run: runValSubmit, pending: valSubmitting } = useAsyncAction(); const submitVal = async (e) => { @@ -297,6 +382,7 @@ export default function InvestissementPeDetail() { notes: valForm.notes || undefined, }); setValForm(emptyValForm); + setValModalOpen(false); await load(); } catch (e) { setValErr(e.message); } }; @@ -505,13 +591,31 @@ export default function InvestissementPeDetail() { return ( <> -
- -

{deal.nom_deal}

-
- - -
+
+

+ {deal.plateforme_logo ? ( + {deal.plateforme_nom} + ) : ( + + {deal.plateforme_nom} + + )} + · + {deal.nom_deal} +

+ +
{err &&
{err}
} @@ -639,9 +743,44 @@ export default function InvestissementPeDetail() { {/* ── Valorisations ── */}
-

Valorisations

+
+

Valorisations

+ +
+ + + + {valorisations.length === 0 && ( + + )} + {valorisations.map(v => ( + + + + + + + ))} + +
DateValorisation netteNotes
Aucune valorisation enregistrée
{fmtDate(v.date_valorisation)} + {fmtEUR(v.valorisation_nette)} + {v.capital_deploye != null && ( +
+ {fmtEUR(v.capital_deploye)} déployé · {(v.plus_value_latente || 0) >= 0 ? '+' : ''}{fmtEUR(v.plus_value_latente || 0)} +
+ )} +
{v.notes || '—'}
+
+ + setValModalOpen(false)} + busy={valSubmitting} + width={560} + > {valErr &&
{valErr}
} -
runValSubmit(submitVal, e)} className="form-grid" style={{ marginBottom: 16 }}> + runValSubmit(submitVal, e)} className="form-grid">
setValForm({ ...valForm, date_valorisation: e.target.value })} /> @@ -680,36 +819,23 @@ export default function InvestissementPeDetail() {
- - - - {valorisations.length === 0 && ( - - )} - {valorisations.map(v => ( - - - - - - - ))} - -
DateValorisation netteNotes
Aucune valorisation enregistrée
{fmtDate(v.date_valorisation)} - {fmtEUR(v.valorisation_nette)} - {v.capital_deploye != null && ( -
- {fmtEUR(v.capital_deploye)} déployé · {(v.plus_value_latente || 0) >= 0 ? '+' : ''}{fmtEUR(v.plus_value_latente || 0)} -
- )} -
{v.notes || '—'}
-
+ + + {/* Menu "⋮" du bloc Valorisations : un seul item pour l'instant (Nouvelle valorisation), + pensé pour en accueillir d'autres plus tard (ex. import/export dédié). */} + setValMenu(null)} + items={[ + { label: 'Nouvelle valorisation', icon: <>, action: () => { setValMenu(null); setValForm(emptyValForm); setValErr(null); setValModalOpen(true); } }, + ]} + /> {/* ── Remboursements ── */}

Remboursements

- +
@@ -728,6 +854,14 @@ export default function InvestissementPeDetail() {
DateMontant verséNotes
+ setRembMenu(null)} + items={[ + { label: 'Nouveau remboursement', icon: <>, action: () => { setRembMenu(null); navigate(`/remboursements?new=1&deal=${deal.id}`); } }, + ]} + /> + {/* ── Frais d'opération ── */}
@@ -735,7 +869,7 @@ export default function InvestissementPeDetail() { Frais d'opération {fraisOps.length > 0 && Total : {fmtEUR(totalFrais)}} - +
{fraisOps.length === 0 ? (
Aucun frais journalisé pour l'instant.
@@ -770,13 +904,19 @@ export default function InvestissementPeDetail() { )}
+ setFraisMenu(null)} + items={[ + { label: 'Ajouter un frais', icon: <>, action: () => { setFraisMenu(null); openAddFrais(); } }, + ]} + /> + {/* ── Documents ── */}

Mes documents

- +
{docsErr &&
{docsErr}
} {documents.length === 0 ? ( @@ -806,6 +946,19 @@ export default function InvestissementPeDetail() { )}
+ + setDocsMenu(null)} + items={[ + { + label: uploadingDoc ? 'Envoi…' : 'Ajouter un document', + disabled: uploadingDoc, + icon: <>, + action: () => { setDocsMenu(null); docFileInputRef.current?.click(); }, + }, + ]} + />
+ + {/* ── Menu "⋮" (18/09/26, demande Olivier — inspiré du menu "⋮" crowdlending) ── + Modifier/Supprimer y sont déplacés depuis les anciens boutons de la topbar ; + Exporter est nouveau (GET /investissements-pe/:id/export). Pas d'équivalent PE + pour "Réinvestir"/"Réviser les conditions" du menu crowdlending (aucun mécanisme + de réinvestissement automatique ni de révision de conditions côté deals PE + aujourd'hui) — volontairement omis plutôt que placés en façade non fonctionnelle. */} + setCardMenu(null)} + minWidth={160} + items={[ + { label: 'Modifier', icon: <>, action: () => { setCardMenu(null); openEditDeal(); } }, + { label: 'Exporter', icon: <>, action: () => { setCardMenu(null); exportDeal(); } }, + { label: 'Supprimer', danger: true, separatorBefore: true, icon: <>, action: () => { setCardMenu(null); setDeleteConfirm(true); } }, + ]} + /> ); } diff --git a/frontend/src/pages/InvestissementsPe.jsx b/frontend/src/pages/InvestissementsPe.jsx index 20f06c5..16eb472 100644 --- a/frontend/src/pages/InvestissementsPe.jsx +++ b/frontend/src/pages/InvestissementsPe.jsx @@ -880,7 +880,7 @@ export default function InvestissementsPe() { Aucun investissement )} {filteredRows.map(r => ( - navigate(`/investissements/${r.id}`)}> + navigate(`/investissements/${r.id}`, { state: { from: { path: '/investissements', search: location.search, label: 'Investissements' } } })}> {r.nom_deal} {r.plateforme_nom} {multiDetenteur && {r.investisseur_nom || '—'}}