1672 lines
84 KiB
React
1672 lines
84 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 { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||
import { useUi } from '../context/UiContext.jsx';
|
||
import Modal from '../components/Modal.jsx';
|
||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||
import InteretsChart from '../components/InteretsChart.jsx';
|
||
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
|
||
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
|
||
import DrillCellPanel from '../components/DrillCellPanel.jsx';
|
||
import { InteretsChartProvider } from '../context/InteretsChartContext.jsx';
|
||
import { fmtEUR, fmtDate, today, memberLabel } from '../utils/format.js';
|
||
import * as XLSX from 'xlsx';
|
||
|
||
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||
|
||
/* ── 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>
|
||
);
|
||
}
|
||
|
||
|
||
|
||
const BONUS_VALUES = ['BONUS_PARRAINAGE', 'BONUS_PLATEFORME'];
|
||
const ICONS_BASE = '/api/icons-files/';
|
||
const BONUS_TYPE_MAP = { BONUS_PARRAINAGE: 'bonus_parrainage', BONUS_PLATEFORME: 'bonus_plateforme' };
|
||
|
||
const empty = {
|
||
investissement_id: '', date_remb: today(),
|
||
capital: 0, cashback: 0,
|
||
interets_bruts_avant_local: 0,
|
||
taxe_locale: 0,
|
||
interets_bruts: 0, prelev_sociaux: 0,
|
||
prelev_forfaitaire: 0,
|
||
statut: 'paye', notes: '',
|
||
methode_remboursement: 'portefeuille',
|
||
compte_id: '',
|
||
// champs bonus
|
||
bonus_plateforme_id: '',
|
||
bonus_investisseur_id: '',
|
||
};
|
||
|
||
/* ── Export helpers ─────────────────────────────────────────── */
|
||
function dlBlob(content, filename, type) {
|
||
const blob = new Blob([content], { type });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = filename; a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function rembToCSV(rows) {
|
||
const BOM = ''; const sep = ';';
|
||
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||
const headers = ['Date','Plateforme','Projet','Capital (€)','Cashback (€)','Intérêts bruts (€)','Prélèv. sociaux (€)','Impôt revenu (€)','Intérêts nets (€)','Net reçu (€)'];
|
||
const data = rows.map(r => [
|
||
r.date_remb, r.plateforme_nom||'', r.nom_projet||'',
|
||
String(r.capital||0).replace('.',','), String(r.cashback||0).replace('.',','),
|
||
String(r.interets_bruts||0).replace('.',','), String(r.prelev_sociaux||0).replace('.',','),
|
||
String(r.prelev_forfaitaire||0).replace('.',','), String(r.interets_nets||0).replace('.',','),
|
||
String(r.net_recu||0).replace('.',','),
|
||
]);
|
||
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
|
||
}
|
||
|
||
function rembToXLS(rows) {
|
||
const data = rows.map(r => ({
|
||
'Date': r.date_remb,
|
||
'Plateforme': r.plateforme_nom || '',
|
||
'Projet': r.nom_projet || '',
|
||
'Capital (€)': r.capital || 0,
|
||
'Cashback (€)': r.cashback || 0,
|
||
'Intérêts bruts (€)': r.interets_bruts || 0,
|
||
'Prélèv. sociaux (€)': r.prelev_sociaux || 0,
|
||
'Impôt revenu (€)': r.prelev_forfaitaire || 0,
|
||
'Intérêts nets (€)': r.interets_nets || 0,
|
||
'Net reçu (€)': r.net_recu || 0,
|
||
}));
|
||
const ws = XLSX.utils.json_to_sheet(data);
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, 'Remboursements');
|
||
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
||
}
|
||
function rembToJSON(rows) {
|
||
return JSON.stringify(rows.map(r => ({
|
||
date_remb: r.date_remb, plateforme: r.plateforme_nom||'', projet: r.nom_projet||'',
|
||
capital: r.capital||0, cashback: r.cashback||0,
|
||
interets_bruts: r.interets_bruts||0, prelev_sociaux: r.prelev_sociaux||0,
|
||
prelev_forfaitaire: r.prelev_forfaitaire||0, interets_nets: r.interets_nets||0,
|
||
net_recu: r.net_recu||0,
|
||
})), 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>
|
||
);
|
||
}
|
||
|
||
/* ── Composant principal ─────────────────────────────────────── */
|
||
export default function Remboursements() {
|
||
const { activeId, activeView, investisseurs } = useInvestisseur();
|
||
const { displayMode } = useUi();
|
||
const { search } = useLocation();
|
||
const navigate = useNavigate();
|
||
|
||
/* netMode dérivé du contexte global */
|
||
const netMode = displayMode === 'net';
|
||
|
||
/* ── State ── */
|
||
const [allRows, setAllRows] = useState([]);
|
||
const [corrections, setCorrections] = useState([]);
|
||
const [investissements, setInvestissements] = useState([]);
|
||
const [plateformes, setPlateformes] = useState([]);
|
||
const [pfuRates, setPfuRates] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
/* Charts / filtres visuels */
|
||
const [filterPlatId, setFilterPlatId] = useState(''); // filtre depuis onglet Plateformes
|
||
|
||
/* Onglets */
|
||
const [activeTab, setActiveTab] = useState('plateformes');
|
||
const [listFocused, setListFocused] = useState(false);
|
||
|
||
/* ── Drill-down cellule vision mensuelle ── */
|
||
const [drillCell, setDrillCell] = useState(null);
|
||
const [drillRefreshKey, setDrillRefreshKey] = useState(0);
|
||
const drillPanelRef = useRef(null);
|
||
const tipTableRef = useRef(null);
|
||
|
||
/* ── Navigation retour Dashboard ── */
|
||
const autoOpenRef = useRef(false); // garantit l'ouverture auto une seule fois
|
||
const backToDashboardRef = useRef(null); // URL de retour quand from=dashboard
|
||
|
||
const handleCellClick = (cellInfo) => {
|
||
setDrillCell(prev => (prev?.key === cellInfo.key ? null : cellInfo));
|
||
};
|
||
|
||
/* Scroll automatique à l'ouverture/fermeture du panneau */
|
||
useEffect(() => {
|
||
if (drillCell) {
|
||
// Petit délai pour laisser React rendre le panneau avant de scroller
|
||
setTimeout(() => {
|
||
drillPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}, 50);
|
||
} else {
|
||
setTimeout(() => {
|
||
tipTableRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}, 50);
|
||
}
|
||
}, [drillCell]);
|
||
|
||
/* Filtre du tableau Remboursements */
|
||
const [tableFilter, setTableFilter] = useState({ plateforme_id: '', investissement_id: '', year: '', month: '', type: '' });
|
||
|
||
/* Filtre année du tableau Plateformes */
|
||
const [rembPlatYear, setRembPlatYear] = useState(String(new Date().getFullYear()));
|
||
|
||
/* Modal */
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [form, setForm] = useState(empty);
|
||
const [err, setErr] = useState(null);
|
||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||
const [corrDeleteConfirm, setCorrDeleteConfirm] = useState(null);
|
||
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
|
||
const [libIcons, setLibIcons] = useState({});
|
||
const [filterPortefeuille, setFilterPortefeuille] = useState(true);
|
||
const [filterCompteCourant, setFilterCompteCourant] = useState(true);
|
||
const [openMenu, setOpenMenu] = useState(null);
|
||
|
||
/* Projections échéances */
|
||
const [selectedSimul, setSelectedSimul] = useState('');
|
||
const [echeances, setEcheances] = useState([]);
|
||
const [simulBusy, setSimulBusy] = useState(false);
|
||
const [simulMsg, setSimulMsg] = useState(null);
|
||
|
||
/* ── Chargement ── */
|
||
const load = async () => {
|
||
if (!activeId && activeView !== 'all') return;
|
||
setLoading(true);
|
||
setAllRows([]); // vide les données résiduelles
|
||
setInvestissements([]); // idem pour la liste projections
|
||
setSelectedSimul(''); // reset sélection projection
|
||
try {
|
||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||
const [r, inv, corr] = await Promise.all([
|
||
api.get('/remboursements', scopeParams),
|
||
api.get('/investissements', scopeParams),
|
||
api.get('/corrections', scopeParams).catch(() => []),
|
||
]);
|
||
setAllRows(r); setInvestissements(inv); setCorrections(corr);
|
||
} finally {
|
||
setLoading(false);
|
||
setDrillRefreshKey(k => k + 1);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
api.get('/pfu').then(setPfuRates).catch(() => {});
|
||
api.get('/plateformes').then(setPlateformes).catch(() => {});
|
||
api.get('/icons').then(rows => { const m = {}; rows.forEach(r => { m[r.name] = r.filename; }); setLibIcons(m); }).catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
|
||
|
||
// Ferme tous les menus contextuels au scroll
|
||
useEffect(() => {
|
||
const closeAll = () => {
|
||
setOpenMenu(null);
|
||
};
|
||
window.addEventListener('scroll', closeAll, true);
|
||
return () => window.removeEventListener('scroll', closeAll, true);
|
||
}, []);
|
||
|
||
/* Auto-sélection du premier investissement dès le chargement */
|
||
useEffect(() => {
|
||
if (selectedSimul || !investissements.length) return;
|
||
setSelectedSimul(String(investissements[0].id));
|
||
}, [investissements]); /* eslint-disable-next-line */
|
||
|
||
useEffect(() => {
|
||
if (!selectedSimul) { setEcheances([]); return; }
|
||
api.get('/simul', { investissement_id: selectedSimul }).then(setEcheances).catch(() => {});
|
||
}, [selectedSimul]);
|
||
|
||
const generateSimul = async () => {
|
||
if (!selectedSimul) return;
|
||
setSimulBusy(true); setSimulMsg(null);
|
||
try {
|
||
const r = await api.post('/simul/generate', { investissement_id: Number(selectedSimul), replace: true });
|
||
setSimulMsg(`✔ ${r.inserted} échéances générées.`);
|
||
const e = await api.get('/simul', { investissement_id: selectedSimul });
|
||
setEcheances(e);
|
||
} catch (e) { setSimulMsg(`✗ ${e.message}`); }
|
||
finally { setSimulBusy(false); }
|
||
};
|
||
|
||
/* ── Données pour graphiques et KPI : filtrées par plateforme ET année ── */
|
||
const chartRows = useMemo(() =>
|
||
allRows.filter(r => {
|
||
if (filterPlatId && String(r.plateforme_id) !== filterPlatId) return false;
|
||
if (rembPlatYear && r.date_remb.slice(0, 4) !== rembPlatYear) return false;
|
||
return true;
|
||
}),
|
||
[allRows, filterPlatId, rembPlatYear]
|
||
);
|
||
|
||
/* ── Corrections filtrées (même scope plateforme + année) ── */
|
||
const filteredCorrections = useMemo(() =>
|
||
corrections.filter(c => {
|
||
if (filterPlatId && String(c.plateforme_id) !== filterPlatId) return false;
|
||
if (rembPlatYear && c.date.slice(0, 4) !== rembPlatYear) return false;
|
||
return true;
|
||
}),
|
||
[corrections, filterPlatId, rembPlatYear]
|
||
);
|
||
|
||
/* ── KPIs (calculés sur chartRows + corrections) ── */
|
||
const totals = useMemo(() => {
|
||
const base = chartRows.reduce((acc, r) => {
|
||
acc.capital += r.capital || 0;
|
||
acc.cashback += r.cashback || 0;
|
||
acc.interets += r.interets_bruts || 0;
|
||
acc.interets_nets += r.interets_nets || 0;
|
||
return acc;
|
||
}, { capital: 0, cashback: 0, interets: 0, interets_nets: 0 });
|
||
// Les corrections s'ajoutent uniquement au net (brut = 0)
|
||
base.interets_nets += filteredCorrections.reduce((s, c) => s + c.montant, 0);
|
||
return base;
|
||
}, [chartRows, filteredCorrections]);
|
||
|
||
/* ── Totaux KPI année N-1 (pour TrendBadge) ── */
|
||
const rembPrevYear = String(Number(rembPlatYear || new Date().getFullYear()) - 1);
|
||
const prevTotals = useMemo(() => {
|
||
const prevRows = allRows.filter(r => {
|
||
if (filterPlatId && String(r.plateforme_id) !== filterPlatId) return false;
|
||
return r.date_remb.slice(0, 4) === rembPrevYear;
|
||
});
|
||
const base = prevRows.reduce((acc, r) => {
|
||
acc.capital += r.capital || 0;
|
||
acc.cashback += r.cashback || 0;
|
||
acc.interets += r.interets_bruts || 0;
|
||
acc.interets_nets += r.interets_nets || 0;
|
||
return acc;
|
||
}, { capital: 0, cashback: 0, interets: 0, interets_nets: 0 });
|
||
const prevCorr = corrections.filter(c => {
|
||
if (filterPlatId && String(c.plateforme_id) !== filterPlatId) return false;
|
||
return c.date.slice(0, 4) === rembPrevYear;
|
||
});
|
||
base.interets_nets += prevCorr.reduce((s, c) => s + c.montant, 0);
|
||
return base;
|
||
}, [allRows, corrections, filterPlatId, rembPrevYear]);
|
||
|
||
/* ── Données par plateforme (onglet Plateformes) ── */
|
||
const byPlatform = useMemo(() => {
|
||
const src = rembPlatYear
|
||
? allRows.filter(r => r.date_remb.slice(0, 4) === rembPlatYear)
|
||
: allRows;
|
||
const map = {};
|
||
for (const r of src) {
|
||
const key = String(r.plateforme_id);
|
||
if (!map[key]) map[key] = {
|
||
plateforme_id: r.plateforme_id, nom: r.plateforme_nom,
|
||
detenteur_nom: r.plateforme_detenteur_nom || null,
|
||
capital: 0, cashback: 0, interets_bruts: 0, interets_nets: 0, net_recu: 0, count: 0,
|
||
};
|
||
map[key].capital += r.capital || 0;
|
||
map[key].cashback += r.cashback || 0;
|
||
map[key].interets_bruts += r.interets_bruts || 0;
|
||
map[key].interets_nets += r.interets_nets || 0;
|
||
map[key].net_recu += r.net_recu || 0;
|
||
map[key].count++;
|
||
}
|
||
return Object.values(map).sort((a, b) => b.interets_nets - a.interets_nets);
|
||
}, [allRows, rembPlatYear]);
|
||
|
||
const totalInterets = byPlatform.reduce((s, p) => s + (netMode ? p.interets_nets : p.interets_bruts), 0);
|
||
|
||
const byPlatformTotals = useMemo(() =>
|
||
byPlatform.reduce((acc, p) => {
|
||
acc.capital += p.capital;
|
||
acc.cashback += p.cashback;
|
||
acc.interets_bruts += p.interets_bruts;
|
||
acc.interets_nets += p.interets_nets;
|
||
acc.net_recu += p.net_recu;
|
||
acc.count += p.count;
|
||
return acc;
|
||
}, { capital: 0, cashback: 0, interets_bruts: 0, interets_nets: 0, net_recu: 0, count: 0 }),
|
||
[byPlatform]
|
||
);
|
||
|
||
/* ── Tableau remboursements : filtrage client-side ── */
|
||
const tableRows = useMemo(() => {
|
||
// Remboursements filtrés
|
||
const remb = allRows.filter(r => {
|
||
if (tableFilter.plateforme_id && String(r.plateforme_id) !== String(tableFilter.plateforme_id)) return false;
|
||
// Filtre porte-monnaie / compte-courant
|
||
if (!filterPortefeuille && !filterCompteCourant) return false;
|
||
if (!filterPortefeuille && (r.methode_remboursement === 'portefeuille' || !r.methode_remboursement)) return false;
|
||
if (!filterCompteCourant && r.methode_remboursement === 'compte_courant') return false;
|
||
// Filtre type : les corrections n'ont pas de type remboursement
|
||
if (tableFilter.type && tableFilter.type !== 'correction_solde' && r.type !== tableFilter.type) return false;
|
||
if (tableFilter.type === 'correction_solde') return false; // géré séparément
|
||
// Filtre projet
|
||
const isBonusTypeFilter = tableFilter.type === 'bonus_parrainage' || tableFilter.type === 'bonus_plateforme';
|
||
if (!isBonusTypeFilter && tableFilter.investissement_id) {
|
||
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme';
|
||
if (isBonus) return false;
|
||
if (String(r.investissement_id) !== String(tableFilter.investissement_id)) return false;
|
||
}
|
||
const [y, m] = r.date_remb.split('-');
|
||
if (tableFilter.year && y !== tableFilter.year) return false;
|
||
if (tableFilter.month && m !== tableFilter.month.padStart(2, '0')) return false;
|
||
return true;
|
||
});
|
||
|
||
// Corrections filtrées (normalisées pour l'affichage)
|
||
const showCorrections = filterPortefeuille && (!tableFilter.type || tableFilter.type === 'correction_solde');
|
||
const corr = showCorrections ? corrections.filter(c => {
|
||
if (tableFilter.plateforme_id && String(c.plateforme_id) !== String(tableFilter.plateforme_id)) return false;
|
||
const [y, m] = c.date.split('-');
|
||
if (tableFilter.year && y !== tableFilter.year) return false;
|
||
if (tableFilter.month && m !== tableFilter.month.padStart(2, '0')) return false;
|
||
return true;
|
||
}).map(c => ({
|
||
...c,
|
||
_is_correction: true,
|
||
date_remb: c.date,
|
||
type: 'correction_solde',
|
||
nom_projet: c.notes || 'Correction de solde',
|
||
capital: 0, cashback: 0, interets_bruts: 0, prelev_sociaux: 0,
|
||
prelev_forfaitaire: 0, interets_nets: c.montant, net_recu: c.montant,
|
||
statut: 'paye',
|
||
})) : [];
|
||
|
||
return [...remb, ...corr].sort((a, b) =>
|
||
(b.date_remb || '').localeCompare(a.date_remb || '')
|
||
);
|
||
}, [allRows, corrections, tableFilter, filterPortefeuille, filterCompteCourant]);
|
||
|
||
/* ── Pagination remboursements ── */
|
||
const {
|
||
pagedItems: pagedTableRows, page: rembPage, setPage: setRembPage,
|
||
pageSize: rembPageSize, setPageSize: setRembPageSize,
|
||
totalPages: rembTotalPages, totalItems: rembTotalItems, PAGE_SIZES,
|
||
} = usePagination(tableRows, 'cl_pagesize_remb', [tableFilter]);
|
||
|
||
const years = useMemo(() =>
|
||
[...new Set(allRows.map(r => r.date_remb.slice(0, 4)))].sort().reverse(),
|
||
[allRows]
|
||
);
|
||
|
||
/* ── Investissements éligibles ── */
|
||
const investissementsActifs = investissements.filter(i => i.statut !== 'rembourse');
|
||
|
||
/* ── Ouverture auto depuis le bouton global "Nouveau remboursement" ── */
|
||
useEffect(() => {
|
||
if (new URLSearchParams(search).get('new') !== '1') return;
|
||
navigate('/remboursements', { replace: true });
|
||
setEditingId(null);
|
||
setForm({ ...empty, investissement_id: '' });
|
||
setErr(null);
|
||
setModalOpen(true);
|
||
}, [search]); /* eslint-disable-next-line */
|
||
|
||
/* ── Ouverture auto depuis le Dashboard (clic sur ligne DrillCellPanel) ── */
|
||
useEffect(() => {
|
||
if (autoOpenRef.current) return;
|
||
const sp = new URLSearchParams(search);
|
||
const editRembId = sp.get('edit-remb');
|
||
const openSimulInvId = sp.get('open-simul');
|
||
if (!editRembId && !openSimulInvId) return;
|
||
|
||
/* Mémorise l'URL de retour Dashboard (une seule fois) */
|
||
if (sp.get('from') === 'dashboard' && !backToDashboardRef.current) {
|
||
const q = new URLSearchParams({ 'drill-annee': sp.get('drill-annee'), 'drill-mois': sp.get('drill-mois') });
|
||
if (sp.get('drill-plat')) q.set('drill-plat', sp.get('drill-plat'));
|
||
backToDashboardRef.current = `/?${q}`;
|
||
}
|
||
|
||
if (editRembId && allRows.length) {
|
||
const remb = allRows.find(r => r.id === Number(editRembId));
|
||
if (remb) {
|
||
autoOpenRef.current = true;
|
||
navigate('/remboursements', { replace: true });
|
||
openEdit(remb);
|
||
}
|
||
}
|
||
|
||
if (openSimulInvId && investissements.length && plateformes.length) {
|
||
const invId = Number(openSimulInvId);
|
||
const inv = investissements.find(i => i.id === invId);
|
||
if (inv) {
|
||
autoOpenRef.current = true;
|
||
navigate('/remboursements', { replace: true });
|
||
openFromSimul({
|
||
investissement_id: invId,
|
||
date_prevue: sp.get('simul-date') || '',
|
||
capital_prevu: Number(sp.get('simul-capital') || 0),
|
||
interets_prevus: Number(sp.get('simul-interets') || 0),
|
||
});
|
||
}
|
||
}
|
||
}, [allRows.length, investissements.length, plateformes.length]); /* eslint-disable-next-line */
|
||
|
||
/* ── Modal helpers ── */
|
||
const openNew = () => {
|
||
setEditingId(null);
|
||
setForm({ ...empty, investissement_id: '' });
|
||
setErr(null); setModalOpen(true);
|
||
};
|
||
|
||
const openEdit = (r) => {
|
||
setEditingId(r.id);
|
||
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme';
|
||
const sentinelle = r.type === 'bonus_parrainage' ? 'BONUS_PARRAINAGE' : r.type === 'bonus_plateforme' ? 'BONUS_PLATEFORME' : '';
|
||
setForm({
|
||
investissement_id: isBonus ? sentinelle : r.investissement_id,
|
||
date_remb: r.date_remb,
|
||
capital: r.capital, cashback: r.cashback || 0,
|
||
interets_bruts_avant_local: r.interets_bruts_avant_local || 0,
|
||
taxe_locale: r.taxe_locale || 0,
|
||
interets_bruts: r.interets_bruts,
|
||
prelev_sociaux: r.prelev_sociaux, prelev_forfaitaire: r.prelev_forfaitaire,
|
||
statut: r.statut, notes: r.notes || '',
|
||
methode_remboursement: r.methode_remboursement || 'portefeuille',
|
||
compte_id: r.compte_id || '',
|
||
bonus_plateforme_id: r.bonus_plateforme_id || '',
|
||
bonus_investisseur_id: r.bonus_investisseur_id || '',
|
||
});
|
||
setErr(null); setModalOpen(true);
|
||
// Charge les comptes du détenteur pour le dropdown Compte de réception
|
||
if (!isBonus) {
|
||
const invForEdit = investissements.find(i => i.id === r.investissement_id);
|
||
if (invForEdit?.investisseur_id) {
|
||
api.get(`/investissements/comptes-par-investisseur/${invForEdit.investisseur_id}`)
|
||
.then(setComptesInvestisseur)
|
||
.catch(() => setComptesInvestisseur([]));
|
||
} else {
|
||
setComptesInvestisseur([]);
|
||
}
|
||
}
|
||
};
|
||
|
||
/* Ouvre le formulaire remboursement pré-rempli depuis une ligne de projection */
|
||
const openFromSimul = (s) => {
|
||
const inv = investissements.find(i => i.id === s.investissement_id);
|
||
const plat = inv ? plateformes.find(p => p.id === inv.plateforme_id) : null;
|
||
const bruts = s.interets_prevus || 0;
|
||
const rates = getRatesForYear(s.date_prevue);
|
||
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
|
||
? plat.methode_remboursement
|
||
: (inv?.methode_remboursement || 'portefeuille');
|
||
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
|
||
const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0;
|
||
const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts;
|
||
setEditingId(null);
|
||
setForm({
|
||
...empty,
|
||
investissement_id: s.investissement_id,
|
||
date_remb: s.date_prevue,
|
||
capital: s.capital_prevu || 0,
|
||
cashback: 0,
|
||
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
||
taxe_locale,
|
||
interets_bruts: brutsApresLocal,
|
||
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
|
||
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
|
||
statut: 'paye',
|
||
notes: '',
|
||
methode_remboursement: methode,
|
||
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
|
||
});
|
||
setErr(null);
|
||
setModalOpen(true);
|
||
// Charge les comptes du détenteur pour le dropdown Compte de réception
|
||
if (inv?.investisseur_id) {
|
||
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
|
||
.then(setComptesInvestisseur)
|
||
.catch(() => setComptesInvestisseur([]));
|
||
} else {
|
||
setComptesInvestisseur([]);
|
||
}
|
||
};
|
||
|
||
const openRowMenu = (e, row) => {
|
||
e.stopPropagation();
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
setOpenMenu({ row, x: rect.right, y: rect.bottom });
|
||
};
|
||
|
||
const deleteRemb = (id) => {
|
||
setCorrDeleteConfirm({
|
||
message: 'Supprimer définitivement ce remboursement ?',
|
||
onConfirm: async () => {
|
||
await api.del(`/remboursements/${id}`);
|
||
setCorrDeleteConfirm(null);
|
||
load();
|
||
},
|
||
});
|
||
};
|
||
|
||
const close = () => {
|
||
setModalOpen(false); setEditingId(null); setForm(empty); setErr(null); setConfirmingDelete(false);
|
||
if (backToDashboardRef.current) {
|
||
const url = backToDashboardRef.current;
|
||
backToDashboardRef.current = null;
|
||
navigate(url);
|
||
}
|
||
};
|
||
|
||
/* ── PFU rates ── */
|
||
const getLastKnownRates = () =>
|
||
pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
|
||
|
||
const getRatesForYear = (dateStr) => {
|
||
if (!dateStr || !pfuRates.length) return null;
|
||
const year = parseInt(dateStr.slice(0, 4), 10);
|
||
return pfuRates.find(r => r.annee === year) ?? getLastKnownRates();
|
||
};
|
||
|
||
const round2 = (n) => Math.round(n * 100) / 100;
|
||
|
||
const computeInteretsNets = (f) =>
|
||
round2(Number(f.interets_bruts || 0) - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
||
|
||
const computeNet = (f) =>
|
||
round2(Number(f.capital || 0) + Number(f.cashback || 0) + computeInteretsNets(f));
|
||
|
||
const setField = (k, v) => {
|
||
const next = { ...form, [k]: v };
|
||
|
||
// Fiscalité locale : calcule taxe_locale et interets_bruts à partir du brut avant retenue
|
||
if (k === 'interets_bruts_avant_local') {
|
||
const inv = investissements.find(i => i.id === Number(next.investissement_id));
|
||
const plat = plateformes.find(p => p.id === inv?.plateforme_id);
|
||
const taux = plat?.fiscalite === 'avec_fiscalite_locale' ? (plat?.taux_fiscalite_locale || 0) : 0;
|
||
next.taxe_locale = round2(Number(v) * taux / 100);
|
||
next.interets_bruts = round2(Number(v) - next.taxe_locale);
|
||
}
|
||
// Correction manuelle de la taxe locale : recalcule interets_bruts
|
||
if (k === 'taxe_locale') {
|
||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local) - Number(v));
|
||
}
|
||
|
||
if (k === 'date_remb' || k === 'interets_bruts' || k === 'interets_bruts_avant_local' || k === 'taxe_locale') {
|
||
const dateStr = k === 'date_remb' ? v : next.date_remb;
|
||
const bruts = Number(next.interets_bruts) || 0;
|
||
const rates = getRatesForYear(dateStr);
|
||
if (rates) {
|
||
next.prelev_sociaux = round2(bruts * rates.prelev_sociaux / 100);
|
||
next.prelev_forfaitaire = round2(bruts * rates.impot_revenu / 100);
|
||
}
|
||
}
|
||
// Quand l'investissement change, on hérite silencieusement de la méthode de la plateforme
|
||
// (si fermée) ou on remet le défaut (si ouverte, l'utilisateur choisira dans le formulaire)
|
||
if (k === 'investissement_id' && !BONUS_VALUES.includes(v)) {
|
||
const inv = investissements.find(i => i.id === Number(v));
|
||
const plat = plateformes.find(p => p.id === inv?.plateforme_id);
|
||
if (plat && plat.methode_remboursement !== 'choix_investisseur') {
|
||
next.methode_remboursement = plat.methode_remboursement;
|
||
} else {
|
||
next.methode_remboursement = inv?.methode_remboursement || 'portefeuille';
|
||
}
|
||
next.compte_id = '';
|
||
// Réinitialise le brut avant fiscalité locale lors d'un changement d'investissement
|
||
next.interets_bruts_avant_local = 0;
|
||
// Charge les comptes du détenteur et auto-sélectionne si compte_courant
|
||
if (inv?.investisseur_id) {
|
||
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
|
||
.then(comptes => {
|
||
setComptesInvestisseur(comptes);
|
||
if (next.methode_remboursement === 'compte_courant') {
|
||
// Priorité : compte lié à l'investissement, sinon premier compte_courant
|
||
const inherited = inv.compte_id ? comptes.find(c => c.id === inv.compte_id) : null;
|
||
const def = inherited ?? comptes.find(c => c.type === 'compte_courant') ?? comptes[0];
|
||
if (def) setForm(f => ({ ...f, compte_id: String(def.id) }));
|
||
}
|
||
})
|
||
.catch(() => setComptesInvestisseur([]));
|
||
} else {
|
||
setComptesInvestisseur([]);
|
||
}
|
||
}
|
||
setForm(next);
|
||
};
|
||
|
||
const isBonus = BONUS_VALUES.includes(form.investissement_id);
|
||
|
||
const submit = async (e) => {
|
||
e?.preventDefault?.();
|
||
setErr(null);
|
||
try {
|
||
let payload;
|
||
if (isBonus) {
|
||
payload = {
|
||
type: BONUS_TYPE_MAP[form.investissement_id],
|
||
bonus_plateforme_id: Number(form.bonus_plateforme_id),
|
||
bonus_investisseur_id: form.bonus_investisseur_id ? Number(form.bonus_investisseur_id) : undefined,
|
||
date_remb: form.date_remb,
|
||
cashback: Number(form.cashback || 0),
|
||
statut: form.statut,
|
||
notes: form.notes || undefined,
|
||
};
|
||
} else {
|
||
payload = {
|
||
type: 'normal',
|
||
investissement_id: Number(form.investissement_id),
|
||
date_remb: form.date_remb,
|
||
capital: Number(form.capital || 0),
|
||
cashback: Number(form.cashback || 0),
|
||
interets_bruts_avant_local: Number(form.interets_bruts_avant_local || 0),
|
||
taxe_locale: Number(form.taxe_locale || 0),
|
||
interets_bruts: Number(form.interets_bruts || 0),
|
||
prelev_sociaux: Number(form.prelev_sociaux || 0),
|
||
prelev_forfaitaire: Number(form.prelev_forfaitaire || 0),
|
||
statut: form.statut,
|
||
notes: form.notes || undefined,
|
||
methode_remboursement: form.methode_remboursement || 'portefeuille',
|
||
compte_id: form.methode_remboursement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
|
||
};
|
||
}
|
||
if (editingId) await api.put(`/remboursements/${editingId}`, payload);
|
||
else await api.post('/remboursements', payload);
|
||
close(); await load();
|
||
} catch (e) { setErr(e.message); }
|
||
};
|
||
|
||
const onDelete = async () => {
|
||
await api.del(`/remboursements/${editingId}`);
|
||
close(); load();
|
||
};
|
||
|
||
/* ── Projections : helper taux PFU ── */
|
||
const getPfuReduction = (dateStr) => {
|
||
if (!dateStr || !pfuRates.length) return 0;
|
||
const year = parseInt(dateStr.slice(0, 4), 10);
|
||
const rates = pfuRates.find(r => r.annee === year) ?? getLastKnownRates();
|
||
return (rates.prelev_sociaux + rates.impot_revenu) / 100;
|
||
};
|
||
|
||
/* ── Projections : totaux ── */
|
||
const simulInv = investissements.find(i => i.id === Number(selectedSimul));
|
||
const simulTotals = echeances.reduce((acc, e) => {
|
||
const brut = e.interets_prevus || 0;
|
||
const reduction = getPfuReduction(e.date_prevue);
|
||
acc.capital += e.capital_prevu || 0;
|
||
acc.interets += brut;
|
||
acc.interets_net += brut * (1 - reduction);
|
||
acc.total += e.total_prevu || 0;
|
||
return acc;
|
||
}, { capital: 0, interets: 0, interets_net: 0, total: 0 });
|
||
|
||
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
|
||
|
||
/* ── Rendu ── */
|
||
return (
|
||
<>
|
||
<div className="topbar">
|
||
<h2><PageIcon name="remboursement" />Remboursements</h2>
|
||
{filterPlatId && (
|
||
<button className="dr-clear-btn" onClick={() => setFilterPlatId('')}>
|
||
✕ Effacer le filtre plateforme
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Graphiques ── */}
|
||
{!listFocused && <div className="charts-row">
|
||
<InteretsChart
|
||
rows={chartRows}
|
||
netMode={netMode}
|
||
/>
|
||
<InteretsDistributionChart
|
||
rows={chartRows}
|
||
netMode={netMode}
|
||
/>
|
||
</div>}
|
||
|
||
{/* ── KPIs ── */}
|
||
{!listFocused && <div className="dr-kpi-row">
|
||
<div className="kpi">
|
||
<div className="label">Capital remboursé</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.capital)}</span>
|
||
<TrendBadge current={totals.capital} prev={prevTotals.capital} />
|
||
</div>
|
||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.capital)} en {rembPrevYear}</div>
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="label">Cashback</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(totals.cashback)}</span>
|
||
<TrendBadge current={totals.cashback} prev={prevTotals.cashback} />
|
||
</div>
|
||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.cashback)} en {rembPrevYear}</div>
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="label">Intérêts — {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.interets_nets : totals.interets)}</span>
|
||
<TrendBadge current={netMode ? totals.interets_nets : totals.interets} prev={netMode ? prevTotals.interets_nets : prevTotals.interets} />
|
||
</div>
|
||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(netMode ? prevTotals.interets_nets : prevTotals.interets)} en {rembPrevYear}</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 === 'remboursements' ? ' active' : ''}`}
|
||
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
|
||
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
|
||
onClick={() => setActiveTab('projections')}>Projections Échéances</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
|
||
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
|
||
</h3>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<select value={rembPlatYear} onChange={e => setRembPlatYear(e.target.value)}
|
||
style={{
|
||
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
|
||
borderRadius: 6, border: '1px solid var(--border)',
|
||
background: 'var(--surface-2)', color: 'var(--text-muted)',
|
||
cursor: 'pointer', outline: 'none',
|
||
}}>
|
||
<option value="">Toutes les années</option>
|
||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
{filterPlatId && (
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||
Graphiques et KPIs filtrés sur cette plateforme
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="icon-btn"
|
||
title={listFocused ? 'Réduire' : 'Agrandir'}
|
||
onClick={() => { const next = !listFocused; setListFocused(next); setRembPageSize(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={byPlatform.length === 0}
|
||
onCSV={() => dlBlob(rembToCSV(allRows), 'remboursements.csv', 'text/csv;charset=utf-8')}
|
||
onXLS={() => dlBlob(rembToXLS(allRows), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||
onJSON={() => dlBlob(rembToJSON(allRows), 'remboursements.json', 'application/json')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Plateforme</th>
|
||
<th>Détenteur</th>
|
||
<th className="num">Capital remboursé</th>
|
||
<th className="num">Cashback</th>
|
||
<th className="num">Intérêts ({netMode ? 'Net' : 'Brut'})</th>
|
||
<th className="num">Cashback + Intérêts ({netMode ? 'Net' : 'Brut'})</th>
|
||
<th className="num">Échéances</th>
|
||
<th className="num" style={{ minWidth: 120 }}>Poids</th>
|
||
<th style={{ width: 28 }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{byPlatform.length === 0 && (
|
||
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||
{loading ? 'Chargement…' : 'Aucun remboursement'}
|
||
</td></tr>
|
||
)}
|
||
{byPlatform.map(p => {
|
||
const isActive = String(filterPlatId) === String(p.plateforme_id);
|
||
const val = netMode ? p.interets_nets : p.interets_bruts;
|
||
const poids = totalInterets !== 0 ? (val / totalInterets) * 100 : 0;
|
||
return (
|
||
<tr key={p.plateforme_id}
|
||
className={`dr-row${isActive ? ' dr-row-selected' : ''}`}
|
||
style={{ cursor: 'pointer' }}
|
||
onClick={() => setFilterPlatId(isActive ? '' : String(p.plateforme_id))}
|
||
title={isActive ? 'Cliquer pour retirer le filtre' : 'Cliquer pour filtrer sur cette plateforme'}
|
||
>
|
||
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
|
||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>
|
||
<td className="num">{fmtEUR(p.capital)}</td>
|
||
<td className="num">{p.cashback > 0 ? fmtEUR(p.cashback) : '—'}</td>
|
||
<td className="num">{fmtEUR(val)}</td>
|
||
<td className="num">{fmtEUR(p.cashback + val)}</td>
|
||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
|
||
<td className="num">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
|
||
</div>
|
||
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
|
||
{poids.toFixed(1)} %
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>
|
||
{isActive ? '✕' : ''}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
{byPlatform.length > 1 && (
|
||
<tfoot>
|
||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||
<td>Total — {byPlatform.length} plateformes</td>
|
||
<td />
|
||
<td className="num">{fmtEUR(byPlatformTotals.capital)}</td>
|
||
<td className="num">{byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'}</td>
|
||
<td className="num">{fmtEUR(netMode ? byPlatformTotals.interets_nets : byPlatformTotals.interets_bruts)}</td>
|
||
<td className="num">{fmtEUR(byPlatformTotals.cashback + (netMode ? byPlatformTotals.interets_nets : byPlatformTotals.interets_bruts))}</td>
|
||
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{byPlatformTotals.count}</td>
|
||
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
|
||
<td />
|
||
</tr>
|
||
</tfoot>
|
||
)}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ══ ONGLET VISION MENSUELLE ══ */}
|
||
{activeTab === 'vision-mensuelle' && (
|
||
<div style={{ padding: '0 24px' }}>
|
||
{/* Ancre fixe pour le scroll retour au tableau — toujours dans le DOM */}
|
||
<div ref={tipTableRef} />
|
||
<InteretsChartProvider netMode={netMode} pfuRates={pfuRates} activeView={activeView} activeId={activeId}>
|
||
{!drillCell && <div><TableauInteretsPlateforme
|
||
activeView={activeView}
|
||
activeId={activeId}
|
||
pfuRates={pfuRates}
|
||
onCapitalMensuel={() => {}}
|
||
onCellClick={handleCellClick}
|
||
activeCell={drillCell}
|
||
expandButton={
|
||
<button
|
||
type="button"
|
||
className="icon-btn"
|
||
title={listFocused ? 'Réduire' : 'Agrandir'}
|
||
style={{ marginLeft: 4 }}
|
||
onClick={() => { const next = !listFocused; setListFocused(next); setRembPageSize(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>}
|
||
{/* ── Panneau de détail : Remboursements reçus et projection ── */}
|
||
<div ref={drillPanelRef}>
|
||
<DrillCellPanel
|
||
cell={drillCell}
|
||
onClose={() => setDrillCell(null)}
|
||
pfuRates={pfuRates}
|
||
activeView={activeView}
|
||
activeId={activeId}
|
||
onEditRecu={openEdit}
|
||
onEditProjet={openFromSimul}
|
||
refreshKey={drillRefreshKey}
|
||
investissements={investissements}
|
||
plateformes={plateformes}
|
||
onBulkDone={load}
|
||
/>
|
||
</div>
|
||
</InteretsChartProvider>
|
||
</div>
|
||
)}
|
||
|
||
{/* ══ ONGLET REMBOURSEMENTS ══ */}
|
||
{activeTab === 'remboursements' && (
|
||
<div style={{ padding: '0 24px' }}>
|
||
<div className="card">
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||
<h3 style={{ margin: 0 }}>Remboursements</h3>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
{/* Filtre porte-monnaie */}
|
||
<button
|
||
type="button"
|
||
className="icon-btn"
|
||
title={filterPortefeuille ? 'Masquer porte-monnaie' : 'Afficher porte-monnaie'}
|
||
onClick={() => setFilterPortefeuille(v => !v)}
|
||
style={{ opacity: filterPortefeuille ? 1 : 0.35, padding: 3 }}
|
||
>
|
||
{libIcons['porte-monnaie']
|
||
? <img src={ICONS_BASE + libIcons['porte-monnaie']} width={20} height={20} style={{ display: 'block', filter: 'var(--icon-filter, none)' }} />
|
||
: <span style={{ width: 20, height: 20, display: 'block', borderRadius: 3, background: 'var(--text-muted)' }} />}
|
||
</button>
|
||
{/* Filtre compte courant */}
|
||
<button
|
||
type="button"
|
||
className="icon-btn"
|
||
title={filterCompteCourant ? 'Masquer compte courant' : 'Afficher compte courant'}
|
||
onClick={() => setFilterCompteCourant(v => !v)}
|
||
style={{ opacity: filterCompteCourant ? 1 : 0.35, padding: 3 }}
|
||
>
|
||
{libIcons['compte-courant']
|
||
? <img src={ICONS_BASE + libIcons['compte-courant']} width={20} height={20} style={{ display: 'block', filter: 'var(--icon-filter, none)' }} />
|
||
: <span style={{ width: 20, height: 20, display: 'block', borderRadius: 3, background: 'var(--text-muted)' }} />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="icon-btn"
|
||
title={listFocused ? 'Réduire' : 'Agrandir'}
|
||
onClick={() => { const next = !listFocused; setListFocused(next); setRembPageSize(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={tableRows.length === 0}
|
||
onCSV={() => dlBlob(rembToCSV(tableRows), 'remboursements.csv', 'text/csv;charset=utf-8')}
|
||
onXLS={() => dlBlob(rembToXLS(tableRows), 'remboursements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||
onJSON={() => dlBlob(rembToJSON(tableRows), 'remboursements.json', 'application/json')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filtres */}
|
||
<div className="row" style={{ marginBottom: 12 }}>
|
||
<div>
|
||
<label>Plateforme</label>
|
||
<select value={tableFilter.plateforme_id} onChange={e => setTableFilter({ ...tableFilter, plateforme_id: e.target.value })}>
|
||
<option value="">Toutes</option>
|
||
{plateformes.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Type</label>
|
||
<select value={tableFilter.type} onChange={e => setTableFilter({ ...tableFilter, type: e.target.value, investissement_id: '' })}>
|
||
<option value="">Tous</option>
|
||
<option value="normal">Remboursements</option>
|
||
<option value="bonus_parrainage">Bonus Parrainage</option>
|
||
<option value="bonus_plateforme">Bonus Plateforme</option>
|
||
<option value="correction_solde">Corrections de solde</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Projet</label>
|
||
<select value={tableFilter.investissement_id}
|
||
onChange={e => setTableFilter({ ...tableFilter, investissement_id: e.target.value, type: e.target.value ? 'normal' : tableFilter.type })}
|
||
disabled={tableFilter.type && tableFilter.type !== 'normal'}>
|
||
<option value="">Tous</option>
|
||
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Année</label>
|
||
<select value={tableFilter.year} onChange={e => setTableFilter({ ...tableFilter, year: e.target.value })}>
|
||
<option value="">Toutes</option>
|
||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Mois</label>
|
||
<select value={tableFilter.month} onChange={e => setTableFilter({ ...tableFilter, month: e.target.value })}>
|
||
<option value="">Tous</option>
|
||
{MOIS_FR.map((m, i) => <option key={i+1} value={String(i+1).padStart(2,'0')}>{m}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tableau */}
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Date</th><th>Plateforme</th><th>Détenteur</th><th>Nom du projet</th><th>Versement</th>
|
||
<th className="num">Capital Remboursé</th><th className="num">Capital Restant dû</th>
|
||
<th className="num">Cashback</th>
|
||
<th className="num">Intérêts Bruts</th>
|
||
<th className="num">Prélèv. sociaux</th><th className="num">Impôt revenu</th>
|
||
<th className="num">Intérêts Nets</th>
|
||
<th className="num">Montant Net</th>
|
||
<th style={{ width: 36 }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{tableRows.length === 0 && (
|
||
<tr><td colSpan={14} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||
{loading ? 'Chargement…' : 'Aucun remboursement'}
|
||
</td></tr>
|
||
)}
|
||
{pagedTableRows.map(r => {
|
||
const isBonus = r.type === 'bonus_parrainage' || r.type === 'bonus_plateforme';
|
||
const isCorrection = r._is_correction === true;
|
||
const rowKey = isCorrection ? `corr-${r.id}` : `remb-${r.id}`;
|
||
return (
|
||
<tr key={rowKey} style={{ cursor: 'pointer' }}
|
||
title={isCorrection ? 'Cliquer pour supprimer cette correction' : 'Modifier ce remboursement'}
|
||
onClick={() => {
|
||
if (isCorrection) {
|
||
setCorrDeleteConfirm({
|
||
message: `Supprimer la correction de ${r.montant > 0 ? '+' : ''}${r.montant} € du ${fmtDate(r.date_remb)} ?`,
|
||
onConfirm: () => {
|
||
api.del(`/corrections/${r.id}`).then(() => load()).catch(console.error);
|
||
setCorrDeleteConfirm(null);
|
||
},
|
||
});
|
||
} else {
|
||
openEdit(r);
|
||
}
|
||
}}>
|
||
<td>{fmtDate(r.date_remb)}</td>
|
||
<td>{r.plateforme_nom}</td>
|
||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>
|
||
{isCorrection ? (r.investisseur_nom || '—') : (r.plateforme_detenteur_nom || '—')}
|
||
</td>
|
||
<td>
|
||
{isCorrection
|
||
? <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>{r.nom_projet}</span>
|
||
: isBonus
|
||
? <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>{r.nom_projet}</span>
|
||
: r.nom_projet}
|
||
</td>
|
||
<td style={{ fontSize: 'var(--fs-sm)' }}>
|
||
{isCorrection ? (
|
||
<span className="text-muted" style={{ fontStyle: 'italic' }}>Correction</span>
|
||
) : isBonus ? (
|
||
<span className="text-muted" style={{ fontStyle: 'italic' }}>
|
||
{r.type === 'bonus_parrainage' ? 'Parrainage' : 'Bonus'}
|
||
</span>
|
||
) : r.methode_remboursement === 'compte_courant' ? (
|
||
<span title={r.compte_nom || r.nom_compte_courant || ''}>
|
||
{r.compte_nom || r.nom_compte_courant || <span className="text-muted">Compte courant</span>}
|
||
</span>
|
||
) : (
|
||
<span className="text-muted">Porte-monnaie</span>
|
||
)}
|
||
</td>
|
||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.capital)}</td>
|
||
<td className="num" style={{ color: !isBonus && !isCorrection && r.capital_restant_du <= 0 ? 'var(--success)' : undefined }}>
|
||
{(isBonus || isCorrection) ? '—' : fmtEUR(Math.max(0, r.capital_restant_du ?? 0))}
|
||
</td>
|
||
<td className="num">{r.cashback ? fmtEUR(r.cashback) : '—'}</td>
|
||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.interets_bruts)}</td>
|
||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_sociaux)}</td>
|
||
<td className="num">{(isBonus || isCorrection) ? '—' : fmtEUR(r.prelev_forfaitaire)}</td>
|
||
<td className="num">
|
||
{isCorrection
|
||
? <span style={{ color: r.montant >= 0 ? 'var(--success)' : 'var(--danger)', fontWeight: 600 }}>
|
||
{r.montant >= 0 ? '+' : ''}{fmtEUR(r.montant)}
|
||
</span>
|
||
: isBonus ? '—' : fmtEUR(r.interets_nets)}
|
||
</td>
|
||
<td className="num">
|
||
{isCorrection
|
||
? <span style={{ color: r.montant >= 0 ? 'var(--success)' : 'var(--danger)', fontWeight: 600 }}>
|
||
{r.montant >= 0 ? '+' : ''}{fmtEUR(r.montant)}
|
||
</span>
|
||
: fmtEUR(r.net_recu)}
|
||
</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={rembPage} setPage={setRembPage}
|
||
pageSize={rembPageSize} setPageSize={setRembPageSize}
|
||
totalPages={rembTotalPages} totalItems={rembTotalItems}
|
||
PAGE_SIZES={PAGE_SIZES}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ══ ONGLET PROJECTIONS ÉCHÉANCES ══ */}
|
||
{activeTab === 'projections' && (
|
||
<div style={{ padding: '0 24px' }}>
|
||
<div className="card" style={{ marginBottom: 16 }}>
|
||
<div className="row">
|
||
<div style={{ flex: 2 }}>
|
||
<label>Investissement</label>
|
||
<select value={selectedSimul} onChange={e => setSelectedSimul(e.target.value)}>
|
||
<option value="">— Choisir —</option>
|
||
{investissements.map(i => (
|
||
<option key={i.id} value={i.id}>
|
||
{i.nom_projet} ({fmtEUR(i.montant_investi)} – {i.taux_interet ?? '?'} % – {i.duree_mois ?? '?'} m – {i.type_remb || '?'})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<button className="primary" onClick={generateSimul}
|
||
disabled={!selectedSimul || simulBusy} style={{ width: '100%' }}>
|
||
{simulBusy ? '…' : 'Générer / Régénérer'}
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 2 }}>
|
||
<button
|
||
type="button"
|
||
className="icon-btn"
|
||
title={listFocused ? 'Réduire' : 'Agrandir'}
|
||
onClick={() => { const next = !listFocused; setListFocused(next); setRembPageSize(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>
|
||
</div>
|
||
{simulMsg && (
|
||
<div className={simulMsg.startsWith('✔') ? 'success-msg' : 'error'} style={{ marginTop: 12 }}>
|
||
{simulMsg}
|
||
</div>
|
||
)}
|
||
{simulInv && (!simulInv.taux_interet || !simulInv.duree_mois) && (
|
||
<div className="error" style={{ marginTop: 12 }}>
|
||
Cet investissement n'a pas de <strong>taux</strong> et/ou de <strong>durée</strong>. Renseignez-les dans la fiche.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Tableau / état vide ── */}
|
||
{(() => {
|
||
/* Détermine le message d'état vide selon le contexte */
|
||
let emptyIcon = null;
|
||
let emptyTitle = null;
|
||
let emptyHint = null;
|
||
|
||
if (loading) {
|
||
emptyTitle = 'Chargement…';
|
||
} else if (investissements.length === 0) {
|
||
emptyIcon = (
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.3, marginBottom: 10 }}>
|
||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||
</svg>
|
||
);
|
||
emptyTitle = 'Aucun investissement enregistré';
|
||
emptyHint = 'Créez votre premier investissement pour visualiser les projections d\'échéances.';
|
||
} else if (investissements.every(i => i.statut === 'rembourse')) {
|
||
emptyIcon = (
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.3, marginBottom: 10 }}>
|
||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
||
</svg>
|
||
);
|
||
emptyTitle = 'Tous les investissements ont été remboursés';
|
||
emptyHint = 'Il n\'y a plus de projection à afficher. Les projections concernent les projets encore en cours.';
|
||
} else if (!selectedSimul) {
|
||
emptyIcon = (
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.3, marginBottom: 10 }}>
|
||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||
</svg>
|
||
);
|
||
emptyTitle = 'Aucun projet sélectionné';
|
||
emptyHint = 'Sélectionnez un projet dans la liste ci-dessus pour visualiser son échéancier prévisionnel.';
|
||
} else if (selectedSimul && echeances.length === 0 && !simulBusy) {
|
||
emptyIcon = (
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.3, marginBottom: 10 }}>
|
||
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||
<path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/>
|
||
</svg>
|
||
);
|
||
emptyTitle = 'Échéancier non généré';
|
||
emptyHint = 'Cliquez sur « Générer / Régénérer » ci-dessus pour créer la projection de ce projet.';
|
||
}
|
||
|
||
const isEmpty = !!(emptyTitle && echeances.length === 0);
|
||
|
||
return (
|
||
<div className="card">
|
||
{!isEmpty && (
|
||
<div className="kpi-grid" style={{ marginBottom: 12 }}>
|
||
<div className="kpi">
|
||
<div className="label">Capital prévu</div>
|
||
<div className="value">{fmtEUR(simulTotals.capital)}</div>
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="label">
|
||
Intérêts prévus — {netMode ? 'Net (estimé)' : 'Brut'}
|
||
</div>
|
||
<div className="value success">
|
||
{fmtEUR(netMode ? simulTotals.interets_net : simulTotals.interets)}
|
||
</div>
|
||
</div>
|
||
<div className="kpi">
|
||
<div className="label">Total prévu</div>
|
||
<div className="value">{fmtEUR(simulTotals.total)}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Date prévue</th>
|
||
<th className="num">Capital</th>
|
||
<th className="num">Intérêts ({netMode ? 'Net estimé' : 'Brut'})</th>
|
||
<th className="num">Total échéance</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isEmpty ? (
|
||
<tr>
|
||
<td colSpan={5} style={{ textAlign: 'center', padding: '48px 24px', height: 220 }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, color: 'var(--text-muted)' }}>
|
||
{emptyIcon}
|
||
<span style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--text)' }}>{emptyTitle}</span>
|
||
{emptyHint && <span style={{ fontSize: '0.85rem', maxWidth: 360, textAlign: 'center', lineHeight: 1.5 }}>{emptyHint}</span>}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
echeances.map(e => {
|
||
const reduction = getPfuReduction(e.date_prevue);
|
||
const interets = netMode
|
||
? (e.interets_prevus || 0) * (1 - reduction)
|
||
: (e.interets_prevus || 0);
|
||
return (
|
||
<tr key={e.id}>
|
||
<td>{e.numero_echeance}</td>
|
||
<td>{fmtDate(e.date_prevue)}</td>
|
||
<td className="num">{fmtEUR(e.capital_prevu)}</td>
|
||
<td className="num">{fmtEUR(interets)}</td>
|
||
<td className="num">{fmtEUR(e.total_prevu)}</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{/* ══ MODAL ══ */}
|
||
{(() => {
|
||
const pfuYear = getRatesForYear(form.date_remb);
|
||
const fmtRate = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
|
||
const labelPS = `Prélèvements sociaux${pfuYear ? ` (${fmtRate(pfuYear.prelev_sociaux)})` : ''} (€)`;
|
||
const labelIR = `Impôt sur le revenu${pfuYear ? ` (${fmtRate(pfuYear.impot_revenu)})` : ''} (€)`;
|
||
const interetsNets = computeInteretsNets(form);
|
||
|
||
// Liste d'investissements disponibles dans le select
|
||
const invListForSelect = (editingId
|
||
? investissements.filter(i => i.statut !== 'rembourse' || i.id === Number(form.investissement_id))
|
||
: investissementsActifs
|
||
).slice().sort((a, b) => {
|
||
const pa = (a.plateforme_nom || '').toLowerCase();
|
||
const pb = (b.plateforme_nom || '').toLowerCase();
|
||
if (pa !== pb) return pa.localeCompare(pb, 'fr');
|
||
return (a.nom_projet || '').localeCompare(b.nom_projet || '', 'fr');
|
||
});
|
||
|
||
// Méthode de remboursement — plateforme de l'investissement sélectionné
|
||
const currentInv = !isBonus ? investissements.find(i => i.id === Number(form.investissement_id)) : null;
|
||
const currentPlat = currentInv ? plateformes.find(p => p.id === currentInv.plateforme_id) : null;
|
||
const isChoixOuvert = currentPlat?.methode_remboursement === 'choix_investisseur';
|
||
const hasLocalTax = !isBonus && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
|
||
const isExonere = !isBonus && currentInv?.fiscalite_override === 'exonere';
|
||
const isIndicatif = isExonere || (!isBonus && !!currentPlat && currentPlat?.fiscalite !== 'flat_tax');
|
||
const netRecu = isIndicatif
|
||
? round2(Number(form.capital||0) + Number(form.cashback||0) + Number(form.interets_bruts||0))
|
||
: computeNet(form);
|
||
|
||
return (
|
||
<Modal
|
||
open={modalOpen}
|
||
title={editingId ? 'Modifier le remboursement' : 'Nouveau remboursement'}
|
||
onClose={close}
|
||
width={900}
|
||
footer={
|
||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
||
<div style={{ flex: 1 }}>
|
||
{editingId && (
|
||
!confirmingDelete ? (
|
||
<button className="danger" onClick={() => setConfirmingDelete(true)}>
|
||
Supprimer
|
||
</button>
|
||
) : (
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||
Supprimer définitivement ?
|
||
</span>
|
||
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
||
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
||
</span>
|
||
)
|
||
)}
|
||
</div>
|
||
<button onClick={close}>Annuler</button>
|
||
<button className="primary" onClick={submit}>{editingId ? 'Enregistrer' : 'Créer'}</button>
|
||
</div>
|
||
}
|
||
>
|
||
<form onSubmit={submit}>
|
||
{err && <div className="error">{err}</div>}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px' }}>
|
||
{/* ── Sélecteur d'investissement ── */}
|
||
<div style={{ gridColumn: isBonus ? 'span 1' : 'span 3' }}>
|
||
<label>Investissement *</label>
|
||
<select required value={form.investissement_id}
|
||
onChange={e => setField('investissement_id', e.target.value)}>
|
||
<option value="">—</option>
|
||
<option value="BONUS_PARRAINAGE">🎁 Bonus Parrainage</option>
|
||
<option value="BONUS_PLATEFORME">🏆 Bonus Plateforme</option>
|
||
<optgroup label="──────────────────" />
|
||
{invListForSelect.map(i =>
|
||
<option key={i.id} value={i.id}>{i.plateforme_nom} — {i.nom_projet} ({fmtEUR(i.montant_investi)})</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
{isBonus && (
|
||
<div style={{ gridColumn: 'span 2' }}>
|
||
<label>Plateforme associée (bonus)</label>
|
||
<select value={form.bonus_plateforme_id} onChange={e => {
|
||
const plat = plateformes.find(p => String(p.id) === e.target.value);
|
||
setForm({ ...form, bonus_plateforme_id: e.target.value, bonus_investisseur_id: plat?.investisseur_id ? String(plat.investisseur_id) : '' });
|
||
}}>
|
||
<option value="">— Aucune —</option>
|
||
{plateformes.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Champs remboursement normal ── */}
|
||
{!isBonus && (<>
|
||
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
|
||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Remboursement</span>
|
||
</div>
|
||
{hasLocalTax && (
|
||
<div style={{ gridColumn: 'span 2' }}>
|
||
<label>Intérêts bruts avant retenue locale ({currentPlat.taux_fiscalite_locale} %) (€)</label>
|
||
<input type="number" step="0.01" value={form.interets_bruts_avant_local}
|
||
onChange={e => setField('interets_bruts_avant_local', e.target.value)} />
|
||
</div>
|
||
)}
|
||
{hasLocalTax && (
|
||
<div>
|
||
<label>Retenue à la source locale (€)</label>
|
||
<input type="number" step="0.01" value={form.taxe_locale}
|
||
onChange={e => setField('taxe_locale', e.target.value)} />
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label>Capital (€)</label>
|
||
<input type="number" step="0.01" value={form.capital}
|
||
onChange={e => setField('capital', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label>{hasLocalTax ? 'Intérêts bruts après retenue locale (€)' : 'Intérêts bruts (€)'}</label>
|
||
<input type="number" step="0.01" value={form.interets_bruts}
|
||
readOnly={!!hasLocalTax}
|
||
style={hasLocalTax ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
|
||
onChange={e => !hasLocalTax && setField('interets_bruts', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label>Cashback (€)</label>
|
||
<input type="number" step="0.01" value={form.cashback}
|
||
onChange={e => setField('cashback', e.target.value)} />
|
||
</div>
|
||
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
|
||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Imposition{isIndicatif ? ' — indicatif' : ''}</span>
|
||
</div>
|
||
<div>
|
||
<label>{labelPS}</label>
|
||
<input type="number" step="0.01" value={form.prelev_sociaux}
|
||
readOnly={isIndicatif}
|
||
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
|
||
onChange={e => !isIndicatif && setForm({ ...form, prelev_sociaux: Number(e.target.value) })} />
|
||
</div>
|
||
<div>
|
||
<label>{labelIR}</label>
|
||
<input type="number" step="0.01" value={form.prelev_forfaitaire}
|
||
readOnly={isIndicatif}
|
||
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
|
||
onChange={e => !isIndicatif && setForm({ ...form, prelev_forfaitaire: Number(e.target.value) })} />
|
||
</div>
|
||
<div>
|
||
<label>Total prélèvements (€)</label>
|
||
<input type="number" step="0.01"
|
||
value={round2((Number(form.prelev_sociaux) || 0) + (Number(form.prelev_forfaitaire) || 0))}
|
||
readOnly
|
||
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
|
||
</div>
|
||
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||
<span style={{ color: 'var(--text-muted)' }}>Montant des intérêts après imposition</span>
|
||
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{fmtEUR(interetsNets)}</span>
|
||
</div>
|
||
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
|
||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Versement</span>
|
||
</div>
|
||
<div>
|
||
<label>Date *</label>
|
||
<input type="date" required value={form.date_remb}
|
||
onChange={e => setField('date_remb', e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label>Méthode de remboursement</label>
|
||
<select value={form.methode_remboursement || 'portefeuille'}
|
||
onChange={e => {
|
||
const m = e.target.value;
|
||
const def = m === 'compte_courant' ? (comptesInvestisseur.find(c => c.type === 'compte_courant') ?? comptesInvestisseur[0]) : null;
|
||
setForm(f => ({ ...f, methode_remboursement: m, compte_id: def ? String(def.id) : '' }));
|
||
}}>
|
||
<option value="portefeuille">Porte-monnaie de la plateforme</option>
|
||
<option value="compte_courant">Compte courant de l'investisseur</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
{form.methode_remboursement === 'compte_courant' && (
|
||
<>
|
||
<label>Compte de réception</label>
|
||
{comptesInvestisseur.length > 0 ? (
|
||
<select value={form.compte_id || ''}
|
||
onChange={e => setForm(f => ({ ...f, compte_id: e.target.value }))}>
|
||
<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. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
<label>Montant versé (€)</label>
|
||
<input type="number" step="0.01" value={netRecu} readOnly
|
||
style={{ background: 'var(--bg-input-readonly, rgba(255,255,255,.05))', cursor: 'default' }} />
|
||
</div>
|
||
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||
<span style={{ color: 'var(--text-muted)' }}>
|
||
{isIndicatif
|
||
? "L'imposition n'est pas déduite du montant versé (fiscalité appliquée à titre indicatif)."
|
||
: "L'imposition est déduite du montant versé (capital + cashback + intérêts nets)."}
|
||
</span>
|
||
</div>
|
||
</>)}
|
||
|
||
{/* ── Bonus ── */}
|
||
{isBonus && (
|
||
<div>
|
||
<label>Date *</label>
|
||
<input type="date" required value={form.date_remb}
|
||
onChange={e => setField('date_remb', e.target.value)} />
|
||
</div>
|
||
)}
|
||
{isBonus && (
|
||
<div style={{ gridColumn: 'span 2' }}>
|
||
<label>Montant (€)</label>
|
||
<input type="number" step="0.01" value={form.cashback}
|
||
onChange={e => setField('cashback', e.target.value)} />
|
||
</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,
|
||
}}>
|
||
{!openMenu.row._is_correction && (
|
||
<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 = 'transparent'}
|
||
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={() => {
|
||
const row = openMenu.row;
|
||
setOpenMenu(null);
|
||
if (row._is_correction) {
|
||
setCorrDeleteConfirm({
|
||
message: 'Supprimer définitivement cette correction ?',
|
||
onConfirm: async () => {
|
||
await api.del(`/corrections/${row.id}`);
|
||
setCorrDeleteConfirm(null);
|
||
load();
|
||
},
|
||
});
|
||
} else {
|
||
deleteRemb(row.id);
|
||
}
|
||
}}>
|
||
<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 6V4h6v2"/></svg>
|
||
Supprimer
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{corrDeleteConfirm && (
|
||
<ConfirmModal
|
||
message={corrDeleteConfirm.message}
|
||
onConfirm={corrDeleteConfirm.onConfirm}
|
||
onCancel={() => setCorrDeleteConfirm(null)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|