Files
crowdlending-app/frontend/src/pages/InvestissementPeDetail.jsx
T
2026-09-18 10:35:43 +02:00

872 lines
45 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 { 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';
import { useAsyncAction } from '../hooks/useAsyncAction.js';
import { fmtEUR, fmtDate, fmtOctets, today } 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>
);
}
export default function InvestissementPeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { investisseurs } = useInvestisseur();
const { active: activeWorkspace } = useWorkspace() || {};
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); }
};
const onDeleteDeal = async () => {
try {
await api.del(`/investissements-pe/${id}`);
navigate('/investissements');
} catch (e) { setErr(e.message); }
};
/* ── Valorisations ── */
const [valForm, setValForm] = useState(emptyValForm);
const [valErr, setValErr] = useState(null);
const [valDeleteTarget, setValDeleteTarget] = 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);
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">
<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>
{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}` : ''}
</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 }}>
<h3 style={{ marginTop: 0, marginBottom: 14 }}>Valorisations</h3>
{valErr && <div className="error">{valErr}</div>}
<form onSubmit={e => runValSubmit(submitVal, e)} className="form-grid" style={{ marginBottom: 16 }}>
<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>
<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>
{/* ── 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>
</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>
{/* ── 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>
<button onClick={openAddFrais}>+ Ajouter un frais</button>
</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>
{/* ── 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>
</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>
</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)}
/>
<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
investissementOptions={[]}
confirmingDelete={fraisConfirmingDelete}
setConfirmingDelete={setFraisConfirmingDelete}
/>
</>
);
}