652 lines
33 KiB
React
652 lines
33 KiB
React
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 (
|
|
<div ref={ref} style={{ position: 'relative' }}>
|
|
<div
|
|
onClick={() => 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',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
<span style={{ fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500 }}>
|
|
Période
|
|
</span>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
|
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</div>
|
|
<span style={{ color: '#fff', fontSize: isGlobal ? '1rem' : '1.8rem', fontWeight: 700, lineHeight: 1.1 }}>
|
|
{displayLabel}
|
|
</span>
|
|
</div>
|
|
|
|
{open && (
|
|
<div style={{
|
|
position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 200,
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)', overflow: 'hidden',
|
|
}}>
|
|
{options.map(opt => (
|
|
<div
|
|
key={opt.value}
|
|
onClick={() => { 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}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div className="topbar"><h2><PageIcon name="plateforme" />Plateformes</h2></div>
|
|
<EmptyState
|
|
title="Aucune plateforme dans cet espace de travail"
|
|
message={`Ajoutez d'abord une plateforme à ${activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'ce workspace'} depuis Mes plateformes.`}
|
|
cta="Configurer mes plateformes"
|
|
to="/settings?section=plateformes&openAdd=1"
|
|
/>
|
|
</>
|
|
);
|
|
|
|
if (!loading && plats.length === 0) return (
|
|
<>
|
|
<div className="topbar"><h2><PageIcon name="plateforme" />Plateformes</h2></div>
|
|
<EmptyState
|
|
icon="📊"
|
|
title="Aucun deal pour le moment"
|
|
message="Vos plateformes sont configurées, mais ce détenteur n'a encore aucun deal enregistré dessus. Ajoutez un investissement, ou changez de détenteur (menu en haut à droite), pour voir apparaître les données."
|
|
cta="Ajouter un investissement"
|
|
to="/investissements?new=1"
|
|
/>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="topbar">
|
|
<h2><PageIcon name="plateforme" />Plateformes</h2>
|
|
</div>
|
|
|
|
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
|
|
|
|
{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) ── */}
|
|
<div className="charts-row">
|
|
<InvChart
|
|
rows={platDeals}
|
|
remboursements={platRembsForChart}
|
|
reinvestissements={[]}
|
|
platYear={selectedYear}
|
|
/>
|
|
<div className="dist-chart-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
<PlatSelector
|
|
platOptions={plats}
|
|
selectedPlatName={selectedPlat?.nom}
|
|
onSelect={setSelectedPlatName}
|
|
/>
|
|
<AnneeSelector
|
|
availableYears={availableYears}
|
|
selectedYear={selectedYear}
|
|
onSelect={setSelectedYear}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── KPIs ── */}
|
|
<div className="dr-kpi-row">
|
|
<div className="kpi">
|
|
<div className="label">Capital investi</div>
|
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.investi)}</div>
|
|
</div>
|
|
<div className="kpi">
|
|
<div className="label">Valorisation nette (NAV)</div>
|
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.nav)}</div>
|
|
</div>
|
|
<div className="kpi">
|
|
<div className="label">Distributions cumulées</div>
|
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtEUR(kpis.distributions)}</div>
|
|
</div>
|
|
<div className="kpi" title="TVPI = (NAV + distributions cumulées) / capital investi">
|
|
<div className="label">TVPI</div>
|
|
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4 }}>{fmtMultiple(kpis.tvpi)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Onglets ── */}
|
|
<div className="dr-tabs">
|
|
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
|
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
|
<button className={`dr-tab${activeTab === 'distributions' ? ' active' : ''}`}
|
|
onClick={() => setActiveTab('distributions')}>Distributions</button>
|
|
<button className={`dr-tab${activeTab === 'depots-retraits' ? ' active' : ''}`}
|
|
onClick={() => setActiveTab('depots-retraits')}>Dépôts / Retraits</button>
|
|
<button className={`dr-tab${activeTab === 'mouvements-pm' ? ' active' : ''}`}
|
|
onClick={() => setActiveTab('mouvements-pm')}>Mouvements Porte-monnaie</button>
|
|
</div>
|
|
|
|
{/* ====== ONGLET INVESTISSEMENTS ====== */}
|
|
{activeTab === 'investissements' && (
|
|
<div className="card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
|
<h3 style={{ margin: 0 }}>Investissements sur {selectedPlat.nom}</h3>
|
|
<button className="primary" onClick={() => navigate('/investissements?new=1')}>+ Nouveau deal</button>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Deal</th>{multiDetenteur && <th>Détenteur</th>}<th>Souscrit le</th><th className="num">Montant investi</th>
|
|
<th className="num">Dernière valorisation</th><th className="num">TVPI</th>
|
|
<th>Statut</th><th>Détention</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{platDeals.length === 0 && (
|
|
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center' }}>Aucun deal sur cette plateforme</td></tr>
|
|
)}
|
|
{platDeals.map(d => (
|
|
<tr key={d.id} className="dr-row" style={{ cursor: 'pointer' }}
|
|
onClick={() => navigate(`/investissements/${d.id}`)}>
|
|
<td>{d.nom_deal}</td>
|
|
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{d.investisseur_nom || '—'}</td>}
|
|
<td>{fmtDate(d.date_souscription)}</td>
|
|
<td className="num">{fmtEUR(d.montant_investi)}</td>
|
|
<td className="num">{d.derniere_valorisation != null ? fmtEUR(d.derniere_valorisation) : '—'}</td>
|
|
<td className="num">{fmtMultiple(d.tvpi)}</td>
|
|
<td>{STATUT_LABELS[d.statut] || d.statut}</td>
|
|
<td className="text-muted">{MODE_DETENTION_LABELS[d.mode_detention] || 'Direct'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* ====== ONGLET DISTRIBUTIONS ====== */}
|
|
{activeTab === 'distributions' && (
|
|
<div className="card">
|
|
<h3 style={{ margin: '0 0 14px' }}>Distributions perçues sur {selectedPlat.nom}</h3>
|
|
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
|
|
Les distributions se saisissent depuis la fiche de chaque deal, page Investissements
|
|
(comme un remboursement) — cette vue est un récapitulatif en lecture seule.
|
|
</p>
|
|
<table>
|
|
<thead>
|
|
<tr><th>Date</th><th>Deal</th>{multiDetenteur && <th>Détenteur</th>}<th>Nature</th><th className="num">Montant net perçu</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{platRembs.length === 0 && (
|
|
<tr><td colSpan={multiDetenteur ? 5 : 4} className="text-muted" style={{ textAlign: 'center' }}>Aucune distribution enregistrée</td></tr>
|
|
)}
|
|
{[...platRembs].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id).map(r => (
|
|
<tr key={r.id}>
|
|
<td>{fmtDate(r.date_remb)}</td>
|
|
<td>{deals.find(d => d.id === r.investissement_pe_id)?.nom_deal || '—'}</td>
|
|
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.investisseur_nom || '—'}</td>}
|
|
{/* 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). */}
|
|
<td className="text-muted">{DISTRIBUTION_NATURE_LABELS[r.distribution_nature] || 'Restitution de capital'}</td>
|
|
<td className="num">{fmtEUR(r.net_recu)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* ====== ONGLET DÉPÔTS / RETRAITS ====== */}
|
|
{activeTab === 'depots-retraits' && (
|
|
<div className="card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
|
<h3 style={{ margin: 0 }}>Dépôts / Retraits — {selectedPlat.nom}</h3>
|
|
<button className="primary" onClick={openDrAdd}>+ Nouveau dépôt/retrait</button>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr><th>Date</th><th>Type</th>{multiDetenteur && <th>Détenteur</th>}<th className="num">Montant</th><th>Libellé</th><th style={{ width: 40 }}></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{platDepots.length === 0 && (
|
|
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
|
|
)}
|
|
{[...platDepots].sort((a, b) => b.date_operation.localeCompare(a.date_operation) || b.id - a.id).map(r => (
|
|
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openDrEdit(r)}>
|
|
<td>{fmtDate(r.date_operation)}</td>
|
|
<td>{r.type === 'depot' ? 'Dépôt' : 'Retrait'}</td>
|
|
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{(() => { const inv = investisseurs.find(i => i.id === r.investisseur_id); return inv ? memberLabel(inv) : '—'; })()}</td>}
|
|
<td className="num">{fmtEUR(r.montant)}</td>
|
|
<td className="text-muted">{r.libelle || '—'}</td>
|
|
<td onClick={e => e.stopPropagation()}>
|
|
<button className="icon-btn" title="Supprimer" onClick={() => setDrDeleteTarget(r)}>✕</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* ====== ONGLET MOUVEMENTS PORTE-MONNAIE ====== */}
|
|
{activeTab === 'mouvements-pm' && (
|
|
<div className="card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
|
<h3 style={{ margin: 0 }}>Mouvements Porte-monnaie — {selectedPlat.nom}</h3>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<span className="text-muted" style={{ fontSize: 13 }}>Solde indicatif : <strong>{fmtEUR(soldeIndicatif)}</strong></span>
|
|
<ExportDropdown disabled={walletSorted.length === 0}
|
|
onCSV={() => exportWallet('csv')} onXLS={() => exportWallet('xls')} onJSON={() => exportWallet('json')} />
|
|
</div>
|
|
</div>
|
|
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
|
|
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.
|
|
</p>
|
|
<table>
|
|
<thead>
|
|
<tr><th>Date</th><th>Type</th>{multiDetenteur && <th>Détenteur</th>}<th>Libellé</th><th className="num">Montant</th><th className="num">Solde indicatif</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{walletSorted.length === 0 && (
|
|
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
|
|
)}
|
|
{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 (
|
|
<tr key={r.id}>
|
|
<td>{fmtDate(r.date_operation)}</td>
|
|
<td>{MOUV_TYPE_LABELS[r.type] || r.type}</td>
|
|
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.investisseur_nom || '—'}</td>}
|
|
<td className="text-muted">{r.libelle || '—'}</td>
|
|
<td className="num" style={{ color: signed < 0 ? 'var(--danger, #dc2626)' : signed > 0 ? 'var(--success, #16a34a)' : undefined }}>
|
|
{signed > 0 ? '+' : ''}{fmtEUR(signed)}
|
|
</td>
|
|
<td className="num text-muted">{fmtEUR(soldeById.get(r.id) ?? 0)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<Modal
|
|
open={drModalOpen}
|
|
title={drEditingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
|
|
onClose={closeDr}
|
|
busy={drSubmitting}
|
|
footer={
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
|
<div>
|
|
{drEditingId && (
|
|
<button className="danger" disabled={drSubmitting}
|
|
onClick={() => { const target = { id: drEditingId }; closeDr(); setDrDeleteTarget(target); }}>
|
|
Supprimer
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button disabled={drSubmitting} onClick={closeDr}>Annuler</button>
|
|
<button className="primary" disabled={drSubmitting} onClick={() => runDrSubmit(submitDr)}>
|
|
{drSubmitting ? (drEditingId ? 'Enregistrement…' : 'Création…') : (drEditingId ? 'Enregistrer' : 'Créer')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<form onSubmit={e => runDrSubmit(submitDr, e)}>
|
|
{drErr && <div className="error">{drErr}</div>}
|
|
<div className="form-grid">
|
|
<div>
|
|
<label>Date *</label>
|
|
<input type="date" required value={drForm.date_operation} onChange={e => setDrForm({ ...drForm, date_operation: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<label>Type *</label>
|
|
<select required value={drForm.type} onChange={e => setDrForm({ ...drForm, type: e.target.value })}>
|
|
<option value="depot">Dépôt</option>
|
|
<option value="retrait">Retrait</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Montant (€) *</label>
|
|
<input type="number" step="0.01" min="0" required value={drForm.montant} onChange={e => setDrForm({ ...drForm, montant: e.target.value })} />
|
|
</div>
|
|
<div style={{ gridColumn: '1 / -1' }}>
|
|
<label>Libellé</label>
|
|
<input value={drForm.libelle} onChange={e => setDrForm({ ...drForm, libelle: e.target.value })} placeholder="Ex. Appel de fonds" />
|
|
</div>
|
|
<div>
|
|
<label>Référence</label>
|
|
<input value={drForm.reference} onChange={e => setDrForm({ ...drForm, reference: e.target.value })} />
|
|
</div>
|
|
{multiDetenteur && (
|
|
<div style={{ gridColumn: '1 / -1' }}>
|
|
<label>Détenteur *</label>
|
|
<select required value={drForm.investisseur_id} onChange={e => setDrForm({ ...drForm, investisseur_id: e.target.value })}>
|
|
<option value="">—</option>
|
|
{investisseurs.map(i => <option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>)}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div style={{ gridColumn: '1 / -1' }}>
|
|
<label>Notes</label>
|
|
<input value={drForm.notes} onChange={e => setDrForm({ ...drForm, notes: e.target.value })} />
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<ConfirmModal
|
|
open={!!drDeleteTarget}
|
|
title="Supprimer ce mouvement ?"
|
|
message="Cette action est irréversible."
|
|
confirmLabel="Supprimer"
|
|
onConfirm={onDeleteDr}
|
|
onCancel={() => setDrDeleteTarget(null)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|