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

1616 lines
78 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 } 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 (
<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));
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: [],
};
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 (
<>
<div className="topbar"><h2>Investissements</h2></div>
<EmptyState />
</>
);
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>
{prevTotals && <TrendBadge current={totals.encours} prev={prevTotals.encours} />}
</div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.encours)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 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>
{prevTotals && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />}
</div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.defaut)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 1}</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>
{prevTotals && <TrendBadge current={totals.investi} prev={prevTotals.investi} />}
</div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.investi)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 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>
{prevTotals && <TrendBadge current={totals.cap_remb} prev={prevTotals.cap_remb} />}
</div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.cap_remb)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 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>
{prevTotals && <TrendBadge current={netMode ? totals.int_perc_net : totals.int_perc} prev={netMode ? prevTotals.int_perc_net : prevTotals.int_perc} />}
</div>
{prevTotals && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {platYear ? Number(platYear) - 1 : new Date().getFullYear() - 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>
<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={9} 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>
<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>
<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>
</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><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 </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={13} 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));
return (
<tr key={r.id}
onClick={() => navigate(`/investissements/${r.id}`)}
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>
<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 ── */}
<Modal
open={modalOpen}
title={editingId ? 'Modifier l\'investissement' : 'Nouvel investissement'}
onClose={close}
width={720}
footer={
<>
<button onClick={close}>Annuler</button>
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
</>
}
>
<form onSubmit={submit}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Plateforme *</label>
<select required value={form.plateforme_id} onChange={e => {
const platId = e.target.value;
const platObj = plats.find(p => String(p.id) === String(platId));
const platMethode = platObj?.methode_remboursement;
const newMethode = platMethode === 'choix_investisseur' ? '' : (platMethode || '');
// Pré-remplir catégories/secteurs depuis la plateforme
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: [] }));
}
}}>
<option value=""></option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
</select>
</div>
{(() => {
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
if (!platMethode) return null;
if (platMethode === 'choix_investisseur') return (
<div>
<label>Méthode de remboursement</label>
<select value={form.methode_remboursement} onChange={e => {
const methode = e.target.value;
const defaultCompte = methode === 'compte_courant'
? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0])
: null;
setForm({ ...form, methode_remboursement: methode, nom_compte_courant: defaultCompte?.nom || '', compte_id: String(defaultCompte?.id || '') });
}}>
<option value=""> Non renseignée </option>
<option value="portefeuille">Porte-monnaie de la plateforme</option>
<option value="compte_courant">Compte courant de l'investisseur</option>
</select>
</div>
);
// Méthode fixée par la plateforme — lecture seule
return (
<div>
<label>Méthode de remboursement</label>
<input readOnly value={platMethode === 'compte_courant' ? "Compte courant de l'investisseur" : 'Porte-monnaie de la plateforme'}
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'default' }} />
</div>
);
})()}
{form.methode_remboursement === 'compte_courant' && (
<div>
<label>Compte de réception *</label>
{comptesInvestisseur.length > 0 ? (
<select
required
value={form.compte_id}
onChange={e => {
const c = comptesInvestisseur.find(c => String(c.id) === e.target.value);
setForm({ ...form, compte_id: e.target.value, nom_compte_courant: c?.nom || '' });
}}
>
<option value="">— Choisir un compte —</option>
{comptesInvestisseur.map(c => (
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
))}
</select>
) : (
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
</p>
)}
</div>
)}
{/* Détenteur auto-déduit de la plateforme — champ masqué */}
<input type="hidden" value={form.investisseur_id} />
<div style={{ gridColumn: 'span 2' }}>
<label>Nom du projet *</label>
<input required value={form.nom_projet} onChange={e => setForm({ ...form, nom_projet: e.target.value })} />
</div>
<div>
<label>Montant investi (€) *</label>
<input type="number" step="0.01" min="0" required value={form.montant_investi}
onChange={e => setForm({ ...form, montant_investi: e.target.value })} />
</div>
<div>
<label>Taux annuel (%) *</label>
<input type="number" step="0.01" required value={form.taux_interet}
onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
</div>
<div>
<label>Durée (mois) *</label>
<input type="number" min="0" required value={form.duree_mois} onChange={e => {
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);
}} />
</div>
<div>
<label>Type de prêt</label>
<select value={form.type_remb} onChange={e => {
const t = e.target.value;
const next = {
...form, type_remb: t,
freq_interets: t === 'differe' ? 'in_fine' : (form.freq_interets === 'in_fine' ? 'mensuel' : form.freq_interets),
};
if (t === 'differe') {
// Recalculer la date d'échéance depuis souscription+durée (prioritaire sur l'ancienne valeur)
if (form.date_souscription && form.duree_mois) {
const dpe = addMonthsFE(form.date_souscription, 1);
const cible = addMonthsFE(dpe, Number(form.duree_mois) - 1);
next.date_premiere_echeance = cible;
next.date_cible = cible;
} else if (form.date_premiere_echeance) {
next.date_cible = form.date_premiere_echeance;
}
} else {
if (form.date_souscription) {
next.date_premiere_echeance = addMonthsFE(form.date_souscription, 1);
if (next.date_premiere_echeance && form.duree_mois) {
next.date_cible = addMonthsFE(next.date_premiere_echeance, Number(form.duree_mois) - 1);
}
}
}
setForm(next);
}}>
<option value="in_fine">Prêt in fine</option>
<option value="amortissable">Prêt amortissable</option>
<option value="differe">Prêt différé</option>
</select>
</div>
<div>
<label>Fréquence intérêts</label>
<select
value={form.type_remb === 'differe' ? 'in_fine' : form.freq_interets}
disabled={form.type_remb === 'differe'}
onChange={e => setForm({ ...form, freq_interets: e.target.value })}
>
{form.type_remb === 'differe' ? (
<option value="in_fine">In fine (unique)</option>
) : (
<>
<option value="mensuel">Mensuel</option>
<option value="trimestriel">Trimestriel</option>
</>
)}
</select>
</div>
<div>
<label>Date souscription *</label>
<input type="date" required value={form.date_souscription} onChange={e => {
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;
}
}} />
</div>
<div>
<label>{form.type_remb === 'differe' ? "Date d'échéance (versement unique) *" : 'Date 1ère échéance *'}</label>
<input type="date" required value={form.date_premiere_echeance}
onChange={e => {
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 && (
<label style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
marginTop: 6, cursor: 'pointer', userSelect: 'none',
fontSize: 'var(--fs-sm)', color: 'var(--text)',
width: 'fit-content',
}}>
<input
type="checkbox"
style={{ width: 'auto', flexShrink: 0 }}
checked={form.echeance_fin_de_mois}
onChange={e => {
const checked = e.target.checked;
setForm(prev => {
const next = { ...prev, echeance_fin_de_mois: checked };
if (checked && prev.date_premiere_echeance) {
const newDpe = lastDayOfMonthFE(prev.date_premiere_echeance);
next.date_premiere_echeance = newDpe;
if (prev.type_remb === 'differe') {
next.date_cible = newDpe;
} else if (newDpe && prev.duree_mois) {
next.date_cible = addMonthsFinDeMoisFE(newDpe, Number(prev.duree_mois) - 1);
}
}
return next;
});
}}
/>
Dernier jour du mois
</label>
)}
</div>
<div>
<label>Date cible contractuelle{form.type_remb === 'differe' ? ' calculée' : ''}</label>
<input type="date" value={form.date_cible}
readOnly={form.type_remb === 'differe'}
onChange={e => setForm({ ...form, date_cible: e.target.value })}
/>
</div>
<div>
<label>Pays d'exposition</label>
<CountrySelect
value={form.pays_exposition || 'FR'}
onChange={code => setForm(f => ({ ...f, pays_exposition: code }))}
/>
</div>
<div>
<label>Émetteur</label>
<input value={form.emetteur}
onChange={e => setForm({ ...form, emetteur: e.target.value })}
placeholder="Nom de l'emprunteur"
/>
</div>
{editingId && (
<div>
<label>Statut</label>
<select value={form.statut} onChange={e => setForm({ ...form, statut: e.target.value })}>
<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>
</select>
</div>
)}
<div>
<label>Référence</label>
<input value={form.reference}
onChange={e => setForm({ ...form, reference: e.target.value })}
placeholder="N° de dossier, code projet…"
/>
</div>
<div style={{ gridColumn: 'span 2' }}>
<label>Notes</label>
<textarea rows={3} value={form.notes}
onChange={e => setForm({ ...form, notes: e.target.value })}
placeholder="Informations complémentaires…"
/>
</div>
{/* Catégories d'investissement */}
<div style={{ gridColumn: 'span 3' }}>
<label>Catégories d'investissement</label>
<InvSelect
items={categoriesInv}
selected={form.categories_inv_ids || []}
onChange={ids => setForm(f => ({ ...f, categories_inv_ids: ids }))}
addApiPath="/categories-inv"
onItemAdded={cat => setCategoriesInv(prev => [...prev, cat].sort((a,b) => a.nom.localeCompare(b.nom)))}
emptyLabel="Aucune catégorie d'investissement"
addLabel="Ajouter une catégorie d'investissement"
inputPlaceholder="Nom de la catégorie…"
/>
</div>
{/* Secteurs d'investissement */}
<div style={{ gridColumn: 'span 3' }}>
<label>Secteurs d'investissement</label>
<InvSelect
items={secteursInv}
selected={form.secteurs_inv_ids || []}
onChange={ids => setForm(f => ({ ...f, secteurs_inv_ids: ids }))}
addApiPath="/secteurs-inv"
onItemAdded={sect => setSecteursInv(prev => [...prev, sect].sort((a,b) => a.nom.localeCompare(b.nom)))}
emptyLabel="Aucun secteur d'investissement"
addLabel="Ajouter un secteur d'investissement"
inputPlaceholder="Nom du secteur…"
/>
</div>
</div>
</form>
</Modal>
{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)}
/>
</>
);
}