MAJ
This commit is contained in:
1 parent
27c4c57a58
commit
6bba34396c
3 files changed
+313
-41
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); onOpen({ x: r.right, y: r.bottom }); }}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: size, height: size, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
title="Actions"
|
||||
>
|
||||
<svg width={size >= 34 ? 16 : 15} height={size >= 34 ? 16 : 15} viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CardMenu({ pos, onClose, items, minWidth = 190 }) {
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={onClose} />
|
||||
<div style={{
|
||||
position: 'fixed', left: pos.x, top: pos.y,
|
||||
transform: 'translateX(-100%) translateY(4px)',
|
||||
zIndex: 300,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
padding: '4px 0', minWidth,
|
||||
}}>
|
||||
{items.filter(Boolean).map(({ label, icon, action, danger, separatorBefore, disabled }) => (
|
||||
<div key={label}>
|
||||
{separatorBefore && <div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />}
|
||||
<button
|
||||
disabled={disabled}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
|
||||
background: 'none', border: 'none', cursor: disabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--fs-sm)', textAlign: 'left', opacity: disabled ? 0.6 : 1,
|
||||
color: disabled ? 'var(--text-muted)' : (danger ? 'var(--danger)' : 'var(--text)'),
|
||||
}}
|
||||
onMouseEnter={e => { if (!disabled) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; }}
|
||||
onClick={() => { if (!disabled) action(); }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">{icon}</svg>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button onClick={() => navigate('/investissements')} style={{ marginRight: 12 }}>← Retour</button>
|
||||
<h2><PageIcon name="investissement" />{deal.nom_deal}</h2>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={openEditDeal}>Modifier</button>
|
||||
<button className="danger" onClick={() => setDeleteConfirm(true)}>Supprimer</button>
|
||||
</div>
|
||||
<div className="topbar" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<h2 style={{ flex: 1, margin: 0, display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
{deal.plateforme_logo ? (
|
||||
<img
|
||||
src={`${(import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '')}/api/logos/${deal.plateforme_logo}`}
|
||||
alt={deal.plateforme_nom}
|
||||
title={deal.plateforme_nom}
|
||||
className="logo-plateforme"
|
||||
style={{ height: 38, width: 'auto', maxWidth: 140, objectFit: 'contain', flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted, #6b7280)', fontWeight: 500, fontSize: '0.85em', whiteSpace: 'nowrap' }}>
|
||||
{deal.plateforme_nom}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ color: 'var(--text-muted, #6b7280)', fontWeight: 300, fontSize: '0.85em' }}>·</span>
|
||||
<span>{deal.nom_deal}</span>
|
||||
</h2>
|
||||
<MenuTrigger onOpen={setCardMenu} size={34} />
|
||||
<button
|
||||
onClick={() => navOrigin ? navigate({ pathname: navOrigin.path, search: navOrigin.search || '' }) : navigate('/investissements')}
|
||||
style={{ whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
>
|
||||
← {navOrigin?.label ?? 'Investissements'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ margin: '0 24px 16px' }}>{err}</div>}
|
||||
@@ -639,9 +743,44 @@ export default function InvestissementPeDetail() {
|
||||
|
||||
{/* ── Valorisations ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: 14 }}>Valorisations</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Valorisations</h3>
|
||||
<MenuTrigger onOpen={setValMenu} />
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th className="num">Valorisation nette</th><th>Notes</th><th style={{ width: 40 }}></th></tr></thead>
|
||||
<tbody>
|
||||
{valorisations.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center' }}>Aucune valorisation enregistrée</td></tr>
|
||||
)}
|
||||
{valorisations.map(v => (
|
||||
<tr key={v.id}>
|
||||
<td>{fmtDate(v.date_valorisation)}</td>
|
||||
<td className="num">
|
||||
{fmtEUR(v.valorisation_nette)}
|
||||
{v.capital_deploye != null && (
|
||||
<div className="text-muted" style={{ fontSize: 11, fontWeight: 400 }}>
|
||||
{fmtEUR(v.capital_deploye)} déployé · {(v.plus_value_latente || 0) >= 0 ? '+' : ''}{fmtEUR(v.plus_value_latente || 0)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted">{v.notes || '—'}</td>
|
||||
<td><button className="icon-btn" title="Supprimer" onClick={() => setValDeleteTarget(v)}>✕</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={valModalOpen}
|
||||
title="Nouvelle valorisation"
|
||||
onClose={() => setValModalOpen(false)}
|
||||
busy={valSubmitting}
|
||||
width={560}
|
||||
>
|
||||
{valErr && <div className="error">{valErr}</div>}
|
||||
<form onSubmit={e => runValSubmit(submitVal, e)} className="form-grid" style={{ marginBottom: 16 }}>
|
||||
<form onSubmit={e => runValSubmit(submitVal, e)} className="form-grid">
|
||||
<div>
|
||||
<label>Date *</label>
|
||||
<input type="date" required value={valForm.date_valorisation} onChange={e => setValForm({ ...valForm, date_valorisation: e.target.value })} />
|
||||
@@ -680,36 +819,23 @@ export default function InvestissementPeDetail() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th className="num">Valorisation nette</th><th>Notes</th><th style={{ width: 40 }}></th></tr></thead>
|
||||
<tbody>
|
||||
{valorisations.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center' }}>Aucune valorisation enregistrée</td></tr>
|
||||
)}
|
||||
{valorisations.map(v => (
|
||||
<tr key={v.id}>
|
||||
<td>{fmtDate(v.date_valorisation)}</td>
|
||||
<td className="num">
|
||||
{fmtEUR(v.valorisation_nette)}
|
||||
{v.capital_deploye != null && (
|
||||
<div className="text-muted" style={{ fontSize: 11, fontWeight: 400 }}>
|
||||
{fmtEUR(v.capital_deploye)} déployé · {(v.plus_value_latente || 0) >= 0 ? '+' : ''}{fmtEUR(v.plus_value_latente || 0)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted">{v.notes || '—'}</td>
|
||||
<td><button className="icon-btn" title="Supprimer" onClick={() => setValDeleteTarget(v)}>✕</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 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é). */}
|
||||
<CardMenu
|
||||
pos={valMenu}
|
||||
onClose={() => setValMenu(null)}
|
||||
items={[
|
||||
{ label: 'Nouvelle valorisation', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setValMenu(null); setValForm(emptyValForm); setValErr(null); setValModalOpen(true); } },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── Remboursements ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Remboursements</h3>
|
||||
<button onClick={() => navigate(`/remboursements?new=1&deal=${deal.id}`)}>+ Nouveau remboursement</button>
|
||||
<MenuTrigger onOpen={setRembMenu} />
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th className="num">Montant versé</th><th>Notes</th></tr></thead>
|
||||
@@ -728,6 +854,14 @@ export default function InvestissementPeDetail() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CardMenu
|
||||
pos={rembMenu}
|
||||
onClose={() => setRembMenu(null)}
|
||||
items={[
|
||||
{ label: 'Nouveau remboursement', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setRembMenu(null); navigate(`/remboursements?new=1&deal=${deal.id}`); } },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── Frais d'opération ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
@@ -735,7 +869,7 @@ export default function InvestissementPeDetail() {
|
||||
Frais d'opération
|
||||
{fraisOps.length > 0 && <span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>Total : {fmtEUR(totalFrais)}</span>}
|
||||
</h3>
|
||||
<button onClick={openAddFrais}>+ Ajouter un frais</button>
|
||||
<MenuTrigger onOpen={setFraisMenu} />
|
||||
</div>
|
||||
{fraisOps.length === 0 ? (
|
||||
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun frais journalisé pour l'instant.</div>
|
||||
@@ -770,13 +904,19 @@ export default function InvestissementPeDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardMenu
|
||||
pos={fraisMenu}
|
||||
onClose={() => setFraisMenu(null)}
|
||||
items={[
|
||||
{ label: 'Ajouter un frais', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setFraisMenu(null); openAddFrais(); } },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── Documents ── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Mes documents</h3>
|
||||
<button onClick={() => docFileInputRef.current?.click()} disabled={uploadingDoc}>
|
||||
{uploadingDoc ? 'Envoi…' : '+ Ajouter un document'}
|
||||
</button>
|
||||
<MenuTrigger onOpen={setDocsMenu} />
|
||||
</div>
|
||||
{docsErr && <div className="error">{docsErr}</div>}
|
||||
{documents.length === 0 ? (
|
||||
@@ -806,6 +946,19 @@ export default function InvestissementPeDetail() {
|
||||
)}
|
||||
<input ref={docFileInputRef} type="file" onChange={handleDocFileChange} style={{ display: 'none' }} />
|
||||
</div>
|
||||
|
||||
<CardMenu
|
||||
pos={docsMenu}
|
||||
onClose={() => setDocsMenu(null)}
|
||||
items={[
|
||||
{
|
||||
label: uploadingDoc ? 'Envoi…' : 'Ajouter un document',
|
||||
disabled: uploadingDoc,
|
||||
icon: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></>,
|
||||
action: () => { setDocsMenu(null); docFileInputRef.current?.click(); },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InvestissementPeFormModal
|
||||
@@ -866,6 +1019,23 @@ export default function InvestissementPeDetail() {
|
||||
confirmingDelete={fraisConfirmingDelete}
|
||||
setConfirmingDelete={setFraisConfirmingDelete}
|
||||
/>
|
||||
|
||||
{/* ── 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. */}
|
||||
<CardMenu
|
||||
pos={cardMenu}
|
||||
onClose={() => setCardMenu(null)}
|
||||
minWidth={160}
|
||||
items={[
|
||||
{ label: 'Modifier', icon: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>, action: () => { setCardMenu(null); openEditDeal(); } },
|
||||
{ label: 'Exporter', icon: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>, action: () => { setCardMenu(null); exportDeal(); } },
|
||||
{ label: 'Supprimer', danger: true, separatorBefore: true, icon: <><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></>, action: () => { setCardMenu(null); setDeleteConfirm(true); } },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -880,7 +880,7 @@ export default function InvestissementsPe() {
|
||||
<tr><td colSpan={multiDetenteur ? 11 : 10} className="text-muted" style={{ textAlign: 'center' }}>Aucun investissement</td></tr>
|
||||
)}
|
||||
{filteredRows.map(r => (
|
||||
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => navigate(`/investissements/${r.id}`)}>
|
||||
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => navigate(`/investissements/${r.id}`, { state: { from: { path: '/investissements', search: location.search, label: 'Investissements' } } })}>
|
||||
<td>{r.nom_deal}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted">{r.investisseur_nom || '—'}</td>}
|
||||
|
||||
Reference in new issue
Block a user