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
@@ -397,7 +397,7 @@ function TauxCreditImpotSection() {
|
||||
)}
|
||||
|
||||
{editing && <TciModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
|
||||
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onClose={() => setDeleting(null)} />}
|
||||
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,13 +46,14 @@ const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code
|
||||
const FISCALITE_LABELS = { flat_tax: 'Flat tax (30%)', sans_fiscalite_locale: 'Sans fiscalité locale', avec_fiscalite_locale: 'Avec fiscalité locale' };
|
||||
const CATEGORIE_PLATEFORME_LABELS = { psfp: 'Prestataire de Services de Financement Participatif', psan: 'Prestataire de Services sur Actifs Numériques' };
|
||||
const FRAIS_PLATEFORME_LABELS = { aucun: 'Aucun frais', entree_gestion_directe: "Frais d'entrée + Frais de gestion (Prélèvement direct)" };
|
||||
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [] };
|
||||
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [], workspace_ids: [] };
|
||||
|
||||
/* ── Modal création / édition référentiel ─────────────────────── */
|
||||
function RefModal({ row, onClose, onSaved }) {
|
||||
const isNew = !row?.id;
|
||||
const [catsInv, setCatsInv] = useState([]);
|
||||
const [secteursInv, setSecteursInv] = useState([]);
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -70,15 +71,30 @@ function RefModal({ row, onClose, onSaved }) {
|
||||
categories: [],
|
||||
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
|
||||
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
|
||||
workspace_ids: (row.workspaces || []).map(w => w.id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/ref-categories'),
|
||||
api.get('/ref-secteurs'),
|
||||
]).then(([cats, sects]) => {
|
||||
api.get('/admin/workspaces'),
|
||||
]).then(([cats, sects, ws]) => {
|
||||
setCatsInv(cats);
|
||||
setSecteursInv(sects);
|
||||
setWorkspaces(ws);
|
||||
// À la création, présélectionne le workspace "crowdlending" (seul
|
||||
// workspace réel aujourd'hui) — l'utilisateur reste libre d'ajuster ;
|
||||
// au moins une affectation reste exigée à l'enregistrement.
|
||||
if (isNew) {
|
||||
setForm(f => (f.workspace_ids && f.workspace_ids.length > 0) ? f : {
|
||||
...f,
|
||||
workspace_ids: (() => {
|
||||
const cl = ws.find(w => w.slug === 'crowdlending');
|
||||
return cl ? [cl.id] : ws.map(w => w.id);
|
||||
})(),
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
@@ -88,6 +104,7 @@ function RefModal({ row, onClose, onSaved }) {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
|
||||
if (!form.workspace_ids || form.workspace_ids.length === 0) { setErr('Sélectionnez au moins un espace de travail.'); return; }
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
// Champs gérés par RefModal
|
||||
@@ -105,6 +122,7 @@ function RefModal({ row, onClose, onSaved }) {
|
||||
description: form.description.trim() || null,
|
||||
categories_inv_ids: form.categories_inv_ids || [],
|
||||
secteurs_inv_ids: form.secteurs_inv_ids || [],
|
||||
workspace_ids: form.workspace_ids || [],
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
@@ -165,6 +183,26 @@ function RefModal({ row, onClose, onSaved }) {
|
||||
<option value="psan">{CATEGORIE_PLATEFORME_LABELS.psan}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Espaces de travail *</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{workspaces.map(w => {
|
||||
const checked = (form.workspace_ids || []).includes(w.id);
|
||||
return (
|
||||
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => set('workspace_ids', e.target.checked
|
||||
? [...(form.workspace_ids || []), w.id]
|
||||
: (form.workspace_ids || []).filter(id => id !== w.id))} />
|
||||
<span className="chip-cat" style={{ fontSize: 11 }}>{w.nom}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
|
||||
Cette entrée du référentiel ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).
|
||||
</div>
|
||||
</div>
|
||||
<div><label>Description</label>
|
||||
<textarea value={form.description} onChange={e => set('description', e.target.value)}
|
||||
style={{ minHeight: 180, resize: 'vertical' }} placeholder="Informations générales sur la plateforme…" /></div>
|
||||
@@ -327,6 +365,7 @@ function ReferentielSection() {
|
||||
const { search: urlSearch } = useLocation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterDomicil, setFilterDomicil] = useState('');
|
||||
const [filterWorkspace, setFilterWorkspace] = useState('');
|
||||
const [filterCategorie, setFilterCategorie] = useState(
|
||||
() => new URLSearchParams(urlSearch).get('filterCat') || ''
|
||||
);
|
||||
@@ -337,6 +376,12 @@ function ReferentielSection() {
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||
}, [rows]);
|
||||
|
||||
const allWorkspaces = useMemo(() => {
|
||||
const map = new Map();
|
||||
rows.forEach(r => (r.workspaces || []).forEach(w => map.set(w.id, w.nom)));
|
||||
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'fr'));
|
||||
}, [rows]);
|
||||
|
||||
const allDomiciliations = useMemo(() => {
|
||||
const set = new Set();
|
||||
rows.forEach(r => { if (r.domiciliation) set.add(r.domiciliation); });
|
||||
@@ -346,16 +391,17 @@ function ReferentielSection() {
|
||||
const filteredRows = useMemo(() => {
|
||||
let r = rows;
|
||||
if (filterDomicil) r = r.filter(x => x.domiciliation === filterDomicil);
|
||||
if (filterWorkspace) r = r.filter(x => (x.workspaces || []).some(w => String(w.id) === filterWorkspace));
|
||||
if (filterCategorie) r = r.filter(x => (x.categories_inv || []).some(c => c.nom === filterCategorie));
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
r = r.filter(x => x.nom.toLowerCase().includes(q));
|
||||
}
|
||||
return r;
|
||||
}, [rows, search, filterDomicil, filterCategorie]);
|
||||
}, [rows, search, filterDomicil, filterWorkspace, filterCategorie]);
|
||||
|
||||
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
|
||||
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterCategorie]);
|
||||
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterWorkspace, filterCategorie]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -486,6 +532,16 @@ function ReferentielSection() {
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 14px', fontSize: 14, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select
|
||||
value={filterWorkspace}
|
||||
onChange={e => setFilterWorkspace(e.target.value)}
|
||||
style={{ flex: 1, padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
|
||||
>
|
||||
<option value="">Tous les espaces de travail</option>
|
||||
{allWorkspaces.map(([id, nom]) => (
|
||||
<option key={id} value={String(id)}>{nom}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterDomicil}
|
||||
onChange={e => setFilterDomicil(e.target.value)}
|
||||
@@ -504,9 +560,9 @@ function ReferentielSection() {
|
||||
<option value="">Toutes les catégories</option>
|
||||
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{(search || filterDomicil || filterCategorie) && (
|
||||
{(search || filterDomicil || filterWorkspace || filterCategorie) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterCategorie(''); }}
|
||||
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterWorkspace(''); setFilterCategorie(''); }}
|
||||
style={{ padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}
|
||||
>Effacer les filtres</button>
|
||||
)}
|
||||
@@ -537,15 +593,14 @@ function ReferentielSection() {
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Héritage</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
{rows.length === 0
|
||||
? 'Aucune entrée dans le référentiel. Créez-en une ou importez une plateforme orpheline.'
|
||||
: 'Aucun résultat pour ces critères.'}
|
||||
@@ -573,16 +628,9 @@ function ReferentielSection() {
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}><span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={row.domiciliation} size={15} />{countryLabel(row.domiciliation)}</span></td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{(row.categories_inv || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
|
||||
{(row.categories_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories_inv.length - 2}</span>}
|
||||
{(row.categories_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{(row.secteurs_inv || []).slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
|
||||
{(row.secteurs_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.secteurs_inv.length - 2}</span>}
|
||||
{(row.secteurs_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
{(row.workspaces || []).slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
|
||||
{(row.workspaces || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.workspaces.length - 2}</span>}
|
||||
{(row.workspaces || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import ValorisationPeChart from '../components/ValorisationPeChart.jsx';
|
||||
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useWorkspace } from '../context/WorkspaceContext.jsx';
|
||||
import { fmtEUR, fmtPct } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
|
||||
/**
|
||||
* Tableau de bord Private Equity (chantier "dashboard PE", 17/09/26) — première
|
||||
* version structurelle demandée par Olivier ("un premier jet avec tout") : KPI
|
||||
* de portefeuille (porte-monnaie, capital investi, NAV, TVPI/DPI/RVPI, TRI,
|
||||
* frais cumulés, fraîcheur des valorisations) + graphiques (courbe de
|
||||
* valorisation globale, répartitions par plateforme/stratégie, frais par
|
||||
* catégorie) + classement des deals par performance. Réutilise
|
||||
* ValorisationPeChart (déjà utilisé sur la page Investissements PE) pour la
|
||||
* courbe, et GET /api/dashboard-pe (déjà monté côté backend, jusqu'ici
|
||||
* consommé uniquement par DepotsRetraitsSimple.jsx) pour le solde du
|
||||
* porte-monnaie — même snapshot que le Dashboard crowdlending. Les
|
||||
* répartitions passent par un petit composant local BarBreakdown plutôt que
|
||||
* FraisDistributionChart, dont l'en-tête affiche le mot "Frais" en dur —
|
||||
* trompeur pour une répartition de capital investi.
|
||||
*/
|
||||
|
||||
const STATUT_LABELS = {
|
||||
en_attente: 'En attente',
|
||||
valide: 'Validé',
|
||||
cloture: 'Clôturé',
|
||||
perte_definitive: 'Perte définitive',
|
||||
};
|
||||
|
||||
const PALETTE = [
|
||||
'#1e40af', '#9333ea', '#0d9488', '#f59e0b',
|
||||
'#e8547a', '#10b981', '#f97316', '#06b6d4',
|
||||
];
|
||||
|
||||
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
|
||||
|
||||
/* ── Carte KPI ──────────────────────────────────────────────── */
|
||||
function KpiCard({ title, value, note, color }) {
|
||||
return (
|
||||
<div className="kpi">
|
||||
<div className="label">{title}</div>
|
||||
<div style={{ fontSize: '1.35rem', fontWeight: 700, marginTop: 4, color: color || 'var(--text)' }}>{value}</div>
|
||||
{note && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{note}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Répartition en barres horizontales (générique, léger) ─────
|
||||
* items : [{ label, value }] — trié par valeur décroissante, largeur des
|
||||
* barres proportionnelle au maximum. Volontairement plus simple qu'un
|
||||
* treemap SVG (DistributionChart / FraisDistributionChart) : ce composant
|
||||
* reste neutre (pas de titre métier en dur) pour être réutilisé sur
|
||||
* plusieurs répartitions différentes sans être trompeur. */
|
||||
function BarBreakdown({ title, items }) {
|
||||
const data = (items || []).filter(i => i.value > 0).sort((a, b) => b.value - a.value);
|
||||
const max = data.length ? data[0].value : 0;
|
||||
const total = data.reduce((s, i) => s + i.value, 0);
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 14px' }}>{title}</h3>
|
||||
{data.length === 0 && (
|
||||
<div className="text-muted" style={{ textAlign: 'center', padding: '20px 0' }}>Aucune donnée</div>
|
||||
)}
|
||||
{data.map((d, i) => (
|
||||
<div key={d.label} style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85em', marginBottom: 3 }}>
|
||||
<span>{d.label}</span>
|
||||
<span className="text-muted">{fmtEUR(d.value)} · {total > 0 ? Math.round(d.value / total * 100) : 0}%</span>
|
||||
</div>
|
||||
<div style={{ background: 'var(--surface-2)', borderRadius: 6, height: 8, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${max > 0 ? (d.value / max * 100) : 0}%`, height: '100%',
|
||||
background: PALETTE[i % PALETTE.length], borderRadius: 6,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Mini-jauge TVPI (classement par deal) — échelle visuelle 0 → x3 (au-delà,
|
||||
* la barre sature à 100% : l'essentiel est de voir d'un coup d'œil qui est
|
||||
* au-dessus/en-dessous de x1, pas d'avoir une échelle linéaire fidèle à un
|
||||
* multiple hors norme). ── */
|
||||
function TvpiBar({ value }) {
|
||||
if (value == null) return <span className="text-muted">—</span>;
|
||||
const pct = Math.min(value, 3) / 3 * 100;
|
||||
const color = value >= 1 ? 'var(--success)' : 'var(--danger)';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ position: 'relative', width: 90, height: 8, background: 'var(--surface-2)', borderRadius: 4, flexShrink: 0 }}>
|
||||
<div style={{ position: 'absolute', inset: 0, width: `${pct}%`, background: color, borderRadius: 4 }} />
|
||||
<div title="x1 (seuil de rentabilité)" style={{ position: 'absolute', left: `${(1 / 3) * 100}%`, top: -2, width: 1, height: 12, background: 'var(--text-muted)' }} />
|
||||
</div>
|
||||
<span style={{ fontWeight: 600, color }}>{fmtMultiple(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPe() {
|
||||
const { activeId: investisseurId } = useInvestisseur();
|
||||
const { active } = useWorkspace() || {};
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [valorisations, setValorisations] = useState([]);
|
||||
const [fraisOps, setFraisOps] = useState([]);
|
||||
const [rembs, setRembs] = useState([]);
|
||||
const [wallet, setWallet] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!investisseurId) return;
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
Promise.all([
|
||||
api.get('/investissements-pe'),
|
||||
api.get('/investissements-pe/valorisations'),
|
||||
api.get('/frais-operations'),
|
||||
api.get('/remboursements'),
|
||||
api.get('/dashboard-pe'),
|
||||
]).then(([inv, val, frais, remb, dash]) => {
|
||||
setRows(inv);
|
||||
setValorisations(val);
|
||||
setFraisOps(frais);
|
||||
setRembs(remb);
|
||||
setWallet(dash);
|
||||
}).catch(e => setErr(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [investisseurId, active?.id]);
|
||||
|
||||
/* ── Agrégats KPI ── */
|
||||
const totals = useMemo(() => {
|
||||
let investi = 0, nav = 0, distributions = 0, fraisCumules = 0, actifs = 0, soldes = 0;
|
||||
const staleDeals = [];
|
||||
const staleThreshold = new Date();
|
||||
staleThreshold.setMonth(staleThreshold.getMonth() - 6);
|
||||
const staleThresholdStr = staleThreshold.toISOString().slice(0, 10);
|
||||
|
||||
for (const r of rows) {
|
||||
investi += r.montant_investi;
|
||||
nav += (r.derniere_valorisation ?? r.montant_investi);
|
||||
distributions += r.distributions_cumulees || 0;
|
||||
const enCours = r.statut === 'valide' || r.statut === 'en_attente';
|
||||
if (enCours) {
|
||||
actifs++;
|
||||
if (!r.derniere_valorisation_date || r.derniere_valorisation_date < staleThresholdStr) {
|
||||
staleDeals.push(r.nom_deal);
|
||||
}
|
||||
} else {
|
||||
soldes++;
|
||||
}
|
||||
}
|
||||
for (const f of fraisOps) fraisCumules += f.montant || 0;
|
||||
|
||||
return {
|
||||
investi, nav, distributions, fraisCumules, actifs, soldes,
|
||||
staleCount: staleDeals.length, staleDeals,
|
||||
tvpi: investi > 0 ? (nav + distributions) / investi : null,
|
||||
dpi: investi > 0 ? distributions / investi : null,
|
||||
rvpi: investi > 0 ? nav / investi : null,
|
||||
};
|
||||
}, [rows, fraisOps]);
|
||||
|
||||
/* ── TRI (XIRR) global du portefeuille — même méthode que le XIRR
|
||||
* crowdlending (Dashboard.jsx) : flux réels datés + valeur résiduelle des
|
||||
* deals encore actifs, valorisée "comme vendue aujourd'hui". ── */
|
||||
const xirrData = useMemo(() => {
|
||||
const cutoff = new Date().toISOString().slice(0, 10);
|
||||
const flows = [];
|
||||
for (const r of rows) {
|
||||
if (!r.date_souscription) continue;
|
||||
flows.push({ amount: -r.montant_investi, date: r.date_souscription });
|
||||
}
|
||||
for (const rb of rembs) {
|
||||
if (rb.investissement_pe_id == null) continue;
|
||||
const d = rb.date_remb?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flows.push({ amount: rb.net_recu || 0, date: d });
|
||||
}
|
||||
// Frais réglés hors "source" (vraie sortie de trésorerie du détenteur) —
|
||||
// même logique que le XIRR net crowdlending.
|
||||
for (const fo of fraisOps) {
|
||||
if (fo.mode_reglement !== 'portefeuille' && fo.mode_reglement !== 'compte_courant') continue;
|
||||
const d = fo.date_operation?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flows.push({ amount: -(fo.montant || 0), date: d });
|
||||
}
|
||||
let estime = false;
|
||||
for (const r of rows) {
|
||||
if (r.statut !== 'valide' && r.statut !== 'en_attente') continue;
|
||||
const val = r.derniere_valorisation ?? r.montant_investi;
|
||||
if (val > 0.01) { estime = true; flows.push({ amount: val, date: cutoff }); }
|
||||
}
|
||||
flows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
return { rate: xirr(flows), estime };
|
||||
}, [rows, rembs, fraisOps]);
|
||||
|
||||
/* ── Répartitions ── */
|
||||
const capitalParPlateforme = useMemo(() => {
|
||||
const map = {};
|
||||
for (const r of rows) map[r.plateforme_nom] = (map[r.plateforme_nom] || 0) + r.montant_investi;
|
||||
return Object.entries(map).map(([label, value]) => ({ label, value }));
|
||||
}, [rows]);
|
||||
|
||||
const capitalParStrategie = useMemo(() => {
|
||||
const map = {};
|
||||
for (const r of rows) {
|
||||
const k = r.strategie || 'Non renseignée';
|
||||
map[k] = (map[k] || 0) + r.montant_investi;
|
||||
}
|
||||
return Object.entries(map).map(([label, value]) => ({ label, value }));
|
||||
}, [rows]);
|
||||
|
||||
const fraisParCategorie = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisOps) {
|
||||
const k = FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie || 'Autre';
|
||||
map[k] = (map[k] || 0) + (f.montant || 0);
|
||||
}
|
||||
return Object.entries(map).map(([label, value]) => ({ label, value }));
|
||||
}, [fraisOps]);
|
||||
|
||||
/* ── Classement des deals par TVPI ── */
|
||||
const ranking = useMemo(() => [...rows].sort((a, b) => {
|
||||
if (a.tvpi == null && b.tvpi == null) return 0;
|
||||
if (a.tvpi == null) return 1;
|
||||
if (b.tvpi == null) return -1;
|
||||
return b.tvpi - a.tvpi;
|
||||
}), [rows]);
|
||||
|
||||
const xirrDisplay = xirrData.rate != null ? fmtPct(xirrData.rate * 100) : 'Non calculable';
|
||||
const xirrColor = xirrData.rate != null ? (xirrData.rate >= 0 ? 'var(--success)' : 'var(--danger)') : undefined;
|
||||
const xirrNote = xirrData.rate == null
|
||||
? 'Pas assez de flux datés pour calculer un taux'
|
||||
: (xirrData.estime ? 'Estimé — deals actifs valorisés à date du jour' : 'Taux définitif — tous les deals sont soldés');
|
||||
|
||||
const staleNote = totals.staleCount === 0
|
||||
? 'Toutes les valorisations actives sont à jour'
|
||||
: totals.staleDeals.slice(0, 3).join(', ') + (totals.staleDeals.length > 3 ? '…' : '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="dashboard" />Tableau de bord — {active?.nom || 'Private Equity'}</h2></div>
|
||||
|
||||
{loading && <div className="text-muted" style={{ padding: '24px 0' }}>Chargement…</div>}
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
{!loading && !err && rows.length === 0 && (
|
||||
<EmptyState
|
||||
icon="📈"
|
||||
title="Aucun investissement enregistré"
|
||||
message="Ajoutez votre premier investissement Private Equity pour voir apparaître les indicateurs et graphiques du portefeuille."
|
||||
cta="Ajouter un investissement"
|
||||
to="/investissements?new=1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !err && rows.length > 0 && (
|
||||
<>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard title="Capital investi total" value={fmtEUR(totals.investi)} />
|
||||
<KpiCard
|
||||
title="Porte-monnaie"
|
||||
value={wallet ? fmtEUR(wallet.solde_portefeuille_total) : '…'}
|
||||
note="Cash disponible sur les plateformes, hors compte courant"
|
||||
/>
|
||||
<KpiCard title="Valorisation nette (NAV)" value={fmtEUR(totals.nav)} />
|
||||
<KpiCard title="Distributions cumulées" value={fmtEUR(totals.distributions)} />
|
||||
<KpiCard title="TVPI portefeuille" value={fmtMultiple(totals.tvpi)} note="Valeur totale / capital investi" />
|
||||
<KpiCard title="DPI (réalisé)" value={fmtMultiple(totals.dpi)} note="Distributions / capital investi" />
|
||||
<KpiCard title="RVPI (non réalisé)" value={fmtMultiple(totals.rvpi)} note="Valorisation résiduelle / capital investi" />
|
||||
<KpiCard title="TRI (XIRR) global" value={xirrDisplay} color={xirrColor} note={xirrNote} />
|
||||
<KpiCard title="Frais cumulés" value={fmtEUR(totals.fraisCumules)} />
|
||||
<KpiCard title="Deals" value={`${totals.actifs} actif${totals.actifs > 1 ? 's' : ''}`} note={`${totals.soldes} soldé${totals.soldes > 1 ? 's' : ''}`} />
|
||||
<KpiCard
|
||||
title="Valorisations à rafraîchir"
|
||||
value={String(totals.staleCount)}
|
||||
color={totals.staleCount > 0 ? 'var(--warning)' : undefined}
|
||||
note={staleNote}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '5fr 3fr', gap: 16, marginTop: 8, marginBottom: 16 }}>
|
||||
<ValorisationPeChart rows={rows} valorisations={valorisations} platYear={null} />
|
||||
<BarBreakdown title="Capital investi par plateforme" items={capitalParPlateforme} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||
<BarBreakdown title="Capital investi par stratégie" items={capitalParStrategie} />
|
||||
<BarBreakdown title="Frais cumulés par catégorie" items={fraisParCategorie} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 14px' }}>Performance par deal</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Deal</th><th>Plateforme</th><th>Statut</th>
|
||||
<th className="num">Capital investi</th>
|
||||
<th className="num">Distributions</th>
|
||||
<th className="num">Valorisation actuelle</th>
|
||||
<th>TVPI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ranking.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.nom_deal}</td>
|
||||
<td className="text-muted">{r.plateforme_nom}</td>
|
||||
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
|
||||
<td className="num">{fmtEUR(r.montant_investi)}</td>
|
||||
<td className="num">{fmtEUR(r.distributions_cumulees || 0)}</td>
|
||||
<td className="num">{fmtEUR(r.derniere_valorisation ?? r.montant_investi)}</td>
|
||||
<td><TvpiBar value={r.tvpi} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
@@ -115,6 +116,7 @@ export default function DepotsRetraits() {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyMouvementForm);
|
||||
const [err, setErr] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRow, setSelectedRow] = useState(null);
|
||||
const [drPlatYear, setDrPlatYear] = useState('');
|
||||
@@ -1679,20 +1681,23 @@ export default function DepotsRetraits() {
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le mouvement' : 'Nouveau mouvement'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{editingId && <button className="danger" onClick={onDelete}>Supprimer</button>}
|
||||
{editingId && <button onClick={() => openCorrection(null)}>Correction de solde</button>}
|
||||
{editingId && <button className="danger" disabled={submitting} onClick={onDelete}>Supprimer</button>}
|
||||
{editingId && <button disabled={submitting} onClick={() => openCorrection(null)}>Correction de solde</button>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={close}>Annuler</button>
|
||||
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<form onSubmit={e => runSubmit(submit, e)}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } 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 SoldeChart from '../components/SoldeChart.jsx';
|
||||
import DistributionChart from '../components/DistributionChart.jsx';
|
||||
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import { fmtEUR, fmtDate, rangeFromDate } from '../utils/format.js';
|
||||
import { emptyMouvementForm } from '../components/MouvementsShared.jsx';
|
||||
|
||||
/**
|
||||
* Dépôts / Retraits — version simplifiée, utilisée par tout workspace autre
|
||||
* que crowdlending (Private Equity pour l'instant — cf. DepotsRetraitsRouter
|
||||
* dans App.jsx, jamais lié en dur à un slug précis).
|
||||
*
|
||||
* Depuis le 17/09/26 (demande d'Olivier : "adapter la page dépôt/retrait pour
|
||||
* qu'elle ressemble à la page dépôt/retrait de Crowdlending mais avec les
|
||||
* données de Private Equity"), cette page reprend le graphique de solde
|
||||
* cumulé, la Distribution par plateforme, les KPIs (Dépôts/Retraits/Diff/
|
||||
* Porte-monnaie) et les onglets Plateformes + Vision mensuelle de
|
||||
* pages/DepotsRetraits.jsx (crowdlending).
|
||||
*
|
||||
* Volontairement absents pour cette itération (décisions Olivier du 17/09/26) :
|
||||
* - L'onglet "Mouvements" façon crowdlending (journal unifié dépôts/retraits +
|
||||
* corrections + remboursements + souscriptions + frais/prélèvements fiscaux,
|
||||
* cf. MouvementsShared.jsx) : trop couplé à la fiscalité crowdlending, différé.
|
||||
* L'onglet "Mouvements" ci-dessous reste la version simple préexistante
|
||||
* (depots_retraits uniquement), avec les mêmes filtres type/plateforme/année
|
||||
* que les autres onglets.
|
||||
* - L'onglet "Objectifs" (table `objectifs`, pas encore rattachée à un
|
||||
* workspace_id) : hors scope pour l'instant.
|
||||
* - La Distribution est groupée par Plateforme comme côté Crowdlending (le
|
||||
* composant DistributionChart groupe en dur sur plateforme_nom, choix
|
||||
* confirmé par Olivier plutôt qu'un regroupement par deal).
|
||||
*
|
||||
* Le porte-monnaie par plateforme et les KPIs viennent du nouveau endpoint
|
||||
* GET /api/dashboard-pe (backend/src/routes/dashboardPe.js) — équivalent PE de
|
||||
* GET /api/dashboard, basé sur investissements_pe.montant_investi au lieu de
|
||||
* investissements, sans corrections/réinvestissements/frais_operations.
|
||||
*/
|
||||
export default function DepotsRetraitsSimple() {
|
||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [dashData, setDashData] = useState(null);
|
||||
const [dashDataAsOf, setDashDataAsOf] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyMouvementForm);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
// Défaut 'TOUT' aligné sur DepotsRetraits.jsx (crowdlending) — évite qu'un
|
||||
// jeu de données encore clairsemé (peu de mouvements, potentiellement plus
|
||||
// vieux qu'1 an) fasse disparaître silencieusement la Distribution.
|
||||
const [chartRange, setChartRange] = useState('TOUT');
|
||||
const [filter, setFilter] = useState({ type: '', plateforme_ids: [] });
|
||||
const [platYear, setPlatYear] = useState('');
|
||||
|
||||
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
|
||||
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces).
|
||||
const plats = useMemo(
|
||||
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||
[allPlats, activeWorkspace],
|
||||
);
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [dr, pl, dash] = await Promise.all([
|
||||
api.get('/depots-retraits'),
|
||||
api.get('/plateformes'),
|
||||
api.get('/dashboard-pe'),
|
||||
]);
|
||||
setRows(dr);
|
||||
setAllPlats(pl);
|
||||
setDashData(dash);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (investisseurId) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [investisseurId, activeWorkspace?.id]);
|
||||
|
||||
const years = useMemo(
|
||||
() => [...new Set(rows.map(r => r.date_operation.slice(0, 4)))].sort().reverse(),
|
||||
[rows],
|
||||
);
|
||||
|
||||
// Snapshots fin d'année (année sélectionnée + année précédente, pour le
|
||||
// TrendBadge) — chargés à la demande, mêmes principes que DepotsRetraits.jsx.
|
||||
const prevYear = String(Number(platYear || new Date().getFullYear()) - 1);
|
||||
useEffect(() => {
|
||||
if (!platYear) return;
|
||||
[platYear, prevYear].forEach(y => {
|
||||
const key = `${y}-12-31`;
|
||||
if (dashDataAsOf[key]) return;
|
||||
api.get('/dashboard-pe', { asOf: key })
|
||||
.then(snap => setDashDataAsOf(prev => ({ ...prev, [key]: snap })))
|
||||
.catch(() => {});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platYear, investisseurId, activeWorkspace?.id]);
|
||||
|
||||
const investisseurForPlat = (platId) => {
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
const firstId = plats[0]?.id || '';
|
||||
setForm({ ...emptyMouvementForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Ouverture auto depuis le bouton global "Nouveau dépôt/retrait" (topbar/burger,
|
||||
// cf. Layout.jsx — même ?new=1 que la page crowdlending). Le paramètre est
|
||||
// retiré de l'URL dès sa lecture, indépendamment du chargement des
|
||||
// plateformes ; un ref différé ouvre la modale une fois `plats` disponible,
|
||||
// pour ne jamais perdre la demande si elle arrive avant le premier chargement.
|
||||
const pendingNew = useRef(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('new') !== '1') return;
|
||||
navigate('/depots-retraits', { replace: true });
|
||||
if (plats.length > 0) {
|
||||
openAdd();
|
||||
} else {
|
||||
pendingNew.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNew.current && plats.length > 0) {
|
||||
pendingNew.current = false;
|
||||
openAdd();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plats.length]);
|
||||
|
||||
const openEdit = (r) => {
|
||||
setEditingId(r.id);
|
||||
setForm({
|
||||
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 || '',
|
||||
});
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyMouvementForm); setErr(null); };
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault?.(); setErr(null);
|
||||
const payload = {
|
||||
investisseur_id: Number(form.investisseur_id) || undefined,
|
||||
plateforme_id: Number(form.plateforme_id),
|
||||
date_operation: form.date_operation,
|
||||
type: form.type,
|
||||
montant: Number(form.montant),
|
||||
libelle: form.libelle || undefined,
|
||||
reference: form.reference || undefined,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
try {
|
||||
if (editingId) await api.put(`/depots-retraits/${editingId}`, payload);
|
||||
else await api.post('/depots-retraits', payload);
|
||||
close();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api.del(`/depots-retraits/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── Graphique / Distribution / KPIs (mêmes principes que DepotsRetraits.jsx,
|
||||
* sans le filtre mois — pas d'onglet Mouvements multi-source ici) ── */
|
||||
const kpiRows = rows.filter(r => {
|
||||
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
if (platYear && r.date_operation.slice(0, 4) !== platYear) return false;
|
||||
return true;
|
||||
});
|
||||
const chartRows = kpiRows.filter(r => !filter.type || r.type === filter.type);
|
||||
const distributionRows = useMemo(() => {
|
||||
const from = rangeFromDate(chartRange);
|
||||
if (!from) return chartRows;
|
||||
return chartRows.filter(r => r.date_operation.slice(0, 10) >= from);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartRows, chartRange]);
|
||||
|
||||
const totals = kpiRows.reduce((acc, r) => {
|
||||
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
|
||||
return acc;
|
||||
}, { depots: 0, retraits: 0 });
|
||||
|
||||
const soldeFromSnapshot = (snap) => {
|
||||
if (!snap) return null;
|
||||
if (filter.plateforme_ids.length > 0) {
|
||||
return (snap.cashByPlatform ?? [])
|
||||
.filter(c => filter.plateforme_ids.includes(String(c.plateforme_id)))
|
||||
.reduce((s, c) => s + (c.solde_portefeuille ?? 0), 0);
|
||||
}
|
||||
return snap.solde_portefeuille_total ?? null;
|
||||
};
|
||||
|
||||
const kpiSoldePortefeuille = (() => {
|
||||
if (!platYear) {
|
||||
if (filter.plateforme_ids.length === 1 && dashData) {
|
||||
const p = dashData.cashByPlatform?.find(c => String(c.plateforme_id) === filter.plateforme_ids[0]);
|
||||
return p?.solde_portefeuille ?? null;
|
||||
}
|
||||
return dashData?.solde_portefeuille_total ?? null;
|
||||
}
|
||||
return soldeFromSnapshot(dashDataAsOf[`${platYear}-12-31`]);
|
||||
})();
|
||||
const prevKpiSoldePortefeuille = platYear ? soldeFromSnapshot(dashDataAsOf[`${prevYear}-12-31`]) : null;
|
||||
|
||||
const prevTotals = useMemo(() => {
|
||||
if (!platYear) return { depots: 0, retraits: 0 };
|
||||
return rows.filter(r => {
|
||||
if (filter.plateforme_ids.length > 0 && !filter.plateforme_ids.includes(String(r.plateforme_id))) return false;
|
||||
return r.date_operation.slice(0, 4) === prevYear;
|
||||
}).reduce((acc, r) => {
|
||||
if (r.type === 'depot') acc.depots += r.montant; else acc.retraits += r.montant;
|
||||
return acc;
|
||||
}, { depots: 0, retraits: 0 });
|
||||
}, [rows, filter.plateforme_ids, platYear, prevYear]);
|
||||
|
||||
/* ── Données de l'onglet Plateformes (directement depuis dashboard-pe,
|
||||
* pas de recalcul client contrairement à DepotsRetraits.jsx — le endpoint
|
||||
* PE supporte déjà ?asOf nativement) ── */
|
||||
const platData = useMemo(() => {
|
||||
const src = platYear
|
||||
? (dashDataAsOf[`${platYear}-12-31`]?.cashByPlatform ?? [])
|
||||
: (dashData?.cashByPlatform ?? []);
|
||||
return [...src].sort((a, b) =>
|
||||
a.nom.localeCompare(b.nom, 'fr') || (a.detenteur_nom ?? '').localeCompare(b.detenteur_nom ?? '', 'fr'));
|
||||
}, [dashData, dashDataAsOf, platYear]);
|
||||
|
||||
const platTotals = useMemo(() => platData.reduce((acc, p) => {
|
||||
acc.depots += p.depots; acc.retraits += p.retraits;
|
||||
acc.solde_net += p.solde_net; acc.solde_portefeuille += p.solde_portefeuille ?? 0;
|
||||
return acc;
|
||||
}, { depots: 0, retraits: 0, solde_net: 0, solde_portefeuille: 0 }), [platData]);
|
||||
|
||||
/* ── Onglet Mouvements : liste simple depots_retraits, avec les mêmes
|
||||
* filtres type/plateforme/année que les autres onglets ── */
|
||||
const mouvementsRows = useMemo(
|
||||
() => [...kpiRows]
|
||||
.filter(r => !filter.type || r.type === filter.type)
|
||||
.sort((a, b) => b.date_operation.localeCompare(a.date_operation) || b.id - a.id),
|
||||
[kpiRows, filter.type],
|
||||
);
|
||||
|
||||
const hasActiveFilter = filter.type || filter.plateforme_ids.length > 0 || platYear;
|
||||
const clearFilters = () => { setFilter({ type: '', plateforme_ids: [] }); setPlatYear(''); };
|
||||
|
||||
if (!loading && plats.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="depots-retraits" />Dépôts / Retraits</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 pour pouvoir y enregistrer des dépôts et retraits.`}
|
||||
cta="Configurer mes plateformes"
|
||||
to="/settings?section=plateformes&openAdd=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!loading && !modalOpen && rows.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2></div>
|
||||
<EmptyState
|
||||
icon="💶"
|
||||
title="Aucun dépôt enregistré"
|
||||
message="Vos plateformes sont configurées, mais aucun dépôt n'a encore été enregistré."
|
||||
cta="Ajouter un dépôt"
|
||||
to="/depots-retraits?new=1"
|
||||
/>
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? 'Création…' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DepotsRetraitsForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs}
|
||||
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} investisseurForPlat={investisseurForPlat} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2>
|
||||
{hasActiveFilter && (
|
||||
<button className="dr-clear-btn" onClick={clearFilters}>✕ Effacer les filtres</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
|
||||
|
||||
{/* ── Graphiques ── */}
|
||||
<div className="charts-row">
|
||||
<SoldeChart rows={chartRows} range={chartRange} onRangeChange={setChartRange} />
|
||||
<DistributionChart rows={distributionRows} />
|
||||
</div>
|
||||
|
||||
{/* ── KPIs ── */}
|
||||
<div className="dr-kpi-row">
|
||||
<div
|
||||
className={`kpi dr-kpi-clickable${filter.type === 'depot' ? ' dr-kpi-active dr-kpi-active-success' : ''}`}
|
||||
onClick={() => setFilter(f => ({ ...f, type: f.type === 'depot' ? '' : 'depot' }))}
|
||||
title="Filtrer sur les dépôts"
|
||||
>
|
||||
<div className="label">Dépôts</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots)}</span>
|
||||
{platYear && <TrendBadge current={totals.depots} prev={prevTotals.depots} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots)} en {prevYear}</div>}
|
||||
</div>
|
||||
<div
|
||||
className={`kpi dr-kpi-clickable${filter.type === 'retrait' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
|
||||
onClick={() => setFilter(f => ({ ...f, type: f.type === 'retrait' ? '' : 'retrait' }))}
|
||||
title="Filtrer sur les retraits"
|
||||
>
|
||||
<div className="label">Retraits</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.retraits)}</span>
|
||||
{platYear && <TrendBadge current={totals.retraits} prev={prevTotals.retraits} invert={true} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>}
|
||||
</div>
|
||||
<div
|
||||
className={`kpi${hasActiveFilter ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
|
||||
onClick={hasActiveFilter ? clearFilters : undefined}
|
||||
title={hasActiveFilter ? 'Effacer les filtres' : undefined}
|
||||
style={hasActiveFilter ? { cursor: 'pointer' } : {}}
|
||||
>
|
||||
<div className="label">Diff. Dépôts vs Retraits</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.depots - totals.retraits)}</span>
|
||||
{platYear && <TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>}
|
||||
</div>
|
||||
<div className="kpi" title="Cash disponible sur les porte-monnaie des plateformes">
|
||||
<div className="label">Porte-monnaie</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{kpiSoldePortefeuille !== null ? fmtEUR(kpiSoldePortefeuille) : '…'}</span>
|
||||
{platYear && kpiSoldePortefeuille !== null && <TrendBadge current={kpiSoldePortefeuille} prev={prevKpiSoldePortefeuille} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevKpiSoldePortefeuille)} en {prevYear}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Onglets ── */}
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('mouvements')}>Mouvements</button>
|
||||
</div>
|
||||
|
||||
{/* ====== ONGLET PLATEFORMES ====== */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Répartition par plateforme
|
||||
{platYear && <span style={{ marginLeft: 8 }}>pour l'année {platYear}</span>}
|
||||
</h3>
|
||||
<select value={platYear} onChange={e => setPlatYear(e.target.value)}
|
||||
style={{
|
||||
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
|
||||
borderRadius: 6, border: '1px solid var(--border)',
|
||||
background: 'var(--surface-2)', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', outline: 'none',
|
||||
}}>
|
||||
<option value="">Toutes les années</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!dashData ? (
|
||||
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{platData.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
|
||||
)}
|
||||
{(() => {
|
||||
const totalSolde = platTotals.solde_net;
|
||||
return platData.map(p => {
|
||||
const isActive = filter.plateforme_ids.includes(String(p.plateforme_id));
|
||||
const poids = totalSolde !== 0 ? (p.solde_net / totalSolde) * 100 : 0;
|
||||
return (
|
||||
<tr key={p.plateforme_id}
|
||||
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setFilter(f => ({
|
||||
...f,
|
||||
plateforme_ids: isActive
|
||||
? f.plateforme_ids.filter(id => id !== String(p.plateforme_id))
|
||||
: [...f.plateforme_ids, String(p.plateforme_id)]
|
||||
}))}>
|
||||
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{fmtEUR(p.depots)}</td>
|
||||
<td className="num">{fmtEUR(p.retraits)}</td>
|
||||
<td className="num">{fmtEUR(p.solde_net)}</td>
|
||||
<td className="num">{fmtEUR(p.solde_portefeuille ?? 0)}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{poids.toFixed(1)} %</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isActive ? '✕' : ''}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</tbody>
|
||||
{platData.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {platData.length} plateformes</td>
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num">{fmtEUR(platTotals.depots)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.retraits)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.solde_net)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.solde_portefeuille)}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET VISION MENSUELLE ====== */}
|
||||
{activeTab === 'vision-mensuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<DepotsMensuelTable allRows={rows} plats={plats} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET MOUVEMENTS ====== */}
|
||||
{activeTab === 'mouvements' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Mouvements</h3>
|
||||
<button className="primary" onClick={openAdd}>+ Nouveau dépôt/retrait</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
|
||||
<th>Type</th><th className="num">Montant</th><th>Libellé</th><th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mouvementsRows.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
|
||||
)}
|
||||
{mouvementsRows.map(r => (
|
||||
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openEdit(r)}>
|
||||
<td>{fmtDate(r.date_operation)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</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={() => setDeleteTarget(r)}>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le mouvement' : 'Nouveau dépôt/retrait'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
{editingId && (
|
||||
<button className="danger" disabled={submitting}
|
||||
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DepotsRetraitsForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs}
|
||||
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} investisseurForPlat={investisseurForPlat} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Supprimer ce mouvement ?"
|
||||
message="Cette action est irréversible."
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Indicateur de progression (copié de pages/DepotsRetraits.jsx — pas
|
||||
* exporté par MouvementsShared.jsx) ── */
|
||||
function TrendBadge({ current, prev, invert = false }) {
|
||||
if (prev == null || Math.abs(prev) < 0.005) return null;
|
||||
const diff = current - prev;
|
||||
const pct = (diff / prev) * 100;
|
||||
const up = diff > 0;
|
||||
const neutral = diff === 0;
|
||||
const good = neutral ? null : (invert ? !up : up);
|
||||
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
|
||||
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
|
||||
const arrow = neutral ? '→' : up ? '↗' : '↘';
|
||||
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 8px', borderRadius: 20,
|
||||
background: bg, color, fontSize: '0.76em', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{arrow} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
|
||||
* points d'entrée (page avec mouvements existants, et état vide). Champs
|
||||
* strictement alignés sur le Schema de backend/src/routes/depotsRetraits.js. */
|
||||
function DepotsRetraitsForm({ form, setForm, plats, investisseurs, multiDetenteur, err, runSubmit, submit, investisseurForPlat }) {
|
||||
return (
|
||||
<form onSubmit={e => runSubmit(submit, e)}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={form.plateforme_id} onChange={e => {
|
||||
const platId = e.target.value;
|
||||
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId) }));
|
||||
}}>
|
||||
<option value="">—</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Date *</label>
|
||||
<input type="date" required value={form.date_operation} onChange={e => setForm({ ...form, date_operation: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Type *</label>
|
||||
<select required value={form.type} onChange={e => setForm({ ...form, 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={form.montant} onChange={e => setForm({ ...form, montant: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Libellé</label>
|
||||
<input value={form.libelle} onChange={e => setForm({ ...form, libelle: e.target.value })} placeholder="Ex. Appel de fonds" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Référence</label>
|
||||
<input value={form.reference} onChange={e => setForm({ ...form, reference: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Détenteur *</label>
|
||||
<select required value={form.investisseur_id} onChange={e => setForm({ ...form, investisseur_id: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
|
||||
<optgroup label="Famille">
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
|
||||
<optgroup label="Entreprises">
|
||||
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Notes</label>
|
||||
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
@@ -198,6 +199,7 @@ export default function Frais() {
|
||||
};
|
||||
|
||||
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
|
||||
// Callback générique (onFieldChange) — mêmes effets de bord que le formulaire d'origine
|
||||
// (InvestissementDetail.jsx) : changer mode_reglement réinitialise compte_id sauf en passant
|
||||
@@ -540,8 +542,9 @@ export default function Frais() {
|
||||
form={form}
|
||||
onFieldChange={setField}
|
||||
err={err}
|
||||
onSubmit={submit}
|
||||
onDelete={onDelete}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDelete)}
|
||||
submitting={submitting}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
investissementOptions={investissementOptions}
|
||||
confirmingDelete={confirmingDelete}
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useWorkspace } from '../context/WorkspaceContext.jsx';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import FraisFormModal from '../components/FraisFormModal.jsx';
|
||||
import FraisCumulChart from '../components/FraisCumulChart.jsx';
|
||||
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
|
||||
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
|
||||
/**
|
||||
* Page dédiée "Frais" pour le workspace Private Equity (chantier "frais PE", 17/09/26 —
|
||||
* demande Olivier : "il faut une page sur les frais"). Adaptation à l'identique de Frais.jsx
|
||||
* (crowdlending) — même structure (graphiques, KPI, onglets Plateformes / Typologie /
|
||||
* Investissements / Opérations), parité complète demandée explicitement par Olivier — avec
|
||||
* les différences suivantes :
|
||||
* - source des données : /investissements-pe (deals) au lieu de /investissements, et
|
||||
* GET /frais-operations sans filtre se borne déjà au workspace ACTIF côté backend
|
||||
* (resolveActiveWorkspaceId), donc renvoie naturellement les frais PE ici ;
|
||||
* - libellé "deal" / nom_deal au lieu de "projet" / nom_projet ;
|
||||
* - gating d'accès via hasFraisPe (cf. AuthContext.jsx) plutôt que hasFrais, cf. FraisPeOnly
|
||||
* dans App.jsx.
|
||||
*
|
||||
* Le composant FraisFormModal (partagé, inchangé) attend en interne un champ de formulaire
|
||||
* nommé `investissement_id` pour son sélecteur "Investissement" — ce nom est conservé tel
|
||||
* quel dans l'état local de cette page (setField / emptyForm ci-dessous) et traduit en
|
||||
* `investissement_pe_id` uniquement au moment de construire le payload envoyé à l'API
|
||||
* (submit ci-dessous), pour ne rien modifier au composant partagé avec crowdlending.
|
||||
*/
|
||||
|
||||
const FRAIS_MODE_LABELS = {
|
||||
source: 'À la source',
|
||||
portefeuille: 'Porte-monnaie',
|
||||
compte_courant: 'Compte courant',
|
||||
remboursement: 'Retenu sur remboursement',
|
||||
};
|
||||
|
||||
function emptyForm() {
|
||||
return { investissement_id: '', montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), notes: '', categorie: 'autre' };
|
||||
}
|
||||
|
||||
export default function FraisPe() {
|
||||
const { activeId, activeView } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
|
||||
const [allFraisOps, setAllFraisOps] = useState([]);
|
||||
const [deals, setDeals] = useState([]);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [platLoaded, setPlatLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
/* Onglets (parité avec Frais.jsx) */
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
|
||||
/* Modale ajout/édition */
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [err, setErr] = useState(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||
|
||||
// Plateformes éligibles au workspace actif uniquement (GET /plateformes n'est pas filtré
|
||||
// côté serveur — seulement enrichi de row.workspaces), même patron que InvestissementsPe.jsx.
|
||||
const plateformes = useMemo(
|
||||
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||
[allPlats, activeWorkspace],
|
||||
);
|
||||
|
||||
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
const load = async () => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
const [f, d] = await Promise.all([
|
||||
api.get('/frais-operations'),
|
||||
api.get('/investissements-pe', scopeParams),
|
||||
]);
|
||||
setAllFraisOps(f);
|
||||
setDeals(d);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/plateformes').then(d => { setAllPlats(d); setPlatLoaded(true); }).catch(() => setPlatLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView, activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const deal = deals.find(d => String(d.id) === String(form.investissement_id));
|
||||
if (!modalOpen || !deal?.investisseur_id) { setComptesInvestisseur([]); return; }
|
||||
api.get(`/investissements/comptes-par-investisseur/${deal.investisseur_id}`)
|
||||
.then(setComptesInvestisseur)
|
||||
.catch(() => setComptesInvestisseur([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modalOpen, form.investissement_id]);
|
||||
|
||||
const fraisFiltered = allFraisOps;
|
||||
|
||||
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
|
||||
const fraisMoyen = fraisFiltered.length ? fraisTotal / fraisFiltered.length : 0;
|
||||
const fraisSouscriptionTotal = useMemo(() =>
|
||||
fraisFiltered.filter(f => f.categorie === 'souscription').reduce((s, f) => s + (f.montant || 0), 0),
|
||||
[fraisFiltered]
|
||||
);
|
||||
const fraisDistributionTotal = useMemo(() =>
|
||||
fraisFiltered.filter(f => f.categorie === 'distribution').reduce((s, f) => s + (f.montant || 0), 0),
|
||||
[fraisFiltered]
|
||||
);
|
||||
|
||||
/* ── Répartition par catégorie (onglet "Typologie") ── */
|
||||
const byCategorie = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisFiltered) {
|
||||
const key = f.categorie || 'autre';
|
||||
if (!map[key]) map[key] = { key, label: FRAIS_CATEGORIE_LABELS[key] || key, montant: 0, count: 0 };
|
||||
map[key].montant += f.montant || 0;
|
||||
map[key].count += 1;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.montant - a.montant);
|
||||
}, [fraisFiltered]);
|
||||
|
||||
/* ── Répartition par plateforme (onglet "Plateformes") ── */
|
||||
const byPlateforme = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisFiltered) {
|
||||
const key = f.plateforme_id;
|
||||
if (!map[key]) map[key] = { key, label: f.plateforme_nom || '—', detenteur: f.investisseur_nom || '—', montant: 0, count: 0 };
|
||||
map[key].montant += f.montant || 0;
|
||||
map[key].count += 1;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.montant - a.montant);
|
||||
}, [fraisFiltered]);
|
||||
|
||||
/* ── Frais par deal (onglet "Investissements") — uniquement les deals concernés par au
|
||||
moins un frais ── */
|
||||
const byInvestissement = useMemo(() => {
|
||||
const map = {};
|
||||
for (const f of fraisFiltered) {
|
||||
const key = f.investissement_pe_id;
|
||||
if (!map[key]) map[key] = {
|
||||
key,
|
||||
plateforme: f.plateforme_nom || '—',
|
||||
detenteur: f.investisseur_nom || '—',
|
||||
projet: f.nom_deal || '—',
|
||||
souscription: 0,
|
||||
distribution: 0,
|
||||
total: 0,
|
||||
};
|
||||
const cat = f.categorie || 'autre';
|
||||
if (cat === 'souscription') map[key].souscription += f.montant || 0;
|
||||
if (cat === 'distribution') map[key].distribution += f.montant || 0;
|
||||
map[key].total += f.montant || 0;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => {
|
||||
const pa = a.plateforme.toLowerCase(), pb = b.plateforme.toLowerCase();
|
||||
if (pa !== pb) return pa.localeCompare(pb, 'fr');
|
||||
return a.projet.localeCompare(b.projet, 'fr');
|
||||
});
|
||||
}, [fraisFiltered]);
|
||||
|
||||
/* ── Tableau détaillé paginé (onglet "Opérations") ── */
|
||||
const {
|
||||
pagedItems: pagedFrais, page, setPage, pageSize, setPageSize,
|
||||
totalPages, totalItems, PAGE_SIZES,
|
||||
} = usePagination(fraisFiltered, 'pe_pagesize_frais', []);
|
||||
|
||||
/* ── Deals pour le sélecteur de la modale (triés plateforme puis deal) — mappés sur les
|
||||
mêmes clés que FraisFormModal attend (plateforme_nom / nom_projet), cf. doc en tête. ── */
|
||||
const investissementOptions = useMemo(() =>
|
||||
deals.slice()
|
||||
.sort((a, b) => {
|
||||
const pa = (a.plateforme_nom || '').toLowerCase();
|
||||
const pb = (b.plateforme_nom || '').toLowerCase();
|
||||
if (pa !== pb) return pa.localeCompare(pb, 'fr');
|
||||
return (a.nom_deal || '').localeCompare(b.nom_deal || '', 'fr');
|
||||
})
|
||||
.map(d => ({ id: d.id, plateforme_nom: d.plateforme_nom, nom_projet: d.nom_deal })),
|
||||
[deals]
|
||||
);
|
||||
|
||||
/* ── CRUD ── */
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm());
|
||||
setErr(null);
|
||||
setConfirmingDelete(false);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (f) => {
|
||||
setEditingId(f.id);
|
||||
setForm({
|
||||
investissement_id: String(f.investissement_pe_id),
|
||||
montant: f.montant,
|
||||
mode_reglement: f.mode_reglement,
|
||||
compte_id: f.compte_id || '',
|
||||
date_operation: f.date_operation,
|
||||
notes: f.notes || '',
|
||||
categorie: f.categorie || 'autre',
|
||||
});
|
||||
setErr(null);
|
||||
setConfirmingDelete(false);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
|
||||
const setField = (key, value) => {
|
||||
setForm(f => {
|
||||
if (key === 'mode_reglement') return { ...f, mode_reglement: value, compte_id: value === 'compte_courant' ? f.compte_id : '' };
|
||||
if (key === 'investissement_id') return { ...f, investissement_id: value, compte_id: '' };
|
||||
return { ...f, [key]: value };
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault?.();
|
||||
setErr(null);
|
||||
try {
|
||||
const payload = {
|
||||
investissement_pe_id: Number(form.investissement_id),
|
||||
montant: Number(form.montant),
|
||||
mode_reglement: form.mode_reglement,
|
||||
compte_id: form.mode_reglement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
|
||||
date_operation: form.date_operation,
|
||||
origine: 'manuel',
|
||||
categorie: form.categorie || 'autre',
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
if (editingId) await api.put(`/frais-operations/${editingId}`, payload);
|
||||
else await api.post('/frais-operations', payload);
|
||||
closeModal();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
setErr(null);
|
||||
try {
|
||||
await api.del(`/frais-operations/${editingId}`);
|
||||
closeModal();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── Rendu ── */
|
||||
if (platLoaded && !loading && plateformes.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2>Frais</h2></div>
|
||||
<EmptyState />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="frais" />Frais</h2>
|
||||
<button className="primary" onClick={openAdd}>+ Ajouter un frais</button>
|
||||
</div>
|
||||
|
||||
{/* ── Graphiques : courbe du cumul des frais + répartition par plateforme ── */}
|
||||
<div className="charts-row">
|
||||
<FraisCumulChart rows={fraisFiltered} />
|
||||
<FraisDistributionChart rows={fraisFiltered} groupBy={f => f.plateforme_nom || '—'} label="par Plateforme" />
|
||||
</div>
|
||||
|
||||
{/* ── KPIs ── */}
|
||||
<div className="dr-kpi-row">
|
||||
<div className="kpi">
|
||||
<div className="label">Frais payés</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Frais de souscription</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisSouscriptionTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Frais de distribution</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisDistributionTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Nombre d'opérations</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fraisFiltered.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Frais moyen / opération</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisMoyen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Onglets ── */}
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'typologie' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('typologie')}>Typologie</button>
|
||||
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
||||
<button className={`dr-tab${activeTab === 'operations' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('operations')}>Opérations</button>
|
||||
</div>
|
||||
|
||||
{/* ══ ONGLET PLATEFORMES ══ */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Répartition par plateforme</h3>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Montant</th>
|
||||
<th className="num">Nombre</th>
|
||||
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byPlateforme.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 5 : 4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byPlateforme.map(p => {
|
||||
const poids = fraisTotal !== 0 ? (p.montant / fraisTotal) * 100 : 0;
|
||||
return (
|
||||
<tr key={p.key}>
|
||||
<td>{p.label}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur}</td>}
|
||||
<td className="num">{fmtEUR(p.montant)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
|
||||
{poids.toFixed(1)} %
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{byPlateforme.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td colSpan={multiDetenteur ? 2 : 1}>Total — {byPlateforme.length} plateformes</td>
|
||||
<td className="num">{fmtEUR(fraisTotal)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET TYPOLOGIE ══ */}
|
||||
{activeTab === 'typologie' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Répartition par catégorie</h3>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Catégorie</th>
|
||||
<th className="num">Montant</th>
|
||||
<th className="num">Nombre</th>
|
||||
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byCategorie.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byCategorie.map(c => {
|
||||
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
|
||||
return (
|
||||
<tr key={c.key}>
|
||||
<td>{c.label}</td>
|
||||
<td className="num">{fmtEUR(c.montant)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
|
||||
{poids.toFixed(1)} %
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{byCategorie.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {byCategorie.length} catégories</td>
|
||||
<td className="num">{fmtEUR(fraisTotal)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET INVESTISSEMENTS — liste des frais par deal ══ */}
|
||||
{activeTab === 'investissements' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Frais par investissement</h3>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th>Deal</th>
|
||||
<th className="num">Souscription</th>
|
||||
<th className="num">Distribution</th>
|
||||
<th className="num">Total Frais</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byInvestissement.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byInvestissement.map(d => (
|
||||
<tr key={d.key}>
|
||||
<td>{d.plateforme}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{d.detenteur}</td>}
|
||||
<td>{d.projet}</td>
|
||||
<td className="num">{fmtEUR(d.souscription)}</td>
|
||||
<td className="num">{fmtEUR(d.distribution)}</td>
|
||||
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(d.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{byInvestissement.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td colSpan={multiDetenteur ? 3 : 2}>Total — {byInvestissement.length} investissements</td>
|
||||
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.souscription, 0))}</td>
|
||||
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.distribution, 0))}</td>
|
||||
<td className="num">{fmtEUR(byInvestissement.reduce((s, d) => s + d.total, 0))}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET OPÉRATIONS — liste détaillée (CRUD) ══ */}
|
||||
{activeTab === 'operations' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Détail des opérations</h3>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Plateforme</th><th>Deal</th><th className="num">Montant</th>
|
||||
<th>Catégorie</th><th>Prélèvement</th><th>Compte</th><th>Notes</th>
|
||||
<th style={{ width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedFrais.length === 0 && (
|
||||
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
|
||||
</td></tr>
|
||||
)}
|
||||
{pagedFrais.map(f => (
|
||||
<tr key={f.id} style={{ cursor: 'pointer' }} title="Modifier ce frais" onClick={() => openEdit(f)}>
|
||||
<td>{fmtDate(f.date_operation)}</td>
|
||||
<td>{f.plateforme_nom}</td>
|
||||
<td>{f.nom_deal}</td>
|
||||
<td className="num">{fmtEUR(f.montant)}</td>
|
||||
<td>{FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}</td>
|
||||
<td style={{ fontSize: 'var(--fs-sm)' }}>{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.compte_nom || '—'}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.notes || '—'}</td>
|
||||
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
|
||||
title="Modifier"
|
||||
onClick={() => openEdit(f)}>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
page={page} setPage={setPage}
|
||||
pageSize={pageSize} setPageSize={setPageSize}
|
||||
totalPages={totalPages} totalItems={totalItems}
|
||||
PAGE_SIZES={PAGE_SIZES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FraisFormModal
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFieldChange={setField}
|
||||
err={err}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDelete)}
|
||||
submitting={submitting}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
investissementOptions={investissementOptions}
|
||||
confirmingDelete={confirmingDelete}
|
||||
setConfirmingDelete={setConfirmingDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||
import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
|
||||
const emptyForm = {
|
||||
investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '',
|
||||
@@ -110,6 +111,12 @@ export default function InvestissementDetail() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [err, setErr] = useState(null);
|
||||
// Gardes anti-double-soumission (cf. useAsyncAction) — une par modale indépendante, pour ne
|
||||
// désactiver que les boutons de la modale concernée pendant sa propre requête.
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const { run: runSubmitRemb, pending: submittingRemb } = useAsyncAction();
|
||||
const { run: runSubmitFrais, pending: submittingFrais } = useAsyncAction();
|
||||
const { run: runSubmitReinv, pending: submittingReinv } = useAsyncAction();
|
||||
// Mémorise la dernière souscription valide (utile pour isAutoCalc malgré les onChange "" intermédiaires)
|
||||
const lastValidSouscriptionRef = useRef('');
|
||||
|
||||
@@ -2534,9 +2541,10 @@ export default function InvestissementDetail() {
|
||||
title={revisionEditId ? 'Corriger la révision' : 'Réviser les conditions du prêt'}
|
||||
onClose={closeRevisionModal}
|
||||
width={520}
|
||||
busy={revisionSaving}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" onClick={closeRevisionModal}>Annuler</button>
|
||||
<button className="ghost" disabled={revisionSaving} onClick={closeRevisionModal}>Annuler</button>
|
||||
<button onClick={submitRevision} disabled={revisionSaving}>
|
||||
{revisionSaving ? 'Enregistrement…' : (revisionEditId ? 'Enregistrer la correction' : 'Enregistrer la révision')}
|
||||
</button>
|
||||
@@ -2634,8 +2642,9 @@ export default function InvestissementDetail() {
|
||||
open={modalOpen}
|
||||
editingId={id}
|
||||
onClose={close}
|
||||
onSubmit={submit}
|
||||
onDelete={onDelete}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDelete)}
|
||||
submitting={submitting}
|
||||
confirmingDelete={confirmingInvDelete}
|
||||
setConfirmingDelete={setConfirmingInvDelete}
|
||||
form={form}
|
||||
@@ -2717,8 +2726,9 @@ export default function InvestissementDetail() {
|
||||
open={rembModalOpen}
|
||||
editingId={editingRembId}
|
||||
onClose={closeRembModal}
|
||||
onSubmit={submitRemb}
|
||||
onDelete={deleteRemb}
|
||||
onSubmit={e => runSubmitRemb(submitRemb, e)}
|
||||
onDelete={() => runSubmitRemb(deleteRemb)}
|
||||
submitting={submittingRemb}
|
||||
confirmingDelete={confirmingRembDelete}
|
||||
setConfirmingDelete={setConfirmingRembDelete}
|
||||
err={rembErr}
|
||||
@@ -2738,27 +2748,30 @@ export default function InvestissementDetail() {
|
||||
title="Ajouter un réinvestissement"
|
||||
onClose={closeReinvModal}
|
||||
width={480}
|
||||
busy={submittingReinv}
|
||||
footer={
|
||||
reinvTab === 'manuel' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={closeReinvModal}>Annuler</button>
|
||||
<button className="primary" onClick={submitReinv}>Enregistrer</button>
|
||||
<button disabled={submittingReinv} onClick={closeReinvModal}>Annuler</button>
|
||||
<button className="primary" disabled={submittingReinv} onClick={() => runSubmitReinv(submitReinv)}>
|
||||
{submittingReinv ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={closeReinvModal}>Annuler</button>
|
||||
<button disabled={submittingReinv} onClick={closeReinvModal}>Annuler</button>
|
||||
{!autoReinvActive ? (
|
||||
<button className="primary" onClick={async () => {
|
||||
<button className="primary" disabled={submittingReinv} onClick={() => runSubmitReinv(async () => {
|
||||
await api.put(`/investissements/${id}/auto-reinvest`, { active: true });
|
||||
await load();
|
||||
closeReinvModal();
|
||||
}}>Activer</button>
|
||||
})}>{submittingReinv ? '…' : 'Activer'}</button>
|
||||
) : (
|
||||
<button className="danger" onClick={async () => {
|
||||
<button className="danger" disabled={submittingReinv} onClick={() => runSubmitReinv(async () => {
|
||||
await api.put(`/investissements/${id}/auto-reinvest`, { active: false });
|
||||
await load();
|
||||
closeReinvModal();
|
||||
}}>Désactiver</button>
|
||||
})}>{submittingReinv ? '…' : 'Désactiver'}</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -2781,7 +2794,7 @@ export default function InvestissementDetail() {
|
||||
</div>
|
||||
|
||||
{reinvTab === 'manuel' ? (
|
||||
<form onSubmit={submitReinv}>
|
||||
<form onSubmit={e => runSubmitReinv(submitReinv, e)}>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: 16, fontSize: 13 }}>
|
||||
Un réinvestissement ajoute du capital à ce prêt à une date donnée. La projection
|
||||
des intérêts sera recalculée à partir de cette date.
|
||||
@@ -2854,9 +2867,10 @@ export default function InvestissementDetail() {
|
||||
title="Modifier la date de la 1ère échéance"
|
||||
onClose={() => setEditDpeModal(false)}
|
||||
width={420}
|
||||
busy={editDpeSaving}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setEditDpeModal(false)}>Annuler</button>
|
||||
<button disabled={editDpeSaving} onClick={() => setEditDpeModal(false)}>Annuler</button>
|
||||
<button className="primary" onClick={saveEditDpe} disabled={editDpeSaving}>
|
||||
{editDpeSaving ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
@@ -2886,6 +2900,7 @@ export default function InvestissementDetail() {
|
||||
title="Traitement en masse des remboursements"
|
||||
onClose={() => { if (!bulkRembProcessing) setBulkRembModal(false); }}
|
||||
width={560}
|
||||
busy={bulkRembProcessing}
|
||||
footer={(() => {
|
||||
const selectedCount = bulkRembItems.filter(it => it.selected).length;
|
||||
return bulkRembDone ? (
|
||||
@@ -3279,7 +3294,8 @@ export default function InvestissementDetail() {
|
||||
form={fraisForm}
|
||||
onFieldChange={setFraisField}
|
||||
err={fraisErr}
|
||||
onSubmit={submitFrais}
|
||||
onSubmit={e => runSubmitFrais(submitFrais, e)}
|
||||
submitting={submittingFrais}
|
||||
comptesInvestisseur={comptesFraisInvestisseur}
|
||||
locked
|
||||
/>
|
||||
@@ -3297,9 +3313,10 @@ export default function InvestissementDetail() {
|
||||
title="Déclarer une perte définitive"
|
||||
onClose={closePerteModal}
|
||||
width={520}
|
||||
busy={perteSaving}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" onClick={closePerteModal}>Annuler</button>
|
||||
<button className="ghost" disabled={perteSaving} onClick={closePerteModal}>Annuler</button>
|
||||
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
|
||||
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
|
||||
</button>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
@@ -296,6 +297,7 @@ export default function Investissements() {
|
||||
const [form, setForm] = useState(empty);
|
||||
const [err, setErr] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
// Proposition de dépôt après création d'un investissement si le porte-monnaie ne
|
||||
@@ -1437,7 +1439,8 @@ export default function Investissements() {
|
||||
open={modalOpen}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
onSubmit={submit}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
submitting={submitting}
|
||||
form={form}
|
||||
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
|
||||
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
|
||||
|
||||
@@ -0,0 +1,938 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } 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 ValorisationPeChart from '../components/ValorisationPeChart.jsx';
|
||||
import DistributionChart from '../components/DistributionChart.jsx';
|
||||
import ValorisationMensuelleTable from '../components/ValorisationMensuelleTable.jsx';
|
||||
import InvestissementPeFormModal from '../components/InvestissementPeFormModal.jsx';
|
||||
import FraisFormModal from '../components/FraisFormModal.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
|
||||
const STATUT_LABELS = {
|
||||
en_attente: 'En attente',
|
||||
valide: 'Validé',
|
||||
cloture: 'Clôturé',
|
||||
perte_definitive: 'Perte définitive',
|
||||
};
|
||||
|
||||
export const emptyDealForm = {
|
||||
plateforme_id: '',
|
||||
investisseur_id: '',
|
||||
nom_deal: '',
|
||||
strategie: '',
|
||||
date_souscription: today(),
|
||||
duree_annees: '',
|
||||
montant_investi: '',
|
||||
objectif: '',
|
||||
statut: 'valide',
|
||||
reference: '',
|
||||
notes: '',
|
||||
// Étape "Frais" de la modale (chantier 17/09/26, demande Olivier — captures Fundora) : deux
|
||||
// lignes indépendantes journalisées comme deux entrées frais_operations distinctes. Frais
|
||||
// d'entrée → catégorie 'souscription' (unique par deal) ; Frais de gestion → catégorie
|
||||
// 'gestion', origine 'souscription' (distingue cette ligne d'un frais de gestion ajouté plus
|
||||
// tard depuis la page Frais PE ou la liste des investissements).
|
||||
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
|
||||
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
|
||||
// Id de la valorisation "initiale" auto-générée à la date de souscription pour refléter les
|
||||
// frais réglés "à la source" (cf. AUTO_VAL_NOTE / submit ci-dessous) — null si aucune n'existe
|
||||
// encore pour ce deal. Décision Olivier 17/09/26 : le montant investi (souscrit) ne bouge
|
||||
// JAMAIS, c'est la valorisation qui encaisse immédiatement les frais d'entrée/de gestion à la
|
||||
// date de souscription — jusqu'à ce qu'une vraie valorisation du fonds vienne la remplacer.
|
||||
valorisation_initiale_id: null,
|
||||
};
|
||||
|
||||
|
||||
// Marqueur de la valorisation "initiale" auto-générée par l'étape "Frais" de la modale (cf.
|
||||
// submit ci-dessous) — permet de la retrouver/remplacer sans toucher aux valorisations saisies
|
||||
// manuellement par ailleurs (modale "Valorisations" annexe).
|
||||
export const AUTO_VAL_NOTE = 'Valorisation initiale (frais de souscription)';
|
||||
|
||||
// Accès rapide "+ frais" depuis une ligne de la liste des investissements (chantier "frais PE",
|
||||
// 17/09/26, demande Olivier) — permet d'ajouter un frais complémentaire (gestion récurrente,
|
||||
// distribution, autre) sans passer par la page Frais PE. Catégorie par défaut 'gestion' :
|
||||
// 'souscription' reste exclusivement gérée par l'étape "Frais" de la modale d'investissement
|
||||
// (cf. FraisFormModal.jsx, qui exclut déjà cette catégorie de son sélecteur).
|
||||
const emptyFraisAddForm = { montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), categorie: 'gestion', notes: '' };
|
||||
|
||||
const fmtMultiple = (n) => (n != null ? `x${n.toFixed(2).replace('.', ',')}` : '—');
|
||||
|
||||
/* ── Indicateur de progression (copié de pages/Investissements.jsx —
|
||||
* pas exporté par un composant partagé) ── */
|
||||
function TrendBadge({ current, prev, invert = false }) {
|
||||
if (prev == null || Math.abs(prev) < 0.005) return null;
|
||||
const diff = current - prev;
|
||||
const pct = (diff / prev) * 100;
|
||||
const up = diff > 0;
|
||||
const neutral = diff === 0;
|
||||
const good = neutral ? null : (invert ? !up : up);
|
||||
const color = neutral ? 'var(--text-muted)' : good ? '#16a34a' : '#dc2626';
|
||||
const bg = neutral ? 'var(--surface-2)' : good ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
|
||||
const arrow = neutral ? '→' : up ? '↗' : '↘';
|
||||
const label = `${up ? '+' : ''}${Math.abs(pct) < 10 ? pct.toFixed(1) : Math.round(pct)}%`;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 8px', borderRadius: 20,
|
||||
background: bg, color, fontSize: '0.76em', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{arrow} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Investissements — Private Equity (17/09/26, cf.
|
||||
* project_workspaces_transformation.md). Sert tout workspace autre que
|
||||
* crowdlending (cf. InvestissementsRouter dans App.jsx, jamais lié en dur à
|
||||
* un slug précis), sur une table dédiée investissements_pe : un deal PE n'a
|
||||
* ni échéancier connu à l'avance ni taux d'intérêt fixe — c'est un montant
|
||||
* unique investi (le porte-monnaie diminue d'un coup, comme en crowdlending,
|
||||
* décision Olivier), valorisé périodiquement par le fonds. Les distributions
|
||||
* (retour de capital, plus-value) se saisissent depuis la page Remboursements
|
||||
* (types bonus_plateforme / intérêts_plateforme), avec un lien optionnel vers
|
||||
* le deal pour calculer son TVPI.
|
||||
*
|
||||
* Depuis le 17/09/26 (demande d'Olivier : "modifier la page investissements
|
||||
* de PE pour qu'elle ressemble à celle de crowdlending"), cette page reprend
|
||||
* le graphique cumulé, la Distribution par plateforme, 5 KPIs et les onglets
|
||||
* Plateformes / Vision mensuelle / Investissements de pages/Investissements.jsx
|
||||
* (crowdlending) — MAIS avec une sémantique adaptée au modèle PE (décisions
|
||||
* Olivier du 17/09/26, cf. les 4 questions posées avant l'implémentation) :
|
||||
* - Le graphique et la 1ère carte KPI n'affichent pas un "capital en cours"
|
||||
* (qui décroît avec les remboursements, notion sans équivalent en PE
|
||||
* puisque montant_investi n'est jamais décrémenté) mais la VALORISATION
|
||||
* TOTALE du portefeuille : pour chaque deal, sa dernière valeur connue
|
||||
* (montant_investi à la souscription, puis chaque valorisation saisie la
|
||||
* remplace), sommée sur tous les deals — cf. ValorisationPeChart.jsx.
|
||||
* - La 2e carte "Investissements à risque" devient "Pertes définitives"
|
||||
* (somme du montant investi des deals au statut perte_definitive) — PE n'a
|
||||
* pas de notion de retard/défaut comparable au crowdlending.
|
||||
* - Les cartes 4 et 5 ("Investissements remboursés" / "Intérêts perçus" en
|
||||
* crowdlending, deux notions distinctes) deviennent "Distributions
|
||||
* cumulées" (retour de cash du fonds, capital et plus-value confondus —
|
||||
* pas de séparation en PE) et "TVPI moyen" (multiple pondéré du
|
||||
* portefeuille : (valorisation + distributions) / capital investi).
|
||||
* - L'onglet "Vision mensuelle" devient un tableau de VALORISATION mensuelle
|
||||
* par plateforme (ValorisationMensuelleTable.jsx), et non plus un capital
|
||||
* en cours basé sur des remboursements qui n'existent pas pour ces deals.
|
||||
*
|
||||
* Approximation assumée pour les comparaisons N/N-1 (TrendBadge) sur les
|
||||
* cartes Distributions et TVPI : distributions_cumulees (renvoyé par
|
||||
* GET /investissements-pe) est une valeur globale, non bornée à une date —
|
||||
* la comparaison restreint seulement l'ENSEMBLE des deals pris en compte
|
||||
* (souscrits avant la coupure), pas le montant des distributions elles-
|
||||
* mêmes à cette date. Seule la valorisation (dont l'historique daté est
|
||||
* connu via GET /investissements-pe/valorisations) est bornée exactement à
|
||||
* la coupure. Même principe d'approximation que "Investissements à risque"
|
||||
* côté crowdlending, qui utilise déjà le statut ACTUEL plutôt qu'un statut
|
||||
* historique à la date de coupure.
|
||||
*/
|
||||
export default function InvestissementsPe() {
|
||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [valorisations, setValorisations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyDealForm);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
// Comptes du détenteur sélectionné dans le formulaire — nécessaires pour l'étape "Frais"
|
||||
// (option de règlement "compte courant du détenteur"), même mécanisme que Frais.jsx /
|
||||
// Investissements.jsx côté crowdlending (route déjà générique, indépendante du workspace).
|
||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||
|
||||
// Modale annexe "+ frais" (accès rapide depuis une ligne de la liste des investissements)
|
||||
const [fraisAddDeal, setFraisAddDeal] = useState(null);
|
||||
const [fraisAddForm, setFraisAddForm] = useState(emptyFraisAddForm);
|
||||
const [fraisAddErr, setFraisAddErr] = useState(null);
|
||||
const [fraisAddComptes, setFraisAddComptes] = useState([]);
|
||||
const { run: runFraisAddSubmit, pending: fraisAddSubmitting } = useAsyncAction();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
const [filter, setFilter] = useState({ statut: '', plateforme_id: '' });
|
||||
const [platYear, setPlatYear] = useState('');
|
||||
const [listFocused, setListFocused] = useState(false);
|
||||
|
||||
// Valorisations : petite modale annexe, ouverte depuis une ligne du tableau.
|
||||
|
||||
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
|
||||
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces),
|
||||
// même patron que DepotsRetraitsSimple / RemboursementsSimple.
|
||||
const plats = useMemo(
|
||||
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||
[allPlats, activeWorkspace],
|
||||
);
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [inv, pl, val] = await Promise.all([
|
||||
api.get('/investissements-pe'),
|
||||
api.get('/plateformes'),
|
||||
api.get('/investissements-pe/valorisations'),
|
||||
]);
|
||||
setRows(inv);
|
||||
setAllPlats(pl);
|
||||
setValorisations(val);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (investisseurId) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [investisseurId, activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
|
||||
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
|
||||
.then(setComptesInvestisseur)
|
||||
.catch(() => setComptesInvestisseur([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form.investisseur_id, modalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fraisAddDeal?.investisseur_id) { setFraisAddComptes([]); return; }
|
||||
api.get(`/investissements/comptes-par-investisseur/${fraisAddDeal.investisseur_id}`)
|
||||
.then(setFraisAddComptes)
|
||||
.catch(() => setFraisAddComptes([]));
|
||||
}, [fraisAddDeal?.investisseur_id]);
|
||||
|
||||
const openFraisAdd = (deal) => {
|
||||
setFraisAddDeal(deal);
|
||||
setFraisAddForm({ ...emptyFraisAddForm, date_operation: today() });
|
||||
setFraisAddErr(null);
|
||||
};
|
||||
const closeFraisAdd = () => { setFraisAddDeal(null); setFraisAddErr(null); };
|
||||
const submitFraisAdd = async (e) => {
|
||||
e?.preventDefault?.(); setFraisAddErr(null);
|
||||
try {
|
||||
await api.post('/frais-operations', {
|
||||
investissement_pe_id: fraisAddDeal.id,
|
||||
montant: Number(fraisAddForm.montant),
|
||||
mode_reglement: fraisAddForm.mode_reglement,
|
||||
compte_id: fraisAddForm.mode_reglement === 'compte_courant' && fraisAddForm.compte_id ? Number(fraisAddForm.compte_id) : null,
|
||||
date_operation: fraisAddForm.date_operation,
|
||||
origine: 'manuel',
|
||||
categorie: fraisAddForm.categorie,
|
||||
notes: fraisAddForm.notes || undefined,
|
||||
});
|
||||
closeFraisAdd();
|
||||
} catch (e) { setFraisAddErr(e.message); }
|
||||
};
|
||||
|
||||
const investisseurForPlat = (platId) => {
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
const firstId = plats[0]?.id || '';
|
||||
setForm({ ...emptyDealForm, plateforme_id: firstId, investisseur_id: investisseurForPlat(firstId) });
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Ouverture auto depuis le bouton global "Ajout Investissement" (topbar/burger,
|
||||
// cf. Layout.jsx — même ?new=1 que les pages Dépôts/Retraits et Remboursements).
|
||||
const pendingNew = useRef(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('new') !== '1') return;
|
||||
navigate('/investissements', { replace: true });
|
||||
if (plats.length > 0) {
|
||||
openAdd();
|
||||
} else {
|
||||
pendingNew.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNew.current && plats.length > 0) {
|
||||
pendingNew.current = false;
|
||||
openAdd();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plats.length]);
|
||||
|
||||
const openEdit = async (r) => {
|
||||
setEditingId(r.id);
|
||||
setForm({
|
||||
plateforme_id: String(r.plateforme_id || ''),
|
||||
investisseur_id: String(r.investisseur_id || ''),
|
||||
nom_deal: r.nom_deal,
|
||||
strategie: r.strategie || '',
|
||||
date_souscription: r.date_souscription,
|
||||
duree_annees: r.duree_mois ? String(Math.round((r.duree_mois / 12) * 10) / 10) : '',
|
||||
montant_investi: String(r.montant_investi ?? ''),
|
||||
objectif: r.objectif || '',
|
||||
statut: r.statut,
|
||||
reference: r.reference || '',
|
||||
notes: r.notes || '',
|
||||
frais_entree_mode_saisie: 'montant', frais_entree_valeur: '', frais_entree_mode_reglement: 'source', frais_entree_compte_id: '', frais_entree_id: null,
|
||||
frais_gestion_mode_saisie: 'montant', frais_gestion_valeur: '', frais_gestion_mode_reglement: 'source', frais_gestion_compte_id: '', frais_gestion_id: null,
|
||||
});
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
|
||||
// Pré-remplissage best-effort des frais déjà journalisés + de la valorisation "initiale"
|
||||
// auto-générée (cf. AUTO_VAL_NOTE/submit) pour ce deal — n'empêche jamais l'ouverture de la
|
||||
// modale si l'appel échoue.
|
||||
try {
|
||||
const [fraisOps, valRows] = await Promise.all([
|
||||
api.get('/frais-operations', { investissement_pe_id: r.id }),
|
||||
api.get(`/investissements-pe/${r.id}/valorisations`),
|
||||
]);
|
||||
const entree = fraisOps.find(f => f.categorie === 'souscription');
|
||||
const gestion = fraisOps.find(f => f.categorie === 'gestion' && f.origine === 'souscription');
|
||||
const autoVal = valRows.find(v => v.notes === AUTO_VAL_NOTE);
|
||||
setForm(f => ({
|
||||
...f,
|
||||
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,
|
||||
} : {}),
|
||||
}));
|
||||
} catch { /* non bloquant — rattrapable depuis la page Frais PE / la modale Valorisations */ }
|
||||
};
|
||||
|
||||
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyDealForm); setErr(null); };
|
||||
|
||||
// Calcule le TTC d'une ligne de frais depuis son mode de saisie (montant fixe ou % du
|
||||
// montant investi) — même formule que Investissements.jsx (crowdlending).
|
||||
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;
|
||||
};
|
||||
|
||||
// Upsert best-effort d'une ligne frais_operations (PUT si déjà chargée via *_id, POST sinon ;
|
||||
// DELETE si le montant a été vidé alors qu'une entrée existait) — n'empêche jamais
|
||||
// l'enregistrement principal du deal, rattrapable a posteriori depuis la page Frais PE.
|
||||
const upsertFrais = async (dealId, has, ttc, categorie, modeReglement, compteId, fraisId) => {
|
||||
try {
|
||||
if (has) {
|
||||
const fraisPayload = {
|
||||
investissement_pe_id: dealId,
|
||||
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}`, fraisPayload);
|
||||
else await api.post('/frais-operations', fraisPayload);
|
||||
} else if (fraisId) {
|
||||
await api.del(`/frais-operations/${fraisId}`);
|
||||
}
|
||||
} catch { /* non bloquant */ }
|
||||
};
|
||||
|
||||
// Gère la valorisation "initiale" auto-générée à la date de souscription (cf. AUTO_VAL_NOTE) :
|
||||
// le montant investi (souscrit) ne bouge jamais, c'est cette valorisation qui encaisse
|
||||
// immédiatement les frais réglés "à la source" — décision Olivier 17/09/26. Comme l'API ne
|
||||
// propose pas de PUT sur une valorisation, on supprime l'éventuelle ligne existante puis on
|
||||
// recrée à neuf (couvre aussi le cas où la date de souscription a changé). Best-effort, jamais
|
||||
// bloquant pour l'enregistrement principal du deal.
|
||||
const syncValorisationInitiale = async (dealId, totalFraisSource, valorisationInitialeId) => {
|
||||
try {
|
||||
if (valorisationInitialeId) {
|
||||
await api.del(`/investissements-pe/${dealId}/valorisations/${valorisationInitialeId}`);
|
||||
}
|
||||
if (totalFraisSource > 0) {
|
||||
const initiale = Math.max(0, Math.round((Number(form.montant_investi) - totalFraisSource) * 100) / 100);
|
||||
await api.post(`/investissements-pe/${dealId}/valorisations`, {
|
||||
date_valorisation: form.date_souscription,
|
||||
valorisation_nette: initiale,
|
||||
notes: AUTO_VAL_NOTE,
|
||||
});
|
||||
}
|
||||
} catch { /* non bloquant — rattrapable depuis la modale Valorisations */ }
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault?.(); setErr(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;
|
||||
|
||||
// Total des frais réglés "à la source" (aucun mouvement de trésorerie réel) — c'est ce
|
||||
// montant qui vient réduire la VALORISATION à la date de souscription (cf.
|
||||
// syncValorisationInitiale), jamais le montant investi lui-même (décision Olivier 17/09/26).
|
||||
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 {
|
||||
let dealId = editingId;
|
||||
if (editingId) {
|
||||
await api.put(`/investissements-pe/${editingId}`, payload);
|
||||
} else {
|
||||
const created = await api.post('/investissements-pe', payload);
|
||||
dealId = created.id;
|
||||
}
|
||||
await upsertFrais(dealId, hasFraisEntree, fraisEntreeTTC, 'souscription', form.frais_entree_mode_reglement, form.frais_entree_compte_id, form.frais_entree_id);
|
||||
await upsertFrais(dealId, hasFraisGestion, fraisGestionTTC, 'gestion', form.frais_gestion_mode_reglement, form.frais_gestion_compte_id, form.frais_gestion_id);
|
||||
await syncValorisationInitiale(dealId, totalFraisSource, form.valorisation_initiale_id);
|
||||
close();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api.del(`/investissements-pe/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...rows].sort((a, b) => b.date_souscription.localeCompare(a.date_souscription) || b.id - a.id),
|
||||
[rows],
|
||||
);
|
||||
|
||||
/* ── Années disponibles (sélecteur Plateformes) ── */
|
||||
const years = useMemo(
|
||||
() => [...new Set(rows.map(r => r.date_souscription?.slice(0, 4)).filter(Boolean))].sort().reverse(),
|
||||
[rows],
|
||||
);
|
||||
|
||||
/* ── Historique de valorisations trié, par deal (pour bornage à une
|
||||
* coupure — TrendBadge N/N-1 et graphique) ── */
|
||||
const valorisationsByDeal = useMemo(() => {
|
||||
const map = {};
|
||||
for (const v of valorisations) {
|
||||
const d = v.date_valorisation?.slice(0, 10);
|
||||
if (!d || v.valorisation_nette == null) continue;
|
||||
if (!map[v.investissement_pe_id]) map[v.investissement_pe_id] = [];
|
||||
map[v.investissement_pe_id].push({ date: d, value: v.valorisation_nette });
|
||||
}
|
||||
for (const k in map) map[k].sort((a, b) => a.date.localeCompare(b.date));
|
||||
return map;
|
||||
}, [valorisations]);
|
||||
|
||||
/* Valorisation d'un deal à une coupure donnée (ou dernière connue si
|
||||
* cutoff est null) — jamais < montant_investi tant qu'aucune valorisation
|
||||
* n'a été saisie. */
|
||||
const valorisationAtCutoff = (deal, cutoff) => {
|
||||
if (!cutoff) return deal.derniere_valorisation ?? deal.montant_investi;
|
||||
let val = deal.montant_investi;
|
||||
for (const v of (valorisationsByDeal[deal.id] || [])) {
|
||||
if (v.date <= cutoff) val = v.value; else break;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
/* ── Totaux KPI (plateforme + année sélectionnées) ── */
|
||||
const kpiRows = useMemo(() => rows.filter(r => {
|
||||
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
|
||||
if (platYear && r.date_souscription > `${platYear}-12-31`) return false;
|
||||
return true;
|
||||
}), [rows, filter.plateforme_id, platYear]);
|
||||
|
||||
const cutoff = platYear ? `${platYear}-12-31` : null;
|
||||
|
||||
const totals = useMemo(() => kpiRows.reduce((acc, r) => {
|
||||
acc.investi += r.montant_investi;
|
||||
acc.valorisation += valorisationAtCutoff(r, cutoff);
|
||||
acc.distributions += r.distributions_cumulees || 0;
|
||||
if (r.statut === 'perte_definitive') acc.pertes += r.montant_investi;
|
||||
return acc;
|
||||
}, { investi: 0, valorisation: 0, distributions: 0, pertes: 0 }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[kpiRows, cutoff, valorisationsByDeal]);
|
||||
totals.tvpi = totals.investi > 0 ? (totals.valorisation + totals.distributions) / totals.investi : null;
|
||||
|
||||
const prevYear = platYear ? String(Number(platYear) - 1) : null;
|
||||
const prevCutoff = prevYear ? `${prevYear}-12-31` : null;
|
||||
|
||||
const prevRows = useMemo(() => {
|
||||
if (!platYear) return [];
|
||||
return rows.filter(r => {
|
||||
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
|
||||
return r.date_souscription <= prevCutoff;
|
||||
});
|
||||
}, [rows, filter.plateforme_id, platYear, prevCutoff]);
|
||||
|
||||
const prevTotals = useMemo(() => prevRows.reduce((acc, r) => {
|
||||
acc.investi += r.montant_investi;
|
||||
acc.valorisation += valorisationAtCutoff(r, prevCutoff);
|
||||
acc.distributions += r.distributions_cumulees || 0;
|
||||
if (r.statut === 'perte_definitive') acc.pertes += r.montant_investi;
|
||||
return acc;
|
||||
}, { investi: 0, valorisation: 0, distributions: 0, pertes: 0 }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[prevRows, prevCutoff, valorisationsByDeal]);
|
||||
prevTotals.tvpi = prevTotals.investi > 0 ? (prevTotals.valorisation + prevTotals.distributions) / prevTotals.investi : null;
|
||||
|
||||
/* ── Données graphique / Distribution : filtrées par plateforme + année,
|
||||
* sans le filtre statut (comme Investissements.jsx côté crowdlending) ── */
|
||||
const chartRows = useMemo(() => rows.filter(r => {
|
||||
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
|
||||
if (platYear && r.date_souscription > `${platYear}-12-31`) return false;
|
||||
return true;
|
||||
}), [rows, filter.plateforme_id, platYear]);
|
||||
|
||||
const distRows = useMemo(() => chartRows
|
||||
.map(r => ({ plateforme_nom: r.plateforme_nom, montant: valorisationAtCutoff(r, cutoff) }))
|
||||
.filter(r => r.montant > 0),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chartRows, cutoff, valorisationsByDeal]);
|
||||
|
||||
/* ── Onglet Plateformes : indépendant du filtre plateforme (comme
|
||||
* Investissements.jsx), seulement borné par l'année sélectionnée ── */
|
||||
const yearMembers = useMemo(() => rows.filter(r => !platYear || r.date_souscription <= `${platYear}-12-31`), [rows, platYear]);
|
||||
|
||||
const platData = useMemo(() => {
|
||||
const totalInvesti = yearMembers.reduce((s, r) => s + r.montant_investi, 0);
|
||||
const byPlat = {};
|
||||
for (const r of yearMembers) {
|
||||
if (!byPlat[r.plateforme_id]) {
|
||||
byPlat[r.plateforme_id] = {
|
||||
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
|
||||
detenteur_nom: r.investisseur_nom || null,
|
||||
count: 0, investi: 0, valorisation: 0, distributions: 0, pertes: 0,
|
||||
};
|
||||
}
|
||||
const p = byPlat[r.plateforme_id];
|
||||
p.count++;
|
||||
p.investi += r.montant_investi;
|
||||
p.valorisation += valorisationAtCutoff(r, cutoff);
|
||||
p.distributions += r.distributions_cumulees || 0;
|
||||
if (r.statut === 'perte_definitive') p.pertes += r.montant_investi;
|
||||
}
|
||||
return Object.values(byPlat)
|
||||
.sort((a, b) => b.investi - a.investi)
|
||||
.map(p => ({
|
||||
...p,
|
||||
tvpi: p.investi > 0 ? (p.valorisation + p.distributions) / p.investi : null,
|
||||
poids: totalInvesti > 0 ? (p.investi / totalInvesti) * 100 : 0,
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [yearMembers, cutoff, valorisationsByDeal]);
|
||||
|
||||
const platTotals = useMemo(() => platData.reduce((acc, p) => {
|
||||
acc.count += p.count; acc.investi += p.investi;
|
||||
acc.valorisation += p.valorisation; acc.distributions += p.distributions;
|
||||
return acc;
|
||||
}, { count: 0, investi: 0, valorisation: 0, distributions: 0 }), [platData]);
|
||||
platTotals.tvpi = platTotals.investi > 0 ? (platTotals.valorisation + platTotals.distributions) / platTotals.investi : null;
|
||||
|
||||
/* ── Onglet Investissements : liste des deals, filtrée plateforme + statut ── */
|
||||
const filteredRows = useMemo(() => sorted.filter(r => {
|
||||
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
|
||||
if (filter.statut && r.statut !== filter.statut) return false;
|
||||
return true;
|
||||
}), [sorted, filter]);
|
||||
|
||||
const hasFilter = !!(filter.statut || filter.plateforme_id);
|
||||
const clearFilter = () => setFilter({ statut: '', plateforme_id: '' });
|
||||
|
||||
if (!loading && plats.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="investissement" />Investissements</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 pour pouvoir y enregistrer des investissements.`}
|
||||
cta="Configurer mes plateformes"
|
||||
to="/settings?section=plateformes&openAdd=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!loading && !modalOpen && sorted.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="investissement" />Investissements</h2></div>
|
||||
<EmptyState
|
||||
icon="📈"
|
||||
title="Aucun investissement enregistré"
|
||||
message="Vos plateformes sont configurées, mais aucun investissement n'a encore été enregistré."
|
||||
cta="Ajouter un investissement"
|
||||
to="/investissements?new=1"
|
||||
/>
|
||||
<InvestissementPeFormModal
|
||||
open={modalOpen}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
plats={plats}
|
||||
investisseurs={investisseurs}
|
||||
multiDetenteur={multiDetenteur}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
err={err}
|
||||
submitting={submitting}
|
||||
onSubmit={() => runSubmit(submit)}
|
||||
onDelete={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}
|
||||
investisseurForPlat={investisseurForPlat}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="investissement" />Investissements</h2>
|
||||
{hasFilter && (
|
||||
<button className="dr-clear-btn" onClick={clearFilter}>✕ Effacer les filtres</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
|
||||
|
||||
{/* ── Graphiques ── */}
|
||||
{!listFocused && <div className="charts-row">
|
||||
<ValorisationPeChart rows={chartRows} valorisations={valorisations} platYear={platYear} />
|
||||
<DistributionChart rows={distRows} />
|
||||
</div>}
|
||||
|
||||
{/* ── KPIs ── */}
|
||||
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
|
||||
{/* 1 — Valorisation totale */}
|
||||
<div className="kpi">
|
||||
<div className="label">Valorisation totale</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.valorisation)}</span>
|
||||
{platYear && <TrendBadge current={totals.valorisation} prev={prevTotals.valorisation} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.valorisation)} en {prevYear}</div>}
|
||||
</div>
|
||||
|
||||
{/* 2 — Pertes définitives */}
|
||||
<div
|
||||
className={`kpi${totals.pertes > 0 ? ' dr-kpi-clickable' : ''}${filter.statut === 'perte_definitive' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
|
||||
onClick={() => {
|
||||
if (totals.pertes === 0) return;
|
||||
setFilter(f => ({ ...f, statut: f.statut === 'perte_definitive' ? '' : 'perte_definitive' }));
|
||||
setActiveTab('investissements');
|
||||
}}
|
||||
title={totals.pertes > 0 ? 'Filtrer les deals en perte définitive' : undefined}
|
||||
style={totals.pertes > 0 ? { cursor: 'pointer' } : {}}
|
||||
>
|
||||
<div className="label">Pertes définitives</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.pertes > 0 ? 'danger' : ''}>{fmtEUR(totals.pertes)}</span>
|
||||
{platYear && totals.pertes > 0 && <TrendBadge current={totals.pertes} prev={prevTotals.pertes} invert={true} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.pertes)} en {prevYear}</div>}
|
||||
</div>
|
||||
|
||||
{/* 3 — Investissements depuis le début */}
|
||||
<div
|
||||
className={`kpi${hasFilter ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
|
||||
onClick={() => hasFilter && clearFilter()}
|
||||
title={hasFilter ? 'Effacer les filtres' : undefined}
|
||||
style={hasFilter ? { cursor: 'pointer' } : {}}
|
||||
>
|
||||
<div className="label">Investissements depuis le début</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.investi)}</span>
|
||||
{platYear && <TrendBadge current={totals.investi} prev={prevTotals.investi} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.investi)} en {prevYear}</div>}
|
||||
</div>
|
||||
|
||||
{/* 4 — Distributions cumulées */}
|
||||
<div className="kpi">
|
||||
<div className="label">Distributions cumulées</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.distributions)}</span>
|
||||
{platYear && <TrendBadge current={totals.distributions} prev={prevTotals.distributions} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.distributions)} en {prevYear}</div>}
|
||||
</div>
|
||||
|
||||
{/* 5 — TVPI moyen */}
|
||||
<div className="kpi" title="Multiple pondéré du portefeuille : (valorisation + distributions) / capital investi">
|
||||
<div className="label">TVPI moyen</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtMultiple(totals.tvpi)}</span>
|
||||
{platYear && totals.tvpi != null && <TrendBadge current={totals.tvpi} prev={prevTotals.tvpi} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtMultiple(prevTotals.tvpi)} en {prevYear}</div>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ── Onglets ── */}
|
||||
{!listFocused && <div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('investissements')}>Investissements</button>
|
||||
</div>}
|
||||
|
||||
{/* ====== ONGLET PLATEFORMES ====== */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Répartition par plateforme
|
||||
{platYear && <span style={{ marginLeft: 8 }}>pour l'année {platYear}</span>}
|
||||
</h3>
|
||||
<select value={platYear} onChange={e => setPlatYear(e.target.value)}
|
||||
style={{
|
||||
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
|
||||
borderRadius: 6, border: '1px solid var(--border)',
|
||||
background: 'var(--surface-2)', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', outline: 'none',
|
||||
}}>
|
||||
<option value="">Toutes les années</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!rows.length && loading ? (
|
||||
<div className="text-muted" style={{ padding: '12px 0' }}>Chargement…</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Deals</th>
|
||||
<th className="num">Montant investi</th>
|
||||
<th className="num">Valorisation actuelle</th>
|
||||
<th className="num">Distributions</th>
|
||||
<th className="num">TVPI</th>
|
||||
<th className="num">Poids</th>
|
||||
<th style={{ width: 28 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{platData.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
Aucun investissement
|
||||
</td></tr>
|
||||
)}
|
||||
{platData.map(p => {
|
||||
const isActive = String(filter.plateforme_id) === String(p.plateforme_id);
|
||||
return (
|
||||
<tr key={p.plateforme_id}
|
||||
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setFilter(f => ({ ...f, plateforme_id: isActive ? '' : String(p.plateforme_id) }));
|
||||
setActiveTab('investissements');
|
||||
}}>
|
||||
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{p.count}</td>
|
||||
<td className="num">{fmtEUR(p.investi)}</td>
|
||||
<td className="num">{fmtEUR(p.valorisation)}</td>
|
||||
<td className="num">{fmtEUR(p.distributions)}</td>
|
||||
<td className="num">{fmtMultiple(p.tvpi)}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, p.poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{p.poids.toFixed(1)} %</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isActive ? '✕' : ''}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{platData.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {platData.length} plateformes</td>
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num">{platTotals.count}</td>
|
||||
<td className="num">{fmtEUR(platTotals.investi)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.valorisation)}</td>
|
||||
<td className="num">{fmtEUR(platTotals.distributions)}</td>
|
||||
<td className="num">{fmtMultiple(platTotals.tvpi)}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET VISION MENSUELLE ====== */}
|
||||
{activeTab === 'vision-mensuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<ValorisationMensuelleTable rows={rows} valorisations={valorisations} plats={plats} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET INVESTISSEMENTS ====== */}
|
||||
{activeTab === 'investissements' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Liste des investissements
|
||||
{filteredRows.length !== sorted.length && (
|
||||
<span style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', fontWeight: 400, color: 'var(--text-muted)' }}>
|
||||
{filteredRows.length} / {sorted.length}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button className="primary" onClick={openAdd}>+ Nouvel investissement</button>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 130 }}>
|
||||
<label>Statut</label>
|
||||
<select value={filter.statut} onChange={e => setFilter(f => ({ ...f, statut: e.target.value }))}>
|
||||
<option value="">Tous</option>
|
||||
<option value="en_attente">En attente</option>
|
||||
<option value="valide">Validé</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 130 }}>
|
||||
<label>Plateforme</label>
|
||||
<select value={filter.plateforme_id} onChange={e => setFilter(f => ({ ...f, plateforme_id: e.target.value }))}>
|
||||
<option value="">Toutes</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Deal</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
|
||||
<th>Stratégie</th><th>Date</th><th className="num">Montant investi</th>
|
||||
<th className="num">Dernière valorisation</th><th className="num">TVPI</th>
|
||||
<th>Objectif</th><th>Statut</th><th style={{ width: 70 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 11 : 10} className="text-muted" style={{ textAlign: 'center' }}>Aucun investissement</td></tr>
|
||||
)}
|
||||
{filteredRows.map(r => (
|
||||
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => navigate(`/investissements/${r.id}`)}>
|
||||
<td>{r.nom_deal}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted">{r.investisseur_nom || '—'}</td>}
|
||||
<td className="text-muted">{r.strategie || '—'}</td>
|
||||
<td>{fmtDate(r.date_souscription)}</td>
|
||||
<td className="num">{fmtEUR(r.montant_investi)}</td>
|
||||
<td className="num">{r.derniere_valorisation != null ? fmtEUR(r.derniere_valorisation) : '—'}</td>
|
||||
<td className="num">{fmtMultiple(r.tvpi)}</td>
|
||||
<td className="text-muted">{r.objectif || '—'}</td>
|
||||
<td className="text-muted">{STATUT_LABELS[r.statut] || r.statut}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button className="icon-btn" title="Ajouter un frais" onClick={() => openFraisAdd(r)}>€</button>
|
||||
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InvestissementPeFormModal
|
||||
open={modalOpen}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
plats={plats}
|
||||
investisseurs={investisseurs}
|
||||
multiDetenteur={multiDetenteur}
|
||||
comptesInvestisseur={comptesInvestisseur}
|
||||
err={err}
|
||||
submitting={submitting}
|
||||
onSubmit={() => runSubmit(submit)}
|
||||
onDelete={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}
|
||||
investisseurForPlat={investisseurForPlat}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
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={onDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{/* ── Modale annexe : "+ frais" rapide depuis une ligne de la liste ── */}
|
||||
<FraisFormModal
|
||||
open={!!fraisAddDeal}
|
||||
onClose={closeFraisAdd}
|
||||
editingId={null}
|
||||
form={fraisAddForm}
|
||||
onFieldChange={(k, v) => setFraisAddForm(f => ({ ...f, [k]: v }))}
|
||||
err={fraisAddErr}
|
||||
onSubmit={e => runFraisAddSubmit(submitFraisAdd, e)}
|
||||
onDelete={null}
|
||||
comptesInvestisseur={fraisAddComptes}
|
||||
locked
|
||||
investissementOptions={[]}
|
||||
confirmingDelete={false}
|
||||
setConfirmingDelete={() => {}}
|
||||
submitting={fraisAddSubmitting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
|
||||
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
@@ -435,6 +436,7 @@ export default function Plateformes() {
|
||||
const [rembInv, setRembInv] = useState(null); // investissement lié au remb en cours d'édition
|
||||
const [rembForm, setRembForm] = useState({});
|
||||
const [rembErr, setRembErr] = useState(null);
|
||||
const { run: runSubmitRemb, pending: submittingRemb } = useAsyncAction();
|
||||
const [rembConfirmingDelete, setRembConfirmingDelete] = useState(false);
|
||||
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
|
||||
|
||||
@@ -1082,6 +1084,7 @@ export default function Plateformes() {
|
||||
const [mvEditingId, setMvEditingId] = useState(null);
|
||||
const [mvForm, setMvForm] = useState(emptyMouvementForm);
|
||||
const [mvErr, setMvErr] = useState(null);
|
||||
const { run: runMvSubmit, pending: mvSubmitting } = useAsyncAction();
|
||||
const [mvSelectedRow, setMvSelectedRow] = useState(null);
|
||||
const [mvOpenMenu, setMvOpenMenu] = useState(null); // { row, x, y }
|
||||
const [mvDeleteConfirm, setMvDeleteConfirm] = useState(null);
|
||||
@@ -3100,20 +3103,23 @@ export default function Plateformes() {
|
||||
open={mvModalOpen}
|
||||
title="Modifier le mouvement"
|
||||
onClose={mvCloseEdit}
|
||||
busy={mvSubmitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="danger" onClick={mvOnDelete}>Supprimer</button>
|
||||
<button onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
|
||||
<button className="danger" disabled={mvSubmitting} onClick={mvOnDelete}>Supprimer</button>
|
||||
<button disabled={mvSubmitting} onClick={() => mvOpenCorrection(null)}>Correction de solde</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={mvCloseEdit}>Annuler</button>
|
||||
<button className="primary" onClick={mvSubmit}>Enregistrer</button>
|
||||
<button disabled={mvSubmitting} onClick={mvCloseEdit}>Annuler</button>
|
||||
<button className="primary" disabled={mvSubmitting} onClick={() => runMvSubmit(mvSubmit)}>
|
||||
{mvSubmitting ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={mvSubmit}>
|
||||
<form onSubmit={e => runMvSubmit(mvSubmit, e)}>
|
||||
{mvErr && <div className="error">{mvErr}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
@@ -3260,8 +3266,9 @@ export default function Plateformes() {
|
||||
open={rembModalOpen}
|
||||
editingId={rembEditingId}
|
||||
onClose={closeRembModal}
|
||||
onSubmit={submitRemb}
|
||||
onDelete={onDeleteRemb}
|
||||
onSubmit={e => runSubmitRemb(submitRemb, e)}
|
||||
onDelete={() => runSubmitRemb(onDeleteRemb)}
|
||||
submitting={submittingRemb}
|
||||
confirmingDelete={rembConfirmingDelete}
|
||||
setConfirmingDelete={setRembConfirmingDelete}
|
||||
err={rembErr}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
@@ -245,6 +246,7 @@ export default function Remboursements() {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(empty);
|
||||
const [err, setErr] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [corrDeleteConfirm, setCorrDeleteConfirm] = useState(null);
|
||||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||||
@@ -1543,8 +1545,9 @@ export default function Remboursements() {
|
||||
open={modalOpen}
|
||||
editingId={editingId}
|
||||
onClose={close}
|
||||
onSubmit={submit}
|
||||
onDelete={onDelete}
|
||||
onSubmit={e => runSubmit(submit, e)}
|
||||
onDelete={() => runSubmit(onDelete)}
|
||||
submitting={submitting}
|
||||
confirmingDelete={confirmingDelete}
|
||||
setConfirmingDelete={setConfirmingDelete}
|
||||
err={err}
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } 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 { usePagination } from '../hooks/usePagination.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
import InteretsChart from '../components/InteretsChart.jsx';
|
||||
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
bonus_plateforme: 'Bonus plateforme',
|
||||
bonus_parrainage: 'Bonus parrainage',
|
||||
interets_plateforme: 'Intérêts plateforme',
|
||||
};
|
||||
|
||||
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc'];
|
||||
|
||||
/* ── Export (Excel/CSV/JSON) — même patron que Remboursements.jsx (crowdlending,
|
||||
* chantier "remboursements PE", 17/09/26, à la demande d'Olivier), colonnes
|
||||
* adaptées au modèle PE (pas de capital, lien vers un deal optionnel). ── */
|
||||
function dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function rembPeRow(r, dealNameById) {
|
||||
return {
|
||||
date_remb: r.date_remb, plateforme_nom: r.plateforme_nom || '',
|
||||
detenteur_nom: r.plateforme_detenteur_nom || '',
|
||||
type: TYPE_LABELS[r.type] || r.type,
|
||||
deal: r.investissement_pe_id ? (dealNameById[r.investissement_pe_id] || '') : '',
|
||||
cashback: r.cashback || 0, interets_bruts: r.interets_bruts || 0, frais_ttc: r.frais_ttc || 0,
|
||||
prelev_sociaux: r.prelev_sociaux || 0, prelev_forfaitaire: r.prelev_forfaitaire || 0,
|
||||
interets_nets: r.interets_nets || 0, net_recu: r.net_recu || 0,
|
||||
statut: r.statut,
|
||||
};
|
||||
}
|
||||
|
||||
function rembPeToCSV(rows, dealNameById) {
|
||||
const BOM = '\ufeff'; const sep = ';';
|
||||
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
const headers = ['Date','Plateforme','Détenteur','Type','Investissement','Cashback (€)','Intérêts bruts (€)','Frais TTC (€)','Prélèv. sociaux (€)','Prélèvement forfaitaire (€)','Intérêts nets (€)','Net reçu (€)','Statut'];
|
||||
const data = rows.map(r => {
|
||||
const d = rembPeRow(r, dealNameById);
|
||||
return [
|
||||
d.date_remb, d.plateforme_nom, d.detenteur_nom, d.type, d.deal,
|
||||
String(d.cashback).replace('.', ','), String(d.interets_bruts).replace('.', ','), String(d.frais_ttc).replace('.', ','),
|
||||
String(d.prelev_sociaux).replace('.', ','), String(d.prelev_forfaitaire).replace('.', ','),
|
||||
String(d.interets_nets).replace('.', ','), String(d.net_recu).replace('.', ','), d.statut,
|
||||
];
|
||||
});
|
||||
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
|
||||
}
|
||||
|
||||
function rembPeToXLS(rows, dealNameById) {
|
||||
const data = rows.map(r => {
|
||||
const d = rembPeRow(r, dealNameById);
|
||||
return {
|
||||
'Date': d.date_remb, 'Plateforme': d.plateforme_nom, 'Détenteur': d.detenteur_nom, 'Type': d.type,
|
||||
'Investissement': d.deal, 'Cashback (€)': d.cashback, 'Intérêts bruts (€)': d.interets_bruts,
|
||||
'Frais TTC (€)': d.frais_ttc, 'Prélèv. sociaux (€)': d.prelev_sociaux, 'Prélèvement forfaitaire (€)': d.prelev_forfaitaire,
|
||||
'Intérêts nets (€)': d.interets_nets, 'Net reçu (€)': d.net_recu, 'Statut': d.statut,
|
||||
};
|
||||
});
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Remboursements');
|
||||
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
function rembPeToJSON(rows, dealNameById) {
|
||||
return JSON.stringify(rows.map(r => rembPeRow(r, dealNameById)), null, 2);
|
||||
}
|
||||
|
||||
/* ── Menu déroulant d'export (CSV/Excel/JSON) — identique au composant de
|
||||
* Remboursements.jsx : rien ici n'est spécifique crowdlending. ── */
|
||||
function ExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
|
||||
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 choose = fn => { setOpen(false); fn(); };
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button type="button" className="icon-btn" disabled={disabled} onClick={() => setOpen(o => !o)} title="Exporter">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="export-dropdown" role="menu">
|
||||
<button role="menuitem" onClick={() => choose(onCSV)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
|
||||
</svg>
|
||||
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onXLS)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
|
||||
</svg>
|
||||
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onJSON)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
|
||||
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
|
||||
</svg>
|
||||
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyRembForm = {
|
||||
type: 'bonus_plateforme',
|
||||
bonus_plateforme_id: '',
|
||||
bonus_investisseur_id: '',
|
||||
// Lien optionnel vers un deal PE (investissements_pe, 17/09/26) : permet de
|
||||
// rattacher une distribution à un investissement précis pour calculer son
|
||||
// TVPI, quand plusieurs deals partagent une même plateforme (ex. Fundora).
|
||||
investissement_pe_id: '',
|
||||
date_remb: today(),
|
||||
statut: 'paye',
|
||||
notes: '',
|
||||
// Bonus (parrainage / plateforme)
|
||||
cashback: '',
|
||||
// Intérêts plateforme
|
||||
interets_bruts_avant_local: '',
|
||||
taxe_locale: '',
|
||||
interets_bruts: '',
|
||||
frais_ttc: '',
|
||||
prelev_sociaux: '',
|
||||
prelev_forfaitaire: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Remboursements — version simplifiée, utilisée par tout workspace autre que
|
||||
* crowdlending (Private Equity pour l'instant — cf. RemboursementsRouter dans
|
||||
* App.jsx, jamais lié en dur à un slug précis), même patron que
|
||||
* DepotsRetraitsSimple.jsx.
|
||||
*
|
||||
* Un remboursement "normal" (type par défaut de pages/Remboursements.jsx) est
|
||||
* obligatoirement rattaché à un investissement (investissement_id), or la
|
||||
* table investissements n'a pas encore de workspace_id et sa page reste
|
||||
* réservée à crowdlending (cf. project_workspaces_transformation.md, Étape 2
|
||||
* à venir table par table). Cette page se limite donc, à la demande
|
||||
* d'Olivier (17/09/26), aux 3 types de remboursement qui ne dépendent que
|
||||
* d'une plateforme + d'un détenteur : bonus parrainage, bonus plateforme,
|
||||
* intérêts plateforme — déjà pleinement utilisables puisque plateformes
|
||||
* supporte le multi-workspace (N-N) depuis une étape précédente.
|
||||
*
|
||||
* L'endpoint (/remboursements) est directement partagé avec la page
|
||||
* crowdlending, déjà flêché par workspace côté serveur via l'en-tête
|
||||
* X-Workspace-Id (cf. workspaceScope.js, Étape 2a/2c du 17/09/26).
|
||||
*/
|
||||
export default function RemboursementsSimple() {
|
||||
const { activeId: investisseurId, investisseurs } = useInvestisseur();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const [allPlats, setAllPlats] = useState([]);
|
||||
const [deals, setDeals] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(emptyRembForm);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const { run: runSubmit, pending: submitting } = useAsyncAction();
|
||||
|
||||
// Plateformes éligibles au workspace actif uniquement (GET /plateformes
|
||||
// n'est pas filtré côté serveur — seulement enrichi de row.workspaces),
|
||||
// même patron que DepotsRetraitsSimple.
|
||||
const plats = useMemo(
|
||||
() => allPlats.filter(p => (p.workspaces || []).some(w => w.id === activeWorkspace?.id)),
|
||||
[allPlats, activeWorkspace],
|
||||
);
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
// ── Onglets + filtres (chantier "remboursements PE", 17/09/26 — rapprochement de
|
||||
// la page Remboursements crowdlending, à la demande d'Olivier) : Plateformes
|
||||
// (graphiques + répartition), Vision mensuelle, Remboursements (liste filtrable,
|
||||
// paginée, exportable). Pas d'onglets "Mois en cours"/"Projections" : ils reposent
|
||||
// sur un échéancier prévisionnel qui n'existe pas pour un deal PE.
|
||||
const [activeTab, setActiveTab] = useState('plateformes');
|
||||
const [rembPlatYear, setRembPlatYear] = useState('');
|
||||
const [vmYear, setVmYear] = useState('');
|
||||
const [tableFilter, setTableFilter] = useState({ plateforme_id: '', type: '', investissement_pe_id: '', year: '', month: '' });
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rb, pl, dl] = await Promise.all([
|
||||
api.get('/remboursements'),
|
||||
api.get('/plateformes'),
|
||||
api.get('/investissements-pe'),
|
||||
]);
|
||||
setRows(rb);
|
||||
setAllPlats(pl);
|
||||
setDeals(dl);
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (investisseurId) load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [investisseurId, activeWorkspace?.id]);
|
||||
|
||||
const investisseurForPlat = (platId) => {
|
||||
const plat = plats.find(p => String(p.id) === String(platId));
|
||||
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || '');
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null);
|
||||
const firstId = plats[0]?.id || '';
|
||||
setForm({ ...emptyRembForm, bonus_plateforme_id: firstId, bonus_investisseur_id: investisseurForPlat(firstId) });
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Ouverture auto depuis le bouton global "Nouveau remboursement" (topbar/burger,
|
||||
// cf. Layout.jsx — même ?new=1 que la page crowdlending et que Dépôts/Retraits).
|
||||
const pendingNew = useRef(false);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('new') !== '1') return;
|
||||
navigate('/remboursements', { replace: true });
|
||||
if (plats.length > 0) {
|
||||
openAdd();
|
||||
} else {
|
||||
pendingNew.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNew.current && plats.length > 0) {
|
||||
pendingNew.current = false;
|
||||
openAdd();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [plats.length]);
|
||||
|
||||
// Le type n'est pas modifiable en édition : le backend choisit sa branche
|
||||
// d'UPDATE (et donc les colonnes réellement écrites) d'après form.type
|
||||
// soumis, pas d'après le type déjà stocké en base — changer de type en
|
||||
// cours d'édition désynchroniserait la ligne. On fige donc le type choisi
|
||||
// à la création.
|
||||
const openEdit = (r) => {
|
||||
setEditingId(r.id);
|
||||
setForm({
|
||||
type: r.type,
|
||||
bonus_plateforme_id: String(r.bonus_plateforme_id || ''),
|
||||
bonus_investisseur_id: String(r.bonus_investisseur_id || ''),
|
||||
investissement_pe_id: r.investissement_pe_id ? String(r.investissement_pe_id) : '',
|
||||
date_remb: r.date_remb,
|
||||
statut: r.statut,
|
||||
notes: r.notes || '',
|
||||
cashback: String(r.cashback ?? ''),
|
||||
interets_bruts_avant_local: String(r.interets_bruts_avant_local ?? ''),
|
||||
taxe_locale: String(r.taxe_locale ?? ''),
|
||||
interets_bruts: String(r.interets_bruts ?? ''),
|
||||
frais_ttc: String(r.frais_ttc ?? ''),
|
||||
prelev_sociaux: String(r.prelev_sociaux ?? ''),
|
||||
prelev_forfaitaire: String(r.prelev_forfaitaire ?? ''),
|
||||
});
|
||||
setErr(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const close = () => { setModalOpen(false); setEditingId(null); setForm(emptyRembForm); setErr(null); };
|
||||
|
||||
const submit = async (e) => {
|
||||
e?.preventDefault?.(); setErr(null);
|
||||
const base = {
|
||||
type: form.type,
|
||||
bonus_plateforme_id: Number(form.bonus_plateforme_id),
|
||||
bonus_investisseur_id: Number(form.bonus_investisseur_id) || undefined,
|
||||
investissement_pe_id: Number(form.investissement_pe_id) || undefined,
|
||||
date_remb: form.date_remb,
|
||||
statut: form.statut,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
const payload = form.type === 'interets_plateforme'
|
||||
? {
|
||||
...base,
|
||||
interets_bruts_avant_local: Number(form.interets_bruts_avant_local) || 0,
|
||||
taxe_locale: Number(form.taxe_locale) || 0,
|
||||
interets_bruts: Number(form.interets_bruts) || 0,
|
||||
frais_ttc: Number(form.frais_ttc) || 0,
|
||||
prelev_sociaux: Number(form.prelev_sociaux) || 0,
|
||||
prelev_forfaitaire: Number(form.prelev_forfaitaire) || 0,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
cashback: Number(form.cashback) || 0,
|
||||
};
|
||||
try {
|
||||
if (editingId) await api.put(`/remboursements/${editingId}`, payload);
|
||||
else await api.post('/remboursements', payload);
|
||||
close();
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api.del(`/remboursements/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...rows].sort((a, b) => b.date_remb.localeCompare(a.date_remb) || b.id - a.id),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const statutLabel = (s) => ({ paye: 'Payé', retard: 'En retard', partiel: 'Partiel', impaye: 'Impayé' }[s] || s);
|
||||
|
||||
const years = useMemo(
|
||||
() => [...new Set(rows.map(r => r.date_remb.slice(0, 4)))].sort().reverse(),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const dealNameById = useMemo(() => {
|
||||
const map = {};
|
||||
for (const d of deals) map[d.id] = d.nom_deal;
|
||||
return map;
|
||||
}, [deals]);
|
||||
|
||||
/* ── Onglet Plateformes : graphiques + répartition ──────────────────────
|
||||
* InteretsChart/InteretsDistributionChart (crowdlending) ne lisent que
|
||||
* date_remb + interets_nets/interets_bruts — génériques, réutilisés tels
|
||||
* quels. Le modèle PE n'a pas de bascule brut/net au niveau agrégat (pas
|
||||
* de fiscalité à la source recalculée ici) : on aligne les deux champs sur
|
||||
* net_recu (cashback + intérêts nets déjà calculés côté backend) pour que
|
||||
* la courbe/le treemap représentent le total réellement perçu. */
|
||||
const netMode = true;
|
||||
const chartRows = useMemo(() => {
|
||||
const src = rembPlatYear ? rows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear) : rows;
|
||||
return src.map(r => ({ ...r, interets_nets: r.net_recu, interets_bruts: r.net_recu }));
|
||||
}, [rows, rembPlatYear]);
|
||||
|
||||
const byPlatform = useMemo(() => {
|
||||
const src = rembPlatYear ? rows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear) : rows;
|
||||
const map = {};
|
||||
for (const r of src) {
|
||||
const key = String(r.plateforme_id);
|
||||
if (!map[key]) map[key] = {
|
||||
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
|
||||
detenteur_nom: r.plateforme_detenteur_nom || null,
|
||||
cashback: 0, interets_nets: 0, net_recu: 0, count: 0,
|
||||
};
|
||||
map[key].cashback += r.cashback || 0;
|
||||
map[key].interets_nets += r.interets_nets || 0;
|
||||
map[key].net_recu += r.net_recu || 0;
|
||||
map[key].count++;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.net_recu - a.net_recu);
|
||||
}, [rows, rembPlatYear]);
|
||||
|
||||
const byPlatformTotals = useMemo(() => byPlatform.reduce((acc, p) => {
|
||||
acc.cashback += p.cashback; acc.interets_nets += p.interets_nets;
|
||||
acc.net_recu += p.net_recu; acc.count += p.count;
|
||||
return acc;
|
||||
}, { cashback: 0, interets_nets: 0, net_recu: 0, count: 0 }), [byPlatform]);
|
||||
|
||||
const totalNetRecu = byPlatformTotals.net_recu;
|
||||
|
||||
/* ── Onglet Vision mensuelle : grille Plateforme × mois, année sélectionnée.
|
||||
* Pas de composant réutilisable côté crowdlending (TableauInteretsPlateforme
|
||||
* dépend d'un endpoint + contexte bâtis autour d'un échéancier réel/projeté
|
||||
* qui n'existe pas pour un deal PE) — grille reconstruite ici, client-side,
|
||||
* à partir des lignes déjà chargées. ── */
|
||||
const effectiveVmYear = vmYear || years[0] || String(new Date().getFullYear());
|
||||
const vmData = useMemo(() => {
|
||||
const map = {};
|
||||
for (const r of rows) {
|
||||
if (r.date_remb.slice(0, 4) !== effectiveVmYear) continue;
|
||||
const key = String(r.plateforme_id);
|
||||
if (!map[key]) map[key] = { plateforme_id: r.plateforme_id, nom: r.plateforme_nom, months: Array(12).fill(0), total: 0 };
|
||||
const m = Number(r.date_remb.slice(5, 7)) - 1;
|
||||
map[key].months[m] += r.net_recu || 0;
|
||||
map[key].total += r.net_recu || 0;
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.total - a.total);
|
||||
}, [rows, effectiveVmYear]);
|
||||
|
||||
const vmMonthTotals = useMemo(() => {
|
||||
const totals = Array(12).fill(0);
|
||||
for (const p of vmData) for (let i = 0; i < 12; i++) totals[i] += p.months[i];
|
||||
return totals;
|
||||
}, [vmData]);
|
||||
|
||||
const vmGrandTotal = vmMonthTotals.reduce((s, v) => s + v, 0);
|
||||
|
||||
/* ── Onglet Remboursements : liste filtrable, paginée, exportable ── */
|
||||
const tableRows = useMemo(() => sorted.filter(r => {
|
||||
if (tableFilter.plateforme_id && String(r.plateforme_id) !== tableFilter.plateforme_id) return false;
|
||||
if (tableFilter.type && r.type !== tableFilter.type) return false;
|
||||
if (tableFilter.investissement_pe_id && String(r.investissement_pe_id || '') !== tableFilter.investissement_pe_id) return false;
|
||||
if (tableFilter.year && r.date_remb.slice(0, 4) !== tableFilter.year) return false;
|
||||
if (tableFilter.month && r.date_remb.slice(5, 7) !== tableFilter.month) return false;
|
||||
return true;
|
||||
}), [sorted, tableFilter]);
|
||||
|
||||
const {
|
||||
pagedItems: pagedTableRows, page: rembPage, setPage: setRembPage,
|
||||
pageSize: rembPageSize, setPageSize: setRembPageSize,
|
||||
totalPages: rembTotalPages, totalItems: rembTotalItems, PAGE_SIZES,
|
||||
} = usePagination(tableRows, 'cl_pagesize_remb_pe', [tableFilter]);
|
||||
|
||||
if (!loading && plats.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="remboursement" />Remboursements</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 pour pouvoir y enregistrer des remboursements.`}
|
||||
cta="Configurer mes plateformes"
|
||||
to="/settings?section=plateformes&openAdd=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!loading && !modalOpen && sorted.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="remboursement" />Remboursements</h2></div>
|
||||
<EmptyState
|
||||
icon="💰"
|
||||
title="Aucun remboursement enregistré"
|
||||
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré."
|
||||
cta="Ajouter un remboursement"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? 'Création…' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
|
||||
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
|
||||
investisseurForPlat={investisseurForPlat} editingId={editingId} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="remboursement" />Remboursements</h2>
|
||||
<button className="primary" onClick={openAdd}>+ Nouveau remboursement</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
|
||||
|
||||
<div className="charts-row">
|
||||
<InteretsChart rows={chartRows} netMode={netMode} />
|
||||
<InteretsDistributionChart rows={chartRows} netMode={netMode} />
|
||||
</div>
|
||||
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'remboursements' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
|
||||
</div>
|
||||
|
||||
{/* ══ ONGLET PLATEFORMES ══ */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Répartition par plateforme
|
||||
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<select value={rembPlatYear} onChange={e => setRembPlatYear(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', outline: 'none' }}>
|
||||
<option value="">Toutes les années</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
<ExportDropdown
|
||||
disabled={byPlatform.length === 0}
|
||||
onCSV={() => dlBlob(rembPeToCSV(rows, dealNameById), 'remboursements.csv', 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(rembPeToXLS(rows, dealNameById), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(rembPeToJSON(rows, dealNameById), 'remboursements.json', 'application/json')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Cashback</th>
|
||||
<th className="num">Intérêts (nets)</th>
|
||||
<th className="num">Total net perçu</th>
|
||||
<th className="num">Mouvements</th>
|
||||
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byPlatform.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun remboursement'}
|
||||
</td></tr>
|
||||
)}
|
||||
{byPlatform.map(p => {
|
||||
const poids = totalNetRecu !== 0 ? (p.net_recu / totalNetRecu) * 100 : 0;
|
||||
return (
|
||||
<tr key={p.plateforme_id} className="dr-row" style={{ cursor: 'pointer' }}
|
||||
onClick={() => { setTableFilter(f => ({ ...f, plateforme_id: String(p.plateforme_id) })); setActiveTab('remboursements'); }}
|
||||
title="Cliquer pour voir le détail dans l'onglet Remboursements"
|
||||
>
|
||||
<td>{p.nom}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{p.cashback > 0 ? fmtEUR(p.cashback) : '—'}</td>
|
||||
<td className="num">{p.interets_nets > 0 ? fmtEUR(p.interets_nets) : '—'}</td>
|
||||
<td className="num">{fmtEUR(p.net_recu)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
|
||||
<td className="num">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>{poids.toFixed(1)} %</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{byPlatform.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {byPlatform.length} plateformes</td>
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num">{byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'}</td>
|
||||
<td className="num">{byPlatformTotals.interets_nets > 0 ? fmtEUR(byPlatformTotals.interets_nets) : '—'}</td>
|
||||
<td className="num">{fmtEUR(byPlatformTotals.net_recu)}</td>
|
||||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{byPlatformTotals.count}</td>
|
||||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET VISION MENSUELLE ══ */}
|
||||
{activeTab === 'vision-mensuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Vision mensuelle — {effectiveVmYear}</h3>
|
||||
<select value={effectiveVmYear} onChange={e => setVmYear(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', outline: 'none' }}>
|
||||
{(years.length ? years : [effectiveVmYear]).map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
{MOIS_ABBR.map(m => <th key={m} className="num">{m}</th>)}
|
||||
<th className="num">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vmData.length === 0 && (
|
||||
<tr><td colSpan={14} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : `Aucun remboursement en ${effectiveVmYear}`}
|
||||
</td></tr>
|
||||
)}
|
||||
{vmData.map(p => (
|
||||
<tr key={p.plateforme_id}>
|
||||
<td>{p.nom}</td>
|
||||
{p.months.map((v, i) => <td key={i} className="num">{v > 0 ? fmtEUR(v) : '—'}</td>)}
|
||||
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(p.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{vmData.length > 1 && (
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total</td>
|
||||
{vmMonthTotals.map((v, i) => <td key={i} className="num">{v > 0 ? fmtEUR(v) : '—'}</td>)}
|
||||
<td className="num">{fmtEUR(vmGrandTotal)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ ONGLET REMBOURSEMENTS ══ */}
|
||||
{activeTab === 'remboursements' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h3 style={{ margin: 0 }}>Remboursements</h3>
|
||||
<ExportDropdown
|
||||
disabled={tableRows.length === 0}
|
||||
onCSV={() => dlBlob(rembPeToCSV(tableRows, dealNameById), 'remboursements.csv', 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(rembPeToXLS(tableRows, dealNameById), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(rembPeToJSON(tableRows, dealNameById), 'remboursements.json', 'application/json')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<div>
|
||||
<label>Plateforme</label>
|
||||
<select value={tableFilter.plateforme_id} onChange={e => setTableFilter({ ...tableFilter, plateforme_id: e.target.value })}>
|
||||
<option value="">Toutes</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Type</label>
|
||||
<select value={tableFilter.type} onChange={e => setTableFilter({ ...tableFilter, type: e.target.value })}>
|
||||
<option value="">Tous</option>
|
||||
<option value="bonus_plateforme">Bonus plateforme</option>
|
||||
<option value="bonus_parrainage">Bonus parrainage</option>
|
||||
<option value="interets_plateforme">Intérêts plateforme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Investissement</label>
|
||||
<select value={tableFilter.investissement_pe_id} onChange={e => setTableFilter({ ...tableFilter, investissement_pe_id: e.target.value })}>
|
||||
<option value="">Tous</option>
|
||||
{deals.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Année</label>
|
||||
<select value={tableFilter.year} onChange={e => setTableFilter({ ...tableFilter, year: e.target.value })}>
|
||||
<option value="">Toutes</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Mois</label>
|
||||
<select value={tableFilter.month} onChange={e => setTableFilter({ ...tableFilter, month: e.target.value })}>
|
||||
<option value="">Tous</option>
|
||||
{MOIS_FR.map((m, i) => <option key={i + 1} value={String(i + 1).padStart(2, '0')}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
|
||||
<th>Type</th><th className="num">Net perçu</th><th>Statut</th><th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedTableRows.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center' }}>Aucun mouvement</td></tr>
|
||||
)}
|
||||
{pagedTableRows.map(r => (
|
||||
<tr key={r.id} className="dr-row" style={{ cursor: 'pointer' }} onClick={() => openEdit(r)}>
|
||||
<td>{fmtDate(r.date_remb)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
{multiDetenteur && <td className="text-muted">{r.plateforme_detenteur_nom || '—'}</td>}
|
||||
<td>{TYPE_LABELS[r.type] || r.type}</td>
|
||||
<td className="num">{fmtEUR(r.net_recu)}</td>
|
||||
<td className="text-muted">{statutLabel(r.statut)}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button className="icon-btn" title="Supprimer" onClick={() => setDeleteTarget(r)}>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination page={rembPage} setPage={setRembPage} pageSize={rembPageSize} setPageSize={setRembPageSize}
|
||||
totalPages={rembTotalPages} totalItems={rembTotalItems} PAGE_SIZES={PAGE_SIZES} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
|
||||
onClose={close}
|
||||
busy={submitting}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
{editingId && (
|
||||
<button className="danger" disabled={submitting}
|
||||
onClick={() => { const target = { id: editingId }; close(); setDeleteTarget(target); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={submitting} onClick={close}>Annuler</button>
|
||||
<button className="primary" disabled={submitting} onClick={() => runSubmit(submit)}>
|
||||
{submitting ? (editingId ? 'Enregistrement…' : 'Création…') : (editingId ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
|
||||
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
|
||||
investisseurForPlat={investisseurForPlat} editingId={editingId} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Supprimer ce remboursement ?"
|
||||
message="Cette action est irréversible."
|
||||
confirmLabel="Supprimer"
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
|
||||
* points d'entrée (page avec mouvements existants, et état vide). Champs
|
||||
* strictement alignés sur le Schema de backend/src/routes/remboursements.js,
|
||||
* limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme
|
||||
* (le type "normal", lié à un investissement, reste crowdlending uniquement). */
|
||||
function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDetenteur, err, runSubmit, submit, investisseurForPlat, editingId }) {
|
||||
const isInterets = form.type === 'interets_plateforme';
|
||||
|
||||
// Deals PE de la plateforme sélectionnée (un remboursement bonus/intérêts
|
||||
// peut être rattaché à un deal précis, pour le calcul de son TVPI — cf.
|
||||
// pages/InvestissementsPe.jsx). Optionnel : plusieurs plateformes n'ont
|
||||
// qu'un seul deal, ou aucun deal PE suivi pour l'instant.
|
||||
const dealsForPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id));
|
||||
|
||||
const previewNet = () => {
|
||||
if (isInterets) {
|
||||
const bruts = Number(form.interets_bruts) || 0;
|
||||
const frais = Number(form.frais_ttc) || 0;
|
||||
const ps = Number(form.prelev_sociaux) || 0;
|
||||
const pf = Number(form.prelev_forfaitaire) || 0;
|
||||
return Math.round((bruts - frais - ps - pf) * 100) / 100;
|
||||
}
|
||||
return Number(form.cashback) || 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={e => runSubmit(submit, e)}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
<div>
|
||||
<label>Type *</label>
|
||||
<select required value={form.type} disabled={!!editingId}
|
||||
title={editingId ? 'Le type ne peut pas être modifié après création' : undefined}
|
||||
onChange={e => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="bonus_plateforme">Bonus plateforme</option>
|
||||
<option value="bonus_parrainage">Bonus parrainage</option>
|
||||
<option value="interets_plateforme">Intérêts plateforme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Plateforme *</label>
|
||||
<select required value={form.bonus_plateforme_id} onChange={e => {
|
||||
const platId = e.target.value;
|
||||
setForm(f => ({ ...f, bonus_plateforme_id: platId, bonus_investisseur_id: investisseurForPlat(platId), investissement_pe_id: '' }));
|
||||
}}>
|
||||
<option value="">—</option>
|
||||
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{dealsForPlat.length > 0 && (
|
||||
<div>
|
||||
<label>Investissement (optionnel)</label>
|
||||
<select value={form.investissement_pe_id} onChange={e => setForm({ ...form, investissement_pe_id: e.target.value })}>
|
||||
<option value="">— Aucun (non rattaché à un deal) —</option>
|
||||
{dealsForPlat.map(d => <option key={d.id} value={d.id}>{d.nom_deal}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>Date *</label>
|
||||
<input type="date" required value={form.date_remb} onChange={e => setForm({ ...form, date_remb: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{!isInterets && (
|
||||
<div>
|
||||
<label>Montant reçu (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.cashback} onChange={e => setForm({ ...form, cashback: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInterets && (
|
||||
<>
|
||||
<div>
|
||||
<label>Intérêts bruts (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.interets_bruts} onChange={e => setForm({ ...form, interets_bruts: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Intérêts bruts avant taxe locale (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.interets_bruts_avant_local} onChange={e => setForm({ ...form, interets_bruts_avant_local: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Taxe locale (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.taxe_locale} onChange={e => setForm({ ...form, taxe_locale: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Frais TTC (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.frais_ttc} onChange={e => setForm({ ...form, frais_ttc: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Prélèvements sociaux (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.prelev_sociaux} onChange={e => setForm({ ...form, prelev_sociaux: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Prélèvement forfaitaire (€)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.prelev_forfaitaire} onChange={e => setForm({ ...form, prelev_forfaitaire: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label>Statut</label>
|
||||
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
|
||||
<option value="paye">Payé</option>
|
||||
<option value="retard">En retard</option>
|
||||
<option value="partiel">Partiel</option>
|
||||
<option value="impaye">Impayé</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Détenteur *</label>
|
||||
<select required value={form.bonus_investisseur_id} onChange={e => setForm({ ...form, bonus_investisseur_id: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
|
||||
<optgroup label="Famille">
|
||||
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}{i.is_principal ? ' (principal)' : ''}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
|
||||
<optgroup label="Entreprises">
|
||||
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
|
||||
<option key={i.id} value={i.id}>{i.nom}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label>Notes</label>
|
||||
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 13 }}>
|
||||
Net perçu estimé : {fmtEUR(previewNet())}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useUi } from '../../context/UiContext.jsx';
|
||||
import { api } from '../../api.js';
|
||||
import { getSuggestedPalettes } from '../../utils/chartPalettes.js';
|
||||
import { COLOR_THEMES, colorThemeSwatch } from '../../utils/colorThemes.js';
|
||||
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
|
||||
import ThemeSelect from '../../components/ThemeSelect.jsx';
|
||||
|
||||
|
||||
@@ -281,6 +282,7 @@ function ChartColorPicker({ value, onChange }) {
|
||||
export default function AppearanceSection() {
|
||||
const { mode, setMode } = useTheme();
|
||||
const { fontScale, setFontScale, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, colorTheme, adminColorTheme, setColorTheme } = useUi();
|
||||
const { activeWorkspaces, hasMultipleWorkspaces, setWorkspaceColor } = useWorkspace() || {};
|
||||
const [libIcons, setLibIcons] = useState({});
|
||||
const suggestedPalettes = useMemo(() => getSuggestedPalettes(), []);
|
||||
// "Automatique" (value '') suit le thème par défaut de l'administrateur —
|
||||
@@ -335,6 +337,40 @@ export default function AppearanceSection() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Couleur par espace de travail (17/09/26) : visible seulement si
|
||||
plusieurs espaces sont actifs (rien à distinguer sinon). Par
|
||||
défaut chaque espace hérite de la couleur de l'application
|
||||
ci-dessus ; l'utilisateur peut la personnaliser par espace, et
|
||||
elle s'applique automatiquement en basculant dessus (cf.
|
||||
Layout.jsx / UiContext.jsx workspaceColorOverride). */}
|
||||
{hasMultipleWorkspaces && (
|
||||
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)' }}>Couleur des espaces de travail</div>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, marginBottom: 14, maxWidth: 420 }}>
|
||||
Attribuez une couleur à chaque espace : elle remplace automatiquement la couleur
|
||||
de l'application quand vous basculez dessus. Par défaut, un espace hérite de la
|
||||
couleur de l'application ci-dessus.
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{activeWorkspaces.map((w) => (
|
||||
<div key={w.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 'var(--fs-sm)' }}>{w.libelle_menu || w.nom}</span>
|
||||
<div style={{ width: 260, flexShrink: 0 }}>
|
||||
<ThemeSelect
|
||||
value={w.color_theme || ''}
|
||||
onChange={(v) => setWorkspaceColor(w.id, v || null)}
|
||||
options={[
|
||||
{ value: '', label: "Hérite de la couleur de l'application", swatch: colorThemeSwatch(colorTheme || adminColorTheme) },
|
||||
...COLOR_THEMES,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../../api.js';
|
||||
import { memberLabel } from '../../utils/format.js';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
|
||||
|
||||
const EXONERATION_LABELS = {
|
||||
aucune: 'Aucune',
|
||||
@@ -85,6 +86,8 @@ export default function ComptesSection() {
|
||||
const [editCompte, setEditCompte] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const { run: runAddCompte, pending: addingCompte } = useAsyncAction();
|
||||
const { run: runSaveEdit, pending: savingEdit } = useAsyncAction();
|
||||
|
||||
const openNew = () => { setNewCompte(EMPTY_COMPTE); setErr(null); setShowNew(true); };
|
||||
|
||||
@@ -204,14 +207,15 @@ export default function ComptesSection() {
|
||||
open={showNew}
|
||||
title="Nouveau compte"
|
||||
onClose={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}
|
||||
busy={addingCompte}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
|
||||
<button type="button" onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-new-compte" type="submit">Créer</button>
|
||||
<button type="button" disabled={addingCompte} onClick={() => { setShowNew(false); setNewCompte(EMPTY_COMPTE); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-new-compte" type="submit" disabled={addingCompte}>{addingCompte ? 'Création…' : 'Créer'}</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="form-new-compte" onSubmit={addCompte} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<form id="form-new-compte" onSubmit={e => runAddCompte(addCompte, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
|
||||
</form>
|
||||
@@ -222,19 +226,20 @@ export default function ComptesSection() {
|
||||
open={!!editCompte}
|
||||
title="Modifier le compte"
|
||||
onClose={() => { setEditCompte(null); setErr(null); }}
|
||||
busy={savingEdit}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<button className="danger" type="button" onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
|
||||
<button className="danger" type="button" disabled={savingEdit} onClick={() => { setEditCompte(null); del(comptes.find(c => c.id === editCompte?.id) ?? editCompte); }}>
|
||||
Supprimer
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-edit-compte" type="submit">Enregistrer</button>
|
||||
<button type="button" disabled={savingEdit} onClick={() => { setEditCompte(null); setErr(null); }}>Annuler</button>
|
||||
<button className="primary" form="form-edit-compte" type="submit" disabled={savingEdit}>{savingEdit ? 'Enregistrement…' : 'Enregistrer'}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="form-edit-compte" onSubmit={saveEdit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<form id="form-edit-compte" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
|
||||
</form>
|
||||
|
||||
@@ -8,7 +8,9 @@ import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
|
||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
|
||||
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
|
||||
import { memberLabel, fmtDate } from '../../utils/format.js';
|
||||
import { useAsyncAction } from '../../hooks/useAsyncAction.js';
|
||||
|
||||
function dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
@@ -54,7 +56,7 @@ function fmtFiscalite(p) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
|
||||
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null, workspace_ids: [] };
|
||||
|
||||
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
|
||||
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
|
||||
@@ -230,6 +232,12 @@ function PlatDetailPanel({ plat, onEdit }) {
|
||||
label: "Secteurs d'investissement",
|
||||
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
|
||||
},
|
||||
{
|
||||
label: 'Espaces de travail',
|
||||
value: (plat.workspaces || []).length > 0
|
||||
? <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{plat.workspaces.map(w => <span key={w.id} className="chip-workspace">{w.nom}</span>)}</div>
|
||||
: '—',
|
||||
},
|
||||
plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut },
|
||||
plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut },
|
||||
{
|
||||
@@ -290,20 +298,25 @@ function PlatDetailPanel({ plat, onEdit }) {
|
||||
|
||||
|
||||
/* ── Picker référentiel ──────────────────────────────────────── */
|
||||
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }) {
|
||||
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err, activeWorkspaces = [] }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [domFilter, setDomFilter] = useState('all'); // 'all' | 'fr' | 'etr'
|
||||
const [confirming, setConfirming] = useState(null); // ref en cours de confirmation
|
||||
const [dateOuverture, setDateOuverture] = useState('');
|
||||
const [chosenWorkspace, setChosenWorkspace] = useState(null); // workspace choisi (si l'utilisateur en a plusieurs)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setConfirming(null); setDateOuverture(''); }
|
||||
}, [open]);
|
||||
if (open) {
|
||||
setConfirming(null); setDateOuverture('');
|
||||
setChosenWorkspace(activeWorkspaces.length === 1 ? activeWorkspaces[0].id : null);
|
||||
}
|
||||
}, [open]); // eslint-disable-line
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = referentiel.filter(r => {
|
||||
if (chosenWorkspace && !(r.workspaces || []).some(w => w.id === chosenWorkspace)) return false;
|
||||
if (domFilter === 'fr' && r.domiciliation !== 'FR') return false;
|
||||
if (domFilter === 'etr' && r.domiciliation === 'FR') return false;
|
||||
if (q && !r.nom.toLowerCase().includes(q)) return false;
|
||||
@@ -315,6 +328,34 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
return f ? (LOGO_BASE + f) : null;
|
||||
};
|
||||
|
||||
/* ── Étape de choix de l'espace de travail (si plusieurs disponibles) ── */
|
||||
if (activeWorkspaces.length > 1 && chosenWorkspace === null) {
|
||||
return (
|
||||
<Modal open={open} title="Ajouter une plateforme" onClose={onClose} width={480}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', lineHeight: 1.5 }}>
|
||||
Sur quel espace de travail voulez-vous ajouter cette plateforme ? Seules les plateformes
|
||||
du référentiel éligibles à cet espace vous seront proposées.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{activeWorkspaces.map(w => (
|
||||
<button key={w.id} type="button" onClick={() => setChosenWorkspace(w.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px', borderRadius: 8, border: '1.5px solid var(--border)',
|
||||
background: 'var(--surface)', cursor: 'pointer', textAlign: 'left',
|
||||
fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)',
|
||||
}}>
|
||||
{w.libelle_menu || w.nom}
|
||||
<span style={{ opacity: 0.5 }}>→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Étape de confirmation : date d'ouverture du compte ── */
|
||||
if (confirming) {
|
||||
const img = imgFor(confirming);
|
||||
@@ -327,7 +368,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<button type="button" className="ghost" onClick={() => setConfirming(null)}>← Retour</button>
|
||||
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture)}>
|
||||
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture, chosenWorkspace)}>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
@@ -384,6 +425,15 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
Les informations (domiciliation, fiscalité…) seront pré-remplies automatiquement.
|
||||
</p>
|
||||
|
||||
{activeWorkspaces.length > 1 && (
|
||||
<button type="button" onClick={() => setChosenWorkspace(null)}
|
||||
style={{ alignSelf: 'flex-start', background: 'none', border: 'none', padding: 0,
|
||||
color: 'var(--primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
|
||||
← Espace de travail : {activeWorkspaces.find(w => w.id === chosenWorkspace)?.libelle_menu
|
||||
|| activeWorkspaces.find(w => w.id === chosenWorkspace)?.nom || ''} (changer)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Barre de recherche + chips filtre */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
@@ -494,7 +544,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
|
||||
/* ── Formulaire plateforme partagé (création + édition) ─────────── */
|
||||
function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPreview,
|
||||
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo }) {
|
||||
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo, activeWorkspaces = [] }) {
|
||||
|
||||
const isFrance = state.domiciliation === 'FR';
|
||||
|
||||
@@ -575,6 +625,27 @@ function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setL
|
||||
<input type="date" value={state.date_ouverture || ''} onChange={e => setter(s => ({ ...s, date_ouverture: e.target.value }))}
|
||||
style={{ width: '100%', boxSizing: 'border-box' }} />
|
||||
))}
|
||||
|
||||
{/* Espaces de travail (uniquement affiché si plusieurs disponibles) */}
|
||||
{activeWorkspaces.length > 1 && row('Espaces de travail *', (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{activeWorkspaces.map(w => {
|
||||
const checked = (state.workspace_ids || []).includes(w.id);
|
||||
return (
|
||||
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => setter(s => ({
|
||||
...s,
|
||||
workspace_ids: e.target.checked
|
||||
? [...(s.workspace_ids || []), w.id]
|
||||
: (s.workspace_ids || []).filter(id => id !== w.id),
|
||||
}))} />
|
||||
<span className="chip-workspace" style={{ fontSize: 11 }}>{w.libelle_menu || w.nom}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
), "Cette plateforme ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).")}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -741,6 +812,7 @@ function platFormSteps({ state, setter, logoFile, setLogoFile, logoPreview, setL
|
||||
|
||||
export default function PlateformesSection() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { activeWorkspaces } = useWorkspace();
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────
|
||||
const [plats, setPlats] = useState([]);
|
||||
@@ -779,6 +851,8 @@ export default function PlateformesSection() {
|
||||
|
||||
|
||||
const [err, setErr] = useState(null);
|
||||
const { run: runAddPlat, pending: addingPlat } = useAsyncAction();
|
||||
const { run: runSaveEditPlat, pending: savingEditPlat } = useAsyncAction();
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const platImportRef = useRef(null);
|
||||
@@ -886,8 +960,9 @@ export default function PlateformesSection() {
|
||||
};
|
||||
|
||||
/* ── Ajouter depuis le référentiel ──────────────────────────── */
|
||||
const addPlatFromRef = async (ref, dateOuverture) => {
|
||||
const addPlatFromRef = async (ref, dateOuverture, workspaceId) => {
|
||||
try {
|
||||
const wsId = workspaceId ?? activeWorkspaces[0]?.id;
|
||||
await api.post('/plateformes', {
|
||||
nom: ref.nom,
|
||||
url: ref.url || '',
|
||||
@@ -902,6 +977,7 @@ export default function PlateformesSection() {
|
||||
freq_interets_defaut: ref.freq_interets_defaut || null,
|
||||
referentiel_id: ref.id,
|
||||
date_ouverture: dateOuverture || null,
|
||||
workspace_ids: wsId ? [wsId] : [],
|
||||
});
|
||||
setShowAddPicker(false);
|
||||
await load();
|
||||
@@ -911,6 +987,10 @@ export default function PlateformesSection() {
|
||||
/* ── Plateformes ─────────────────────────────────────────────── */
|
||||
const addPlat = async (e) => {
|
||||
e.preventDefault(); setErr(null); setMsg(null);
|
||||
if (activeWorkspaces.length > 1 && (!newPlat.workspace_ids || newPlat.workspace_ids.length === 0)) {
|
||||
setErr('Sélectionnez au moins un espace de travail.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await api.post('/plateformes', {
|
||||
...newPlat,
|
||||
@@ -922,6 +1002,9 @@ export default function PlateformesSection() {
|
||||
type_pret_defaut: newPlat.type_pret_defaut || null,
|
||||
freq_interets_defaut: newPlat.freq_interets_defaut || null,
|
||||
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
|
||||
workspace_ids: (newPlat.workspace_ids && newPlat.workspace_ids.length > 0)
|
||||
? newPlat.workspace_ids
|
||||
: (activeWorkspaces[0] ? [activeWorkspaces[0].id] : []),
|
||||
});
|
||||
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
|
||||
setNewPlat(EMPTY_PLAT);
|
||||
@@ -997,6 +1080,7 @@ export default function PlateformesSection() {
|
||||
secteurs_inv_ids: platSectsInv.map(s => s.id),
|
||||
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
|
||||
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
|
||||
workspace_ids: (p.workspaces || []).map(w => w.id),
|
||||
domiciliation: p.domiciliation || 'france',
|
||||
fiscalite: p.fiscalite || 'flat_tax',
|
||||
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
|
||||
@@ -1017,6 +1101,10 @@ export default function PlateformesSection() {
|
||||
|
||||
const saveEditPlat = async (e) => {
|
||||
e.preventDefault(); setErr(null); setMsg(null);
|
||||
if (activeWorkspaces.length > 1 && (!editPlat.workspace_ids || editPlat.workspace_ids.length === 0)) {
|
||||
setErr('Sélectionnez au moins un espace de travail.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(`/plateformes/${editPlat.id}`, {
|
||||
nom: editPlat.nom, url: editPlat.url || '',
|
||||
@@ -1032,6 +1120,9 @@ export default function PlateformesSection() {
|
||||
date_ouverture: editPlat.date_ouverture || null,
|
||||
type_pret_defaut: editPlat.type_pret_defaut || null,
|
||||
freq_interets_defaut: editPlat.freq_interets_defaut || null,
|
||||
workspace_ids: (editPlat.workspace_ids && editPlat.workspace_ids.length > 0)
|
||||
? editPlat.workspace_ids
|
||||
: (activeWorkspaces[0] ? [activeWorkspaces[0].id] : []),
|
||||
});
|
||||
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
|
||||
// Sauvegarder les associations catégories/secteurs d'investissement
|
||||
@@ -1114,8 +1205,7 @@ export default function PlateformesSection() {
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
|
||||
{multiDetenteur && <th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>}
|
||||
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
@@ -1123,13 +1213,12 @@ export default function PlateformesSection() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{plats.length === 0 && (
|
||||
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
|
||||
<tr><td colSpan={multiDetenteur ? 7 : 6} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
|
||||
)}
|
||||
{plats.map((p, i) => {
|
||||
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
|
||||
const inv = platInvestisseur(p);
|
||||
const cats = p.categories_inv || [];
|
||||
const sects = p.secteurs_inv || [];
|
||||
const wsList = p.workspaces || [];
|
||||
return (
|
||||
<tr key={p.id}
|
||||
onClick={() => setSelectedPlat(selectedPlat?.id === p.id ? null : p)}
|
||||
@@ -1158,16 +1247,9 @@ export default function PlateformesSection() {
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
|
||||
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
|
||||
{cats.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
|
||||
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
|
||||
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
{wsList.slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
|
||||
{wsList.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{wsList.length - 2}</span>}
|
||||
{wsList.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
{multiDetenteur && (
|
||||
@@ -1245,6 +1327,7 @@ export default function PlateformesSection() {
|
||||
onSelect={addPlatFromRef}
|
||||
onManual={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}
|
||||
err={err}
|
||||
activeWorkspaces={activeWorkspaces}
|
||||
/>
|
||||
|
||||
{/* ── Modal : Ajouter une plateforme ── */}
|
||||
@@ -1255,12 +1338,14 @@ export default function PlateformesSection() {
|
||||
logoPreview: newPlatLogoPreview, setLogoPreview: setNewPlatLogoPreview,
|
||||
investisseurs, referentiel,
|
||||
categoriesInv: [], secteursInv: [],
|
||||
activeWorkspaces,
|
||||
});
|
||||
return (
|
||||
<PlateformeFormModal
|
||||
title="Nouvelle plateforme"
|
||||
onClose={() => { setShowNewPlat(false); setNewPlat(EMPTY_PLAT); setNewPlatLogoFile(null); setNewPlatLogoPreview(null); setErr(null); }}
|
||||
onSubmit={addPlat}
|
||||
onSubmit={e => runAddPlat(addPlat, e)}
|
||||
saving={addingPlat}
|
||||
err={err}
|
||||
submitLabel="Créer"
|
||||
width={600}
|
||||
@@ -1279,12 +1364,14 @@ export default function PlateformesSection() {
|
||||
categoriesInv, secteursInv,
|
||||
isEdit: true,
|
||||
onDelLogo: () => delLogoPlat(editPlat.id),
|
||||
activeWorkspaces,
|
||||
});
|
||||
return (
|
||||
<PlateformeFormModal
|
||||
title="Modifier la plateforme"
|
||||
onClose={() => { setEditPlat(null); setEditPlatLogoFile(null); setEditPlatLogoPreview(null); setErr(null); }}
|
||||
onSubmit={saveEditPlat}
|
||||
onSubmit={e => runSaveEditPlat(saveEditPlat, e)}
|
||||
saving={savingEditPlat}
|
||||
err={err}
|
||||
submitLabel="Enregistrer"
|
||||
width={600}
|
||||
|
||||
Reference in new issue
Block a user