Files
crowdlending-app/frontend/src/pages/InvestissementDetail.jsx
T
2026-06-16 21:03:15 +02:00

2452 lines
127 KiB
React

import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } 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 CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js';
const emptyForm = {
investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '',
date_souscription: today(), date_premiere_echeance: '', date_cible: '', date_debut_simul: '',
montant_investi: '', taux_interet: '', duree_mois: '',
type_remb: 'in_fine', freq_interets: 'mensuel',
statut: 'en_cours', reference: '', notes: '', pays_exposition: 'FR',
echeance_fin_de_mois: false,
methode_remboursement: '', nom_compte_courant: '', compte_id: '',
};
/**
* Catégorie par défaut lors d'un changement de plateforme :
* - 0 catégories → ''
* - 1 catégorie → cette catégorie
* - 2+ → première de la liste (pas d'historique disponible sur la page détail)
*/
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));
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);
}
function typeLabel(type_remb, freq_interets) {
if (type_remb === 'in_fine') return `In fine · ${freq_interets === 'trimestriel' ? 'Trimestriel' : 'Mensuel'}`;
if (type_remb === 'amortissable') return `Amortissable · ${freq_interets === 'trimestriel' ? 'Trimestriel' : 'Mensuel'}`;
if (type_remb === 'differe') return 'Différé (versement unique)';
return '—';
}
const STATUT_META = {
en_cours: { label: 'En cours', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' },
rembourse: { label: 'Remboursé', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' },
en_retard: { label: 'En retard', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
procedure: { label: 'Procédure', color: '#f97316', bg: 'rgba(249,115,22,0.12)' },
cloture: { label: 'Clôturé', color: '#6b7280', bg: 'rgba(107,114,128,0.12)' },
};
/**
* XIRR — taux de rendement interne annualisé sur flux datés.
* cashflows : [{ amount: number, date: string 'YYYY-MM-DD' }]
* le premier flux doit être négatif (investissement initial).
* Retourne le taux annualisé (ex: 0.112 = 11.2%) ou null si non convergent.
*/
function xirr(cashflows) {
if (!cashflows || cashflows.length < 2) return null;
const t0 = new Date(cashflows[0].date).getTime();
const years = cashflows.map(cf => (new Date(cf.date).getTime() - t0) / (365.25 * 864e5));
const amounts = cashflows.map(cf => cf.amount);
const npv = r => amounts.reduce((s, a, i) => s + a / Math.pow(1 + r, years[i]), 0);
const dnpv = r => amounts.reduce((s, a, i) => s - years[i] * a / Math.pow(1 + r, years[i] + 1), 0);
let rate = 0.1;
for (let i = 0; i < 200; i++) {
const f = npv(rate);
const df = dnpv(rate);
if (Math.abs(df) < 1e-12) break;
const next = rate - f / df;
if (Math.abs(next - rate) < 1e-8) return isFinite(next) ? next : null;
rate = next;
if (rate < -0.999) rate = -0.999; // garde-fou
}
return null;
}
export default function InvestissementDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { activeId, investisseurs } = useInvestisseur();
const { displayMode } = useUi();
const netMode = displayMode === 'net';
const [inv, setInv] = useState(null);
const [plats, setPlats] = useState([]);
const [comptesCourants, setComptesCourants] = useState([]);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
const [comptesRembInvestisseur, setComptesRembInvestisseur] = useState([]);
const [pfuRates, setPfuRates] = useState([]);
// Modal édition investissement
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [err, setErr] = useState(null);
// Mémorise la dernière souscription valide (utile pour isAutoCalc malgré les onChange "" intermédiaires)
const lastValidSouscriptionRef = useRef('');
// Charge les comptes du détenteur quand 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 */
// Origine de la navigation (ex. 'dashboard') — permet de revenir en arrière après la modale remboursement
const fromRef = useRef(null);
// Modal saisie / édition remboursement
const [rembModalOpen, setRembModalOpen] = useState(false);
const [editingRembId, setEditingRembId] = useState(null);
const [rembForm, setRembForm] = useState({});
const [rembErr, setRembErr] = useState(null);
// Charge les comptes du détenteur pour le formulaire remboursement
useEffect(() => {
if (!rembModalOpen || !inv?.investisseur_id) { setComptesRembInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
.then(comptes => {
setComptesRembInvestisseur(comptes);
if (rembForm.methode_remboursement === 'compte_courant' && !rembForm.compte_id) {
const def = comptes.find(c => c.type === 'compte_courant') ?? comptes[0];
if (def) setRembForm(f => ({ ...f, compte_id: String(def.id) }));
}
})
.catch(() => setComptesRembInvestisseur([]));
}, [rembModalOpen]); /* eslint-disable-next-line */
const [confirmingRembDelete, setConfirmingRembDelete] = useState(false);
const [confirmingInvDelete, setConfirmingInvDelete] = useState(false);
const [confirmingHistDelete, setConfirmingHistDelete] = useState(null); // id de l'entrée à supprimer
const [loading, setLoading] = useState(true);
const [recalculating, setRecalculating] = useState(false);
// Modal réinvestissement
const [reinvModalOpen, setReinvModalOpen] = useState(false);
const [reinvForm, setReinvForm] = useState({ montant: '', date_reinvestissement: today(), note: '' });
const [reinvErr, setReinvErr] = useState(null);
const [confirmingReinvDelete, setConfirmingReinvDelete] = useState(null);
const [openMenu, setOpenMenu] = useState(null);
const [rowDeleteConfirm, setRowDeleteConfirm] = useState(null);
const [cardMenu, setCardMenu] = useState(null);
const [fiscaliteOverrideConfirm, setFiscaliteOverrideConfirm] = useState(false);
const [rembMenu, setRembMenu] = useState(null);
const [reinvMenu, setReinvMenu] = useState(null);
const [simulMenu, setSimulMenu] = useState(null);
const [editDpeModal, setEditDpeModal] = useState(false);
const [editDpeValue, setEditDpeValue] = useState('');
const [editDpeErr, setEditDpeErr] = useState(null);
const [editDpeSaving, setEditDpeSaving] = useState(false);
const [bulkRembModal, setBulkRembModal] = useState(false);
const [bulkRembItems, setBulkRembItems] = useState([]);
const [bulkRembProcessing, setBulkRembProcessing] = useState(false);
const [bulkRembProgress, setBulkRembProgress] = useState(0);
const [bulkRembDone, setBulkRembDone] = useState(false);
const [reinvTab, setReinvTab] = useState('manuel'); // 'manuel' | 'auto'
const load = async () => {
setLoading(true);
try {
const [data, p, cats, comptes] = await Promise.all([
api.get(`/investissements/${id}`),
api.get('/plateformes'),
api.get('/investissements/comptes-courants'),
]);
setInv(data);
setPlats(p);
setComptesCourants(comptes);
} finally { setLoading(false); }
};
useEffect(() => { if (activeId) load(); /* eslint-disable-next-line */ }, [id, activeId]);
useEffect(() => { api.get('/pfu').then(setPfuRates).catch(() => {}); }, []);
// Ferme tous les menus contextuels au scroll
useEffect(() => {
const closeAll = () => {
setOpenMenu(null);
setCardMenu(null);
setRembMenu(null);
setReinvMenu(null);
setSimulMenu(null);
};
window.addEventListener('scroll', closeAll, true);
return () => window.removeEventListener('scroll', closeAll, true);
}, []);
// Ouverture automatique de la modale remboursement si remb-date est dans l'URL
// Attend que pfuRates soit chargé pour calculer les prélèvements indicatifs
useEffect(() => {
const rembDate = searchParams.get('remb-date');
if (!rembDate || !inv || !pfuRates.length) return;
// Capturer l'origine en premier (avant l'ouverture de la modale)
const from = searchParams.get('from');
if (from) fromRef.current = from;
setSearchParams(p => { p.delete('remb-date'); p.delete('from'); return p; }, { replace: true });
const simul = inv.simul || [];
const s = simul.find(x => x.date_prevue === rembDate);
if (s) {
openRembFromSimul(s);
} else {
// Pas d'entrée simul exacte — ouvre avec la date et les prélèvements estimés
const plat = plats.find(p => p.id === inv?.plateforme_id);
const rates = getRatesForYear(rembDate);
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
? plat.methode_remboursement
: (inv?.methode_remboursement || 'portefeuille');
setEditingRembId(null);
setRembForm(prev => ({
...prev,
date_remb: rembDate,
prelev_sociaux: rates ? round2((prev.interets_bruts || 0) * rates.prelev_sociaux / 100) : 0,
prelev_forfaitaire: rates ? round2((prev.interets_bruts || 0) * rates.impot_revenu / 100) : 0,
methode_remboursement: methode,
}));
setRembErr(null);
setRembModalOpen(true);
}
/* eslint-disable-next-line */
}, [inv, pfuRates, searchParams.get('remb-date')]);
// ── Helpers PFU / calculs remboursement ────────────────────────
const round2 = n => Math.round(n * 100) / 100;
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 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 setRembField = (k, v) => {
setRembForm(prev => {
const next = { ...prev, [k]: v };
const plat = plats.find(p => p.id === inv?.plateforme_id);
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
// Fiscalité locale : calcule taxe_locale et interets_bruts depuis le brut avant retenue
if (k === 'interets_bruts_avant_local') {
const taux = hasLocalTax ? (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);
}
}
return next;
});
};
const openRembFromSimul = (s) => {
const bruts = s.interets_prevus || 0;
const rates = getRatesForYear(s.date_prevue);
const plat = plats.find(p => p.id === inv?.plateforme_id);
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;
const baseRates = brutsApresLocal;
setEditingRembId(null);
setRembForm({
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(baseRates * rates.prelev_sociaux / 100) : 0,
prelev_forfaitaire: rates ? round2(baseRates * rates.impot_revenu / 100) : 0,
statut: 'paye',
notes: '',
methode_remboursement: methode,
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
});
setRembErr(null);
setRembModalOpen(true);
};
const openEditRemb = (r) => {
setEditingRembId(r.id);
setRembForm({
date_remb: r.date_remb,
capital: r.capital ?? 0,
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 ?? 0,
prelev_sociaux: r.prelev_sociaux ?? 0,
prelev_forfaitaire: r.prelev_forfaitaire ?? 0,
statut: r.statut,
notes: r.notes || '',
methode_remboursement: r.methode_remboursement || 'portefeuille',
compte_id: r.compte_id || '',
});
setRembErr(null);
setRembModalOpen(true);
};
const openNewRemb = () => {
const plat = plats.find(p => p.id === inv?.plateforme_id);
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
? plat.methode_remboursement
: (inv?.methode_remboursement || 'portefeuille');
setEditingRembId(null);
setRembForm({
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: methode,
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
});
setRembErr(null);
setRembModalOpen(true);
};
const closeRembModal = () => {
const goBack = fromRef.current === 'dashboard';
fromRef.current = null;
setRembModalOpen(false); setEditingRembId(null); setRembErr(null); setConfirmingRembDelete(false);
if (goBack) navigate('/');
};
const recalculateSchedule = async () => {
setRecalculating(true);
try {
await api.post('/simul/recalculate', { investissement_id: Number(id) });
await load();
} catch (e) {
alert('Erreur lors du recalcul : ' + e.message);
} finally {
setRecalculating(false);
}
};
const saveEditDpe = async () => {
setEditDpeErr(null);
if (!editDpeValue) { setEditDpeErr('Veuillez saisir une date.'); return; }
setEditDpeSaving(true);
try {
const payload = {
plateforme_id: inv.plateforme_id,
investisseur_id: inv.investisseur_id,
nom_projet: inv.nom_projet,
emetteur: inv.emetteur || undefined,
date_souscription: inv.date_souscription,
date_premiere_echeance: editDpeValue,
date_cible: inv.date_cible || undefined,
date_debut_simul: inv.date_debut_simul || undefined,
montant_investi: inv.montant_investi,
taux_interet: inv.taux_interet ?? undefined,
duree_mois: inv.duree_mois ?? undefined,
type_remb: inv.type_remb || undefined,
freq_interets: inv.freq_interets,
statut: inv.statut,
reference: inv.reference || undefined,
notes: inv.notes || undefined,
echeance_fin_de_mois: inv.echeance_fin_de_mois ?? 0,
methode_remboursement: inv.methode_remboursement ?? null,
nom_compte_courant: inv.nom_compte_courant ?? null,
};
await api.put(`/investissements/${id}`, payload);
await api.post('/simul/recalculate', { investissement_id: Number(id) });
await load();
setEditDpeModal(false);
} catch (e) {
setEditDpeErr(e.message || 'Erreur lors de la sauvegarde.');
} finally {
setEditDpeSaving(false);
}
};
const openBulkRembModal = () => {
const todayStr = today();
const plat = plats.find(p => p.id === inv?.plateforme_id);
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;
// Correspondance stricte (sans le fallback capRestant === 0)
const localRembByExactDate = new Map((inv.remboursements || []).map(r => [r.date_remb, r]));
const localRembByMonth = new Map((inv.remboursements || []).map(r => [r.date_remb.slice(0, 7), r]));
const isMatched = (s) =>
localRembByExactDate.has(s.date_prevue) ||
localRembByMonth.has(s.date_prevue?.slice(0, 7));
const pending = (inv.simul || []).filter(s => s.date_prevue < todayStr && !isMatched(s));
const items = pending.map(s => {
const bruts = s.interets_prevus || 0;
const rates = getRatesForYear(s.date_prevue);
const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0;
const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts;
const baseRates = brutsApresLocal;
return {
_simul: s,
payload: {
investissement_id: Number(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(baseRates * rates.prelev_sociaux / 100) : 0,
prelev_forfaitaire: rates ? round2(baseRates * rates.impot_revenu / 100) : 0,
statut: 'paye',
methode_remboursement: methode,
},
};
});
setBulkRembItems(items);
setBulkRembProgress(0);
setBulkRembDone(false);
setBulkRembModal(true);
};
const runBulkRemb = async () => {
setBulkRembProcessing(true);
setBulkRembProgress(0);
try {
for (let i = 0; i < bulkRembItems.length; i++) {
await api.post('/remboursements', bulkRembItems[i].payload);
setBulkRembProgress(i + 1);
}
setBulkRembDone(true);
await load();
} catch (e) {
alert('Erreur lors du traitement : ' + e.message);
} finally {
setBulkRembProcessing(false);
}
};
const submitRemb = async (e) => {
e?.preventDefault?.();
setRembErr(null);
const payload = {
investissement_id: Number(id),
date_remb: rembForm.date_remb,
capital: Number(rembForm.capital || 0),
cashback: Number(rembForm.cashback || 0),
interets_bruts_avant_local: Number(rembForm.interets_bruts_avant_local || 0),
taxe_locale: Number(rembForm.taxe_locale || 0),
interets_bruts: Number(rembForm.interets_bruts || 0),
prelev_sociaux: Number(rembForm.prelev_sociaux || 0),
prelev_forfaitaire: Number(rembForm.prelev_forfaitaire || 0),
statut: rembForm.statut,
notes: rembForm.notes || undefined,
methode_remboursement: rembForm.methode_remboursement || 'portefeuille',
compte_id: rembForm.methode_remboursement === 'compte_courant' && rembForm.compte_id ? Number(rembForm.compte_id) : null,
};
try {
if (editingRembId) await api.put(`/remboursements/${editingRembId}`, payload);
else await api.post('/remboursements', payload);
const goBack = fromRef.current === 'dashboard';
closeRembModal();
if (!goBack) await load();
} catch (e) { setRembErr(e.message); }
};
const deleteRemb = async () => {
try {
await api.del(`/remboursements/${editingRembId}`);
const goBack = fromRef.current === 'dashboard';
closeRembModal();
if (!goBack) await load();
} catch (e) { setConfirmingRembDelete(false); setRembErr(e.message); }
};
const openEdit = () => {
const plat = plats.find(p => p.id === inv.plateforme_id);
const platMethode = plat?.methode_remboursement;
const resolvedMethode = platMethode && platMethode !== 'choix_investisseur'
? platMethode
: (inv.methode_remboursement || '');
setForm({
investisseur_id: String(inv.investisseur_id || ''),
plateforme_id: inv.plateforme_id,
nom_projet: inv.nom_projet,
emetteur: inv.emetteur || '',
date_souscription: inv.date_souscription,
date_premiere_echeance: inv.date_premiere_echeance || '',
date_cible: inv.date_cible || '',
date_debut_simul: inv.date_debut_simul || '',
montant_investi: inv.montant_investi,
taux_interet: inv.taux_interet ?? '',
duree_mois: inv.duree_mois ?? '',
type_remb: inv.type_remb || 'in_fine',
freq_interets: inv.freq_interets || 'mensuel',
statut: inv.statut,
reference: inv.reference || '',
notes: inv.notes || '',
echeance_fin_de_mois: !!inv.echeance_fin_de_mois,
methode_remboursement: resolvedMethode,
nom_compte_courant: inv.nom_compte_courant || '',
compte_id: inv.compte_id || '',
pays_exposition: inv.pays_exposition || 'FR',
});
lastValidSouscriptionRef.current = inv.date_souscription || '';
setErr(null);
setModalOpen(true);
};
const close = () => { setModalOpen(false); setErr(null); setConfirmingInvDelete(false); };
const submit = async (e) => {
e?.preventDefault?.();
setErr(null);
try {
const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe')
&& dayOfMonth(form.date_premiere_echeance) > 27;
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 || '',
date_debut_simul: form.date_debut_simul || '',
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',
};
await api.put(`/investissements/${id}`, payload);
close();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
await api.del(`/investissements/${id}`);
navigate('/investissements');
};
const deleteHist = async (hid) => {
try {
await api.del(`/investissements/${id}/historique/${hid}`);
setConfirmingHistDelete(null);
await load();
} catch (e) { /* silencieux */ }
};
const openReinvModal = (tab = 'manuel') => {
setReinvForm({ montant: '', date_reinvestissement: today(), note: '' });
setReinvErr(null);
setReinvTab(tab);
setReinvModalOpen(true);
};
const closeReinvModal = () => { setReinvModalOpen(false); setReinvErr(null); setConfirmingReinvDelete(null); };
const submitReinv = async (e) => {
e?.preventDefault?.();
setReinvErr(null);
const montant = Number(reinvForm.montant);
if (!montant || montant <= 0) { setReinvErr('Le montant doit être positif.'); return; }
if (!reinvForm.date_reinvestissement) { setReinvErr('La date est requise.'); return; }
try {
await api.post('/reinvestissements', {
investissement_id: Number(id),
montant,
date_reinvestissement: reinvForm.date_reinvestissement,
note: reinvForm.note || null,
});
closeReinvModal();
await load();
} catch (e) { setReinvErr(e.message); }
};
const deleteReinv = async (reinvId) => {
try {
await api.del(`/reinvestissements/${reinvId}`);
setConfirmingReinvDelete(null);
await load();
} catch (e) { setReinvErr(e.message); }
};
const openRowMenu = (e, row) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ row, x: rect.right, y: rect.bottom });
};
if (loading) return <div style={{ padding: 32 }}>Chargement</div>;
if (!inv) return <div style={{ padding: 32 }}>Investissement introuvable.</div>;
const remb = inv.remboursements || [];
const simul = inv.simul || [];
const todayStr = today();
const historique = inv.historique || [];
const reinvs = inv.reinvestissements || [];
// Capital total = montant initial + réinvestissements
const capitalTotal = inv.capital_total ?? inv.montant_investi;
const autoReinvActive = !!inv?.auto_reinvest;
const hasReinvests = reinvs.length > 0 || autoReinvActive;
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
// Calculés depuis les remboursements chargés (la route /:id ne retourne pas ces agrégats)
const capitalRembourse = remb.reduce((s, r) => s + (r.capital || 0), 0);
const capRestant = Math.max(0, capitalTotal - capitalRembourse);
const interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0);
const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 0), 0);
// Rapprochement projections ↔ remboursements réels
// Priorité : date exacte, sinon même mois (YYYY-MM)
// Si tout le capital est remboursé (capRestant = 0), fallback sur le dernier remboursement
const rembByExactDate = new Map(remb.map(r => [r.date_remb, r]));
const rembByMonth = new Map(remb.map(r => [r.date_remb.slice(0, 7), r]));
// Groupement multi-remb par mois (pour les mois avec plusieurs versements / rattrapages)
const rembsArrayByMonth = new Map();
remb.forEach(r => {
const m = r.date_remb.slice(0, 7);
if (!rembsArrayByMonth.has(m)) rembsArrayByMonth.set(m, []);
rembsArrayByMonth.get(m).push(r);
});
const lastRemb = remb.length > 0
? remb.reduce((latest, r) => r.date_remb > latest.date_remb ? r : latest, remb[0])
: null;
const matchRemb = (date_prevue) =>
rembByExactDate.get(date_prevue)
?? rembByMonth.get(date_prevue?.slice(0, 7))
?? (capRestant === 0 ? lastRemb : null);
// Retourne tous les remboursements du mois d'une projection (pour somme exacte)
// Pas de priorité date exacte : on veut toujours le total du mois
const matchRembsAll = (date_prevue) => {
const monthRembs = rembsArrayByMonth.get(date_prevue?.slice(0, 7));
if (monthRembs?.length) return monthRembs;
if (capRestant === 0 && lastRemb) return [lastRemb];
return [];
};
// ── Export dossier JSON ────────────────────────────────────────
const exportDossier = () => {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const name = `Dossier_Investissement_${inv.id}_${ts}.json`;
const payload = {
version: '1.0',
type: 'dossier_investissement',
exported_at: new Date().toISOString(),
investissement: {
nom_projet: inv.nom_projet,
emetteur: inv.emetteur,
date_souscription: inv.date_souscription,
date_premiere_echeance: inv.date_premiere_echeance,
date_cible: inv.date_cible,
date_debut_simul: inv.date_debut_simul,
montant_investi: inv.montant_investi,
taux_interet: inv.taux_interet,
duree_mois: inv.duree_mois,
type_remb: inv.type_remb,
freq_interets: inv.freq_interets,
statut: inv.statut,
reference: inv.reference,
source: inv.source,
notes: inv.notes,
},
plateforme: {
nom: inv.plateforme_nom,
},
remboursements: (inv.remboursements || []).map(r => ({
date_remb: r.date_remb,
capital: r.capital,
cashback: r.cashback,
interets_bruts: r.interets_bruts,
prelev_sociaux: r.prelev_sociaux,
prelev_forfaitaire: r.prelev_forfaitaire,
interets_nets: r.interets_nets,
net_recu: r.net_recu,
statut: r.statut,
notes: r.notes,
})),
projections: (inv.simul || []).map(s => ({
numero_echeance: s.numero_echeance,
date_prevue: s.date_prevue,
capital_prevu: s.capital_prevu,
interets_prevus: s.interets_prevus,
total_prevu: s.total_prevu,
})),
reinvestissements: (inv.reinvestissements || []).map(r => ({
date_reinvestissement: r.date_reinvestissement,
montant: r.montant,
source: r.source,
note: r.note,
})),
historique: (inv.historique || []).map(h => ({
type_evenement: h.type_evenement,
changements: h.changements,
notes: h.notes,
created_at: h.created_at,
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
};
// Rendement réel annualisé (XIRR) — calculé uniquement si l'opération est remboursée
let rendementReelBrut = null;
let rendementReel = null;
if (inv.statut === 'rembourse' && remb.length > 0 && inv.date_souscription) {
// Flux sortants supplémentaires (réinvestissements = nouveaux décaissements)
const reinvOutflows = reinvs.map(r => ({ amount: -r.montant, date: r.date_reinvestissement }));
// Brut : capital + cashback + intérêts bruts (avant fiscalité)
const cashflowsBrut = [
{ amount: -inv.montant_investi, date: inv.date_souscription },
...reinvOutflows,
...remb.map(r => ({ amount: (r.capital || 0) + (r.cashback || 0) + (r.interets_bruts || 0), date: r.date_remb })),
];
rendementReelBrut = xirr(cashflowsBrut);
// Net : net_recu (après prélèvements sociaux + impôt sur le revenu)
const cashflows = [
{ amount: -inv.montant_investi, date: inv.date_souscription },
...reinvOutflows,
...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })),
];
rendementReel = xirr(cashflows);
}
return (
<>
{/* ── Barre de titre ────────────────────────────────────────── */}
<div className="topbar" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h2 style={{ flex: 1, margin: 0, display: 'flex', alignItems: 'center', gap: 14 }}>
{inv.plateforme_logo ? (
<img
src={`${(import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '')}/api/logos/${inv.plateforme_logo}`}
alt={inv.plateforme_nom}
title={inv.plateforme_nom}
className="logo-plateforme"
style={{ height: 38, width: 'auto', maxWidth: 140, objectFit: 'contain', flexShrink: 0 }}
/>
) : (
<span style={{ color: 'var(--text-muted, #6b7280)', fontWeight: 500, fontSize: '0.85em', whiteSpace: 'nowrap' }}>
{inv.plateforme_nom}
</span>
)}
<span style={{ color: 'var(--text-muted, #6b7280)', fontWeight: 300, fontSize: '0.85em' }}>·</span>
<span>{inv.nom_projet}</span>
</h2>
<button onClick={() => navigate('/investissements')} style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
Investissements
</button>
</div>
{/* ── KPIs ──────────────────────────────────────────────────── */}
<div className="kpi-grid" style={{ marginBottom: 16 }}>
{(() => {
const meta = STATUT_META[inv.statut] || { label: inv.statut, color: '#6b7280', bg: 'rgba(107,114,128,0.12)' };
return (
<div className="kpi" style={{ borderLeft: `4px solid ${meta.color}`, background: meta.bg }}>
<div className="label">Statut</div>
<div className="value" style={{ color: meta.color, fontSize: 20, fontWeight: 700, letterSpacing: '0.01em' }}>
{meta.label}
</div>
</div>
);
})()}
<div className="kpi">
<div className="label">{hasReinvests ? 'Capital total investi' : 'Montant investi'}</div>
<div className="value">{fmtEUR(capitalTotal)}</div>
{hasReinvests && (
<div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>
Initial {fmtEUR(inv.montant_investi)} + {fmtEUR(inv.reinvestissements_total)} réinvesti
</div>
)}
</div>
<div className="kpi">
<div className="label">Capital remboursé</div>
<div className="value success">{fmtEUR(capitalRembourse)}</div>
</div>
<div className="kpi">
<div className="label">Capital restant </div>
<div className="value" style={{ color: capRestant === 0 ? 'var(--success)' : undefined }}>
{fmtEUR(capRestant)}
</div>
</div>
<div className="kpi">
<div className="label">Intérêts perçus {netMode ? 'Net' : 'Brut'}</div>
<div className="value success">{fmtEUR(netMode ? interetsPercusNet : interetsPercus)}</div>
</div>
{(() => {
const rendement = netMode ? rendementReel : rendementReelBrut;
const titleTip = rendement !== null
? (netMode ? 'XIRR sur flux nets réels (après fiscalité)' : 'XIRR sur flux bruts (avant fiscalité)')
: inv.statut !== 'rembourse'
? 'Disponible uniquement sur les investissements remboursés'
: 'Données insuffisantes';
return (
<div className="kpi" title={titleTip}>
<div className="label">Rendement annualisé {netMode ? 'Net' : 'Brut'}</div>
<div className="value" style={{ color: rendement !== null ? (rendement >= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}>
{rendement !== null
? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %`
: inv.statut === 'rembourse' ? '—' : 'En cours…'}
</div>
</div>
);
})()}
</div>
{/* ── Fiche projet ──────────────────────────────────────────── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Informations du projet{inv?.fiscalite_override === 'exonere' && <span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, background: 'var(--success, #22c55e)', color: '#fff', borderRadius: 4, padding: '2px 7px', verticalAlign: 'middle' }}>Exonéré flat tax</span>}</h3>
<button
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setCardMenu({ x: r.right, y: r.bottom }); }}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
title="Actions"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
</svg>
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '14px 32px' }}>
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Plateforme</div>
<div style={{ fontWeight: 600 }}>{inv.plateforme_nom}</div>
</div>
{(inv.categories_inv || []).length > 0 && (
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Catégories d'investissement</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{inv.categories_inv.map(c => (
<span key={c.id} className="chip-cat">{c.nom}</span>
))}
</div>
</div>
)}
{(inv.secteurs_inv || []).length > 0 && (
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Secteurs d'investissement</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{inv.secteurs_inv.map(s => (
<span key={s.id} className="chip-sect">{s.nom}</span>
))}
</div>
</div>
)}
{inv.emetteur && (
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Émetteur</div>
<div>{inv.emetteur}</div>
</div>
)}
{inv.reference && (
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Référence</div>
<div>{inv.reference}</div>
</div>
)}
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Type de prêt</div>
<div>{typeLabel(inv.type_remb, inv.freq_interets)}</div>
</div>
{inv.methode_remboursement && (
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Méthode de remboursement</div>
<div>{inv.methode_remboursement === 'portefeuille' ? 'Porte-monnaie de la plateforme' : `Compte courant de l'investisseur${inv.compte_nom ? ` — ${inv.compte_nom}` : inv.nom_compte_courant ? ` (${inv.nom_compte_courant})` : ''}`}</div>
</div>
)}
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Taux annuel</div>
<div>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</div>
</div>
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Durée</div>
<div>{inv.duree_mois ? `${inv.duree_mois} mois` : '—'}</div>
</div>
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Date souscription</div>
<div>{fmtDate(inv.date_souscription)}</div>
</div>
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Date 1ère échéance</div>
<div>{fmtDate(inv.date_premiere_echeance) || '—'}</div>
</div>
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Date cible contractuelle</div>
<div>{fmtDate(inv.date_cible) || '—'}</div>
</div>
{inv.notes && (
<div style={{ gridColumn: '1 / -1' }}>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Notes</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{inv.notes}</div>
</div>
)}
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Pays d'exposition</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FlagIcon code={inv.pays_exposition || 'FR'} size={18} />
<span>{COUNTRIES.find(c => c.code === (inv.pays_exposition || 'FR'))?.name || (inv.pays_exposition || 'FR')}</span>
</div>
</div>
</div>
</div>
{/* ── Remboursements enregistrés ────────────────────────────── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>
Remboursements enregistrés
<span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>
({remb.length})
</span>
</h3>
<button
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setRembMenu({ x: r.right, y: r.bottom }); }}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
title="Actions"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
</svg>
</button>
</div>
{(() => {
const _plat = plats.find(p => p.id === inv?.plateforme_id);
const _isIndicatif = inv?.fiscalite_override === 'exonere' || _plat?.fiscalite !== 'flat_tax';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10 }}>
<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>
);
})()}
{remb.length === 0 ? (
<p className="text-muted">Aucun remboursement enregistré.</p>
) : (
<table className="remb-table">
<thead>
{(() => {
const tblPlat = plats.find(p => p.id === inv?.plateforme_id);
const showLocalCols = tblPlat?.fiscalite === 'avec_fiscalite_locale' && tblPlat?.taux_fiscalite_locale;
const tblIsIndicatif = inv?.fiscalite_override === 'exonere' || tblPlat?.fiscalite !== 'flat_tax';
return (
<tr>
<th>Date</th>
<th className="num">Capital</th>
{showLocalCols && <th className="num">Int. bruts Plateforme</th>}
{showLocalCols && <th className="num">Taxe Locale</th>}
<th className="num">Intérêts ({netMode ? 'Net' : 'Brut'})</th>
<th className="num">Cashback</th>
<th className="num">Imposition{tblIsIndicatif ? ' — indicatif' : ''}</th>
<th className="num">Montant versé</th>
<th style={{ width: 36 }}></th>
</tr>
);
})()}
</thead>
<tbody>
{remb.map(r => (
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => openEditRemb(r)}>
<td>{fmtDate(r.date_remb)}</td>
<td className="num">{fmtEUR(r.capital)}</td>
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
<td className="num">{r.interets_bruts_avant_local ? fmtEUR(r.interets_bruts_avant_local) : '—'}</td>
)}
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
<td className="num">{r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'}</td>
)}
<td className="num">{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}</td>
<td className="num">{fmtEUR(r.cashback)}</td>
<td className="num cell-tooltip"
data-tooltip={`Prélèvements sociaux : ${fmtEUR(r.prelev_sociaux)}\nImpôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}`}>
{fmtEUR((r.prelev_sociaux || 0) + (r.prelev_forfaitaire || 0))}
</td>
<td className="num"><strong>{fmtEUR(r.net_recu)}</strong></td>
<td style={{ width: 36, textAlign: 'center' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
onClick={e => { e.stopPropagation(); openRowMenu(e, { ...r, _table: 'remb' }); }}>⋮</button>
</td>
</tr>
))}
</tbody>
{remb.length > 1 && (() => {
const tot = remb.reduce((acc, r) => ({
capital: acc.capital + (r.capital || 0),
interets_bruts_avant_local: acc.interets_bruts_avant_local + (r.interets_bruts_avant_local || 0),
taxe_locale: acc.taxe_locale + (r.taxe_locale || 0),
interets_bruts: acc.interets_bruts + (r.interets_bruts || 0),
prelev_sociaux: acc.prelev_sociaux + (r.prelev_sociaux || 0),
prelev_forfaitaire: acc.prelev_forfaitaire + (r.prelev_forfaitaire || 0),
cashback: acc.cashback + (r.cashback || 0),
interets_nets: acc.interets_nets + (r.interets_nets || 0),
net_recu: acc.net_recu + (r.net_recu || 0),
}), { capital: 0, interets_bruts_avant_local: 0, taxe_locale: 0, interets_bruts: 0, prelev_sociaux: 0, prelev_forfaitaire: 0, cashback: 0, interets_nets: 0, net_recu: 0 });
return (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border, rgba(255,255,255,.12))', fontWeight: 700 }}>
<td className="text-muted" style={{ fontSize: 11 }}>Total</td>
<td className="num">{fmtEUR(tot.capital)}</td>
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
<td className="num">{fmtEUR(tot.interets_bruts_avant_local)}</td>
)}
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
<td className="num">{fmtEUR(tot.taxe_locale)}</td>
)}
<td className="num">{fmtEUR(netMode ? tot.interets_nets : tot.interets_bruts)}</td>
<td className="num">{fmtEUR(tot.cashback)}</td>
<td className="num cell-tooltip"
data-tooltip={`Prélèvements sociaux : ${fmtEUR(tot.prelev_sociaux)}\nImpôt sur le revenu : ${fmtEUR(tot.prelev_forfaitaire)}`}>
{fmtEUR(tot.prelev_sociaux + tot.prelev_forfaitaire)}
</td>
<td className="num">{fmtEUR(tot.net_recu)}</td>
<td></td>
</tr>
</tfoot>
);
})()}
</table>
)}
</div>
{/* ── Réinvestissements ────────────────────────────────────── */}
{hasReinvests && (
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>
Réinvestissements complémentaires
<span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>
({reinvs.length})
</span>
</h3>
<button
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setReinvMenu({ x: r.right, y: r.bottom }); }}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
title="Actions"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
</svg>
</button>
</div>
<table>
<thead>
<tr>
<th>Date</th>
<th className="num">Montant</th>
<th>Note</th>
<th className="num">Capital cumulé</th>
<th></th>
</tr>
</thead>
<tbody>
{(() => {
let cumul = inv.montant_investi;
return reinvs.map(r => {
cumul += r.montant;
return (
<tr key={r.id}>
<td>{fmtDate(r.date_reinvestissement)}</td>
<td className="num" style={{ color: 'var(--primary)' }}>
+{fmtEUR(r.montant)}
</td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{r.source === 'auto' && (
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 10, background: 'color-mix(in srgb, var(--primary) 12%, transparent)', color: 'var(--primary)', border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)', whiteSpace: 'nowrap' }}>auto</span>
)}
<span>{r.note || (r.source === 'auto' ? '' : '—')}</span>
</div>
</td>
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(cumul)}</td>
<td style={{ width: 36, textAlign: 'center' }}>
<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, _table: 'reinv' })}>⋮</button>
</td>
</tr>
);
});
})()}
</tbody>
{reinvs.length === 0 && autoReinvActive && (
<tbody>
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 13, fontStyle: 'italic' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'color-mix(in srgb, var(--primary) 12%, transparent)', color: 'var(--primary)', border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)' }}>auto</span>
Réinvestissement automatique des intérêts activé — alimenté après chaque remboursement
</span>
</td>
</tr>
</tbody>
)}
{reinvs.length > 0 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border, rgba(255,255,255,.12))', fontWeight: 700 }}>
<td className="text-muted" style={{ fontSize: 11 }}>Total réinvesti</td>
<td className="num" style={{ color: 'var(--primary)' }}>
+{fmtEUR(inv.reinvestissements_total)}
</td>
<td></td>
<td className="num">{fmtEUR(capitalTotal)}</td>
<td></td>
</tr>
</tfoot>
)}
</table>
</div>
)}
{/* ── Projections de remboursements ─────────────────────────── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<h3 style={{ margin: 0 }}>
Projections de remboursements
<span className="text-muted" style={{ fontWeight: 400, fontSize: 13, marginLeft: 8 }}>
({simul.length} échéance{simul.length > 1 ? 's' : ''})
</span>
</h3>
<button
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setSimulMenu({ x: r.right, y: r.bottom }); }}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
title="Actions"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
</svg>
</button>
</div>
{simul.length > 0 && (
<p className="text-muted" style={{ fontSize: 12, marginTop: 4, marginBottom: 10 }}>
Cliquez sur une ligne pour saisir le remboursement correspondant.
</p>
)}
{simul.length === 0 ? (
<p className="text-muted">Aucune projection disponible (taux et durée requis).</p>
) : (
<table>
<thead>
<tr>
<th className="num">N°</th>
<th>Date prévue</th>
<th className="num">Capital prévu</th>
<th className="num">Intérêts ({netMode ? 'Net estimé' : 'Brut'})</th>
<th className="num">Total prévu</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{simul.map(s => {
const matched = matchRemb(s.date_prevue);
const matchedAll = matchRembsAll(s.date_prevue);
const isPaid = matchedAll.length > 0;
const rates = getRatesForYear(s.date_prevue);
const reduction = rates ? (rates.prelev_sociaux + rates.impot_revenu) / 100 : 0;
const interets = netMode
? (s.interets_prevus || 0) * (1 - reduction)
: (s.interets_prevus || 0);
// Montants réels — somme de tous les remboursements du mois
const interetsReels = isPaid
? (netMode
? matchedAll.reduce((sum, r) => sum + (r.interets_nets || 0), 0)
: matchedAll.reduce((sum, r) => sum + (r.interets_bruts || 0), 0))
: null;
const totalRecu = isPaid
? (netMode
? matchedAll.reduce((sum, r) => sum + (r.net_recu || 0), 0)
: matchedAll.reduce((sum, r) => sum + (r.capital || 0) + (r.cashback || 0) + (r.interets_bruts || 0), 0))
: null;
const isPast = s.date_prevue < todayStr;
const interetsDiffers = isPaid && Math.abs(interetsReels - interets) > 0.01;
const totalDiffers = isPaid && Math.abs(totalRecu - s.total_prevu) > 0.01;
return (
<tr
key={s.id}
onClick={() => isPaid ? openEditRemb(matched) : openRembFromSimul(s)}
style={{ cursor: 'pointer' }}
title={isPaid ? 'Cliquer pour modifier ce remboursement' : 'Cliquer pour saisir ce remboursement'}
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<td className="num">{s.numero_echeance}</td>
<td>{fmtDate(s.date_prevue)}</td>
<td className="num">{fmtEUR(s.capital_prevu)}</td>
<td className="num">
{interetsDiffers ? (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
<span style={{ textDecoration: 'line-through', color: 'var(--text-muted)', fontWeight: 400 }}>{fmtEUR(interets)}</span>
<span>{fmtEUR(interetsReels)}</span>
</span>
) : (isPast && !isPaid) ? (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
<span style={{ textDecoration: 'line-through', color: 'var(--text-muted)', fontWeight: 400 }}>{fmtEUR(interets)}</span>
<span>0,00 €</span>
</span>
) : fmtEUR(interets)}
</td>
<td className="num">
{totalDiffers ? (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
<span style={{ textDecoration: 'line-through', color: 'var(--text-muted)', fontWeight: 400 }}>{fmtEUR(s.total_prevu)}</span>
<strong>{fmtEUR(totalRecu)}</strong>
</span>
) : (isPast && !isPaid) ? (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
<span style={{ textDecoration: 'line-through', color: 'var(--text-muted)', fontWeight: 400 }}>{fmtEUR(s.total_prevu)}</span>
<strong>0,00 €</strong>
</span>
) : (
<strong>{fmtEUR(s.total_prevu)}</strong>
)}
</td>
<td>
{isPaid
? <span style={{ color: 'var(--success, #22c55e)', fontSize: 12, fontWeight: 600 }}>
✓ Payé le {fmtDate(matched.date_remb)}
</span>
: <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>
En attente
</span>
}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* ── Historique des modifications ──────────────────────────── */}
{historique.length > 0 && (
<div className="card" style={{ marginBottom: 16 }}>
<h3 style={{ marginTop: 0, marginBottom: 16 }}>Historique du prêt</h3>
<div style={{ position: 'relative', paddingLeft: 28 }}>
{/* Ligne verticale de la timeline */}
<div style={{
position: 'absolute', left: 9, top: 0, bottom: 0,
width: 2, background: 'var(--border, rgba(255,255,255,.1))',
}} />
{[...historique].reverse().map((evt, idx) => {
const isRestruct = evt.type_evenement === 'restructuration';
const isCreation = evt.type_evenement === 'creation';
const dotColor = isRestruct ? '#f59e0b' : isCreation ? '#22c55e' : 'var(--text-muted)';
const cardBg = isRestruct ? 'rgba(245,158,11,0.07)' : isCreation ? 'rgba(34,197,94,0.05)' : 'transparent';
const cardBorder = isRestruct ? '1px solid rgba(245,158,11,0.3)' : isCreation ? '1px solid rgba(34,197,94,0.2)' : '1px solid var(--border, rgba(255,255,255,.08))';
const fmtVal = (champ, val) => {
if (val === null || val === undefined) return '—';
const typeLabels = { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' };
const statutLabels = { en_cours: 'En cours', rembourse: 'Remboursé', en_retard: 'En retard', procedure: 'Procédure', cloture: 'Clôturé' };
const freqLabels = { mensuel: 'Mensuel', trimestriel: 'Trimestriel', in_fine: 'In fine (unique)' };
if (champ === 'type_remb') return typeLabels[val] ?? val;
if (champ === 'statut') return statutLabels[val] ?? val;
if (champ === 'freq_interets') return freqLabels[val] ?? val;
if (champ === 'taux_interet') return `${val} %`;
if (champ === 'duree_mois') return `${val} mois`;
if (champ === 'montant_investi') return `${Number(val).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
if (/^date_/.test(champ)) return fmtDate(val);
return String(val);
};
// Correction fuseau horaire : SQLite datetime('now') stocke en UTC sans 'Z'
// → on force le parsing UTC pour que toLocaleTimeString convertisse en heure locale
const createdAtLocal = evt.created_at
? new Date(evt.created_at.replace(' ', 'T') + 'Z')
.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
: null;
const isConfirmingDelete = confirmingHistDelete === evt.id;
return (
<div key={evt.id} style={{ position: 'relative', marginBottom: idx < historique.length - 1 ? 16 : 0 }}>
{/* Point sur la timeline */}
<div style={{
position: 'absolute', left: -24, top: 14,
width: 10, height: 10, borderRadius: '50%',
background: dotColor,
border: `2px solid var(--bg-card, #1e293b)`,
zIndex: 1,
}} />
<div style={{
background: cardBg,
border: isConfirmingDelete ? '1px solid rgba(239,68,68,0.5)' : cardBorder,
borderRadius: 8,
padding: '10px 14px',
transition: 'border-color 0.15s',
}}>
{/* En-tête événement */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: isCreation ? 0 : 8 }}>
<span style={{ fontWeight: 700, fontSize: 13, color: dotColor }}>
{isRestruct ? '⟳ Restructuration' : isCreation ? '✦ Création' : '✎ Modification'}
</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{fmtDate(evt.date_evenement)}
{createdAtLocal && <> · {createdAtLocal}</>}
</span>
{/* Bouton supprimer — sur toutes les entrées sauf la création */}
{!isCreation && !isConfirmingDelete && (
<button
title="Supprimer cette entrée d'historique"
onClick={() => setConfirmingHistDelete(evt.id)}
style={{
marginLeft: 'auto', background: 'transparent', border: 'none',
cursor: 'pointer', padding: '2px 6px', borderRadius: 4,
color: 'var(--text-muted)', fontSize: 14,
lineHeight: 1,
}}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
🗑
</button>
)}
</div>
{/* Confirmation de suppression inline */}
{isConfirmingDelete && (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', marginBottom: 10,
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 6,
}}>
<span style={{ fontSize: 16 }}>⚠️</span>
<span style={{ fontSize: 12, flex: 1, color: 'var(--text)', lineHeight: 1.4 }}>
Supprimer cet événement de l'historique ?<br />
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
Cette action est irréversible. La simulation du prêt ne sera pas modifiée.
</span>
</span>
<button
onClick={() => setConfirmingHistDelete(null)}
style={{ fontSize: 12, padding: '4px 10px' }}
>
Annuler
</button>
<button
className="danger"
onClick={() => deleteHist(evt.id)}
style={{ fontSize: 12, padding: '4px 10px' }}
>
Supprimer
</button>
</div>
)}
{/* Détail des champs modifiés */}
{!isCreation && evt.changements.map((c, ci) => (
<div key={ci} style={{
display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap',
fontSize: 12, padding: '3px 0',
borderTop: ci > 0 ? '1px solid var(--border, rgba(255,255,255,.06))' : 'none',
}}>
<span style={{ color: 'var(--text-muted)', minWidth: 160 }}>{c.label}</span>
<span style={{
background: 'rgba(239,68,68,0.12)', color: '#f87171',
borderRadius: 4, padding: '1px 6px',
textDecoration: 'line-through', opacity: 0.8,
}}>
{fmtVal(c.champ, c.ancienne_valeur)}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}></span>
<span style={{
background: 'rgba(34,197,94,0.12)', color: '#4ade80',
borderRadius: 4, padding: '1px 6px',
}}>
{fmtVal(c.champ, c.nouvelle_valeur)}
</span>
</div>
))}
{/* Badge restructuration bien visible */}
{isRestruct && !isConfirmingDelete && (
<div style={{
marginTop: 8, padding: '4px 10px',
background: 'rgba(245,158,11,0.15)', borderRadius: 4,
fontSize: 11, color: '#fbbf24', fontWeight: 600,
}}>
Ce prêt a été restructuré la projection de remboursement a été mise à jour.
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Modal édition ─────────────────────────────────────────── */}
<Modal
open={modalOpen}
title="Modifier l'investissement"
onClose={close}
width={720}
footer={
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
{/* Zone suppression — à gauche */}
<div style={{ flex: 1 }}>
{!confirmingInvDelete ? (
<button className="danger" onClick={() => setConfirmingInvDelete(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={() => setConfirmingInvDelete(false)}>Non</button>
<button className="danger" onClick={() => { onDelete(); }}>Oui, supprimer</button>
</span>
)}
</div>
{/* Actions standard — à droite */}
<button onClick={close}>Annuler</button>
<button className="primary" onClick={submit}>Enregistrer</button>
</div>
}
>
<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 || '');
setForm(f => ({ ...f, plateforme_id: platId, methode_remboursement: newMethode, compte_id: '' }));
}}>
<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>
);
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>
)}
<div>
<label>Détenteur *</label>
<select required value={form.investisseur_id} onChange={e => setForm({ ...form, investisseur_id: e.target.value })}>
<option value="">—</option>
{investisseurs.filter(i => i.type !== 'entreprise').length > 0 && (
<optgroup label="Famille">
{investisseurs.filter(i => i.type !== 'entreprise').map(i => (
<option key={i.id} value={i.id}>
{i.nom}{i.is_principal ? ' (principal)' : ''}
</option>
))}
</optgroup>
)}
{investisseurs.filter(i => i.type === 'entreprise').length > 0 && (
<optgroup label="Entreprises">
{investisseurs.filter(i => i.type === 'entreprise').map(i => (
<option key={i.id} value={i.id}>{i.nom}</option>
))}
</optgroup>
)}
</select>
</div>
<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>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') {
if (form.date_premiere_echeance) next.date_cible = form.date_premiere_echeance;
else 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_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>Durée (mois)</label>
<input type="number" min="0" 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>Taux annuel (%)</label>
<input type="number" step="0.01" value={form.taux_interet} onChange={e => setForm({ ...form, taux_interet: 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>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 {
// Pour in_fine / amortissable : recalcul systématique de premiere_echeance
// (cohérent avec le comportement de "differe" et évite les faux négatifs
// du test isAutoCalc quand premiere_echeance ne vaut pas exactement souscription+1m)
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;
});
// Mémorise la dernière souscription complète HORS setForm (évite les doubles appels Strict Mode)
if (souscription && souscription.length === 10) {
lastValidSouscriptionRef.current = souscription;
}
}} />
</div>
<div>
<label>Date 1ère échéance</label>
<input
type="date"
value={form.date_premiere_echeance}
onChange={e => {
const dpe = e.target.value;
const next = { ...form, date_premiere_echeance: dpe };
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'}
style={form.type_remb === 'differe' ? { opacity: 0.6, cursor: 'default' } : undefined}
onChange={e => {
if (form.type_remb === 'differe') return;
setForm({ ...form, date_cible: e.target.value });
}}
/>
</div>
{/* Champ restructuration : visible uniquement si des remboursements existent
ET que le type de prêt a été modifié (ou qu'une date était déjà définie) */}
{remb.length > 0 && (form.type_remb !== inv.type_remb || !!inv.date_debut_simul) && (
<div style={{ gridColumn: '1 / -1' }}>
<div style={{
borderLeft: '3px solid var(--warning, #f59e0b)',
background: 'rgba(245,158,11,0.08)',
borderRadius: '0 6px 6px 0',
padding: '10px 14px',
marginBottom: 2,
}}>
<label style={{ color: 'var(--warning, #f59e0b)', fontWeight: 600, marginBottom: 4 }}>
⟳ Restructuration — Début de la nouvelle simulation
</label>
<input
type="date"
value={form.date_debut_simul}
min={form.date_premiere_echeance || undefined}
max={form.date_cible || undefined}
onChange={e => setForm({ ...form, date_debut_simul: e.target.value })}
/>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.5 }}>
La projection démarrera à cette date avec le nouveau type de prêt. La durée restante
sera calculée automatiquement à partir de la date de 1ère échéance d'origine.
{form.date_debut_simul && form.date_premiere_echeance && form.duree_mois && (() => {
const [y1, m1] = form.date_premiere_echeance.split('-').map(Number);
const [y2, m2] = form.date_debut_simul.split('-').map(Number);
const elapsed = (y2 - y1) * 12 + (m2 - m1);
const restant = Math.max(1, Number(form.duree_mois) - elapsed);
return elapsed > 0
? <strong style={{ display: 'block', marginTop: 4 }}>
→ {elapsed} mois écoulés · {restant} mois simulés
</strong>
: null;
})()}
</div>
{form.date_debut_simul && (
<button
type="button"
style={{ marginTop: 8, fontSize: 11, padding: '2px 10px' }}
onClick={() => setForm({ ...form, date_debut_simul: '' })}
>
Effacer la restructuration
</button>
)}
</div>
</div>
)}
<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 (ID plateforme)</label>
<input value={form.reference} onChange={e => setForm({ ...form, reference: 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 })} />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label>Notes</label>
<textarea rows={2} value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
</div>
</form>
</Modal>
{/* ── Modal saisie remboursement (depuis projection) ────────── */}
{(() => {
const rates = getRatesForYear(rembForm.date_remb);
const fmtRate = n => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
const labelPS = `Prélèvements sociaux${rates ? ` (${fmtRate(rates.prelev_sociaux)})` : ''} (€)`;
const currentPlat = plats.find(p => p.id === inv?.plateforme_id);
const isChoixOuvert = currentPlat?.methode_remboursement === 'choix_investisseur';
const hasLocalTax = currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
const labelIR = `Impôt sur le revenu${rates ? ` (${fmtRate(rates.impot_revenu)})` : ''} (€)`;
const interetsNets = computeInteretsNets(rembForm);
const netRecu = computeNet(rembForm);
const isExonere = inv?.fiscalite_override === 'exonere';
// Indicatif = plateforme étrangère (non flat_tax) OU investissement exonéré PEA-PME.
// Dans ce cas, les prélèvements sont affichés en lecture seule et le montant versé
// ne les déduit pas (l'investisseur reçoit le brut intégral).
const isIndicatif = isExonere || currentPlat?.fiscalite !== 'flat_tax';
const montantRembourse = isIndicatif
? round2((Number(rembForm.capital)||0) + (Number(rembForm.cashback)||0) + (Number(rembForm.interets_bruts)||0))
: netRecu;
return (
<Modal
open={rembModalOpen}
title={editingRembId ? 'Modifier le remboursement' : 'Saisir le remboursement'}
onClose={closeRembModal}
width={900}
footer={
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
{editingRembId && !confirmingRembDelete && (
<button className="danger" onClick={() => setConfirmingRembDelete(true)}>
Supprimer
</button>
)}
{confirmingRembDelete && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)' }}>
Supprimer ce remboursement ?
</span>
<button onClick={() => setConfirmingRembDelete(false)}>Non</button>
<button className="danger" onClick={deleteRemb}>Oui, supprimer</button>
</div>
)}
{!confirmingRembDelete && (
<div style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
<button onClick={closeRembModal}>Annuler</button>
<button className="primary" onClick={submitRemb}>Enregistrer</button>
</div>
)}
</div>
}
>
<form onSubmit={submitRemb}>
{rembErr && <div className="error">{rembErr}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px' }}>
<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={rembForm.interets_bruts_avant_local ?? 0}
onChange={e => setRembField('interets_bruts_avant_local', e.target.value)} />
</div>
)}
{hasLocalTax && (
<div>
<label>Retenue à la source locale (€)</label>
<input type="number" step="0.01" value={rembForm.taxe_locale ?? 0}
onChange={e => setRembField('taxe_locale', e.target.value)} />
</div>
)}
<div>
<label>Capital (€)</label>
<input type="number" step="0.01" value={rembForm.capital ?? 0}
onChange={e => setRembField('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={rembForm.interets_bruts ?? 0}
readOnly={!!hasLocalTax}
style={hasLocalTax ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !hasLocalTax && setRembField('interets_bruts', e.target.value)} />
</div>
<div>
<label>Cashback (€)</label>
<input type="number" step="0.01" value={rembForm.cashback ?? 0}
onChange={e => setRembField('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={rembForm.prelev_sociaux ?? 0}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setRembField('prelev_sociaux', e.target.value)} />
</div>
<div>
<label>{labelIR}</label>
<input type="number" step="0.01" value={rembForm.prelev_forfaitaire ?? 0}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setRembField('prelev_forfaitaire', e.target.value)} />
</div>
<div>
<label>Total prélèvements (€)</label>
<input type="number" step="0.01"
value={round2((Number(rembForm.prelev_sociaux) || 0) + (Number(rembForm.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={rembForm.date_remb || ''}
onChange={e => setRembField('date_remb', e.target.value)} />
</div>
<div>
<label>Méthode de remboursement</label>
<select value={rembForm.methode_remboursement || 'portefeuille'}
onChange={e => {
const m = e.target.value;
const def = m === 'compte_courant' ? (comptesRembInvestisseur.find(c => c.type === 'compte_courant') ?? comptesRembInvestisseur[0]) : null;
setRembForm(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>
{rembForm.methode_remboursement === 'compte_courant' && (
<div>
<label>Compte de réception</label>
{comptesRembInvestisseur.length > 0 ? (
<select value={rembForm.compte_id || ''}
onChange={e => setRembForm(f => ({ ...f, compte_id: e.target.value }))}>
<option value="">— Choisir un compte —</option>
{comptesRembInvestisseur.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>
)}
</div>
)}
<div>
<label>Montant Versé (€)</label>
<input type="number" step="0.01" value={montantRembourse} 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)' }}>
{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>
</div>
</form>
</Modal>
);
})()}
{/* ── Modal réinvestissement ────────────────────────────────── */}
<Modal
open={reinvModalOpen}
title="Ajouter un réinvestissement"
onClose={closeReinvModal}
width={480}
footer={
reinvTab === 'manuel' ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={closeReinvModal}>Annuler</button>
<button className="primary" onClick={submitReinv}>Enregistrer</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={closeReinvModal}>Annuler</button>
{!autoReinvActive ? (
<button className="primary" onClick={async () => {
await api.put(`/investissements/${id}/auto-reinvest`, { active: true });
await load();
closeReinvModal();
}}>Activer</button>
) : (
<button className="danger" onClick={async () => {
await api.put(`/investissements/${id}/auto-reinvest`, { active: false });
await load();
closeReinvModal();
}}>Désactiver</button>
)}
</div>
)
}
>
{/* Sélecteur Manuel / Automatique */}
<div style={{ display: 'flex', gap: 2, background: 'var(--surface-2)', borderRadius: 8, padding: 3, marginBottom: 20 }}>
{['manuel', 'auto'].map(tab => (
<button key={tab}
onClick={() => setReinvTab(tab)}
style={{
flex: 1, padding: '6px 0', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 500, transition: 'all .15s',
background: reinvTab === tab ? 'var(--surface)' : 'transparent',
color: reinvTab === tab ? 'var(--text)' : 'var(--text-muted)',
boxShadow: reinvTab === tab ? '0 1px 4px rgba(0,0,0,.1)' : 'none',
}}>
{tab === 'manuel' ? 'Manuel' : 'Automatique'}
</button>
))}
</div>
{reinvTab === 'manuel' ? (
<form onSubmit={submitReinv}>
<p className="text-muted" style={{ marginTop: 0, marginBottom: 16, fontSize: 13 }}>
Un réinvestissement ajoute du capital à ce prêt à une date donnée. La projection
des intérêts sera recalculée à partir de cette date.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={{ display: 'block', marginBottom: 4, fontSize: 12, color: 'var(--text-muted)' }}>
Montant réinvesti (€) *
</label>
<input
type="number" step="0.01" min="0.01" required
value={reinvForm.montant}
onChange={e => setReinvForm(f => ({ ...f, montant: e.target.value }))}
placeholder="ex. 500"
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 4, fontSize: 12, color: 'var(--text-muted)' }}>
Date du réinvestissement *
</label>
<input
type="date" required
value={reinvForm.date_reinvestissement}
onChange={e => setReinvForm(f => ({ ...f, date_reinvestissement: e.target.value }))}
style={{ width: '100%' }}
/>
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 12, color: 'var(--text-muted)' }}>
Note (optionnel)
</label>
<input
type="text"
value={reinvForm.note}
onChange={e => setReinvForm(f => ({ ...f, note: e.target.value }))}
placeholder="ex. Abondement contrat, tranche 2…"
style={{ width: '100%' }}
/>
</div>
{reinvErr && <div className="error">{reinvErr}</div>}
</form>
) : (
<div style={{ padding: '8px 0' }}>
{autoReinvActive ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', borderRadius: 8, background: 'color-mix(in srgb, var(--success) 10%, transparent)', border: '1px solid color-mix(in srgb, var(--success) 25%, transparent)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span style={{ fontSize: 13, color: 'var(--success)', fontWeight: 600 }}>Réinvestissement automatique activé</span>
</div>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6 }}>
Après chaque remboursement, les intérêts {inv?.plateforme_fiscalite === 'flat_tax' ? 'nets (prélevés à la source)' : 'bruts'} non nuls sont automatiquement réinvestis sur ce prêt.
</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ margin: 0, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6 }}>
Après chaque remboursement, les intérêts {inv?.plateforme_fiscalite === 'flat_tax' ? 'nets (prélevés à la source)' : 'bruts'} non nuls seront automatiquement réinvestis et intégrés au capital de ce prêt.
</p>
</div>
)}
</div>
)}
</Modal>
{/* ── Modale modification date 1ère échéance ────────────────── */}
<Modal
open={editDpeModal}
title="Modifier la date de la 1ère échéance"
onClose={() => setEditDpeModal(false)}
width={420}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setEditDpeModal(false)}>Annuler</button>
<button className="primary" onClick={saveEditDpe} disabled={editDpeSaving}>
{editDpeSaving ? 'Enregistrement' : 'Enregistrer'}
</button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{editDpeErr && <div className="error">{editDpeErr}</div>}
<div>
<label style={{ display: 'block', marginBottom: 4 }}>Date de la 1ère échéance</label>
<input
type="date"
value={editDpeValue}
onChange={e => setEditDpeValue(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6 }}>
L'échéancier sera recalculé à partir de cette date. Les remboursements déjà enregistrés ne sont pas modifiés.
</p>
</div>
</Modal>
{/* ── Modale traitement en masse des remboursements ─────────── */}
<Modal
open={bulkRembModal}
title="Traitement en masse des remboursements"
onClose={() => { if (!bulkRembProcessing) setBulkRembModal(false); }}
width={560}
footer={
bulkRembDone ? (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="primary" onClick={() => setBulkRembModal(false)}>Fermer</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setBulkRembModal(false)} disabled={bulkRembProcessing}>Annuler</button>
<button className="primary" onClick={runBulkRemb} disabled={bulkRembProcessing || bulkRembItems.length === 0}>
{bulkRembProcessing ? `Traitement… (${bulkRembProgress}/${bulkRembItems.length})` : `Enregistrer ${bulkRembItems.length} remboursement${bulkRembItems.length > 1 ? 's' : ''}`}
</button>
</div>
)
}
>
{bulkRembDone ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span style={{ fontSize: 14, color: 'var(--success)', fontWeight: 600 }}>
{bulkRembItems.length} remboursement{bulkRembItems.length > 1 ? 's' : ''} enregistré{bulkRembItems.length > 1 ? 's' : ''} avec succès.
</span>
</div>
) : bulkRembItems.length === 0 ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
Aucune échéance passée non enregistrée trouvée.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6 }}>
{bulkRembItems.length} échéance{bulkRembItems.length > 1 ? 's' : ''} passée{bulkRembItems.length > 1 ? 's' : ''} sans remboursement enregistré. Chaque entrée sera créée avec les montants projetés.
</p>
<div style={{ maxHeight: 260, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--surface-2)' }}>
<th style={{ padding: '6px 10px', textAlign: 'left', fontWeight: 600 }}>Date</th>
<th style={{ padding: '6px 10px', textAlign: 'right', fontWeight: 600 }}>Capital</th>
<th style={{ padding: '6px 10px', textAlign: 'right', fontWeight: 600 }}>Intérêts bruts</th>
<th style={{ padding: '6px 10px', textAlign: 'right', fontWeight: 600 }}>Total</th>
</tr>
</thead>
<tbody>
{bulkRembItems.map((item, idx) => (
<tr key={idx} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 10px' }}>{fmtDate(item.payload.date_remb)}</td>
<td style={{ padding: '6px 10px', textAlign: 'right' }}>{fmtEUR(item.payload.capital)}</td>
<td style={{ padding: '6px 10px', textAlign: 'right' }}>{fmtEUR(item.payload.interets_bruts)}</td>
<td style={{ padding: '6px 10px', textAlign: 'right', fontWeight: 600 }}>{fmtEUR(item.payload.capital + item.payload.interets_bruts)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Modal>
{simulMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setSimulMenu(null)} />
<div style={{
position: 'fixed', left: simulMenu.x, top: simulMenu.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: 200,
}}>
<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={() => { setSimulMenu(null); setEditDpeValue(inv.date_premiere_echeance || ''); setEditDpeErr(null); setEditDpeModal(true); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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"/>
</svg>
Modifier la date de la 1ère échéance
</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(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setSimulMenu(null); openBulkRembModal(); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<circle cx="3" cy="6" r="1" fill="currentColor"/><circle cx="3" cy="12" r="1" fill="currentColor"/><circle cx="3" cy="18" r="1" fill="currentColor"/>
</svg>
Traitement en masse des remboursements
</button>
{inv.taux_interet && inv.duree_mois ? (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: recalculating ? 'not-allowed' : 'pointer', fontSize: 'var(--fs-sm)', color: recalculating ? 'var(--text-muted)' : 'var(--text)', textAlign: 'left', opacity: recalculating ? 0.6 : 1 }}
onMouseEnter={e => { if (!recalculating) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
disabled={recalculating}
onClick={() => { setSimulMenu(null); recalculateSchedule(); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ animation: recalculating ? 'spin 1s linear infinite' : 'none' }}>
<path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/>
<path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/>
</svg>
{recalculating ? 'Régénération…' : "Régénérer l'échéancier"}
</button>
) : (
<div style={{ padding: '8px 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', fontStyle: 'italic' }}>
Taux et durée requis pour régénérer
</div>
)}
</div>
</>
)}
{reinvMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setReinvMenu(null)} />
<div style={{
position: 'fixed', left: reinvMenu.x, top: reinvMenu.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: 220,
}}>
<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={() => { setReinvMenu(null); openReinvModal(); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Ajouter un réinvestissement
</button>
{reinvs.length > 0 && (
<>
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<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={() => {
setReinvMenu(null);
setRowDeleteConfirm({
message: `Supprimer les ${reinvs.length} réinvestissement${reinvs.length > 1 ? 's' : ''} de ce projet ? Cette action est irréversible.`,
onConfirm: async () => {
await Promise.all(reinvs.map(r => api.del(`/reinvestissements/${r.id}`)));
setRowDeleteConfirm(null);
await load();
},
});
}}>
<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 tous les réinvestissements
</button>
</>
)}
</div>
</>
)}
{rembMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setRembMenu(null)} />
<div style={{
position: 'fixed', left: rembMenu.x, top: rembMenu.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: 200,
}}>
<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={() => { setRembMenu(null); openNewRemb(); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Ajouter un remboursement
</button>
{remb.length > 0 && (
<>
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<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={() => {
setRembMenu(null);
setRowDeleteConfirm({
message: `Supprimer les ${remb.length} remboursement${remb.length > 1 ? 's' : ''} de ce projet ? Cette action est irréversible.`,
onConfirm: async () => {
await api.del(`/remboursements?investissement_id=${id}`);
setRowDeleteConfirm(null);
await load();
},
});
}}>
<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 tous les remboursements
</button>
</>
)}
</div>
</>
)}
{fiscaliteOverrideConfirm && (
<div style={{ position: 'fixed', inset: 0, zIndex: 400, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: 'var(--surface)', borderRadius: 10, padding: '28px 32px', maxWidth: 440, width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
<h3 style={{ margin: '0 0 12px' }}>
{inv?.fiscalite_override === 'exonere' ? 'Rétablir la flat tax' : 'Exonérer de la flat tax'}
</h3>
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', lineHeight: 1.6 }}>
{inv?.fiscalite_override === 'exonere'
? 'Les prochains remboursements appliqueront à nouveau les prélèvements flat tax selon les taux PFU. Les remboursements existants ne sont pas modifiés.'
: 'Les prochains remboursements seront traités sans prélèvements automatiques (comme une plateforme étrangère hors flat tax). Les remboursements existants ne sont pas modifiés.'}
</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={() => setFiscaliteOverrideConfirm(false)}>Annuler</button>
<button className="primary" onClick={async () => {
setFiscaliteOverrideConfirm(false);
const newOverride = inv?.fiscalite_override === 'exonere' ? null : 'exonere';
await api.put(`/investissements/${id}/fiscalite-override`, { override: newOverride });
await load();
}}>Confirmer</button>
</div>
</div>
</div>
)}
{cardMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setCardMenu(null)} />
<div style={{
position: 'fixed', left: cardMenu.x, top: cardMenu.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: 160,
}}>
{[
{ label: 'Modifier', icon: <><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"/></>, action: () => { setCardMenu(null); openEdit(); } },
{ label: 'Réinvestir', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setCardMenu(null); openReinvModal(); } },
{ label: 'Exporter', icon: <><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"/></>, action: () => { setCardMenu(null); exportDossier(); } },
].map(({ label, icon, action }) => (
<button key={label}
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={() => action()}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">{icon}</svg>
{label}
</button>
))}
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
{autoReinvActive && (
<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-muted)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={async () => {
setCardMenu(null);
await api.put(`/investissements/${id}/auto-reinvest`, { active: false });
await load();
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Désactiver le réinvestissement auto
</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={() => {
setCardMenu(null);
setRowDeleteConfirm({
message: `Supprimer l'investissement "${inv?.nom_projet}" ? Cette action est irréversible.`,
onConfirm: async () => {
await api.del(`/investissements/${id}`);
setRowDeleteConfirm(null);
navigate('/investissements');
},
});
}}>
<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={!!rowDeleteConfirm}
message={rowDeleteConfirm?.message}
onConfirm={rowDeleteConfirm?.onConfirm}
onCancel={() => setRowDeleteConfirm(null)}
/>
</>
);
}