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 } from 'react-router-dom'; import { api } from '../api.js'; import InvSelect from '../components/InvSelect.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 CountrySelect from '../components/CountrySelect.jsx'; 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 }) { if (prev == null || prev === 0) 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 ( {arrow} {label} ); } /* ── 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)); 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 (
{open && (
)}
); } /* ── 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: [], }; function addMonthsFE(isoDate, n) { if (!isoDate || !n) return ''; const [y, m, d] = isoDate.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1 + n, d)); return dt.toISOString().slice(0, 10); } /** Retourne le dernier jour du mois de isoDate. Ex : '2024-01-28' → '2024-01-31' */ function lastDayOfMonthFE(isoDate) { if (!isoDate) return isoDate; const [y, m] = isoDate.split('-').map(Number); const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant return dt.toISOString().slice(0, 10); } /** * Ajoute n mois et positionne sur le dernier jour du mois cible. * Évite les débordements JS (31 jan + 1 = 3 mar → ici = 29 fév). */ function addMonthsFinDeMoisFE(isoDate, n) { if (!isoDate || (n === undefined || n === null)) return ''; const [y, m] = isoDate.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1 + Number(n) + 1, 0)); return dt.toISOString().slice(0, 10); } /** Retourne le numéro du jour dans le mois (1-31) ou 0 si absent. */ function dayOfMonth(isoDate) { if (!isoDate || isoDate.length < 10) return 0; return parseInt(isoDate.slice(8, 10), 10); } /* ── Composant principal ─────────────────────────────────────── */ export default function Investissements() { const { activeId, activeView, investisseurs } = useInvestisseur(); const { displayMode } = useUi(); const { search } = useLocation(); const navigate = useNavigate(); 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([]); const [activeTab, setActiveTab] = useState('plateformes'); const [listFocused, setListFocused] = useState(false); const [filter, setFilter] = useState({ statut: '', plateforme_id: '', categorie_inv_id: '', secteur_inv_id: '', year: '', month: '' }); const [categoriesInv, setCategoriesInv] = useState([]); const [secteursInv, setSecteursInv] = useState([]); const [platYear, setPlatYear] = useState(String(new Date().getFullYear())); 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); /* ── 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']; if (ACTIVE.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); 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 }), [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); 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); // 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); 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 (new URLSearchParams(search).get('new') !== '1') return; navigate('/investissements', { replace: true }); if (plats.length > 0) { setEditingId(null); setForm({ ...empty }); setErr(null); setModalOpen(true); } else { pendingNew.current = true; } }, [search]); /* 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 */ /* ── 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); }; 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 || [] }), ]); close(); 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 = () => setFilter({ statut: '', plateforme_id: '', categorie_inv_id: '', secteur_inv_id: '', year: '', month: '' }); const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1; if (!loading && plats.length === 0) return ( <>

Investissements

); return ( <>

Investissements

{hasFilter && ( )}
{/* ── Graphiques ── */} {!listFocused &&
} {/* ── KPIs ── */} {!listFocused &&
{/* 1 — Capital investi */}
{ setFilter(f => ({ ...f, statut: f.statut === 'en_cours' ? '' : 'en_cours' })); setActiveTab('investissements'); }} title="Filtrer les investissements en cours" >
Capital investi
{fmtEUR(totals.encours)} {prevTotals && }
{prevTotals &&
{fmtEUR(prevTotals.encours)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}
}
{/* 2 — Investissements à risque */}
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' } : {}} >
Investissements à risque
0 ? 'danger' : ''}>{fmtEUR(totals.defaut)} {prevTotals && totals.defaut > 0 && }
{prevTotals &&
{fmtEUR(prevTotals.defaut)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}
}
{/* 3 — Investissements depuis le début */}
hasFilter && clearFilter()} title={hasFilter ? 'Effacer les filtres' : undefined} style={hasFilter ? { cursor: 'pointer' } : {}} >
Investissements depuis le début
{fmtEUR(totals.investi)} {prevTotals && }
{prevTotals &&
{fmtEUR(prevTotals.investi)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}
}
{/* 4 — Investissements remboursés */}
Investissements remboursés
{fmtEUR(totals.cap_remb)} {prevTotals && }
{prevTotals &&
{fmtEUR(prevTotals.cap_remb)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}
}
{/* 5 — Intérêts perçus */}
Intérêts perçus — {netMode ? 'Net' : 'Brut'}
{fmtEUR(netMode ? totals.int_perc_net : totals.int_perc)} {prevTotals && }
{prevTotals &&
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}
}
} {/* ── Onglets ── */} {!listFocused &&
} {/* ====== ONGLET PLATEFORMES ====== */} {activeTab === 'plateformes' && (

Répartition par plateforme {platYear && ( pour l'année {platYear} )}

{/* Sélecteur d'année */} 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')} />
{!allRows.length && loading ? (
Chargement…
) : ( {platData.length === 0 && ( )} {platData.map(p => { const isActive = String(filter.plateforme_id) === String(p.plateforme_id); return ( { setFilter(f => ({ ...f, plateforme_id: isActive ? '' : String(p.plateforme_id) })); if (!isActive) setActiveTab('investissements'); }}> ); })} {platData.length > 1 && ( )}
Plateforme Détenteur Projets Montant investi Capital investi Investissements remboursés Intérêts ({netMode ? 'Net' : 'Brut'}) Poids
Aucun investissement
{p.nom} {p.defaut > 0 && ( ! )} {p.detenteur_nom || '—'} {p.count} {fmtEUR(p.investi)} {fmtEUR(p.encours)} {fmtEUR(p.cap_remb)} {fmtEUR(netMode ? p.int_perc_net : p.int_perc)}
{p.poids.toFixed(1)} %
{isActive ? '✕' : ''}
Total — {platData.length} plateformes {platTotals.count} {fmtEUR(platTotals.investi)} {fmtEUR(platTotals.encours)} {fmtEUR(platTotals.cap_remb)} {fmtEUR(netMode ? platTotals.int_perc_net : platTotals.int_perc)} 100 %
)}
)} {/* ====== ONGLET VISION MENSUELLE ====== */} {activeTab === 'vision-mensuelle' && (
{ const next = !listFocused; setListFocused(next); setInvPageSize(next ? 25 : 15); }} > {listFocused ? ( ) : ( )} } />
)} {/* ====== ONGLET INVESTISSEMENTS ====== */} {activeTab === 'investissements' && (
{/* En-tête + export */}

Liste des investissements {rows.length !== allRows.length && ( {rows.length} / {allRows.length} )}

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')} />
{/* Filtres */}
{filter.statut === 'defaut' && (
En retard + Procédure
)}
{rows.length === 0 && ( )} {pagedRows.map(r => { const capInv = r.capital_total ?? r.montant_investi; const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0)); return ( navigate(`/investissements/${r.id}`)} style={{ cursor: 'pointer' }} onMouseEnter={e => e.currentTarget.style.opacity = '0.8'} onMouseLeave={e => e.currentTarget.style.opacity = '1'} > ); })}
DateProjetPlateformeDétenteur MontantTaux DuréeType remb. Inv. remboursésCap. restant dû Int. ({netMode ? 'Net' : 'Brut'}) Statut
{loading ? 'Chargement…' : 'Aucun investissement'}
{fmtDate(r.date_souscription)} {r.nom_projet} {r.emetteur &&
{r.emetteur}
}
{r.plateforme_nom} {r.plateforme_detenteur_nom || '—'} {fmtEUR(capInv)} {r.taux_interet != null ? fmtPct(r.taux_interet) : '—'} {r.duree_mois ? `${r.duree_mois} m.` : '—'} {TYPE_REMB_LABELS[r.type_remb] || '—'} {r.freq_interets && r.type_remb !== 'differe' ? ` · ${r.freq_interets}` : ''} {fmtEUR(r.capital_rembourse || 0)} {fmtEUR(capRestant)} {fmtEUR(netMode ? (r.interets_nets_total || 0) : (r.interets_percus || 0))} {fmtStatut(r.statut)} e.stopPropagation()}>
)} {/* ── Modal création / édition ── */} } >
{err &&
{err}
}
{(() => { const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement; if (!platMethode) return null; if (platMethode === 'choix_investisseur') return (
); // Méthode fixée par la plateforme — lecture seule return (
); })()} {form.methode_remboursement === 'compte_courant' && (
{comptesInvestisseur.length > 0 ? ( ) : (

Aucun compte défini pour ce détenteur. Créer un compte →

)}
)} {/* Détenteur auto-déduit de la plateforme — champ masqué */}
setForm({ ...form, nom_projet: e.target.value })} />
setForm({ ...form, montant_investi: e.target.value })} />
setForm({ ...form, taux_interet: e.target.value })} />
{ const duree = e.target.value; const next = { ...form, duree_mois: duree }; if (duree) { if (form.type_remb === 'differe' && form.date_souscription) { const tempDpe = addMonthsFE(form.date_souscription, 1); const cible = addMonthsFE(tempDpe, Number(duree) - 1); next.date_premiere_echeance = cible; next.date_cible = cible; } else if (form.date_premiere_echeance) { next.date_cible = addMonthsFE(form.date_premiere_echeance, Number(duree) - 1); } } setForm(next); }} />
{ const souscription = e.target.value; setForm(prev => { const next = { ...prev, date_souscription: souscription }; if (!souscription || souscription.length !== 10) return next; if (prev.type_remb === 'differe') { if (prev.duree_mois) { const tempDpe = addMonthsFE(souscription, 1); const cible = addMonthsFE(tempDpe, Number(prev.duree_mois) - 1); next.date_premiere_echeance = cible; next.date_cible = cible; } } else { next.date_premiere_echeance = addMonthsFE(souscription, 1); if (next.date_premiere_echeance && prev.duree_mois) { next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(prev.duree_mois) - 1); } } return next; }); if (souscription && souscription.length === 10) { lastValidSouscriptionRef.current = souscription; } }} />
{ const dpe = e.target.value; const next = { ...form, date_premiere_echeance: dpe }; // Si le jour passe à ≤ 27 on désactive fin-de-mois if (dayOfMonth(dpe) <= 27) next.echeance_fin_de_mois = false; if (form.type_remb === 'differe') { next.date_cible = dpe; } else if (dpe && form.duree_mois) { next.date_cible = next.echeance_fin_de_mois ? addMonthsFinDeMoisFE(dpe, Number(form.duree_mois) - 1) : addMonthsFE(dpe, Number(form.duree_mois) - 1); } setForm(next); }} /> {/* ── Case "Dernier jour du mois" — visible si jour > 27 ── */} {(form.type_remb === 'in_fine' || form.type_remb === 'differe') && dayOfMonth(form.date_premiere_echeance) > 27 && ( )}
setForm({ ...form, date_cible: e.target.value })} />
setForm(f => ({ ...f, pays_exposition: code }))} />
setForm({ ...form, emetteur: e.target.value })} placeholder="Nom de l'emprunteur" />
{editingId && (
)}
setForm({ ...form, reference: e.target.value })} placeholder="N° de dossier, code projet…" />