Améliorations diverses

This commit is contained in:
ocroguennec committed 2026-09-19 19:00:12 +02:00
1 parent 8817ac693d
commit 49fab06012
15 files changed
+908 -147

No files matched your search

+646
View File
@@ -0,0 +1,646 @@
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 } 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><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={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>
<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><th>Nature</th><th className="num">Montant net perçu</th></tr>
</thead>
<tbody>
{platRembs.length === 0 && (
<tr><td colSpan={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>
<td className="text-muted">{r.distribution_nature === 'interet_non_deploye' ? 'Intérêt sur capital non déployé' : '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><th className="num">Montant</th><th>Libellé</th><th style={{ width: 40 }}></th></tr>
</thead>
<tbody>
{platDepots.length === 0 && (
<tr><td colSpan={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>
<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><th>Libellé</th><th className="num">Montant</th><th className="num">Solde indicatif</th></tr>
</thead>
<tbody>
{walletSorted.length === 0 && (
<tr><td colSpan={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>
<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)}
/>
</>
);
}