Implementation de la notion d'espace d'investissement
This commit is contained in:
1 parent
6b6cad7cd6
commit
4b7df66862
54 files changed
+6782
-288
No files matched your search
@@ -0,0 +1,624 @@
|
||||
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 } 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: '', notes: '' };
|
||||
const emptyFraisForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
|
||||
|
||||
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);
|
||||
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 */ }
|
||||
};
|
||||
|
||||
const syncValorisationInitiale = async (totalFraisSource, valorisationInitialeId) => {
|
||||
try {
|
||||
if (valorisationInitialeId) await api.del(`/investissements-pe/${id}/valorisations/${valorisationInitialeId}`);
|
||||
if (totalFraisSource > 0) {
|
||||
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);
|
||||
|
||||
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),
|
||||
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 {
|
||||
await api.post(`/investissements-pe/${id}/valorisations`, {
|
||||
date_valorisation: valForm.date_valorisation,
|
||||
valorisation_nette: Number(valForm.valorisation_nette),
|
||||
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);
|
||||
|
||||
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="card" style={{ marginBottom: 16 }}>
|
||||
<div className="dr-kpi-row" style={{ marginBottom: 0 }}>
|
||||
<div className="kpi"><div className="label">Montant investi</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.montant_investi)}</div></div>
|
||||
<div className="kpi"><div className="label">Valorisation actuelle</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.derniere_valorisation ?? deal.montant_investi)}</div></div>
|
||||
<div className="kpi"><div className="label">TVPI</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(deal.tvpi)}</div></div>
|
||||
<div className="kpi"><div className="label">Distributions cumulées</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(deal.distributions_cumulees)}</div></div>
|
||||
<div className="kpi"><div className="label">Statut</div>
|
||||
<div style={{ fontSize: '1.2rem', fontWeight: 700, marginTop: 4 }}>{STATUT_LABELS[deal.statut] || deal.statut}</div></div>
|
||||
</div>
|
||||
<div className="text-muted" style={{ marginTop: 14, fontSize: 13 }}>
|
||||
{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}` : ''}
|
||||
</div>
|
||||
{deal.notes && <div className="text-muted" style={{ marginTop: 8, fontSize: 13 }}>{deal.notes}</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 value={valForm.valorisation_nette} onChange={e => setValForm({ ...valForm, valorisation_nette: 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)}</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')}>+ 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user