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
Chargement…
; if (!inv) return
Investissement introuvable.
; 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 ────────────────────────────────────────── */}

{inv.plateforme_logo ? ( {inv.plateforme_nom} ) : ( {inv.plateforme_nom} )} · {inv.nom_projet}

{/* ── KPIs ──────────────────────────────────────────────────── */}
{(() => { const meta = STATUT_META[inv.statut] || { label: inv.statut, color: '#6b7280', bg: 'rgba(107,114,128,0.12)' }; return (
Statut
{meta.label}
); })()}
{hasReinvests ? 'Capital total investi' : 'Montant investi'}
{fmtEUR(capitalTotal)}
{hasReinvests && (
Initial {fmtEUR(inv.montant_investi)} + {fmtEUR(inv.reinvestissements_total)} réinvesti
)}
Capital remboursé
{fmtEUR(capitalRembourse)}
Capital restant dû
{fmtEUR(capRestant)}
Intérêts perçus — {netMode ? 'Net' : 'Brut'}
{fmtEUR(netMode ? interetsPercusNet : interetsPercus)}
{(() => { 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 (
Rendement annualisé — {netMode ? 'Net' : 'Brut'}
= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}> {rendement !== null ? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %` : inv.statut === 'rembourse' ? '—' : 'En cours…'}
); })()}
{/* ── Fiche projet ──────────────────────────────────────────── */}

Informations du projet{inv?.fiscalite_override === 'exonere' && Exonéré flat tax}

Plateforme
{inv.plateforme_nom}
{(inv.categories_inv || []).length > 0 && (
Catégories d'investissement
{inv.categories_inv.map(c => ( {c.nom} ))}
)} {(inv.secteurs_inv || []).length > 0 && (
Secteurs d'investissement
{inv.secteurs_inv.map(s => ( {s.nom} ))}
)} {inv.emetteur && (
Émetteur
{inv.emetteur}
)} {inv.reference && (
Référence
{inv.reference}
)}
Type de prêt
{typeLabel(inv.type_remb, inv.freq_interets)}
{inv.methode_remboursement && (
Méthode de remboursement
{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})` : ''}`}
)}
Taux annuel
{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}
Durée
{inv.duree_mois ? `${inv.duree_mois} mois` : '—'}
Date souscription
{fmtDate(inv.date_souscription)}
Date 1ère échéance
{fmtDate(inv.date_premiere_echeance) || '—'}
Date cible contractuelle
{fmtDate(inv.date_cible) || '—'}
{inv.notes && (
Notes
{inv.notes}
)}
Pays d'exposition
{COUNTRIES.find(c => c.code === (inv.pays_exposition || 'FR'))?.name || (inv.pays_exposition || 'FR')}
{/* ── Remboursements enregistrés ────────────────────────────── */}

Remboursements enregistrés ({remb.length})

{(() => { const _plat = plats.find(p => p.id === inv?.plateforme_id); const _isIndicatif = inv?.fiscalite_override === 'exonere' || _plat?.fiscalite !== 'flat_tax'; return (
{_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)."}
); })()} {remb.length === 0 ? (

Aucun remboursement enregistré.

) : ( {(() => { 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 ( {showLocalCols && } {showLocalCols && } ); })()} {remb.map(r => ( openEditRemb(r)}> {plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && ( )} {plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && ( )} ))} {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 ( {plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && ( )} {plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && ( )} ); })()}
Date CapitalInt. bruts PlateformeTaxe LocaleIntérêts ({netMode ? 'Net' : 'Brut'}) Cashback Imposition{tblIsIndicatif ? ' — indicatif' : ''} Montant versé
{fmtDate(r.date_remb)} {fmtEUR(r.capital)}{r.interets_bruts_avant_local ? fmtEUR(r.interets_bruts_avant_local) : '—'}{r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'}{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)} {fmtEUR(r.cashback)} {fmtEUR((r.prelev_sociaux || 0) + (r.prelev_forfaitaire || 0))} {fmtEUR(r.net_recu)}
Total {fmtEUR(tot.capital)}{fmtEUR(tot.interets_bruts_avant_local)}{fmtEUR(tot.taxe_locale)}{fmtEUR(netMode ? tot.interets_nets : tot.interets_bruts)} {fmtEUR(tot.cashback)} {fmtEUR(tot.prelev_sociaux + tot.prelev_forfaitaire)} {fmtEUR(tot.net_recu)}
)}
{/* ── Réinvestissements ────────────────────────────────────── */} {hasReinvests && (

Réinvestissements complémentaires ({reinvs.length})

{(() => { let cumul = inv.montant_investi; return reinvs.map(r => { cumul += r.montant; return ( ); }); })()} {reinvs.length === 0 && autoReinvActive && ( )} {reinvs.length > 0 && ( )}
Date Montant Note Capital cumulé
{fmtDate(r.date_reinvestissement)} +{fmtEUR(r.montant)}
{r.source === 'auto' && ( auto )} {r.note || (r.source === 'auto' ? '' : '—')}
{fmtEUR(cumul)}
auto Réinvestissement automatique des intérêts activé — alimenté après chaque remboursement
Total réinvesti +{fmtEUR(inv.reinvestissements_total)} {fmtEUR(capitalTotal)}
)} {/* ── Projections de remboursements ─────────────────────────── */}

Projections de remboursements ({simul.length} échéance{simul.length > 1 ? 's' : ''})

{simul.length > 0 && (

Cliquez sur une ligne pour saisir le remboursement correspondant.

)} {simul.length === 0 ? (

Aucune projection disponible (taux et durée requis).

) : ( {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 ( 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'} > ); })}
Date prévue Capital prévu Intérêts ({netMode ? 'Net estimé' : 'Brut'}) Total prévu Statut
{s.numero_echeance} {fmtDate(s.date_prevue)} {fmtEUR(s.capital_prevu)} {interetsDiffers ? ( {fmtEUR(interets)} {fmtEUR(interetsReels)} ) : (isPast && !isPaid) ? ( {fmtEUR(interets)} 0,00 € ) : fmtEUR(interets)} {totalDiffers ? ( {fmtEUR(s.total_prevu)} {fmtEUR(totalRecu)} ) : (isPast && !isPaid) ? ( {fmtEUR(s.total_prevu)} 0,00 € ) : ( {fmtEUR(s.total_prevu)} )} {isPaid ? ✓ Payé le {fmtDate(matched.date_remb)} : En attente }
)}
{/* ── Historique des modifications ──────────────────────────── */} {historique.length > 0 && (

Historique du prêt

{/* Ligne verticale de la timeline */}
{[...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 (
{/* Point sur la timeline */}
{/* En-tête événement */}
{isRestruct ? '⟳ Restructuration' : isCreation ? '✦ Création' : '✎ Modification'} {fmtDate(evt.date_evenement)} {createdAtLocal && <> · {createdAtLocal}} {/* Bouton supprimer — sur toutes les entrées sauf la création */} {!isCreation && !isConfirmingDelete && ( )}
{/* Confirmation de suppression inline */} {isConfirmingDelete && (
⚠️ Supprimer cet événement de l'historique ?
Cette action est irréversible. La simulation du prêt ne sera pas modifiée.
)} {/* Détail des champs modifiés */} {!isCreation && evt.changements.map((c, ci) => (
0 ? '1px solid var(--border, rgba(255,255,255,.06))' : 'none', }}> {c.label} {fmtVal(c.champ, c.ancienne_valeur)} {fmtVal(c.champ, c.nouvelle_valeur)}
))} {/* Badge restructuration bien visible */} {isRestruct && !isConfirmingDelete && (
Ce prêt a été restructuré — la projection de remboursement a été mise à jour.
)}
); })}
)} {/* ── Modal édition ─────────────────────────────────────────── */} {/* Zone suppression — à gauche */}
{!confirmingInvDelete ? ( ) : ( Supprimer définitivement ? )}
{/* Actions standard — à droite */}
} >
{err &&
{err}
}
{(() => { const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement; if (!platMethode) return null; if (platMethode === 'choix_investisseur') return (
); return (
); })()} {form.methode_remboursement === 'compte_courant' && (
{comptesInvestisseur.length > 0 ? ( ) : (

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

)}
)}
setForm({ ...form, nom_projet: e.target.value })} />
{ const duree = e.target.value; const next = { ...form, duree_mois: duree }; if (duree) { if (form.type_remb === 'differe' && form.date_souscription) { const tempDpe = addMonthsFE(form.date_souscription, 1); const cible = addMonthsFE(tempDpe, Number(duree) - 1); next.date_premiere_echeance = cible; next.date_cible = cible; } else if (form.date_premiere_echeance) { next.date_cible = addMonthsFE(form.date_premiere_echeance, Number(duree) - 1); } } setForm(next); }} />
setForm({ ...form, taux_interet: e.target.value })} />
setForm({ ...form, montant_investi: e.target.value })} />
{ 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; } }} />
{ 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 && ( )}
{ if (form.type_remb === 'differe') return; setForm({ ...form, date_cible: e.target.value }); }} />
{/* 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) && (
setForm({ ...form, date_debut_simul: e.target.value })} />
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 ? → {elapsed} mois écoulés · {restant} mois simulés : null; })()}
{form.date_debut_simul && ( )}
)}
setForm({ ...form, reference: e.target.value })} />
setForm(f => ({ ...f, pays_exposition: code }))} />
setForm({ ...form, emetteur: e.target.value })} />