Files
crowdlending-app/frontend/src/pages/InvestissementPeDetail.jsx
T
2026-09-26 11:15:26 +02:00

1182 lines
61 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
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 InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, fmtOctets, today, MODE_DETENTION_LABELS } from '../utils/format.js';
import { emptyDealForm, AUTO_VAL_NOTE, AUTO_VAL_NOTE_LEGACY } from './InvestissementsPe.jsx';
/**
* Fiche détaillée d'un deal Private Equity (chantier "fiche détail PE", 17/09/26 — demande
* Olivier : "une page investissement détail adapté qui reprend un peu l'esprit de crowdlending
* ... il faut que je vois tous les remboursements, tous les frais associés et que j'aie la
* possibilité de stocker des documents"). Première passe volontairement sobre (structure posée,
* à enrichir ensuite) — pas encore de reprise des notions Déploiement / TVPI du sous-jacent /
* Obligation / Valeur totale cible visibles sur les captures Fundora d'Olivier, toujours hors
* périmètre comme convenu lors du chantier précédent.
*
* Route : /investissements/:id, branchée ici pour tout workspace non-crowdlending par
* InvestissementDetailRouter (App.jsx) — miroir de InvestissementDetail.jsx (crowdlending), en
* beaucoup plus court : pas d'échéancier, de XIRR, de révisions de prêt ni de réinvestissements
* (notions sans équivalent PE à ce stade).
*
* Modifier l'investissement réutilise directement InvestissementPeFormModal (le même assistant
* par étapes que la liste), y compris son étape "Frais" (souscription/gestion réglés à la
* source) — le bloc "Frais d'opération" ci-dessous gère lui les frais complémentaires
* (gestion/distribution/autre), exactement comme côté crowdlending : les lignes 'souscription'
* et 'gestion'-à-l'origine-souscription y apparaissent en lecture seule, redirigeant vers
* "Modifier l'investissement" plutôt que vers la modale FraisFormModal (qui n'propose pas la
* catégorie 'souscription' dans son sélecteur).
*/
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
const STATUT_LABELS = {
en_attente: 'En attente',
valide: 'Validé',
cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
};
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
compte_courant: 'Compte courant',
remboursement: 'Retenu sur remboursement',
};
const emptyValForm = { date_valorisation: today(), valorisation_nette: '', capital_deploye: '', plus_value_latente: '', notes: '' };
const emptyFraisForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
// ── Tableau de bord "Montant souscrit / Valorisation nette" (18/09/26, demande Olivier —
// reprise de la disposition des captures Fundora : 2 cartes avec barre de répartition + 3
// colonnes Déploiement/Performance/Frais). Petits composants de présentation partagés par les
// deux cartes et les 3 colonnes ci-dessous.
function LegendDot({ color, outline, label, value }) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 9, height: 9, borderRadius: '50%', background: color,
border: outline ? '1px solid var(--border)' : 'none', display: 'inline-block',
}} />
{label} <b>{fmtEUR(value)}</b>
</span>
);
}
function StatRow({ label, value, caption, valueColor }) {
return (
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{label}</div>
<div style={{ fontWeight: 700, fontSize: 14, color: valueColor, whiteSpace: 'nowrap' }}>{value}</div>
</div>
{caption && <div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>{caption}</div>}
</div>
);
}
// ── 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([]);
const [fraisOps, setFraisOps] = useState([]);
const [valorisations, setValorisations] = useState([]);
const [documents, setDocuments] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const plats = allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id));
const load = async () => {
setLoading(true);
try {
const [d, pl, remb, frais, val, docs] = await Promise.all([
api.get(`/investissements-pe/${id}`),
api.get('/plateformes'),
api.get('/remboursements'),
api.get('/frais-operations', { investissement_pe_id: id }),
api.get(`/investissements-pe/${id}/valorisations`),
api.get('/documents', { entity_type: 'investissement_pe', entity_id: id }),
]);
setDeal(d);
setAllPlats(pl);
setRemboursements(remb.filter(r => String(r.investissement_pe_id) === String(id)));
setFraisOps(frais);
setValorisations(val);
setDocuments(docs);
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, [id]);
/* ── Modifier l'investissement (réutilise le même assistant que la liste) ── */
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState(emptyDealForm);
const [formErr, setFormErr] = useState(null);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
const { run: runSubmit, pending: submitting } = useAsyncAction();
const [deleteConfirm, setDeleteConfirm] = useState(false);
useEffect(() => {
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
}, [form.investisseur_id, modalOpen]);
const openEditDeal = () => {
if (!deal) return;
const entree = fraisOps.find(f => f.categorie === 'souscription');
const gestion = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
const autoVal = valorisations.find(v => v.notes === AUTO_VAL_NOTE || v.notes === AUTO_VAL_NOTE_LEGACY);
setForm({
...emptyDealForm,
plateforme_id: String(deal.plateforme_id || ''),
investisseur_id: String(deal.investisseur_id || ''),
nom_deal: deal.nom_deal,
strategie: deal.strategie || '',
date_souscription: deal.date_souscription,
duree_annees: deal.duree_mois ? String(Math.round((deal.duree_mois / 12) * 10) / 10) : '',
montant_investi: String(deal.montant_investi ?? ''),
objectif: deal.objectif || '',
statut: deal.statut,
reference: deal.reference || '',
notes: deal.notes || '',
valorisation_initiale_id: autoVal ? autoVal.id : null,
...(entree ? {
frais_entree_valeur: String(entree.montant), frais_entree_mode_reglement: entree.mode_reglement,
frais_entree_compte_id: entree.compte_id ? String(entree.compte_id) : '', frais_entree_id: entree.id,
} : {}),
...(gestion ? {
frais_gestion_valeur: String(gestion.montant), frais_gestion_mode_reglement: gestion.mode_reglement,
frais_gestion_compte_id: gestion.compte_id ? String(gestion.compte_id) : '', frais_gestion_id: gestion.id,
} : {}),
});
setFormErr(null);
setModalOpen(true);
};
const closeEditDeal = () => { setModalOpen(false); setFormErr(null); };
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : String(deal?.investisseur_id || '');
};
const computeFraisTTC = (modeSaisie, valeur) => {
if (valeur === '' || Number(valeur) <= 0) return 0;
return Math.round((modeSaisie === 'pourcentage'
? Number(form.montant_investi) * Number(valeur) / 100
: Number(valeur)) * 100) / 100;
};
const upsertFrais = async (has, ttc, categorie, modeReglement, compteId, fraisId) => {
try {
if (has) {
const payload = {
investissement_pe_id: Number(id),
montant: ttc,
mode_reglement: modeReglement,
compte_id: modeReglement === 'compte_courant' && compteId ? Number(compteId) : null,
date_operation: form.date_souscription,
origine: 'souscription',
categorie,
};
if (fraisId) await api.put(`/frais-operations/${fraisId}`, payload);
else await api.post('/frais-operations', payload);
} else if (fraisId) {
await api.del(`/frais-operations/${fraisId}`);
}
} catch { /* non bloquant */ }
};
// Toujours créée, même sans frais (= montant investi tel quel) — décision Olivier 18/09/26,
// cf. InvestissementsPe.jsx::syncValorisationInitiale (même logique, dupliquée ici pour la
// modale d'édition de la fiche détail).
const syncValorisationInitiale = async (totalFraisSource, valorisationInitialeId) => {
try {
if (valorisationInitialeId) await api.del(`/investissements-pe/${id}/valorisations/${valorisationInitialeId}`);
const initiale = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
await api.post(`/investissements-pe/${id}/valorisations`, {
date_valorisation: form.date_souscription, valorisation_nette: initiale, notes: AUTO_VAL_NOTE,
});
} catch { /* non bloquant */ }
};
const submitDeal = async (e) => {
e?.preventDefault?.(); setFormErr(null);
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
const fraisApplicable = !!platFraisType && platFraisType !== 'aucun';
const hasFraisEntree = fraisApplicable && form.frais_entree_valeur !== '' && Number(form.frais_entree_valeur) > 0;
const hasFraisGestion = fraisApplicable && form.frais_gestion_valeur !== '' && Number(form.frais_gestion_valeur) > 0;
const fraisEntreeTTC = hasFraisEntree ? computeFraisTTC(form.frais_entree_mode_saisie, form.frais_entree_valeur) : 0;
const fraisGestionTTC = hasFraisGestion ? computeFraisTTC(form.frais_gestion_mode_saisie, form.frais_gestion_valeur) : 0;
const totalFraisSource =
(hasFraisEntree && form.frais_entree_mode_reglement === 'source' ? fraisEntreeTTC : 0) +
(hasFraisGestion && form.frais_gestion_mode_reglement === 'source' ? fraisGestionTTC : 0);
// Capital réellement disponible à déployer = montant investi net des frais prélevés "à la
// source" — même correctif que InvestissementsPe.jsx::submit (18/09/26, Olivier).
const capitalInvestissable = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
const payload = {
plateforme_id: Number(form.plateforme_id),
investisseur_id: Number(form.investisseur_id) || undefined,
nom_deal: form.nom_deal,
strategie: form.strategie || undefined,
date_souscription: form.date_souscription,
duree_mois: form.duree_annees ? Math.round(Number(form.duree_annees) * 12) : undefined,
montant_investi: Number(form.montant_investi),
capital_investissable: capitalInvestissable,
objectif: form.objectif || undefined,
statut: form.statut,
reference: form.reference || undefined,
notes: form.notes || undefined,
};
try {
await api.put(`/investissements-pe/${id}`, payload);
await upsertFrais(hasFraisEntree, fraisEntreeTTC, 'souscription', form.frais_entree_mode_reglement, form.frais_entree_compte_id, form.frais_entree_id);
await upsertFrais(hasFraisGestion, fraisGestionTTC, 'gestion', form.frais_gestion_mode_reglement, form.frais_gestion_compte_id, form.frais_gestion_id);
await syncValorisationInitiale(totalFraisSource, form.valorisation_initiale_id);
closeEditDeal();
await load();
} 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}`);
navigate('/investissements');
} catch (e) { setErr(e.message); }
};
/* ── Déclaration de perte définitive (20/09/26, chantier "Fiscalité PE") ──────────
* Même principe que InvestissementDetail.jsx côté crowdlending (openPerteModal/submitPerte/
* cancelPerteDeclaration) mais adapté : un deal PE n'a pas de statut intermédiaire de
* recouvrement (en_retard/procedure), donc l'action est proposée pour tout statut autre que
* 'perte_definitive' plutôt que réservée à un sous-ensemble (cf. POST /:id/perte-definitive
* côté backend, même garde-fou). Montant préfilli avec le capital pas encore restitué
* (montant investi/investissable − distributions déjà classées "capital"), mais reste éditable
* (perte partielle possible si une partie a déjà été récupérée).
*/
const [perteModalOpen, setPerteModalOpen] = useState(false);
const [perteForm, setPerteForm] = useState({ date_effet: today(), montant_perte: '', motif: '' });
const [perteErr, setPerteErr] = useState(null);
const [perteSaving, setPerteSaving] = useState(false);
const [confirmingPerteCancel, setConfirmingPerteCancel] = useState(false);
const openPerteModal = () => {
const capInvestissable = deal?.capital_investissable ?? deal?.montant_investi ?? 0;
const capNonRestitue = Math.max(0, capInvestissable - (deal?.capital_restitue_cumule || 0));
setPerteForm({ date_effet: today(), montant_perte: String(Math.round(capNonRestitue * 100) / 100), motif: '' });
setPerteErr(null);
setPerteModalOpen(true);
};
const closePerteModal = () => { setPerteModalOpen(false); setPerteErr(null); };
const submitPerte = async (e) => {
e?.preventDefault?.();
setPerteErr(null);
if (!perteForm.date_effet) { setPerteErr("La date d'effet est requise."); return; }
if (perteForm.montant_perte === '' || Number(perteForm.montant_perte) < 0) {
setPerteErr('Le montant de la perte doit être un nombre positif ou nul.');
return;
}
if (!perteForm.motif.trim()) { setPerteErr('Le motif est obligatoire.'); return; }
setPerteSaving(true);
try {
await api.post(`/investissements-pe/${id}/perte-definitive`, {
date_effet: perteForm.date_effet,
montant_perte: Number(perteForm.montant_perte),
motif: perteForm.motif.trim(),
});
setPerteModalOpen(false);
await load();
} catch (e) {
const fieldErrors = e.details?.fieldErrors;
const detail = fieldErrors ? Object.values(fieldErrors).flat().filter(Boolean).join(' ') : '';
setPerteErr(detail || e.message);
} finally { setPerteSaving(false); }
};
const cancelPerteDeclaration = async () => {
try {
await api.del(`/investissements-pe/${id}/perte-definitive`);
setConfirmingPerteCancel(false);
await load();
} catch (e) { setConfirmingPerteCancel(false); }
};
/* ── Valorisations ── */
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) => {
e?.preventDefault?.(); setValErr(null);
try {
// Répartition optionnelle (18/09/26, demande Olivier) : si Capital déployé est renseigné,
// la Valorisation nette est recalculée ici à partir des 3 composantes plutôt que reprise
// du champ (qui est alors désactivé côté UI) — recalculée aussi côté serveur, qui reste la
// source de vérité finale (cf. POST /:id/valorisations).
const hasBreakdown = valForm.capital_deploye !== '';
const capitalDeploye = hasBreakdown ? (Number(valForm.capital_deploye) || 0) : undefined;
const plusValueLatente = hasBreakdown ? (Number(valForm.plus_value_latente) || 0) : undefined;
// Capital investissable (net des frais "à la source"), pas le montant investi brut —
// correctif 18/09/26, cf. deal.capital_investissable.
const capitalInvestissable = Number(deal.capital_investissable ?? deal.montant_investi);
const valorisationNette = hasBreakdown
? Math.round((capitalDeploye + plusValueLatente + Math.max(0, capitalInvestissable - capitalDeploye)) * 100) / 100
: Number(valForm.valorisation_nette);
await api.post(`/investissements-pe/${id}/valorisations`, {
date_valorisation: valForm.date_valorisation,
valorisation_nette: valorisationNette,
capital_deploye: capitalDeploye,
plus_value_latente: plusValueLatente,
notes: valForm.notes || undefined,
});
setValForm(emptyValForm);
setValModalOpen(false);
await load();
} catch (e) { setValErr(e.message); }
};
const onDeleteVal = async () => {
if (!valDeleteTarget) return;
try {
await api.del(`/investissements-pe/${id}/valorisations/${valDeleteTarget.id}`);
setValDeleteTarget(null);
await load();
} catch (e) { setValErr(e.message); }
};
/* ── Frais d'opération complémentaires (gestion/distribution/autre) ── */
const [fraisModalOpen, setFraisModalOpen] = useState(false);
const [fraisEditingId, setFraisEditingId] = useState(null);
const [fraisForm, setFraisForm] = useState(emptyFraisForm);
const [fraisErr, setFraisErr] = useState(null);
const [fraisConfirmingDelete, setFraisConfirmingDelete] = useState(false);
const { run: runFraisSubmit, pending: fraisSubmitting } = useAsyncAction();
const openAddFrais = () => {
setFraisEditingId(null);
setFraisForm({ ...emptyFraisForm, date_operation: today() });
setFraisErr(null);
setFraisConfirmingDelete(false);
setFraisModalOpen(true);
};
const openEditFrais = (f) => {
setFraisEditingId(f.id);
setFraisForm({
montant: f.montant, mode_reglement: f.mode_reglement, compte_id: f.compte_id || '',
date_operation: f.date_operation, categorie: f.categorie || 'autre', notes: f.notes || '',
});
setFraisErr(null);
setFraisConfirmingDelete(false);
setFraisModalOpen(true);
};
const closeFraisModal = () => { setFraisModalOpen(false); setFraisErr(null); setFraisConfirmingDelete(false); };
const submitFrais = async (e) => {
e?.preventDefault?.(); setFraisErr(null);
try {
const payload = {
investissement_pe_id: Number(id),
montant: Number(fraisForm.montant),
mode_reglement: fraisForm.mode_reglement,
compte_id: fraisForm.mode_reglement === 'compte_courant' && fraisForm.compte_id ? Number(fraisForm.compte_id) : null,
date_operation: fraisForm.date_operation,
origine: 'manuel',
categorie: fraisForm.categorie || 'autre',
notes: fraisForm.notes || undefined,
};
if (fraisEditingId) await api.put(`/frais-operations/${fraisEditingId}`, payload);
else await api.post('/frais-operations', payload);
closeFraisModal();
await load();
} catch (e) { setFraisErr(e.message); }
};
const onDeleteFrais = async () => {
try {
await api.del(`/frais-operations/${fraisEditingId}`);
closeFraisModal();
await load();
} catch (e) { setFraisErr(e.message); }
};
/* ── Documents (réutilise le système générique /documents, entity_type 'investissement_pe',
* porté depuis InvestissementDetail.jsx en version simplifiée : bouton "+ Ajouter" direct
* plutôt que menu ⋮, pas d'action "tout supprimer" pour cette première passe) ── */
const [docsErr, setDocsErr] = useState(null);
const [uploadingDoc, setUploadingDoc] = useState(false);
const [renamingDocId, setRenamingDocId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [docDeleteTarget, setDocDeleteTarget] = useState(null);
const docFileInputRef = useRef(null);
const refreshDocuments = async () => {
try { setDocuments(await api.get('/documents', { entity_type: 'investissement_pe', entity_id: id })); }
catch { /* la fiche reste utilisable même si le rafraîchissement échoue */ }
};
const handleDocFileChange = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploadingDoc(true); setDocsErr(null);
try {
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'investissement_pe');
fd.append('entity_id', id);
fd.append('categorie', 'investissement_pe');
fd.append('nom_affichage', file.name.replace(/\.[^./\\]+$/, ''));
await api.postForm('/documents', fd);
await refreshDocuments();
} catch (e) { setDocsErr(e.message); }
finally { setUploadingDoc(false); }
};
const handleDocRenameStart = (doc) => { setRenamingDocId(doc.id); setRenameValue(doc.nom_affichage); };
const handleDocRenameCommit = async (docId) => {
const value = renameValue.trim();
setRenamingDocId(null);
const current = documents.find(d => d.id === docId);
if (!value || !current || value === current.nom_affichage) return;
try { await api.put(`/documents/${docId}`, { nom_affichage: value }); await refreshDocuments(); }
catch (e) { setDocsErr(e.message); }
};
const handleDocDownload = async (doc) => {
try {
const blob = await api.blob(`/documents/${doc.id}/download`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${doc.nom_affichage}.${doc.extension}`; a.click();
URL.revokeObjectURL(url);
} catch (e) { setDocsErr(e.message); }
};
const handleDocDelete = async () => {
if (!docDeleteTarget) return;
try { await api.del(`/documents/${docDeleteTarget.id}`); setDocDeleteTarget(null); await refreshDocuments(); }
catch (e) { setDocsErr(e.message); }
};
if (loading) return <div style={{ padding: 32 }}>Chargement…</div>;
if (!deal) return (
<>
<div className="topbar"><h2>Investissement introuvable</h2></div>
{err && <div className="error" style={{ margin: '0 24px' }}>{err}</div>}
</>
);
const totalFrais = fraisOps.reduce((s, f) => s + (f.montant || 0), 0);
// ── Valorisation : détail de répartition (18/09/26, demande Olivier) ──
// Formulaire "Ajouter une valorisation" : aperçu live de Capital non déployé / Valorisation
// nette pendant la saisie, dès que Capital déployé est renseigné (mode "détaillé" — sinon
// saisie simple d'un seul montant, comme avant). Capital non déployé n'est jamais saisi :
// toujours déduit du montant investi du deal moins le capital déployé.
const valFormHasBreakdown = valForm.capital_deploye !== '';
const valFormCapitalDeploye = valFormHasBreakdown ? (Number(valForm.capital_deploye) || 0) : 0;
const valFormPlusValue = valFormHasBreakdown ? (Number(valForm.plus_value_latente) || 0) : 0;
const valFormCapitalNonDeploye = valFormHasBreakdown
? Math.max(0, Number(deal.capital_investissable ?? deal.montant_investi) - valFormCapitalDeploye) : 0;
const valFormComputedNette = valFormHasBreakdown
? Math.round((valFormCapitalDeploye + valFormPlusValue + valFormCapitalNonDeploye) * 100) / 100
: null;
// Carte "Valorisation nette" (remplace l'ancienne tuile "Valorisation actuelle" du bloc
// Résumé) : reprend la dernière valorisation connue (valorisations[0], déjà triée DESC par
// l'API) et n'affiche la barre de répartition que si CETTE valorisation a le détail — sinon
// retombe sur un simple montant, comme avant (cas des valorisations saisies en mode simple, ou
// de celles antérieures à cette fonctionnalité).
const latestVal = valorisations[0];
const latestValHasBreakdown = latestVal && latestVal.capital_deploye != null;
const latestValTotal = latestVal ? latestVal.valorisation_nette : deal.montant_investi;
const latestValCapitalDeploye = latestValHasBreakdown ? latestVal.capital_deploye : 0;
const latestValPlusValue = latestValHasBreakdown ? (latestVal.plus_value_latente || 0) : 0;
const latestValCapitalNonDeploye = latestValHasBreakdown
? Math.max(0, latestValTotal - latestValCapitalDeploye - latestValPlusValue) : 0;
const valBarTotal = latestValCapitalDeploye + Math.max(0, latestValPlusValue) + latestValCapitalNonDeploye;
const pctDeploye = valBarTotal > 0 ? (latestValCapitalDeploye / valBarTotal) * 100 : 0;
const pctPlusValue = valBarTotal > 0 ? (Math.max(0, latestValPlusValue) / valBarTotal) * 100 : 0;
// Carte "Montant souscrit" + colonnes Déploiement/Performance/Frais (18/09/26, reprise du
// tableau de bord Fundora, demande Olivier — même disposition, même graphique, même tableau à
// 3 colonnes). Frais d'entrée/de gestion "à la source" : mêmes lignes frais_operations que
// celles pré-remplies dans "Modifier l'investissement" (openEditDeal ci-dessus).
const fraisEntreeRow = fraisOps.find(f => f.categorie === 'souscription');
const fraisGestionRow = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
const fraisEntreeMontant = fraisEntreeRow?.montant || 0;
const fraisGestionMontant = fraisGestionRow?.montant || 0;
const capitalInvestissable = Number(deal.capital_investissable ?? deal.montant_investi);
const pctCapitalAuTravail = capitalInvestissable > 0 ? (latestValCapitalDeploye / capitalInvestissable) * 100 : 0;
// TVPI "du sous-jacent" = multiple sur le seul capital déployé (contrairement au TVPI global,
// qui rapporte à tout le montant souscrit) — dérivé de la plus-value latente saisie, pas un
// champ à part : (capital déployé + plus-value latente) / capital déployé.
const tvpiSousJacent = latestValHasBreakdown && latestValCapitalDeploye > 0
? (latestValCapitalDeploye + latestValPlusValue) / latestValCapitalDeploye
: null;
// "Valeur totale cible" = montant souscrit × objectif, uniquement quand l'objectif est un
// multiple simple ("x4,5") — objectif reste volontairement en texte libre côté deal (peut
// aussi être une fourchette de taux, ex. "9-12 %/an" : pas de calcul possible dans ce cas,
// affiche "—").
const objectifMultipleMatch = deal.objectif && deal.objectif.match(/^x\s*(\d+(?:[.,]\d+)?)/i);
const valeurTotaleCible = objectifMultipleMatch
? deal.montant_investi * parseFloat(objectifMultipleMatch[1].replace(',', '.'))
: null;
// Barre "Montant souscrit" : mêmes 4 composantes que le camembert Fundora, mais rapportées au
// montant souscrit BRUT (pas au capital investissable) puisque les frais en font partie.
const pctFraisEntreeSouscrit = deal.montant_investi > 0 ? (fraisEntreeMontant / deal.montant_investi) * 100 : 0;
const pctFraisGestionSouscrit = deal.montant_investi > 0 ? (fraisGestionMontant / deal.montant_investi) * 100 : 0;
const pctDeployeSouscrit = deal.montant_investi > 0 ? (latestValCapitalDeploye / deal.montant_investi) * 100 : 0;
// "Obligation" (18/09/26, demande Olivier) : sous-ensemble des distributions classées "intérêt
// sur capital non déployé" (cf. distribution_nature dans RembFormModal.jsx), reconstituant la
// ligne du même nom sur les captures Fundora — calculé par le backend (obligation_cumulee,
// somme des remboursements de ce deal tagués ainsi), jamais une estimation/un taux fabriqué
// localement. "Distributions" affiché ci-dessous devient donc le reste (capital + non classé),
// les deux sommant toujours à distributions_cumulees (qui reste la base du calcul du TVPI,
// inchangée — cf. GET /:id, investissementsPe.js).
const obligationCumulee = deal.obligation_cumulee || 0;
const distributionsCapital = (deal.distributions_cumulees || 0) - obligationCumulee;
return (
<>
<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>}
<div style={{ padding: '0 24px' }}>
{/* ── Résumé ── */}
<div className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
{deal.plateforme_nom} — {deal.investisseur_nom} · Souscrit le {fmtDate(deal.date_souscription)}
{deal.strategie ? ` · ${deal.strategie}` : ''}
{deal.objectif ? ` · Objectif : ${deal.objectif}` : ''}
{deal.reference ? ` · Réf. ${deal.reference}` : ''}
{deal.statut !== 'valide' ? ` · ${STATUT_LABELS[deal.statut] || deal.statut}` : ''}
{` · ${MODE_DETENTION_LABELS[deal.mode_detention] || 'Direct'}`}
</div>
{deal.notes && <div className="text-muted" style={{ marginBottom: 12, fontSize: 13 }}>{deal.notes}</div>}
{/* Tableau de bord "Montant souscrit / Valorisation nette" (18/09/26, reprise de la
disposition des captures Fundora d'Olivier : 2 cartes avec barre de répartition, puis
3 colonnes Déploiement/Performance/Frais). N'affiche les graphiques que si la
dernière valorisation a le détail de répartition (saisie optionnelle, formulaire
"Valorisations" plus bas) — sinon retombe sur un résumé minimal. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="card">
<h4 style={{ margin: '0 0 2px' }}>1. Montant souscrit</h4>
<div className="text-muted" style={{ fontSize: 12, marginBottom: 10 }}>
Engagement total, décaissé en totalité à la souscription
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 10 }}>{fmtEUR(deal.montant_investi)}</div>
<div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', background: 'var(--surface-2)' }}>
<div style={{ width: `${pctFraisEntreeSouscrit}%`, background: 'var(--text)' }} />
<div style={{ width: `${pctFraisGestionSouscrit}%`, background: 'var(--text)' }} />
<div style={{ width: `${pctDeployeSouscrit}%`, background: 'var(--primary)' }} />
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 10, fontSize: 12, flexWrap: 'wrap' }}>
<LegendDot color="var(--text)" label="Frais d'entrée" value={fraisEntreeMontant} />
<LegendDot color="var(--text)" label="Frais de gestion" value={fraisGestionMontant} />
<LegendDot color="var(--primary)" label="Capital déployé" value={latestValCapitalDeploye} />
<LegendDot color="var(--surface-2)" outline label="Capital non déployé" value={latestValCapitalNonDeploye} />
</div>
</div>
<div className="card">
<h4 style={{ margin: '0 0 2px' }}>2. Valorisation nette</h4>
<div className="text-muted" style={{ fontSize: 12, marginBottom: 10 }}>
Capital déployé + plus-value latente + capital non déployé
</div>
<div style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 10 }}>
{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
</div>
{latestValHasBreakdown ? (
<>
<div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', background: 'var(--surface-2)' }}>
<div style={{ width: `${pctDeploye}%`, background: 'var(--primary)' }} />
<div style={{ width: `${pctPlusValue}%`, background: latestValPlusValue >= 0 ? 'var(--success)' : 'var(--danger)' }} />
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 10, fontSize: 12, flexWrap: 'wrap' }}>
<LegendDot color="var(--primary)" label="Capital déployé" value={latestValCapitalDeploye} />
<LegendDot
color={latestValPlusValue >= 0 ? 'var(--success)' : 'var(--danger)'}
label={latestValPlusValue >= 0 ? 'Plus-value latente' : 'Moins-value latente'}
value={latestValPlusValue}
/>
<LegendDot color="var(--surface-2)" outline label="Capital non déployé" value={latestValCapitalNonDeploye} />
</div>
</>
) : (
<div className="text-muted" style={{ fontSize: 13 }}>
{valorisations.length === 0
? 'Aucune valorisation enregistrée.'
: 'Pas de détail de répartition pour la dernière valorisation (saisie simple).'}
</div>
)}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 16 }}>
<div className="card">
<div style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)', marginBottom: 12 }}>Déploiement</div>
<StatRow
label="Pourcentage du capital au travail"
value={`${pctCapitalAuTravail.toFixed(1).replace('.', ',')} %`}
caption="Pourcentage appelé par le fonds"
/>
<StatRow
label="Capital déployé"
value={fmtEUR(latestValCapitalDeploye)}
caption="(Montant souscrit − frais) × % d'appels de fonds"
/>
<StatRow
label="Capital non déployé"
value={fmtEUR(latestValCapitalNonDeploye)}
caption="(Montant souscrit − frais) × (1 − % d'appels de fonds)"
/>
</div>
<div className="card">
<div style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)', marginBottom: 12 }}>Performance</div>
<StatRow
label="Valorisation nette"
value={fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}
valueColor="var(--success)"
caption={<>
Capital déployé + plus-value latente + capital non déployé
{tvpiSousJacent != null && <><br />TVPI du sous-jacent {fmtMultiple(tvpiSousJacent)}</>}
</>}
/>
<StatRow label="TVPI" value={fmtMultiple(deal.tvpi)} caption="Valorisation nette / montant souscrit" />
<StatRow label="Distributions" value={fmtEUR(distributionsCapital)} caption="Restitution de capital déployé, remboursements non classés inclus" />
<StatRow label="Obligation" value={fmtEUR(obligationCumulee)} caption="Intérêt versé sur le capital non déployé (remboursements classés ainsi)" />
<StatRow
label="Valeur totale cible"
value={valeurTotaleCible != null ? fmtEUR(valeurTotaleCible) : '—'}
caption="Montant souscrit × objectif de l'opportunité"
/>
</div>
<div className="card">
<div style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)', marginBottom: 12 }}>Frais</div>
<StatRow
label="Frais totaux"
value={fmtEUR(fraisEntreeMontant + fraisGestionMontant)}
caption={`Frais d'entrée ${fmtEUR(fraisEntreeMontant)} · Frais de gestion ${fmtEUR(fraisGestionMontant)}`}
/>
</div>
</div>
{/* ── Valorisations ── */}
<div className="card" style={{ marginBottom: 16 }}>
<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">
<div>
<label>Date *</label>
<input type="date" required value={valForm.date_valorisation} onChange={e => setValForm({ ...valForm, date_valorisation: e.target.value })} />
</div>
<div>
<label>Valorisation nette (€) *</label>
<input type="number" step="0.01" min="0" required disabled={valFormHasBreakdown}
value={valFormHasBreakdown ? valFormComputedNette : valForm.valorisation_nette}
onChange={e => setValForm({ ...valForm, valorisation_nette: e.target.value })} />
</div>
<div>
<label>Capital déployé (€)</label>
<input type="number" step="0.01" min="0" placeholder="optionnel — pour le détail de répartition"
value={valForm.capital_deploye}
onChange={e => setValForm({ ...valForm, capital_deploye: e.target.value })} />
</div>
<div>
<label>Capital non déployé (€)</label>
<input type="text" disabled value={valFormHasBreakdown ? fmtEUR(valFormCapitalNonDeploye) : '—'} />
</div>
{valFormHasBreakdown && (
<div style={{ gridColumn: '1 / -1' }}>
<label>Plus-value latente (€)</label>
<input type="number" step="0.01" placeholder="négatif si moins-value"
value={valForm.plus_value_latente}
onChange={e => setValForm({ ...valForm, plus_value_latente: e.target.value })} />
</div>
)}
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<input value={valForm.notes} onChange={e => setValForm({ ...valForm, notes: e.target.value })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<button type="submit" className="primary" disabled={valSubmitting}>
{valSubmitting ? 'Ajout…' : '+ Ajouter une valorisation'}
</button>
</div>
</form>
</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>
<MenuTrigger onOpen={setRembMenu} />
</div>
<table>
<thead><tr><th>Date</th><th className="num">Montant versé</th><th>Notes</th></tr></thead>
<tbody>
{remboursements.length === 0 && (
<tr><td colSpan={3} className="text-muted" style={{ textAlign: 'center' }}>Aucun remboursement enregistré pour ce deal</td></tr>
)}
{remboursements.map(r => (
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => navigate('/remboursements')} title="Voir dans Remboursements">
<td>{fmtDate(r.date_remb)}</td>
<td className="num">{fmtEUR(r.net_recu)}</td>
<td className="text-muted">{r.notes || '—'}</td>
</tr>
))}
</tbody>
</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 }}>
<h3 style={{ margin: 0 }}>
Frais d'opération
{fraisOps.length > 0 && <span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>Total : {fmtEUR(totalFrais)}</span>}
</h3>
<MenuTrigger onOpen={setFraisMenu} />
</div>
{fraisOps.length === 0 ? (
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun frais journalisé pour l'instant.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fraisOps.map(f => {
const isManaged = f.categorie === 'souscription' || (f.categorie === 'gestion' && f.origine === 'souscription');
return (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0, cursor: isManaged ? 'pointer' : 'pointer' }}
onClick={() => isManaged ? openEditDeal() : openEditFrais(f)}
title={isManaged ? "Modifiable depuis « Modifier l'investissement »" : 'Cliquer pour modifier'}>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)' }}>
{fmtEUR(f.montant)}
<span className="text-muted" style={{ fontWeight: 400, marginLeft: 8 }}>
{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}
{f.mode_reglement === 'compte_courant' && f.compte_nom ? ` (${f.compte_nom})` : ''}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{fmtDate(f.date_operation)} · {FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}
{f.notes ? ` · ${f.notes}` : ''}
</div>
</div>
{!isManaged && (
<button className="icon-btn" title="Supprimer" onClick={() => { setFraisEditingId(f.id); onDeleteFrais(); }}>✕</button>
)}
</div>
);
})}
</div>
)}
</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>
<MenuTrigger onOpen={setDocsMenu} />
</div>
{docsErr && <div className="error">{docsErr}</div>}
{documents.length === 0 ? (
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>Aucun document pour l'instant.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{documents.map(doc => (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{renamingDocId === doc.id ? (
<input autoFocus value={renameValue} onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleDocRenameCommit(doc.id); if (e.key === 'Escape') setRenamingDocId(null); }}
onBlur={() => handleDocRenameCommit(doc.id)} style={{ width: '100%', fontSize: 'var(--fs-sm)' }} />
) : (
<div onClick={() => handleDocRenameStart(doc)} title="Cliquer pour renommer"
style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}>
{doc.nom_affichage}.{doc.extension}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{fmtOctets(doc.taille_octets)} · {fmtDate(doc.created_at)}</div>
</div>
<button className="icon-btn" title="Télécharger" onClick={() => handleDocDownload(doc)}>⬇</button>
<button className="icon-btn" title="Supprimer" onClick={() => setDocDeleteTarget(doc)}>✕</button>
</div>
))}
</div>
)}
<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
open={modalOpen}
editingId={id}
onClose={closeEditDeal}
form={form}
setForm={setForm}
plats={plats}
investisseurs={investisseurs}
multiDetenteur={new Set(plats.map(p => p.investisseur_id)).size > 1}
comptesInvestisseur={comptesInvestisseur}
err={formErr}
submitting={submitting}
onSubmit={() => runSubmit(submitDeal)}
onDelete={() => { closeEditDeal(); setDeleteConfirm(true); }}
investisseurForPlat={investisseurForPlat}
/>
<ConfirmModal
open={deleteConfirm}
title="Supprimer cet investissement ?"
message="Cette action est irréversible. Les distributions déjà saisies (Remboursements) sont conservées mais détachées de ce deal."
confirmLabel="Supprimer"
onConfirm={onDeleteDeal}
onCancel={() => setDeleteConfirm(false)}
/>
<ConfirmModal
open={!!valDeleteTarget}
title="Supprimer cette valorisation ?"
message="Cette action est irréversible."
confirmLabel="Supprimer"
onConfirm={onDeleteVal}
onCancel={() => setValDeleteTarget(null)}
/>
<ConfirmModal
open={!!docDeleteTarget}
title="Supprimer ce document ?"
message={docDeleteTarget ? `"${docDeleteTarget.nom_affichage}.${docDeleteTarget.extension}" sera définitivement supprimé.` : ''}
confirmLabel="Supprimer"
onConfirm={handleDocDelete}
onCancel={() => setDocDeleteTarget(null)}
/>
{/* ── Modal déclaration de perte définitive (20/09/26, chantier "Fiscalité PE") ──
Même contenu que la modale équivalente côté crowdlending (InvestissementDetail.jsx),
adapté au vocabulaire PE ("deal" plutôt que "dossier", case 2TY via taxreport-pe). */}
<Modal
open={perteModalOpen}
title="Déclarer une perte définitive"
onClose={closePerteModal}
width={520}
busy={perteSaving}
footer={
<>
<button className="ghost" disabled={perteSaving} onClick={closePerteModal}>Annuler</button>
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
</button>
</>
}
>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
À utiliser uniquement lorsque le capital est réellement irrécouvrable (fonds liquidé
sans distribution, société sous-jacente radiée…) — pas une simple baisse de
valorisation latente. Une fois déclaré, ce deal sort du capital investi/en risque et
le montant est reporté dans votre synthèse fiscale PE (case 2TY), pour l'année de la
date d'effet ci-dessous.
</p>
<form onSubmit={submitPerte}>
<label>Date d'effet (constat de la perte)</label>
<input
type="date"
value={perteForm.date_effet}
onChange={e => setPerteForm(f => ({ ...f, date_effet: e.target.value }))}
required
/>
<label style={{ marginTop: 12, display: 'block' }}>
Montant de la perte
<span className="text-muted"> — capital pas encore restitué : {fmtEUR(Math.max(0, (deal?.capital_investissable ?? deal?.montant_investi ?? 0) - (deal?.capital_restitue_cumule || 0)))}</span>
</label>
<input
type="number" step="0.01" min="0" required
value={perteForm.montant_perte}
onChange={e => setPerteForm(f => ({ ...f, montant_perte: e.target.value }))}
/>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
Préfilli avec le capital pas encore restitué — ajustez si une partie a déjà été
récupérée (perte partielle).
</div>
<label style={{ marginTop: 12, display: 'block' }}>Motif</label>
<textarea
rows={3}
placeholder="Ex : liquidation du fonds, société sous-jacente radiée…"
value={perteForm.motif}
onChange={e => setPerteForm(f => ({ ...f, motif: e.target.value }))}
required
/>
{perteErr && <div className="form-error" style={{ marginTop: 10 }}>{perteErr}</div>}
</form>
</Modal>
<ConfirmModal
open={confirmingPerteCancel}
title="Annuler la déclaration de perte ?"
message="Le deal retrouve son statut précédent et le montant sort de votre synthèse fiscale PE."
confirmLabel="Annuler la déclaration"
onConfirm={cancelPerteDeclaration}
onCancel={() => setConfirmingPerteCancel(false)}
/>
<FraisFormModal
open={fraisModalOpen}
onClose={closeFraisModal}
editingId={fraisEditingId}
form={fraisForm}
onFieldChange={(k, v) => setFraisForm(f => ({ ...f, [k]: v }))}
err={fraisErr}
onSubmit={e => runFraisSubmit(submitFrais, e)}
onDelete={() => runFraisSubmit(onDeleteFrais)}
submitting={fraisSubmitting}
comptesInvestisseur={comptesInvestisseur}
locked
pe
investissementOptions={[]}
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(); } },
// Déclarer/annuler une perte définitive (20/09/26, chantier "Fiscalité PE") — même
// paire d'actions que le menu "⋮" crowdlending (InvestissementDetail.jsx).
deal && deal.statut !== 'perte_definitive' && {
label: 'Déclarer une perte définitive',
icon: <><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></>,
danger: true,
separatorBefore: true,
action: () => { setCardMenu(null); openPerteModal(); },
},
deal && deal.statut === 'perte_definitive' && {
label: 'Annuler la déclaration de perte',
icon: <><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-9.36L1 10"/></>,
separatorBefore: true,
action: () => { setCardMenu(null); setConfirmingPerteCancel(true); },
},
{ 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); } },
]}
/>
</>
);
}