872 lines
45 KiB
React
872 lines
45 KiB
React
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}
|
||
/>
|
||
</>
|
||
);
|
||
}
|