1616 lines
78 KiB
React
1616 lines
78 KiB
React
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 dû</th>
|
||
<th className="num">Int. ({netMode ? 'Net' : 'Brut'})</th>
|
||
<th>Statut</th>
|
||
<th style={{ width: 36 }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.length === 0 && (
|
||
<tr><td colSpan={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)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|