Files
crowdlending-app/frontend/src/pages/Investissements.jsx
T

1463 lines
71 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api.js';
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import { dayOfMonth } from '../utils/dateEcheances.js';
import InvChart from '../components/InvChart.jsx';
import DistributionChart from '../components/DistributionChart.jsx';
import CapitalMensuelTable from '../components/CapitalMensuelTable.jsx';
import { fmtEUR, fmtPct, fmtDate, fmtStatut, today } from '../utils/format.js';
import * as XLSX from 'xlsx';
import EmptyState from '../components/EmptyState.jsx';
/* ── Constantes ──────────────────────────────────────────────── */
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const TYPE_REMB_LABELS = { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' };
/* ── Helper téléchargement ───────────────────────────────────── */
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);
}
/* ── Indicateur de progression ── */
function TrendBadge({ current, prev, invert = false }) {
// prev quasi-nul (résidu flottant) → pourcentage non significatif ; seuil aligné sur
// l'arrondi 2 décimales pour éviter des variations aberrantes type "+39628021025%".
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>
);
}
/* ── Export investissements ──────────────────────────────────── */
function invToCSV(rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = [
'Date souscription', 'Projet', 'Émetteur', 'Plateforme',
'Montant investi (€)', 'Taux (%)', 'Durée (mois)', 'Type remb.',
'Capital remboursé (€)', 'Capital restant dû (€)',
'Intérêts bruts (€)', 'Intérêts nets (€)', 'Statut',
];
const data = rows.map(r => [
r.date_souscription,
r.nom_projet,
r.emetteur || '',
r.plateforme_nom || '',
String(r.montant_investi).replace('.', ','),
r.taux_interet != null ? String(r.taux_interet).replace('.', ',') : '',
r.duree_mois ?? '',
TYPE_REMB_LABELS[r.type_remb] || r.type_remb || '',
String(r.capital_rembourse || 0).replace('.', ','),
String(Math.max(0, r.montant_investi - (r.capital_rembourse || 0))).replace('.', ','),
String(r.interets_percus || 0).replace('.', ','),
String(r.interets_nets_total || 0).replace('.', ','),
r.statut || '',
]);
return BOM + [headers, ...data].map(row => row.map(q).join(sep)).join('\r\n');
}
function invToXLS(rows) {
const data = rows.map(r => {
const capInv = r.capital_total ?? r.montant_investi;
const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0) - (r.capital_perdu || 0));
return {
'Date souscription': r.date_souscription,
'Projet': r.nom_projet,
'Émetteur': r.emetteur || '',
'Plateforme': r.plateforme_nom || '',
'Montant investi (€)': capInv,
'Taux (%)': r.taux_interet ?? '',
'Durée (mois)': r.duree_mois ?? '',
'Type remb.': TYPE_REMB_LABELS[r.type_remb] || r.type_remb || '',
'Capital remboursé (€)': r.capital_rembourse || 0,
'Capital restant dû (€)': capRestant,
'Intérêts bruts (€)': r.interets_percus || 0,
'Intérêts nets (€)': r.interets_nets_total || 0,
'Statut': r.statut || '',
};
});
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Investissements');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function invToJSON(rows) {
return JSON.stringify(rows.map(r => ({
date_souscription: r.date_souscription,
nom_projet: r.nom_projet,
emetteur: r.emetteur || null,
plateforme: r.plateforme_nom || '',
montant_investi: r.montant_investi,
taux_interet: r.taux_interet ?? null,
duree_mois: r.duree_mois ?? null,
type_remb: r.type_remb || '',
capital_rembourse: r.capital_rembourse || 0,
interets_bruts: r.interets_percus || 0,
interets_nets: r.interets_nets_total || 0,
statut: r.statut,
})), null, 2);
}
/* ── Export plateformes ──────────────────────────────────────── */
function platToCSV(rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = [
'Plateforme', 'Projets', 'Montant investi (€)', 'En cours (€)',
'Capital remboursé (€)', 'Intérêts bruts (€)', 'Intérêts nets (€)', 'Poids (%)',
];
const data = rows.map(p => [
p.nom, p.count,
String(p.investi).replace('.', ','),
String(p.encours).replace('.', ','),
String(p.cap_remb).replace('.', ','),
String(p.int_perc).replace('.', ','),
String(p.int_perc_net).replace('.', ','),
p.poids.toFixed(2).replace('.', ','),
]);
return BOM + [headers, ...data].map(row => row.map(q).join(sep)).join('\r\n');
}
function platToXLS(rows) {
const data = rows.map(p => ({
'Plateforme': p.nom,
'Projets': p.count,
'Montant investi (€)': p.investi,
'En cours (€)': p.encours,
'Capital remboursé (€)': p.cap_remb,
'Intérêts bruts (€)': p.int_perc,
'Intérêts nets (€)': p.int_perc_net,
'Poids (%)': +p.poids.toFixed(2),
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Plateformes');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function platToJSON(rows) {
return JSON.stringify(rows.map(p => ({
plateforme: p.nom,
nb_projets: p.count,
montant_investi: p.investi,
en_cours: p.encours,
capital_rembourse: p.cap_remb,
interets_bruts: p.int_perc,
interets_nets: p.int_perc_net,
poids_pct: +p.poids.toFixed(2),
})), null, 2);
}
/* ── ExportDropdown ──────────────────────────────────────────── */
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>
);
}
/* ── Helpers form ────────────────────────────────────────────── */
const empty = {
investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '',
date_souscription: today(), date_premiere_echeance: '', date_cible: '',
montant_investi: '', taux_interet: '', duree_mois: '',
type_remb: 'in_fine', freq_interets: 'mensuel',
statut: 'en_cours', reference: '', notes: '',
echeance_fin_de_mois: false, methode_remboursement: '', nom_compte_courant: '', compte_id: '', pays_exposition: 'FR',
categories_inv_ids: [], secteurs_inv_ids: [],
};
/* ── Composant principal ─────────────────────────────────────── */
export default function Investissements() {
const { activeId, activeView, investisseurs } = useInvestisseur();
const { displayMode } = useUi();
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const platsRef = useRef([]);
const lastValidSouscriptionRef = useRef('');
const [allRows, setAllRows] = useState([]);
const [allRembs, setAllRembs] = useState([]);
const [allReinvests, setAllReinvests] = useState([]);
const [plats, setPlats] = useState([]);
const [comptesCourants, setComptesCourants] = useState([]);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
/* activeTab, filter, platYear → URL params pour survivre au retour arrière */
const activeTab = searchParams.get('tab') || 'plateformes';
const setActiveTab = (tab) => setSearchParams(p => { const n = new URLSearchParams(p); n.set('tab', tab); return n; }, { replace: true });
const [listFocused, setListFocused] = useState(false);
const filter = {
statut: searchParams.get('statut') || '',
plateforme_id: searchParams.get('pid') || '',
categorie_inv_id: searchParams.get('cat') || '',
secteur_inv_id: searchParams.get('sect') || '',
year: searchParams.get('yr') || '',
month: searchParams.get('mo') || '',
};
const setFilter = (updater) => setSearchParams(p => {
const cur = { statut: p.get('statut')||'', plateforme_id: p.get('pid')||'', categorie_inv_id: p.get('cat')||'', secteur_inv_id: p.get('sect')||'', year: p.get('yr')||'', month: p.get('mo')||'' };
const nf = typeof updater === 'function' ? updater(cur) : updater;
const n = new URLSearchParams(p);
['statut','pid','cat','sect','yr','mo'].forEach(k => n.delete(k));
if (nf.statut) n.set('statut', nf.statut);
if (nf.plateforme_id) n.set('pid', nf.plateforme_id);
if (nf.categorie_inv_id) n.set('cat', nf.categorie_inv_id);
if (nf.secteur_inv_id) n.set('sect', nf.secteur_inv_id);
if (nf.year) n.set('yr', nf.year);
if (nf.month) n.set('mo', nf.month);
return n;
}, { replace: true });
const [categoriesInv, setCategoriesInv] = useState([]);
const [secteursInv, setSecteursInv] = useState([]);
const platYear = searchParams.get('platYear') || String(new Date().getFullYear());
const setPlatYear = (y) => setSearchParams(p => { const n = new URLSearchParams(p); n.set('platYear', y); return n; }, { replace: true });
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
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
// couvre pas l'opération (contrôle création uniquement, cf. submit()).
const [depositPrompt, setDepositPrompt] = useState({
open: false, plateforme_id: null, plateforme_nom: '', montant: 0, investissementId: null,
});
/* ── Chargement (sans filtre — filtrage côté client) ── */
const load = async () => {
if (!activeId && activeView !== 'all') return;
setLoading(true);
setAllRows([]);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [r, p, comptes, rembs, reinvests, catInv, sectInv] = await Promise.all([
api.get('/investissements', scopeParams),
api.get('/plateformes'),
api.get('/investissements/comptes-courants'),
api.get('/remboursements', scopeParams),
api.get('/reinvestissements', { scope: 'all' }),
api.get('/categories-inv'),
api.get('/secteurs-inv'),
]);
setAllRows(r);
setAllRembs(rembs);
setAllReinvests(reinvests);
setPlats(p);
setComptesCourants(comptes);
setCategoriesInv(catInv);
setSecteursInv(sectInv);
platsRef.current = p;
} finally { setLoading(false); }
};
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
// Charge les comptes du détenteur courant dès que l'investisseur du formulaire change
useEffect(() => {
if (!form.investisseur_id || !modalOpen) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${form.investisseur_id}`)
.then(comptes => {
setComptesInvestisseur(comptes);
if (form.methode_remboursement === 'compte_courant' && !form.compte_id) {
const def = comptes.find(c => c.type === 'compte_courant') ?? comptes[0];
if (def) setForm(f => ({ ...f, compte_id: String(def.id), nom_compte_courant: def.nom }));
}
})
.catch(() => setComptesInvestisseur([]));
}, [form.investisseur_id, modalOpen]); /* eslint-disable-next-line */
// Ferme tous les menus contextuels au scroll
useEffect(() => {
const closeAll = () => {
setOpenMenu(null);
};
window.addEventListener('scroll', closeAll, true);
return () => window.removeEventListener('scroll', closeAll, true);
}, []);
/* ── Années disponibles (pour le sélecteur) ── */
const years = useMemo(() =>
[...new Set(allRows.map(r => r.date_souscription?.slice(0, 4)).filter(Boolean))].sort().reverse(),
[allRows]);
/* ── Filtrage côté client ── */
const rows = useMemo(() => {
return allRows.filter(r => {
if (filter.statut === 'defaut') {
if (!['en_retard', 'procedure'].includes(r.statut)) return false;
} else if (filter.statut && r.statut !== filter.statut) {
return false;
}
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (filter.categorie_inv_id && !(r.categories_inv || []).some(c => String(c.id) === String(filter.categorie_inv_id))) return false;
if (filter.secteur_inv_id && !(r.secteurs_inv || []).some(s => String(s.id) === String(filter.secteur_inv_id))) return false;
if (filter.year && r.date_souscription?.slice(0, 4) !== filter.year) return false;
if (filter.month && r.date_souscription?.slice(5, 7) !== filter.month.padStart(2, '0')) return false;
return true;
});
}, [allRows, filter]);
/* ── Pagination investissements ── */
const {
pagedItems: pagedRows, page: invPage, setPage: setInvPage,
pageSize: invPageSize, setPageSize: setInvPageSize,
totalPages: invTotalPages, totalItems: invTotalItems, PAGE_SIZES,
} = usePagination(rows, 'cl_pagesize_inv', [filter]);
/* ── Helper : investissement actif au 31/12 de platYear ─────────
* Cohérent avec capitalMensuel du dashboard (test sur le mois de décembre) :
* - souscrit avant la fin de l'année
* - ET (statut encore actif aujourd'hui OU date de clôture ≥ 1er déc.)
* ──────────────────────────────────────────────────────────────── */
const lastRembDateMap = useMemo(() => {
const map = {};
for (const rb of allRembs) {
const id = rb.investissement_id;
const d = rb.date_remb?.slice(0, 10);
if (!id || !d) continue;
if (!map[id] || d > map[id]) map[id] = d;
}
return map;
}, [allRembs]);
const isActiveAtEndOfYear = (r, yr = platYear) => {
if (!yr) return true;
const cutoff = `${yr}-12-31`;
const decStart = `${yr}-12-01`;
if (r.date_souscription > cutoff) return false; // pas encore souscrit
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
// perte_definitive et prolongation restent "actifs" au sens de ce filtre (comme
// en_retard/procedure dont ils découlent) : le dossier ne doit pas disparaître des
// vues par année sous prétexte que la perte a été déclarée ou qu'une prolongation a
// été négociée — voir project_perte_definitive.md et project_prolongation.md
if (ACTIVE.includes(r.statut) || ['perte_definitive', 'prolongation'].includes(r.statut)) return true; // encore actif aujourd'hui
const fin = r.date_cible ?? lastRembDateMap[r.id] ?? null;
return !!fin && fin >= decStart; // clôturé mais actif en décembre
};
/* ── kpiRows : plateforme + année sélectionnées, sans filtre statut ── */
/* (le filtre statut ne doit pas affecter les chiffres des autres KPIs) */
const kpiRows = useMemo(() =>
allRows.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (platYear && !isActiveAtEndOfYear(r)) return false;
return true;
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allRows, filter.plateforme_id, platYear, lastRembDateMap]
);
/* ── Agrégats remboursements filtrés par coupure (platYear), par investissement ── */
const rembParInv = useMemo(() => {
const cutoff = platYear ? `${platYear}-12-31` : null;
const map = {};
for (const rb of allRembs) {
if (!rb.investissement_id) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d) continue;
if (cutoff && d > cutoff) continue;
const id = rb.investissement_id;
if (!map[id]) map[id] = { capital: 0, interets_bruts: 0, interets_nets: 0 };
map[id].capital += rb.capital || 0;
map[id].interets_bruts += rb.interets_bruts || 0;
map[id].interets_nets += rb.interets_nets || 0;
}
return map;
}, [allRembs, platYear]);
// Alias pour rétrocompatibilité
const capRembParInv = useMemo(() =>
Object.fromEntries(Object.entries(rembParInv).map(([id, v]) => [id, v.capital])),
[rembParInv]);
/* ── Réinvestissements cumulés par investissement, filtrés par coupure ── */
const reinvestCumulParInv = useMemo(() => {
const cutoff = platYear ? `${platYear}-12-31` : null;
const map = {};
for (const rv of allReinvests) {
const d = rv.date_reinvestissement?.slice(0, 10);
if (!d) continue;
if (cutoff && d > cutoff) continue;
map[rv.investissement_id] = (map[rv.investissement_id] || 0) + (rv.montant || 0);
}
return map;
}, [allReinvests, platYear]);
/* ── Totaux KPI (depuis kpiRows — réagit à plateforme + année) ── */
const totals = useMemo(() => kpiRows.reduce((acc, r) => {
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
const capRemb = capRembParInv[r.id] || 0;
acc.investi += capInv;
acc.cap_remb += capRemb;
acc.int_perc += rembParInv[r.id]?.interets_bruts || 0;
acc.int_perc_net += rembParInv[r.id]?.interets_nets || 0;
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
acc.encours += capRestant;
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
acc.pertes += (r.capital_perdu || 0);
return acc;
}, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0, pertes: 0 }), [kpiRows, capRembParInv, rembParInv, reinvestCumulParInv]);
/* ── Totaux KPI année N-1 (pour TrendBadge) ── */
const prevTotals = useMemo(() => {
const effectiveYear = platYear || String(new Date().getFullYear());
const prevYear = String(Number(effectiveYear) - 1);
const cutoff = `${prevYear}-12-31`;
// Remboursements cumulés jusqu'à fin de l'année précédente
const prevRembParInv = {};
for (const rb of allRembs) {
if (!rb.investissement_id) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || d > cutoff) continue;
const id = rb.investissement_id;
if (!prevRembParInv[id]) prevRembParInv[id] = { capital: 0, interets_bruts: 0, interets_nets: 0 };
prevRembParInv[id].capital += rb.capital || 0;
prevRembParInv[id].interets_bruts += rb.interets_bruts || 0;
prevRembParInv[id].interets_nets += rb.interets_nets || 0;
}
const prevReinvestCumul = {};
for (const rv of allReinvests) {
const d = rv.date_reinvestissement?.slice(0, 10);
if (!d || d > cutoff) continue;
prevReinvestCumul[rv.investissement_id] = (prevReinvestCumul[rv.investissement_id] || 0) + (rv.montant || 0);
}
const prevRows = allRows.filter(r => {
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
return isActiveAtEndOfYear(r, prevYear);
});
return prevRows.reduce((acc, r) => {
const capInv = r.montant_investi + (prevReinvestCumul[r.id] || 0);
const capRemb = prevRembParInv[r.id]?.capital || 0;
acc.investi += capInv;
acc.cap_remb += capRemb;
acc.int_perc += prevRembParInv[r.id]?.interets_bruts || 0;
acc.int_perc_net += prevRembParInv[r.id]?.interets_nets || 0;
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
acc.encours += capRestant;
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
return acc;
}, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platYear, allRows, allRembs, allReinvests, filter.plateforme_id, lastRembDateMap]);
/* ── Répartition par plateforme (filtrée par platYear si défini) ── */
const platData = useMemo(() => {
const src = platYear
? allRows.filter(r => isActiveAtEndOfYear(r))
: allRows;
const totalInvesti = src.reduce((s, r) => s + r.montant_investi + (reinvestCumulParInv[r.id] || 0), 0);
const byPlat = {};
for (const r of src) {
if (!byPlat[r.plateforme_id]) {
byPlat[r.plateforme_id] = {
plateforme_id: r.plateforme_id,
nom: r.plateforme_nom,
detenteur_nom: r.plateforme_detenteur_nom || null,
count: 0, investi: 0, cap_remb: 0,
int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0,
};
}
const p = byPlat[r.plateforme_id];
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
const capRemb = capRembParInv[r.id] || 0;
p.count++;
p.investi += capInv;
p.cap_remb += capRemb;
p.int_perc += rembParInv[r.id]?.interets_bruts || 0;
p.int_perc_net += rembParInv[r.id]?.interets_nets || 0;
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
// Sans filtre année : les prêts remboursés ont capRestant=0 → pas d'impact
// Avec filtre année : on veut le capital déployé à la coupure, quel que soit le statut actuel
p.encours += capRestant;
if (['en_retard', 'procedure'].includes(r.statut)) p.defaut += capRestant;
}
return Object.values(byPlat)
.sort((a, b) => b.investi - a.investi)
.map(p => ({ ...p, poids: totalInvesti > 0 ? (p.investi / totalInvesti) * 100 : 0 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allRows, platYear, capRembParInv, rembParInv, reinvestCumulParInv, lastRembDateMap]);
/* ── Totaux du tableau plateformes ── */
const platTotals = useMemo(() => platData.reduce((acc, p) => {
acc.count += p.count;
acc.investi += p.investi;
acc.encours += p.encours;
acc.cap_remb += p.cap_remb;
acc.int_perc += p.int_perc;
acc.int_perc_net += p.int_perc_net;
return acc;
}, { count: 0, investi: 0, encours: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0 }), [platData]);
/* ── Données graphiques : filtrées par statut + plateforme + année ── */
const chartRows = useMemo(() => {
return allRows.filter(r => {
if (filter.statut === 'defaut') {
if (!['en_retard', 'procedure'].includes(r.statut)) return false;
} else if (filter.statut && r.statut !== filter.statut) {
return false;
}
if (filter.plateforme_id && String(r.plateforme_id) !== String(filter.plateforme_id)) return false;
if (platYear && !isActiveAtEndOfYear(r)) return false;
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allRows, filter.statut, filter.plateforme_id, platYear, lastRembDateMap]);
/* ── Données pour DistributionChart — capital restant dû à la coupure, par plateforme ── */
const invDistRows = useMemo(() =>
chartRows
.map(r => {
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
const capRemb = capRembParInv[r.id] || 0;
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
return { plateforme_nom: r.plateforme_nom, montant: capRestant };
})
.filter(r => r.montant > 0),
[chartRows, capRembParInv, reinvestCumulParInv]);
/* ── Investisseur par défaut ── */
const defaultInvestisseurId = () => {
if (activeView === 'all') {
const principal = investisseurs.find(i => i.is_principal);
return String((principal || investisseurs[0])?.id || '');
}
return String(activeId || '');
};
/** Retourne l'investisseur_id de la plateforme, ou le défaut si absent */
const investisseurForPlat = (platId) => {
const plat = plats.find(p => String(p.id) === String(platId));
return plat?.investisseur_id ? String(plat.investisseur_id) : defaultInvestisseurId();
};
/* ── Ouverture auto depuis le bouton global ── */
const pendingNew = useRef(false);
useEffect(() => {
if (searchParams.get('new') !== '1') return;
navigate({ pathname: '/investissements', search: (() => { const n = new URLSearchParams(searchParams); n.delete('new'); return n.toString() ? '?' + n.toString() : ''; })() }, { replace: true });
if (plats.length > 0) {
setEditingId(null);
setForm({ ...empty });
setErr(null);
setModalOpen(true);
} else {
pendingNew.current = true;
}
}, [searchParams]); /* eslint-disable-next-line */
useEffect(() => {
if (!pendingNew.current || !plats.length) return;
pendingNew.current = false;
setEditingId(null);
setForm({ ...empty });
setErr(null);
setModalOpen(true);
}, [plats]); /* eslint-disable-next-line */
/* ── Reprise de la saisie après un aller-retour "Déposer" vers Dépôts/Retraits (bouton
dans InvestissementFormModal, étape 1) : la modale se rouvre avec exactement ce qui
avait été saisi avant le dépôt. */
const pendingResume = useRef(null);
useEffect(() => {
if (searchParams.get('resumeInvestissement') !== '1') return;
const n = new URLSearchParams(searchParams); n.delete('resumeInvestissement');
navigate({ pathname: '/investissements', search: n.toString() ? '?' + n.toString() : '' }, { replace: true });
let draft = null;
try {
const raw = sessionStorage.getItem('cl_investissement_draft');
if (raw) { draft = JSON.parse(raw); sessionStorage.removeItem('cl_investissement_draft'); }
} catch { /* ignore */ }
if (!draft?.form) return;
if (plats.length > 0) {
setEditingId(draft.editingId || null);
setForm(draft.form);
setErr(null);
setModalOpen(true);
} else {
pendingResume.current = draft;
}
}, [searchParams]); /* eslint-disable-next-line */
useEffect(() => {
if (!pendingResume.current || !plats.length) return;
const draft = pendingResume.current;
pendingResume.current = null;
setEditingId(draft.editingId || null);
setForm(draft.form);
setErr(null);
setModalOpen(true);
}, [plats]); /* eslint-disable-next-line */
/* ── CRUD ── */
const openNew = () => {
setEditingId(null);
setForm({ ...empty });
setErr(null);
setModalOpen(true);
};
const openEdit = async (row) => {
setEditingId(row.id);
const plat = plats.find(p => p.id === row.plateforme_id);
const platMethode = plat?.methode_remboursement;
const resolvedMethode = platMethode && platMethode !== 'choix_investisseur'
? platMethode
: (row.methode_remboursement || '');
setForm({
investisseur_id: String(row.investisseur_id || ''),
plateforme_id: row.plateforme_id,
nom_projet: row.nom_projet,
emetteur: row.emetteur || '',
date_souscription: row.date_souscription,
date_premiere_echeance: row.date_premiere_echeance || '',
date_cible: row.date_cible || '',
montant_investi: row.montant_investi,
taux_interet: row.taux_interet ?? '',
duree_mois: row.duree_mois ?? '',
type_remb: row.type_remb || 'in_fine',
freq_interets: row.freq_interets || 'mensuel',
statut: row.statut,
reference: row.reference || '',
notes: row.notes || '',
echeance_fin_de_mois: !!row.echeance_fin_de_mois,
methode_remboursement: resolvedMethode,
nom_compte_courant: row.nom_compte_courant || '',
compte_id: row.compte_id || '',
pays_exposition: row.pays_exposition || 'FR',
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
});
setErr(null);
setModalOpen(true);
};
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement,
* le détenteur par défaut, et préremplit catégories/secteurs depuis la plateforme. */
const handlePlateformeChange = (platId) => {
const platObj = plats.find(p => String(p.id) === String(platId));
const platMethode = platObj?.methode_remboursement;
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
if (platId) {
Promise.all([
api.get(`/plateformes/${platId}/categories-inv`).catch(() => []),
api.get(`/plateformes/${platId}/secteurs-inv`).catch(() => []),
]).then(([platCats, platSects]) => {
setForm(f => ({ ...f,
plateforme_id: platId,
investisseur_id: investisseurForPlat(platId),
methode_remboursement: newMethode, compte_id: '',
categories_inv_ids: platCats.map(c => c.id),
secteurs_inv_ids: platSects.map(s => s.id),
}));
});
} else {
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [] }));
}
};
const handleCategoryAdded = (cat) => setCategoriesInv(prev => [...prev, cat].sort((a, b) => a.nom.localeCompare(b.nom)));
const handleSectorAdded = (sect) => setSecteursInv(prev => [...prev, sect].sort((a, b) => a.nom.localeCompare(b.nom)));
const close = () => { setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); };
const submit = async (e) => {
e?.preventDefault?.();
setErr(null);
try {
// La case "Dernier jour du mois" n'est pertinente que pour in_fine / différé
// avec une première échéance après le 27. On la force à 0 sinon.
const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe')
&& dayOfMonth(form.date_premiere_echeance) > 27;
// Validation date d'échéance pour les prêts différés
if (form.type_remb === 'differe' && !form.date_premiere_echeance) {
setErr("La date d'échéance est requise pour un prêt différé. Vérifiez que la durée et la date de souscription sont renseignées.");
return;
}
const payload = {
investisseur_id: Number(form.investisseur_id) || undefined,
plateforme_id: Number(form.plateforme_id),
nom_projet: form.nom_projet,
emetteur: form.emetteur || undefined,
date_souscription: form.date_souscription,
date_premiere_echeance: form.date_premiere_echeance || '',
date_cible: form.date_cible || '',
montant_investi: Number(form.montant_investi),
taux_interet: form.taux_interet === '' ? undefined : Number(form.taux_interet),
duree_mois: form.duree_mois === '' ? undefined : Number(form.duree_mois),
type_remb: form.type_remb || '',
freq_interets: form.type_remb === 'differe' ? 'in_fine' : (form.freq_interets || 'mensuel'),
statut: form.statut,
reference: form.reference || undefined,
notes: form.notes || undefined,
echeance_fin_de_mois: (showFinDeMois && form.echeance_fin_de_mois) ? 1 : 0,
methode_remboursement: form.methode_remboursement || null,
nom_compte_courant: form.methode_remboursement === 'compte_courant' ? (form.nom_compte_courant || null) : null,
compte_id: form.methode_remboursement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
pays_exposition: form.pays_exposition || 'FR',
};
if (editingId) {
await api.put(`/investissements/${editingId}`, payload);
await Promise.all([
api.put(`/investissements/${editingId}/categories-inv`, { ids: form.categories_inv_ids || [] }),
api.put(`/investissements/${editingId}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
]);
close(); await load();
} else {
const created = await api.post('/investissements', payload);
await Promise.all([
api.put(`/investissements/${created.id}/categories-inv`, { ids: form.categories_inv_ids || [] }),
api.put(`/investissements/${created.id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
]);
// Contrôle du porte-monnaie — création uniquement (jamais en édition). Le solde
// recalculé APRÈS création tient déjà compte du capital de ce nouvel investissement
// (formule officielle, cf. mémoire project_solde_portefeuille) : s'il ressort négatif,
// le porte-monnaie ne couvrait pas l'opération. On propose alors un dépôt du montant
// du découvert (rectifiable par l'utilisateur) plutôt que de bloquer la création.
let soldeApres = null;
try {
const r = await api.get('/dashboard/solde-historique', { plateforme_id: form.plateforme_id, date: today() });
soldeApres = r?.solde ?? null;
} catch {
// Contrôle indisponible — n'empêche pas la création pour autant.
}
close();
if (soldeApres !== null && soldeApres < -0.005) {
const plat = plats.find(p => String(p.id) === String(form.plateforme_id));
setDepositPrompt({
open: true,
plateforme_id: Number(form.plateforme_id),
plateforme_nom: plat?.nom || '',
montant: Math.round(Math.abs(soldeApres) * 100) / 100,
investissementId: created.id,
});
} else {
navigate(`/investissements/${created.id}`);
}
}
} catch (e) { setErr(e.message); }
};
const onDelete = (row) => {
setDeleteConfirm({
message: `Supprimer "${row.nom_projet}" ? Toutes les échéances liées seront effacées.`,
onConfirm: async () => {
await api.del(`/investissements/${row.id}`);
setDeleteConfirm(null);
load();
},
});
};
const openRowMenu = (e, row) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ row, x: rect.right, y: rect.bottom });
};
const hasFilter = !!(filter.statut || filter.plateforme_id || filter.categorie_inv_id || filter.secteur_inv_id || filter.year || filter.month);
const netMode = displayMode === 'net';
const clearFilter = () => setSearchParams(p => { const n = new URLSearchParams(p); ['statut','pid','cat','sect','yr','mo'].forEach(k => n.delete(k)); return n; }, { replace: true });
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
if (!loading && plats.length === 0) return (
<>
<div className="topbar"><h2>Investissements</h2></div>
<EmptyState />
</>
);
if (!loading && !modalOpen && allRows.length === 0) return (
<>
<div className="topbar"><h2>Investissements</h2></div>
<EmptyState
icon="📊"
title="Aucun investissement pour le moment"
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez votre premier investissement pour commencer le suivi."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="investissement" />Investissements</h2>
{hasFilter && (
<button className="dr-clear-btn" onClick={clearFilter}>
✕ Effacer les filtres
</button>
)}
</div>
{/* ── Graphiques ── */}
{!listFocused && <div className="charts-row">
<InvChart rows={chartRows} remboursements={allRembs} reinvestissements={allReinvests} platYear={platYear} />
<DistributionChart rows={invDistRows} />
</div>}
{/* ── KPIs ── */}
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
{/* 1 — Capital investi */}
<div
className={`kpi dr-kpi-clickable${filter.statut === 'en_cours' ? ' dr-kpi-active dr-kpi-active-success' : ''}`}
onClick={() => {
setFilter(f => ({ ...f, statut: f.statut === 'en_cours' ? '' : 'en_cours' }));
setActiveTab('investissements');
}}
title="Filtrer les investissements en cours"
>
<div className="label">Capital investi</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.encours)}</span>
{platYear && <TrendBadge current={totals.encours} prev={prevTotals.encours} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.encours)} en {Number(platYear) - 1}</div>}
</div>
{/* 2 — Investissements à risque */}
<div
className={`kpi${totals.defaut > 0 ? ' dr-kpi-clickable' : ''}${filter.statut === 'defaut' ? ' dr-kpi-active dr-kpi-active-danger' : ''}`}
onClick={() => {
if (totals.defaut === 0) return;
setFilter(f => ({ ...f, statut: f.statut === 'defaut' ? '' : 'defaut' }));
setActiveTab('investissements');
}}
title={totals.defaut > 0 ? 'Filtrer les investissements en retard / procédure' : undefined}
style={totals.defaut > 0 ? { cursor: 'pointer' } : {}}
>
<div className="label">Investissements à risque</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }} className={totals.defaut > 0 ? 'danger' : ''}>{fmtEUR(totals.defaut)}</span>
{platYear && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.defaut)} en {Number(platYear) - 1}</div>}
{totals.pertes > 0 && (
<div style={{ fontSize: '0.8em', color: '#7c2d12', marginTop: 4, fontWeight: 600 }}>
dont {fmtEUR(totals.pertes)} déjà déclarés en perte définitive (non compris ci-dessus)
</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 {Number(platYear) - 1}</div>}
</div>
{/* 4 — Investissements remboursés */}
<div className="kpi">
<div className="label">Investissements remboursés</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cap_remb)}</span>
{platYear && <TrendBadge current={totals.cap_remb} prev={prevTotals.cap_remb} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.cap_remb)} en {Number(platYear) - 1}</div>}
</div>
{/* 5 — Intérêts perçus */}
<div className="kpi">
<div className="label">Intérêts perçus — {netMode ? 'Net' : 'Brut'}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(netMode ? totals.int_perc_net : totals.int_perc)}</span>
{platYear && <TrendBadge current={netMode ? totals.int_perc_net : totals.int_perc} prev={netMode ? prevTotals.int_perc_net : prevTotals.int_perc} />}
</div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {Number(platYear) - 1}</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>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Sélecteur d'année */}
<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>
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
onClick={() => {
const next = !listFocused;
setListFocused(next);
setInvPageSize(next ? 25 : 15);
}}
>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
<ExportDropdown
disabled={platData.length === 0}
onCSV={() => dlBlob(platToCSV(platData), `plateformes-investissements${platYear ? '-' + platYear : ''}.csv`, 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(platToXLS(platData), `plateformes-investissements${platYear ? '-' + platYear : ''}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(platToJSON(platData), `plateformes-investissements${platYear ? '-' + platYear : ''}.json`, 'application/json')}
/>
</div>
</div>
{!allRows.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">Projets</th>
<th className="num">Montant investi</th>
<th className="num">Capital investi</th>
<th className="num">Investissements remboursés</th>
<th className="num">Intérêts ({netMode ? 'Net' : 'Brut'})</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) }));
if (!isActive) setActiveTab('investissements');
}}>
<td>
<span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span>
{p.defaut > 0 && (
<span style={{
marginLeft: 6, fontSize: 10, fontWeight: 600,
padding: '1px 5px', borderRadius: 3,
background: 'color-mix(in srgb, var(--danger) 15%, transparent)',
color: 'var(--danger)', verticalAlign: 'middle',
}}>!</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.encours)}</td>
<td className="num">{fmtEUR(p.cap_remb)}</td>
<td className="num">{fmtEUR(netMode ? p.int_perc_net : p.int_perc)}</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 style={{ padding: '10px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
Total — {platData.length} plateformes
</td>
{multiDetenteur && <td />}
<td className="num" style={{ padding: '10px 12px' }}>{platTotals.count}</td>
<td className="num" style={{ padding: '10px 12px' }}>{fmtEUR(platTotals.investi)}</td>
<td className="num" style={{ padding: '10px 12px' }}>{fmtEUR(platTotals.encours)}</td>
<td className="num" style={{ padding: '10px 12px' }}>{fmtEUR(platTotals.cap_remb)}</td>
<td className="num" style={{ padding: '10px 12px' }}>
{fmtEUR(netMode ? platTotals.int_perc_net : platTotals.int_perc)}
</td>
<td className="num" style={{ padding: '10px 12px' }}>
<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' }}>
<CapitalMensuelTable
allRows={allRows}
allRembs={allRembs}
allReinvests={allReinvests}
plats={plats}
expandButton={
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire' : 'Agrandir'}
style={{ marginLeft: 4 }}
onClick={() => { const next = !listFocused; setListFocused(next); setInvPageSize(next ? 25 : 15); }}
>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
}
/>
</div>
)}
{/* ====== ONGLET INVESTISSEMENTS ====== */}
{activeTab === 'investissements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
{/* En-tête + export */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Liste des investissements
{rows.length !== allRows.length && (
<span style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', fontWeight: 400, color: 'var(--text-muted)' }}>
{rows.length} / {allRows.length}
</span>
)}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
className="icon-btn"
title={listFocused ? 'Réduire la liste' : 'Agrandir la liste'}
onClick={() => {
const next = !listFocused;
setListFocused(next);
setActiveTab('investissements');
setInvPageSize(next ? 25 : 15);
}}
>
{listFocused ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
<line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
)}
</button>
<ExportDropdown
disabled={rows.length === 0}
onCSV={() => dlBlob(invToCSV(rows), 'investissements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(invToXLS(rows), 'investissements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(invToJSON(rows), 'investissements.json', 'application/json')}
/>
</div>
</div>
{/* Filtres */}
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
<div style={{ flex: 1, minWidth: 110 }}>
<label>Statut</label>
<select value={filter.statut === 'defaut' ? '' : filter.statut}
onChange={e => setFilter(f => ({ ...f, statut: e.target.value }))}>
<option value="">Tous</option>
<option value="en_cours">En cours</option>
<option value="rembourse">Remboursé</option>
<option value="en_retard">En retard</option>
<option value="procedure">Procédure</option>
<option value="cloture">Clôturé</option>
<option value="perte_definitive">Perte définitive</option>
<option value="prolongation">Prolongation</option>
</select>
</div>
<div style={{ flex: 1, minWidth: 120 }}>
<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 style={{ flex: 1, minWidth: 130 }}>
<label>Catégorie</label>
<select value={filter.categorie_inv_id}
onChange={e => setFilter(f => ({ ...f, categorie_inv_id: e.target.value }))}>
<option value="">Toutes</option>
{categoriesInv.map(c => <option key={c.id} value={c.id}>{c.nom}</option>)}
</select>
</div>
<div style={{ flex: 1, minWidth: 130 }}>
<label>Secteur</label>
<select value={filter.secteur_inv_id}
onChange={e => setFilter(f => ({ ...f, secteur_inv_id: e.target.value }))}>
<option value="">Tous</option>
{secteursInv.map(s => <option key={s.id} value={s.id}>{s.nom}</option>)}
</select>
</div>
<div style={{ flex: 1, minWidth: 90 }}>
<label>Année</label>
<select value={filter.year}
onChange={e => setFilter(f => ({ ...f, year: e.target.value }))}>
<option value="">Toutes</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div style={{ flex: 1, minWidth: 110 }}>
<label>Mois</label>
<select value={filter.month}
onChange={e => setFilter(f => ({ ...f, 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>
{filter.statut === 'defaut' && (
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<span className="badge en_retard" style={{ marginBottom: 2, whiteSpace: 'nowrap' }}>
En retard + Procédure
</span>
</div>
)}
</div>
<table style={{ cursor: 'pointer' }}>
<thead>
<tr>
<th>Date</th><th>Projet</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
<th className="num">Montant</th><th className="num">Taux</th>
<th className="num">Durée</th><th>Type remb.</th>
<th className="num">Inv. remboursés</th><th className="num">Cap. restant dû</th>
<th className="num">Int. ({netMode ? 'Net' : 'Brut'})</th>
<th>Statut</th>
<th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr><td colSpan={multiDetenteur ? 13 : 12} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun investissement'}
</td></tr>
)}
{pagedRows.map(r => {
const capInv = r.capital_total ?? r.montant_investi;
const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0) - (r.capital_perdu || 0));
return (
<tr key={r.id}
onClick={() => navigate(`/investissements/${r.id}`, { state: { from: { path: '/investissements', search: location.search, label: 'Investissements' } } })}
style={{ cursor: 'pointer' }}
onMouseEnter={e => e.currentTarget.style.opacity = '0.8'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<td>{fmtDate(r.date_souscription)}</td>
<td>
<strong>{r.nom_projet}</strong>
{r.emetteur && <div className="text-muted" style={{ fontSize: 11 }}>{r.emetteur}</div>}
</td>
<td>{r.plateforme_nom}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td className="num">{fmtEUR(capInv)}</td>
<td className="num">{r.taux_interet != null ? fmtPct(r.taux_interet) : '—'}</td>
<td className="num">{r.duree_mois ? `${r.duree_mois} m.` : '—'}</td>
<td className="text-muted" style={{ fontSize: 11 }}>
{TYPE_REMB_LABELS[r.type_remb] || '—'}
{r.freq_interets && r.type_remb !== 'differe' ? ` · ${r.freq_interets}` : ''}
</td>
<td className="num">{fmtEUR(r.capital_rembourse || 0)}</td>
<td className="num" style={{ color: capRestant === 0 ? 'var(--success)' : undefined }}>
{fmtEUR(capRestant)}
</td>
<td className="num">{fmtEUR(netMode ? (r.interets_nets_total || 0) : (r.interets_percus || 0))}</td>
<td><span className={`badge ${r.statut}`}>{fmtStatut(r.statut)}</span></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 }}
onClick={e => openRowMenu(e, r)}>⋮</button>
</td>
</tr>
);
})}
</tbody>
</table>
<Pagination
page={invPage} setPage={setInvPage}
pageSize={invPageSize} setPageSize={setInvPageSize}
totalPages={invTotalPages} totalItems={invTotalItems}
PAGE_SIZES={PAGE_SIZES}
/>
</div>
</div>
)}
{/* ── Modal création / édition ── */}
<InvestissementFormModal
open={modalOpen}
editingId={editingId}
onClose={close}
onSubmit={submit}
form={form}
onFieldChange={(key, value) => setForm(f => ({ ...f, [key]: value }))}
onFormPatch={patch => setForm(f => typeof patch === 'function' ? patch(f) : { ...f, ...patch })}
err={err}
plats={plats}
multiDetenteur={multiDetenteur}
investisseurs={investisseurs}
comptesInvestisseur={comptesInvestisseur}
onPlateformeChange={handlePlateformeChange}
categoriesInv={categoriesInv}
secteursInv={secteursInv}
onCategoryAdded={handleCategoryAdded}
onSectorAdded={handleSectorAdded}
width={720}
/>
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{
position: 'fixed', left: openMenu.x, top: openMenu.y,
transform: 'translateX(-100%) translateY(4px)',
zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: '4px 0', minWidth: 140,
}}>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setOpenMenu(null); openEdit(openMenu.row); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Modifier
</button>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setOpenMenu(null); onDelete(openMenu.row); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Supprimer
</button>
</div>
</>
)}
<ConfirmModal
open={!!deleteConfirm}
message={deleteConfirm?.message}
onConfirm={deleteConfirm?.onConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
{/* ── Proposition de dépôt si le porte-monnaie ne couvrait pas le nouvel
investissement (cf. submit()) — montant rectifiable avant de créer le dépôt. ── */}
{depositPrompt.open && (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.4)',
}}>
<div style={{
background: 'var(--surface)', borderRadius: 10,
padding: '24px 28px', width: 420, maxWidth: '90vw',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
border: '1px solid var(--border)',
}}>
<div style={{ marginBottom: 6, fontWeight: 700, fontSize: 'var(--fs-base)' }}>
Porte-monnaie insuffisant
</div>
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', marginBottom: 18, lineHeight: 1.5 }}>
Une fois cet investissement pris en compte, le porte-monnaie de <strong>{depositPrompt.plateforme_nom}</strong> est
à découvert. Voulez-vous créer un dépôt pour le couvrir ?
</div>
<div style={{ marginBottom: 18 }}>
<label style={{ fontSize: 'var(--fs-sm)', fontWeight: 500, display: 'block', marginBottom: 4 }}>
Montant du dépôt (€)
</label>
<input
type="number" step="0.01" min="0"
value={depositPrompt.montant}
onChange={e => setDepositPrompt(p => ({ ...p, montant: e.target.value }))}
style={{ width: '100%', fontSize: 'var(--fs-base)' }}
autoFocus
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => {
const id = depositPrompt.investissementId;
setDepositPrompt(p => ({ ...p, open: false }));
navigate(`/investissements/${id}`);
}}>
Plus tard
</button>
<button
className="primary"
disabled={!depositPrompt.montant || Number(depositPrompt.montant) <= 0}
onClick={() => {
const { plateforme_id, montant } = depositPrompt;
setDepositPrompt(p => ({ ...p, open: false }));
navigate(`/depots-retraits?new=1&plateforme_id=${plateforme_id}&montant=${encodeURIComponent(montant)}&autoCreate=1`);
}}
>
Créer le dépôt
</button>
</div>
</div>
</div>
)}
</>
);
}