MAJ
This commit is contained in:
1 parent
27c4c57a58
commit
6bba34396c
3 files changed
+313
-41
No files matched your search
@@ -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); } },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user