2440 lines
126 KiB
React
2440 lines
126 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 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 dû</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 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>
|
|
) : 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>
|
|
) : (
|
|
<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)}
|
|
/>
|
|
</>
|
|
);
|
|
} |