This commit is contained in:
ocroguennec committed 2026-09-18 17:09:31 +02:00
1 parent 27c4c57a58
commit 6bba34396c
3 files changed
+313 -41

No files matched your search

+210 -40
View File
@@ -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); } },
]}
/>
</>
);
}