import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useSearchParams } 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 EmptyState from '../components/EmptyState.jsx'; import PageIcon from '../components/PageIcon.jsx'; import { useAsyncAction } from '../hooks/useAsyncAction.js'; import { fmtEUR, fmtDate, memberLabel, DISTRIBUTION_NATURE_LABELS } from '../utils/format.js'; import { sortMouvements, computeSoldeIndicatifMap, signedMontant, MOUV_TYPE_LABELS, ExportDropdown, mouvToCSV, mouvToXLS, mouvToJSON, timestampSuffix, dlBlob, } from '../components/MouvementsShared.jsx'; import PlatSelector from '../components/PlatSelector.jsx'; import InvChart from '../components/InvChart.jsx'; /** * Page Plateformes — Private Equity (19/09/26, demande Olivier : "Nouvelle page dédiée mais * similaire dans la présentation graphique [à Plateformes.jsx crowdlending] avec le maximum * d'onglet réplicable. Il me faut aussi les mouvements du porte-monnaie"). * * Contrairement à Plateformes.jsx (3362 lignes, très couplé au modèle crowdlending — * échéancier, "Mois en cours", "Capital investi" amortissable), cette page est reconstruite * plus sobrement autour des mêmes briques visuelles (kpi, dr-tabs, PlatSelector) mais avec * uniquement les onglets qui ont un sens pour un deal PE : Investissements, Distributions, * Dépôts/Retraits, Mouvements Porte-monnaie. Les onglets "Mois en cours" et "Capital investi" * (échéancier/amortissement) n'existent pas côté PE (cf. project_workspaces_transformation.md) * et ne sont donc pas répliqués. * * Onglet "Mouvements Porte-monnaie" : réutilise GET /investissements-pe/mouvements-portefeuille * (déjà utilisé en sens inverse par Plateformes.jsx pour faire remonter les mouvements PE côté * crowdlending) avec le nouveau paramètre ?native=1 (19/09/26) qui bascule le filtre * depots_retraits sur le workspace PE actif au lieu de l'exclure. Limite connue, signalée à * Olivier : si une plateforme est un jour partagée entre crowdlending ET Private Equity, cette * page ne remonte pas encore les mouvements côté crowdlending de cette plateforme (l'inverse * existe déjà côté Plateformes.jsx) — non bloquant aujourd'hui, ses plateformes PE (Fundora, * Baltis) sont dédiées. */ const STATUT_LABELS = { en_attente: 'En attente', valide: 'Validé', cloture: 'Clôturé', perte_definitive: 'Perte définitive', }; const MODE_DETENTION_LABELS = { direct: 'Direct', pea_pme: 'PEA / PEA-PME', assurance_vie: 'Assurance-vie', }; const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—'); /* ── Sélecteur Période (copié de Plateformes.jsx — alimente InvChart via platYear, même principe que le sélecteur Année du graphique "Capital investi" crowdlending) ── */ function AnneeSelector({ availableYears, selectedYear, onSelect }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [open]); const options = [ { value: '', label: 'Depuis le début' }, ...[...availableYears].sort((a, b) => b - a).map(y => ({ value: String(y), label: String(y) })), ]; const displayLabel = selectedYear || 'Depuis le début'; const isGlobal = !selectedYear; return (
setOpen(v => !v)} style={{ background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)', borderRadius: 10, padding: '12px 16px', boxShadow: open ? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)' : '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)', cursor: 'pointer', userSelect: 'none', transition: 'box-shadow .15s', }} >
Période
{displayLabel}
{open && (
{options.map(opt => (
{ onSelect(opt.value); setOpen(false); }} style={{ padding: '10px 14px', cursor: 'pointer', background: opt.value === selectedYear ? 'var(--surface-2)' : 'transparent', fontWeight: opt.value === selectedYear ? 700 : 400, fontSize: 'var(--fs-sm)', borderBottom: '1px solid var(--border)', transition: 'background .1s', }} onMouseEnter={e => { if (opt.value !== selectedYear) e.currentTarget.style.background = 'var(--surface-2)'; }} onMouseLeave={e => { if (opt.value !== selectedYear) e.currentTarget.style.background = 'transparent'; }} > {opt.label}
))}
)}
); } export default function PlateformesPe() { const { activeId, activeView, investisseurs } = useInvestisseur(); const { active: activeWorkspace } = useWorkspace() || {}; const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [allPlats, setAllPlats] = useState([]); const [deals, setDeals] = useState([]); const [depots, setDepots] = useState([]); const [remboursements, setRemboursements] = useState([]); const [walletNative, setWalletNative] = useState([]); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); const [selectedYear, setSelectedYear] = useState(''); // Période du graphique Capital investi const selectedPlatName = searchParams.get('plat') || null; const setSelectedPlatName = (nom) => { setSearchParams(p => { const n = new URLSearchParams(p); n.set('plat', nom); return n; }, { replace: true }); }; const activeTab = searchParams.get('tab') || 'investissements'; const setActiveTab = (tab) => { setSearchParams(p => { const n = new URLSearchParams(p); n.set('tab', tab); return n; }, { replace: true }); }; const multiDetenteur = new Set(allPlats.map(p => p.investisseur_id)).size > 1; // Plateformes affectées au workspace PE actif — même filtre que InvestissementsPe.jsx // (p.workspaces posé par attachWorkspaces() côté backend, GET /plateformes n'est pas // filtré serveur : la liste couvre volontairement tous les workspaces, cf. project_ // referentiel_plateformes.md). // Plateformes affectées au workspace PE actif — indépendamment du détenteur, sert // uniquement à distinguer "aucune plateforme configurée" de "aucun deal pour ce détenteur" // dans les EmptyState ci-dessous (même distinction que Plateformes.jsx crowdlending, cf. // plats vs platOptions). const platsWorkspace = useMemo( () => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)), [allPlats, activeWorkspace], ); // Restreint en plus au détenteur courant (19/09/26, bug signalé par Olivier : "les pages // plateforme et frais ne semblent pas filtrer par détenteur" — `deals` est déjà filtré par // détenteur/scope=all via `load()` ci-dessous, mais la liste des plateformes elle-même était // construite directement sur /plateformes, jamais restreinte au détenteur choisi, contrairement // à Plateformes.jsx crowdlending dont platOptions est dérivé des lignes d'investissement déjà // scopées — même principe repris ici pour rester cohérent avec le sélecteur crowdlending). const plats = useMemo(() => { const dealPlatIds = new Set(deals.map(d => d.plateforme_id)); return platsWorkspace.filter(p => dealPlatIds.has(p.id)); }, [platsWorkspace, deals]); const load = async () => { if (!activeId && activeView !== 'all') return; setLoading(true); setErr(null); try { const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; const [p, d, dr, remb, wallet] = await Promise.all([ api.get('/plateformes'), api.get('/investissements-pe', scopeParams), api.get('/depots-retraits', scopeParams), api.get('/remboursements', { ...scopeParams, type: 'interets_plateforme' }), api.get('/investissements-pe/mouvements-portefeuille', { ...scopeParams, native: 1 }).catch(() => []), ]); setAllPlats(p); setDeals(d); setDepots(dr); setRemboursements(remb); setWalletNative(Array.isArray(wallet) ? wallet : []); } catch (e) { setErr(e.message); } finally { setLoading(false); } }; useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [activeId, activeView, activeWorkspace?.id]); // Sélection automatique de la première plateforme dès que la liste est connue. useEffect(() => { if (!selectedPlatName && plats.length > 0) setSelectedPlatName(plats[0].nom); // eslint-disable-next-line react-hooks/exhaustive-deps }, [plats.length]); const selectedPlat = plats.find(p => p.nom === selectedPlatName) || plats[0]; const platDeals = useMemo( () => selectedPlat ? deals.filter(d => d.plateforme_id === selectedPlat.id) : [], [deals, selectedPlat], ); const platDealIds = useMemo(() => new Set(platDeals.map(d => d.id)), [platDeals]); const platDepots = useMemo( () => selectedPlat ? depots.filter(r => r.plateforme_id === selectedPlat.id) : [], [depots, selectedPlat], ); const platRembs = useMemo( () => platDeals.length ? remboursements.filter(r => platDealIds.has(r.investissement_pe_id)) : [], [remboursements, platDealIds, platDeals.length], ); // Alias investissement_pe_id → investissement_id : InvChart.jsx (composant partagé avec le // crowdlending, cf. project_workspaces_transformation.md) est déjà générique sur les champs // communs aux deux modèles (id / date_souscription / montant_investi identiques dans // investissements et investissements_pe) mais attend investissement_id pour rattacher un // remboursement à sa ligne — un simple alias plutôt qu'une fourche du composant partagé. const platRembsForChart = useMemo( () => platRembs.map(r => ({ ...r, investissement_id: r.investissement_pe_id })), [platRembs], ); // Années disponibles pour le sélecteur "Période" du graphique — même principe que // Plateformes.jsx (années de date_souscription des lignes du scope courant). const availableYears = useMemo(() => { const set = new Set(platDeals.map(d => d.date_souscription?.slice(0, 4)).filter(Boolean)); return [...set].map(Number).sort((a, b) => b - a); }, [platDeals]); const platWallet = useMemo( () => selectedPlat ? walletNative.filter(r => r.plateforme_id === selectedPlat.id).map(r => ({ ...r, _investType: 'pe' })) : [], [walletNative, selectedPlat], ); // KPIs de la plateforme sélectionnée — mêmes formules que le bloc "totals" de // DashboardPe.jsx / backend/src/routes/v1/dashboard.js (bloc investissements_pe), // appliquées ici aux seuls deals de cette plateforme plutôt qu'à tout le portefeuille. const kpis = useMemo(() => { const investi = platDeals.reduce((s, d) => s + (d.montant_investi || 0), 0); const nav = platDeals.reduce((s, d) => s + (d.derniere_valorisation ?? d.montant_investi ?? 0), 0); const distributions = platDeals.reduce((s, d) => s + (d.distributions_cumulees || 0), 0); const tvpi = investi > 0 ? Math.round(((nav + distributions) / investi) * 100) / 100 : null; return { investi, nav, distributions, tvpi }; }, [platDeals]); const walletSorted = useMemo(() => sortMouvements(platWallet), [platWallet]); const soldeById = useMemo(() => computeSoldeIndicatifMap(platWallet), [platWallet]); const soldeIndicatif = walletSorted.length ? (soldeById.get(walletSorted[0].id) ?? 0) : 0; /* ── Modale Dépôt/Retrait (même Schema que backend/src/routes/depotsRetraits.js) ── */ const emptyDrForm = { investisseur_id: '', plateforme_id: '', date_operation: '', type: 'depot', montant: '', libelle: '', reference: '', notes: '' }; const [drModalOpen, setDrModalOpen] = useState(false); const [drEditingId, setDrEditingId] = useState(null); const [drForm, setDrForm] = useState(emptyDrForm); const [drErr, setDrErr] = useState(null); const [drDeleteTarget, setDrDeleteTarget] = useState(null); const { run: runDrSubmit, pending: drSubmitting } = useAsyncAction(); const today = () => new Date().toISOString().slice(0, 10); const openDrAdd = () => { setDrEditingId(null); setDrForm({ ...emptyDrForm, plateforme_id: selectedPlat?.id || '', investisseur_id: selectedPlat?.investisseur_id ? String(selectedPlat.investisseur_id) : '', date_operation: today() }); setDrErr(null); setDrModalOpen(true); }; const openDrEdit = (r) => { setDrEditingId(r.id); setDrForm({ investisseur_id: String(r.investisseur_id || ''), plateforme_id: String(r.plateforme_id || ''), date_operation: r.date_operation, type: r.type, montant: String(r.montant ?? ''), libelle: r.libelle || '', reference: r.reference || '', notes: r.notes || '', }); setDrErr(null); setDrModalOpen(true); }; const closeDr = () => { setDrModalOpen(false); setDrEditingId(null); setDrForm(emptyDrForm); setDrErr(null); }; const submitDr = async (e) => { e?.preventDefault?.(); setDrErr(null); const payload = { investisseur_id: Number(drForm.investisseur_id) || undefined, plateforme_id: Number(drForm.plateforme_id), date_operation: drForm.date_operation, type: drForm.type, montant: Number(drForm.montant), libelle: drForm.libelle || undefined, reference: drForm.reference || undefined, notes: drForm.notes || undefined, }; try { if (drEditingId) await api.put(`/depots-retraits/${drEditingId}`, payload); else await api.post('/depots-retraits', payload); closeDr(); await load(); } catch (e) { setDrErr(e.message); } }; const onDeleteDr = async () => { if (!drDeleteTarget) return; try { await api.del(`/depots-retraits/${drDeleteTarget.id}`); setDrDeleteTarget(null); await load(); } catch (e) { setErr(e.message); } }; /* ── Export de l'onglet Mouvements Porte-monnaie ── */ const exportWallet = (format) => { const rows = walletSorted.map(r => ({ ...r, solde_indicatif: soldeById.get(r.id), type_label: MOUV_TYPE_LABELS[r.type] || r.type, })); const suffix = timestampSuffix(); if (format === 'csv') dlBlob(mouvToCSV(rows), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.csv`, 'text/csv;charset=utf-8;'); else if (format === 'xls') dlBlob(mouvToXLS(rows, multiDetenteur), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.xls`, 'application/vnd.ms-excel'); else dlBlob(mouvToJSON(rows), `mouvements_${selectedPlat?.nom || 'pe'}_${suffix}.json`, 'application/json'); }; if (!loading && platsWorkspace.length === 0) return ( <>

Plateformes

); if (!loading && plats.length === 0) return ( <>

Plateformes

); return ( <>

Plateformes

{err &&
{err}
} {selectedPlat && ( <> {/* ── Graphique + sélecteurs (19/09/26, demande Olivier : "graph sur le capital investi" — même format que le graphique Capital investi de Plateformes.jsx crowdlending, InvChart.jsx réutilisé tel quel cf. commentaire platRembsForChart ci-dessus) ── */}
{/* ── KPIs ── */}
Capital investi
{fmtEUR(kpis.investi)}
Valorisation nette (NAV)
{fmtEUR(kpis.nav)}
Distributions cumulées
{fmtEUR(kpis.distributions)}
TVPI
{fmtMultiple(kpis.tvpi)}
{/* ── Onglets ── */}
{/* ====== ONGLET INVESTISSEMENTS ====== */} {activeTab === 'investissements' && (

Investissements sur {selectedPlat.nom}

{multiDetenteur && } {platDeals.length === 0 && ( )} {platDeals.map(d => ( navigate(`/investissements/${d.id}`)}> {multiDetenteur && } ))}
DealDétenteurSouscrit leMontant investi Dernière valorisationTVPI StatutDétention
Aucun deal sur cette plateforme
{d.nom_deal}{d.investisseur_nom || '—'}{fmtDate(d.date_souscription)} {fmtEUR(d.montant_investi)} {d.derniere_valorisation != null ? fmtEUR(d.derniere_valorisation) : '—'} {fmtMultiple(d.tvpi)} {STATUT_LABELS[d.statut] || d.statut} {MODE_DETENTION_LABELS[d.mode_detention] || 'Direct'}
)} {/* ====== ONGLET DISTRIBUTIONS ====== */} {activeTab === 'distributions' && (

Distributions perçues sur {selectedPlat.nom}

Les distributions se saisissent depuis la fiche de chaque deal, page Investissements (comme un remboursement) — cette vue est un récapitulatif en lecture seule.

{multiDetenteur && } {platRembs.length === 0 && ( )} {[...platRembs].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id).map(r => ( {multiDetenteur && } {/* 20/09/26, chantier "Fiscalité PE" : ternaire remplacé par la constante partagée DISTRIBUTION_NATURE_LABELS (utils/format.js) pour inclure la nouvelle nature 'plus_value' sans dupliquer les libellés (cf. RemboursementsSimple.jsx). */} ))}
DateDealDétenteurNatureMontant net perçu
Aucune distribution enregistrée
{fmtDate(r.date_remb)} {deals.find(d => d.id === r.investissement_pe_id)?.nom_deal || '—'}{r.investisseur_nom || '—'}{DISTRIBUTION_NATURE_LABELS[r.distribution_nature] || 'Restitution de capital'} {fmtEUR(r.net_recu)}
)} {/* ====== ONGLET DÉPÔTS / RETRAITS ====== */} {activeTab === 'depots-retraits' && (

Dépôts / Retraits — {selectedPlat.nom}

{multiDetenteur && } {platDepots.length === 0 && ( )} {[...platDepots].sort((a, b) => b.date_operation.localeCompare(a.date_operation) || b.id - a.id).map(r => ( openDrEdit(r)}> {multiDetenteur && } ))}
DateTypeDétenteurMontantLibellé
Aucun mouvement
{fmtDate(r.date_operation)} {r.type === 'depot' ? 'Dépôt' : 'Retrait'}{(() => { const inv = investisseurs.find(i => i.id === r.investisseur_id); return inv ? memberLabel(inv) : '—'; })()}{fmtEUR(r.montant)} {r.libelle || '—'} e.stopPropagation()}>
)} {/* ====== ONGLET MOUVEMENTS PORTE-MONNAIE ====== */} {activeTab === 'mouvements-pm' && (

Mouvements Porte-monnaie — {selectedPlat.nom}

Solde indicatif : {fmtEUR(soldeIndicatif)} exportWallet('csv')} onXLS={() => exportWallet('xls')} onJSON={() => exportWallet('json')} />

Journal unifié des souscriptions, distributions, frais et dépôts/retraits de cette plateforme — solde indicatif, pas une source de vérité chiffrée (mêmes principes que l'onglet équivalent côté crowdlending). Chaque ligne s'édite depuis son onglet d'origine.

{multiDetenteur && } {walletSorted.length === 0 && ( )} {walletSorted.map(r => { // signedMontant() (MouvementsShared.jsx) reste la seule source de vérité pour ce // calcul, partagée avec l'onglet équivalent côté crowdlending — ne jamais le // réimplémenter ici, cf. project_mouvements_shared_plateformes_tab.md. const signed = signedMontant(r); return ( {multiDetenteur && } ); })}
DateTypeDétenteurLibelléMontantSolde indicatif
Aucun mouvement
{fmtDate(r.date_operation)} {MOUV_TYPE_LABELS[r.type] || r.type}{r.investisseur_nom || '—'}{r.libelle || '—'} 0 ? 'var(--success, #16a34a)' : undefined }}> {signed > 0 ? '+' : ''}{fmtEUR(signed)} {fmtEUR(soldeById.get(r.id) ?? 0)}
)} )}
{drEditingId && ( )}
} >
runDrSubmit(submitDr, e)}> {drErr &&
{drErr}
}
setDrForm({ ...drForm, date_operation: e.target.value })} />
setDrForm({ ...drForm, montant: e.target.value })} />
setDrForm({ ...drForm, libelle: e.target.value })} placeholder="Ex. Appel de fonds" />
setDrForm({ ...drForm, reference: e.target.value })} />
{multiDetenteur && (
)}
setDrForm({ ...drForm, notes: e.target.value })} />
setDrDeleteTarget(null)} /> ); }