Nouvelles features: revision des conditions des prêts

This commit is contained in:
2026-07-12 19:14:27 +02:00
parent ef4576d160
commit 96effbe15a
5 changed files with 552 additions and 7 deletions
+347 -3
View File
@@ -160,6 +160,17 @@ export default function InvestissementDetail() {
const [confirmingRembDelete, setConfirmingRembDelete] = useState(false);
const [confirmingInvDelete, setConfirmingInvDelete] = useState(false);
const [confirmingHistDelete, setConfirmingHistDelete] = useState(null); // id de l'entrée à supprimer
// Modal révision des conditions de prêt (taux/date suite à un événement)
const [revisionModalOpen, setRevisionModalOpen] = useState(false);
const [revisionForm, setRevisionForm] = useState({
date_effet: today(),
changeTaux: false, nouveau_taux: '',
changeDate: false, nouvelle_date_cible: '',
motif: '',
});
const [revisionErr, setRevisionErr] = useState(null);
const [revisionSaving, setRevisionSaving] = useState(false);
const [confirmingRevisionDelete, setConfirmingRevisionDelete] = useState(null); // id de la révision à supprimer
const [loading, setLoading] = useState(true);
const [recalculating, setRecalculating] = useState(false);
// Modal réinvestissement
@@ -611,6 +622,56 @@ export default function InvestissementDetail() {
} catch (e) { /* silencieux */ }
};
const openRevisionModal = () => {
setRevisionForm({
date_effet: today(),
changeTaux: false, nouveau_taux: '',
changeDate: false, nouvelle_date_cible: '',
motif: '',
});
setRevisionErr(null);
setRevisionModalOpen(true);
};
const closeRevisionModal = () => { setRevisionModalOpen(false); setRevisionErr(null); };
const submitRevision = async (e) => {
e?.preventDefault?.();
setRevisionErr(null);
if (!revisionForm.date_effet) { setRevisionErr("La date d'effet est requise."); return; }
if (!revisionForm.motif.trim()) { setRevisionErr('Le motif est obligatoire.'); return; }
if (!revisionForm.changeTaux && !revisionForm.changeDate) {
setRevisionErr('Cochez au moins « Modifier le taux » ou « Modifier la date cible ».');
return;
}
if (revisionForm.changeTaux && revisionForm.nouveau_taux === '') {
setRevisionErr('Saisissez le nouveau taux annuel.');
return;
}
if (revisionForm.changeDate && !revisionForm.nouvelle_date_cible) {
setRevisionErr('Saisissez la nouvelle date cible.');
return;
}
setRevisionSaving(true);
try {
await api.post(`/investissements/${id}/revisions`, {
date_effet: revisionForm.date_effet,
nouveau_taux: revisionForm.changeTaux ? Number(revisionForm.nouveau_taux) : undefined,
nouvelle_date_cible: revisionForm.changeDate ? revisionForm.nouvelle_date_cible : undefined,
motif: revisionForm.motif.trim(),
});
setRevisionModalOpen(false);
await load();
} catch (e) { setRevisionErr(e.message); } finally { setRevisionSaving(false); }
};
const deleteRevision = async (rid) => {
try {
await api.del(`/investissements/${id}/revisions/${rid}`);
setConfirmingRevisionDelete(null);
await load();
} catch (e) { setConfirmingRevisionDelete(null); }
};
const openReinvModal = (tab = 'manuel') => {
setReinvForm({ montant: '', date_reinvestissement: today(), note: '' });
setReinvErr(null);
@@ -658,6 +719,11 @@ export default function InvestissementDetail() {
const simul = inv.simul || [];
const todayStr = today();
const historique = inv.historique || [];
const revisions = inv.revisions || [];
// Dernière révision ayant effectivement changé le taux / la date cible — pour l'affichage
// "ancienne valeur barrée → nouvelle valeur" dans Informations du projet.
const lastTauxRevision = [...revisions].reverse().find(r => String(r.ancien_taux) !== String(r.nouveau_taux));
const lastDateCibleRevision = [...revisions].reverse().find(r => String(r.ancienne_date_cible) !== String(r.nouvelle_date_cible));
const reinvs = inv.reinvestissements || [];
// Capital total = montant initial + réinvestissements
const capitalTotal = inv.capital_total ?? inv.montant_investi;
@@ -670,6 +736,32 @@ export default function InvestissementDetail() {
const interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0);
const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 0), 0);
// Taux annuel implicite calculé à partir des intérêts réellement versés à chaque échéance —
// utile pour repérer une revalorisation de taux non annoncée par la plateforme.
// Base = capital restant dû juste avant le remboursement (montant initial + réinvestissements
// survenus avant ou à cette date, moins le capital déjà remboursé aux échéances précédentes).
// Non pertinent pour un prêt différé (un seul versement, pas de série à comparer).
const tauxImpliciteApplicable = inv.freq_interets === 'mensuel' || inv.freq_interets === 'trimestriel';
const tauxImpliciteByRembId = new Map();
if (tauxImpliciteApplicable) {
const rembTries = [...remb].sort((a, b) => a.date_remb.localeCompare(b.date_remb));
const reinvTries = [...reinvs].sort((a, b) => a.date_reinvestissement.localeCompare(b.date_reinvestissement));
const freqStep = inv.freq_interets === 'trimestriel' ? 3 : 1;
let reinvIdx = 0;
let capitalCourant = inv.montant_investi;
for (const r of rembTries) {
while (reinvIdx < reinvTries.length && reinvTries[reinvIdx].date_reinvestissement <= r.date_remb) {
capitalCourant += reinvTries[reinvIdx].montant;
reinvIdx++;
}
const base = capitalCourant;
const tauxBrut = base > 0 ? (r.interets_bruts || 0) / base * (12 / freqStep) * 100 : null;
const tauxNet = base > 0 ? (r.interets_nets || 0) / base * (12 / freqStep) * 100 : null;
tauxImpliciteByRembId.set(r.id, { base, tauxBrut, tauxNet });
capitalCourant -= (r.capital || 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
@@ -757,6 +849,15 @@ export default function InvestissementDetail() {
notes: h.notes,
created_at: h.created_at,
})),
revisions: (inv.revisions || []).map(r => ({
date_effet: r.date_effet,
ancien_taux: r.ancien_taux,
nouveau_taux: r.nouveau_taux,
ancienne_date_cible: r.ancienne_date_cible,
nouvelle_date_cible: r.nouvelle_date_cible,
motif: r.motif,
created_at: r.created_at,
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -935,7 +1036,21 @@ export default function InvestissementDetail() {
)}
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Taux annuel</div>
<div>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</div>
{lastTauxRevision ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ textDecoration: 'line-through', opacity: 0.6 }}>{fmtPct(lastTauxRevision.ancien_taux)}</span>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}></span>
<span style={{ fontWeight: 600 }}>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</span>
<span title={lastTauxRevision.motif} style={{
fontSize: 10, fontWeight: 600, color: '#a78bfa',
background: 'rgba(167,139,250,0.15)', borderRadius: 4, padding: '1px 6px',
}}>
Révisé le {fmtDate(lastTauxRevision.date_effet)}
</span>
</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>
@@ -951,7 +1066,21 @@ export default function InvestissementDetail() {
</div>
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Date cible contractuelle</div>
<div>{fmtDate(inv.date_cible) || '—'}</div>
{lastDateCibleRevision ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ textDecoration: 'line-through', opacity: 0.6 }}>{fmtDate(lastDateCibleRevision.ancienne_date_cible) || '—'}</span>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}></span>
<span style={{ fontWeight: 600 }}>{fmtDate(inv.date_cible) || '—'}</span>
<span title={lastDateCibleRevision.motif} style={{
fontSize: 10, fontWeight: 600, color: '#a78bfa',
background: 'rgba(167,139,250,0.15)', borderRadius: 4, padding: '1px 6px',
}}>
Révisé le {fmtDate(lastDateCibleRevision.date_effet)}
</span>
</div>
) : (
<div>{fmtDate(inv.date_cible) || '—'}</div>
)}
</div>
{inv.notes && (
<div style={{ gridColumn: '1 / -1' }}>
@@ -969,6 +1098,107 @@ export default function InvestissementDetail() {
</div>
</div>
{/* ── Révisions des conditions de prêt ──────────────────────── */}
{revisions.length > 0 && (
<div className="card" style={{ marginBottom: 16 }}>
<h3 style={{ marginTop: 0, marginBottom: 16 }}>Révisions du prêt</h3>
<div style={{ position: 'relative', paddingLeft: 28 }}>
<div style={{
position: 'absolute', left: 9, top: 0, bottom: 0,
width: 2, background: 'var(--border, rgba(255,255,255,.1))',
}} />
{[...revisions].reverse().map((rev, idx) => {
const isLast = idx === 0; // dernière révision (liste inversée) = seule supprimable
const isConfirmingDelete = confirmingRevisionDelete === rev.id;
const tauxChange = String(rev.ancien_taux) !== String(rev.nouveau_taux);
const dateChange = String(rev.ancienne_date_cible) !== String(rev.nouvelle_date_cible);
return (
<div key={rev.id} style={{ position: 'relative', marginBottom: idx < revisions.length - 1 ? 16 : 0 }}>
<div style={{
position: 'absolute', left: -24, top: 14,
width: 10, height: 10, borderRadius: '50%',
background: '#a78bfa',
border: '2px solid var(--bg-card, #1e293b)',
zIndex: 1,
}} />
<div style={{
background: 'rgba(167,139,250,0.07)',
border: isConfirmingDelete ? '1px solid rgba(239,68,68,0.5)' : '1px solid rgba(167,139,250,0.3)',
borderRadius: 8, padding: '10px 14px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontWeight: 700, fontSize: 13, color: '#a78bfa' }}>⟳ Révision des conditions</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>Effet au {fmtDate(rev.date_effet)}</span>
{isLast && !isConfirmingDelete && (
<button
title="Supprimer cette révision (rollback)"
onClick={() => setConfirmingRevisionDelete(rev.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>
{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 }}>
Annuler cette révision ?<br />
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
Les conditions reviendront à l'état précédent et l'échéancier sera régénéré.
</span>
</span>
<button onClick={() => setConfirmingRevisionDelete(null)} style={{ fontSize: 12, padding: '4px 10px' }}>Annuler</button>
<button className="danger" onClick={() => deleteRevision(rev.id)} style={{ fontSize: 12, padding: '4px 10px' }}>Confirmer</button>
</div>
)}
{tauxChange && (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 100 }}>Taux annuel</span>
<span style={{ background: 'rgba(239,68,68,0.12)', color: '#f87171', borderRadius: 4, padding: '1px 6px', textDecoration: 'line-through', opacity: 0.8 }}>
{rev.ancien_taux != null ? fmtPct(rev.ancien_taux) : ''}
</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' }}>
{rev.nouveau_taux != null ? fmtPct(rev.nouveau_taux) : ''}
</span>
</div>
)}
{dateChange && (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 100 }}>Date cible</span>
<span style={{ background: 'rgba(239,68,68,0.12)', color: '#f87171', borderRadius: 4, padding: '1px 6px', textDecoration: 'line-through', opacity: 0.8 }}>
{fmtDate(rev.ancienne_date_cible) || ''}
</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' }}>
{fmtDate(rev.nouvelle_date_cible) || ''}
</span>
</div>
)}
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text)', lineHeight: 1.4 }}>
<span className="text-muted">Motif : </span>{rev.motif}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Remboursements enregistrés ────────────────────────────── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
@@ -1036,7 +1266,17 @@ export default function InvestissementDetail() {
{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={tauxImpliciteApplicable ? 'num cell-tooltip' : 'num'}
data-tooltip={(() => {
if (!tauxImpliciteApplicable) return undefined;
const ti = tauxImpliciteByRembId.get(r.id);
if (!ti || !(ti.base > 0)) return 'Taux implicite non calculable (capital restant nul ou inconnu)';
return `Taux annuel implicite (calculé sur ce versement)\nBrut : ${fmtPct(ti.tauxBrut)}\nNet : ${fmtPct(ti.tauxNet)}\nBase (capital restant dû) : ${fmtEUR(ti.base)}`;
})()}
>
{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)}`}>
@@ -1468,6 +1708,97 @@ export default function InvestissementDetail() {
</div>
)}
{/* ── Modal révision des conditions de prêt ─────────────────── */}
<Modal
open={revisionModalOpen}
title="Réviser les conditions du prêt"
onClose={closeRevisionModal}
width={520}
footer={
<>
<button className="ghost" onClick={closeRevisionModal}>Annuler</button>
<button onClick={submitRevision} disabled={revisionSaving}>
{revisionSaving ? 'Enregistrement…' : 'Enregistrer la révision'}
</button>
</>
}
>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
À utiliser lorsqu'un événement (retard projet, renégociation…) modifie le taux et/ou la date cible
du prêt. L'échéancier de projection sera régénéré automatiquement à partir de la date d'effet.
</p>
<form onSubmit={submitRevision}>
<label>Date d'effet</label>
<input
type="date"
value={revisionForm.date_effet}
onChange={e => setRevisionForm(f => ({ ...f, date_effet: e.target.value }))}
required
/>
<label style={{
marginTop: 12, display: 'flex', alignItems: 'center', gap: 8,
cursor: 'pointer', userSelect: 'none', fontWeight: 400,
}}>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={revisionForm.changeTaux}
onChange={e => {
const checked = e.target.checked;
setRevisionForm(f => ({ ...f, changeTaux: checked, nouveau_taux: checked ? f.nouveau_taux : '' }));
}}
/>
Modifier le taux annuel <span className="text-muted"> actuel : {inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</span>
</label>
{revisionForm.changeTaux && (
<input
type="number" step="0.01" min="0" required
placeholder="Nouveau taux (%)"
style={{ marginTop: 6 }}
value={revisionForm.nouveau_taux}
onChange={e => setRevisionForm(f => ({ ...f, nouveau_taux: e.target.value }))}
/>
)}
<label style={{
marginTop: 12, display: 'flex', alignItems: 'center', gap: 8,
cursor: 'pointer', userSelect: 'none', fontWeight: 400,
}}>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={revisionForm.changeDate}
onChange={e => {
const checked = e.target.checked;
setRevisionForm(f => ({ ...f, changeDate: checked, nouvelle_date_cible: checked ? f.nouvelle_date_cible : '' }));
}}
/>
Modifier la date cible <span className="text-muted"> actuelle : {fmtDate(inv.date_cible) || '—'}</span>
</label>
{revisionForm.changeDate && (
<>
<input
type="date" required
style={{ marginTop: 6 }}
value={revisionForm.nouvelle_date_cible}
onChange={e => setRevisionForm(f => ({ ...f, nouvelle_date_cible: e.target.value }))}
/>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
La durée du prêt sera recalculée automatiquement pour que l'échéancier atteigne cette date.
</div>
</>
)}
<label style={{ marginTop: 10, display: 'block' }}>Motif de la révision</label>
<textarea
rows={3}
placeholder="Ex : retard du projet, renégociation à la baisse suite à difficultés de l'émetteur"
value={revisionForm.motif}
onChange={e => setRevisionForm(f => ({ ...f, motif: e.target.value }))}
required
/>
{revisionErr && <div className="error" style={{ marginTop: 10 }}>{revisionErr}</div>}
</form>
</Modal>
{/* ── Modal édition ─────────────────────────────────────────── */}
<Modal
open={modalOpen}
@@ -1662,6 +1993,12 @@ export default function InvestissementDetail() {
<div>
<label>Taux annuel (%)</label>
<input type="number" step="0.01" value={form.taux_interet} onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
{lastTauxRevision && (
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
Taux révisé le {fmtDate(lastTauxRevision.date_effet)}. Une modification ici ne sera pas tracée
comme une révision utilisez « Réviser les conditions » pour un changement lié à un événement.
</div>
)}
</div>
<div>
<label>Montant investi () *</label>
@@ -1762,6 +2099,12 @@ export default function InvestissementDetail() {
setForm({ ...form, date_cible: e.target.value });
}}
/>
{lastDateCibleRevision && (
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
Date cible révisée le {fmtDate(lastDateCibleRevision.date_effet)}. Une modification ici ne sera pas tracée
comme une révision utilisez « Réviser les conditions » pour un changement lié à un événement.
</div>
)}
</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) */}
@@ -2410,6 +2753,7 @@ export default function InvestissementDetail() {
{[
{ 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: 'Réviser les conditions', icon: <><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></>, action: () => { setCardMenu(null); openRevisionModal(); } },
{ 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}