Mise en place de la notion de perte définitive, ainsi que la révision avec statut Polongation
This commit is contained in:
1 parent
4ad6ea79e8
commit
8e1b82d161
21 files changed
+1069
-184
No files matched your search
@@ -89,18 +89,27 @@ export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, p
|
||||
const { grid, multiDetenteur } = useMemo(() => {
|
||||
if (!allRows.length) return { grid: null, multiDetenteur: false };
|
||||
|
||||
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
|
||||
// prolongation compte comme actif au même titre que en_retard/procedure dont il
|
||||
// découle (renégociation) — aucune déduction de capital associée, contrairement à
|
||||
// perte_definitive ci-dessous.
|
||||
const ACTIVE = ['en_cours', 'en_retard', 'procedure', 'prolongation'];
|
||||
|
||||
// Index plateformes par id (pour nom + detenteur)
|
||||
const platMap = {};
|
||||
for (const p of plats) platMap[p.id] = p;
|
||||
|
||||
// Pour chaque investissement, capital encours au end of month M
|
||||
// Pour chaque investissement, capital encours au end of month M.
|
||||
// Un investissement en perte_definitive reste compté normalement pour les
|
||||
// mois antérieurs à la date d'effet de la perte (fidélité historique), puis
|
||||
// voit son capital réduit du montant perdu (capital_perdu) à partir de ce
|
||||
// mois — sinon il disparaîtrait du total de la plateforme dès la
|
||||
// déclaration, faisant perdre le capital restant dû (perte partielle).
|
||||
const getCapitalAtEndOfMonth = (inv, Y, M) => {
|
||||
const endM = endOfMonth(Y, M);
|
||||
if (inv.date_souscription > endM) return 0;
|
||||
const startM = startOfMonth(Y, M);
|
||||
const isActive = ACTIVE.includes(inv.statut) ||
|
||||
const isPerteDef = inv.statut === 'perte_definitive';
|
||||
const isActive = ACTIVE.includes(inv.statut) || isPerteDef ||
|
||||
((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
|
||||
if (!isActive) return 0;
|
||||
|
||||
@@ -112,7 +121,11 @@ export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, p
|
||||
.filter(rb => rb.date && rb.date <= endM)
|
||||
.reduce((s, rb) => s + rb.capital, 0);
|
||||
|
||||
return Math.max(0, inv.montant_investi + reinvM - capRembM);
|
||||
let capital = Math.max(0, inv.montant_investi + reinvM - capRembM);
|
||||
if (isPerteDef && inv.perte_date_effet && endM >= inv.perte_date_effet) {
|
||||
capital = Math.max(0, capital - (inv.capital_perdu || 0));
|
||||
}
|
||||
return capital;
|
||||
};
|
||||
|
||||
// Agréger par plateforme (id)
|
||||
|
||||
@@ -53,13 +53,22 @@ export default function InvMensuelTable({ rows, allRembs, allReinvests, year, or
|
||||
return map;
|
||||
}, [allRembs]);
|
||||
|
||||
/* ── Capital encours d'un investissement à fin de mois M ── */
|
||||
/* ── Capital encours d'un investissement à fin de mois M ──
|
||||
* Un investissement en perte_definitive reste compté normalement pour les
|
||||
* mois antérieurs à la date d'effet de la perte (fidélité historique), puis
|
||||
* voit son capital réduit du montant perdu (capital_perdu) à partir de ce
|
||||
* mois — au lieu de disparaître de la table, ce qui ferait perdre tout le
|
||||
* capital restant dû (potentiellement une perte partielle) des mois suivants. */
|
||||
const getCapital = (inv, Y, M) => {
|
||||
const endM = endOfMonth(Y, M);
|
||||
if (inv.date_souscription > endM) return 0;
|
||||
const startM = startOfMonth(Y, M);
|
||||
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
|
||||
const isActive = ACTIVE.includes(inv.statut) ||
|
||||
// prolongation compte comme actif au même titre que en_retard/procedure dont il
|
||||
// découle (renégociation) — aucune déduction de capital associée, contrairement à
|
||||
// perte_definitive ci-dessous.
|
||||
const ACTIVE = ['en_cours', 'en_retard', 'procedure', 'prolongation'];
|
||||
const isPerteDef = inv.statut === 'perte_definitive';
|
||||
const isActive = ACTIVE.includes(inv.statut) || isPerteDef ||
|
||||
((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
|
||||
if (!isActive) return 0;
|
||||
|
||||
@@ -69,7 +78,11 @@ export default function InvMensuelTable({ rows, allRembs, allReinvests, year, or
|
||||
const capRembM = (capRembByInv[inv.id] || [])
|
||||
.filter(rb => rb.date && rb.date <= endM)
|
||||
.reduce((s, rb) => s + rb.capital, 0);
|
||||
return Math.max(0, inv.montant_investi + reinvM - capRembM);
|
||||
let capital = Math.max(0, inv.montant_investi + reinvM - capRembM);
|
||||
if (isPerteDef && inv.perte_date_effet && endM >= inv.perte_date_effet) {
|
||||
capital = Math.max(0, capital - (inv.capital_perdu || 0));
|
||||
}
|
||||
return capital;
|
||||
};
|
||||
|
||||
/* ── Grille : une ligne par investissement ── */
|
||||
|
||||
@@ -156,7 +156,7 @@ function TrendBadge({ current, prev, invert = false }) {
|
||||
}
|
||||
|
||||
/* ── Carte KPI individuelle ── */
|
||||
function KpiCard({ title, value, badge, refValue, onClick, hint }) {
|
||||
function KpiCard({ title, value, badge, refValue, note, onClick, hint }) {
|
||||
return (
|
||||
<div
|
||||
className="kpi"
|
||||
@@ -174,12 +174,21 @@ function KpiCard({ title, value, badge, refValue, onClick, hint }) {
|
||||
{refValue && (
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{refValue}</div>
|
||||
)}
|
||||
{note && (
|
||||
<div style={{ fontSize: '0.8em', color: '#7c2d12', marginTop: 4, fontWeight: 600 }}>{note}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── KPI filtrés par année ── */
|
||||
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal }) {
|
||||
function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements }) {
|
||||
// Montant total déjà déclaré en perte définitive (tous statuts confondus,
|
||||
// seuls les dossiers passés en perte_definitive ont un capital_perdu > 0) —
|
||||
// affiché en complément du KPI "Capital en risque", qui les exclut déjà
|
||||
// (ce ne sont plus des dossiers "à risque" mais des pertes actées).
|
||||
const capitalPertesDefinitives = (investissements || []).reduce((s, r) => s + (r.capital_perdu || 0), 0);
|
||||
|
||||
const {
|
||||
annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth,
|
||||
setInclureInterets, setInclureCapital, setInclureCashback,
|
||||
@@ -451,6 +460,9 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
|
||||
refValue={isCurrentYear && enDefautPrev != null && enDefautPrev > 0
|
||||
? fmtEUR(enDefautPrev) + ' en ' + prevMonthLabel
|
||||
: null}
|
||||
note={capitalPertesDefinitives > 0
|
||||
? `dont ${fmtEUR(capitalPertesDefinitives)} déjà déclarés en perte définitive (non compris ci-dessus)`
|
||||
: null}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Porte-monnaie"
|
||||
@@ -622,7 +634,7 @@ export default function Dashboard() {
|
||||
<DashboardKpis
|
||||
portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData}
|
||||
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
|
||||
correctionsTotal={data?.corrections_total ?? 0}
|
||||
correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
|
||||
/>
|
||||
<YearSelectorKpi />
|
||||
</div>
|
||||
|
||||
@@ -67,11 +67,13 @@ function typeLabel(type_remb, freq_interets) {
|
||||
}
|
||||
|
||||
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)' },
|
||||
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)' },
|
||||
perte_definitive: { label: 'Perte définitive', color: '#374151', bg: 'rgba(55,65,81,0.12)' },
|
||||
prolongation: { label: 'Prolongation', color: '#8b5cf6', bg: 'rgba(139,92,246,0.12)' },
|
||||
};
|
||||
|
||||
|
||||
@@ -137,6 +139,8 @@ export default function InvestissementDetail() {
|
||||
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);
|
||||
// id de la révision en cours de correction (édition en place) ; null = création d'une nouvelle révision
|
||||
const [revisionEditId, setRevisionEditId] = useState(null);
|
||||
const [revisionForm, setRevisionForm] = useState({
|
||||
date_effet: today(),
|
||||
changeTaux: false, nouveau_taux: '',
|
||||
@@ -146,6 +150,12 @@ export default function InvestissementDetail() {
|
||||
const [revisionErr, setRevisionErr] = useState(null);
|
||||
const [revisionSaving, setRevisionSaving] = useState(false);
|
||||
const [confirmingRevisionDelete, setConfirmingRevisionDelete] = useState(null); // id de la révision à supprimer
|
||||
// Modal déclaration de perte définitive (capital irrécouvrable, dossiers en_retard/procedure)
|
||||
const [perteModalOpen, setPerteModalOpen] = useState(false);
|
||||
const [perteForm, setPerteForm] = useState({ date_effet: today(), montant_perte: '', motif: '' });
|
||||
const [perteErr, setPerteErr] = useState(null);
|
||||
const [perteSaving, setPerteSaving] = useState(false);
|
||||
const [confirmingPerteCancel, setConfirmingPerteCancel] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recalculating, setRecalculating] = useState(false);
|
||||
// Modal réinvestissement
|
||||
@@ -609,6 +619,7 @@ export default function InvestissementDetail() {
|
||||
};
|
||||
|
||||
const openRevisionModal = () => {
|
||||
setRevisionEditId(null);
|
||||
setRevisionForm({
|
||||
date_effet: today(),
|
||||
changeTaux: false, nouveau_taux: '',
|
||||
@@ -618,7 +629,22 @@ export default function InvestissementDetail() {
|
||||
setRevisionErr(null);
|
||||
setRevisionModalOpen(true);
|
||||
};
|
||||
const closeRevisionModal = () => { setRevisionModalOpen(false); setRevisionErr(null); };
|
||||
// Réouvre la modale pré-remplie sur la révision existante, pour la corriger en place
|
||||
// (même ligne d'historique conservée) plutôt que de la supprimer puis la recréer.
|
||||
const openEditRevisionModal = (rev) => {
|
||||
setRevisionEditId(rev.id);
|
||||
setRevisionForm({
|
||||
date_effet: rev.date_effet,
|
||||
changeTaux: String(rev.ancien_taux) !== String(rev.nouveau_taux),
|
||||
nouveau_taux: rev.nouveau_taux != null ? String(rev.nouveau_taux) : '',
|
||||
changeDate: String(rev.ancienne_date_cible) !== String(rev.nouvelle_date_cible),
|
||||
nouvelle_date_cible: rev.nouvelle_date_cible || '',
|
||||
motif: rev.motif || '',
|
||||
});
|
||||
setRevisionErr(null);
|
||||
setRevisionModalOpen(true);
|
||||
};
|
||||
const closeRevisionModal = () => { setRevisionModalOpen(false); setRevisionEditId(null); setRevisionErr(null); };
|
||||
|
||||
const submitRevision = async (e) => {
|
||||
e?.preventDefault?.();
|
||||
@@ -639,13 +665,19 @@ export default function InvestissementDetail() {
|
||||
}
|
||||
setRevisionSaving(true);
|
||||
try {
|
||||
await api.post(`/investissements/${id}/revisions`, {
|
||||
const payload = {
|
||||
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(),
|
||||
});
|
||||
};
|
||||
if (revisionEditId) {
|
||||
await api.put(`/investissements/${id}/revisions/${revisionEditId}`, payload);
|
||||
} else {
|
||||
await api.post(`/investissements/${id}/revisions`, payload);
|
||||
}
|
||||
setRevisionModalOpen(false);
|
||||
setRevisionEditId(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
// Surface le détail des erreurs de validation (Zod) plutôt que le message générique
|
||||
@@ -717,6 +749,7 @@ export default function InvestissementDetail() {
|
||||
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 || [];
|
||||
const pertes = inv.pertes || [];
|
||||
// Capital total = montant initial + réinvestissements
|
||||
const capitalTotal = inv.capital_total ?? inv.montant_investi;
|
||||
const autoReinvActive = !!inv?.auto_reinvest;
|
||||
@@ -724,7 +757,10 @@ export default function InvestissementDetail() {
|
||||
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);
|
||||
// Capital déclaré en perte définitive (0 si non déclaré) — déduit du capital restant dû
|
||||
// pour que le prêt sorte réellement du capital actif une fois la perte actée.
|
||||
const capitalPerdu = pertes.reduce((s, p) => s + (p.montant_perte || 0), 0);
|
||||
const capRestant = Math.max(0, capitalTotal - capitalRembourse - capitalPerdu);
|
||||
const interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0);
|
||||
const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 0), 0);
|
||||
|
||||
@@ -850,6 +886,13 @@ export default function InvestissementDetail() {
|
||||
motif: r.motif,
|
||||
created_at: r.created_at,
|
||||
})),
|
||||
pertes: (inv.pertes || []).map(p => ({
|
||||
date_effet: p.date_effet,
|
||||
montant_perte: p.montant_perte,
|
||||
ancien_statut: p.ancien_statut,
|
||||
motif: p.motif,
|
||||
created_at: p.created_at,
|
||||
})),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -858,6 +901,50 @@ export default function InvestissementDetail() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ── Déclaration de perte définitive ──────────────────────────────
|
||||
// Réservée aux dossiers en_retard/procedure (garde-fou côté backend aussi).
|
||||
// Le montant est préfilli avec le capital restant dû mais reste éditable
|
||||
// (perte partielle possible si une partie du capital a déjà été recouvrée).
|
||||
const openPerteModal = () => {
|
||||
setPerteForm({ date_effet: today(), montant_perte: String(Math.round(capRestant * 100) / 100), motif: '' });
|
||||
setPerteErr(null);
|
||||
setPerteModalOpen(true);
|
||||
};
|
||||
const closePerteModal = () => { setPerteModalOpen(false); setPerteErr(null); };
|
||||
|
||||
const submitPerte = async (e) => {
|
||||
e?.preventDefault?.();
|
||||
setPerteErr(null);
|
||||
if (!perteForm.date_effet) { setPerteErr("La date d'effet est requise."); return; }
|
||||
if (perteForm.montant_perte === '' || Number(perteForm.montant_perte) < 0) {
|
||||
setPerteErr('Le montant de la perte doit être un nombre positif ou nul.');
|
||||
return;
|
||||
}
|
||||
if (!perteForm.motif.trim()) { setPerteErr('Le motif est obligatoire.'); return; }
|
||||
setPerteSaving(true);
|
||||
try {
|
||||
await api.post(`/investissements/${id}/perte-definitive`, {
|
||||
date_effet: perteForm.date_effet,
|
||||
montant_perte: Number(perteForm.montant_perte),
|
||||
motif: perteForm.motif.trim(),
|
||||
});
|
||||
setPerteModalOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
const fieldErrors = e.details?.fieldErrors;
|
||||
const detail = fieldErrors ? Object.values(fieldErrors).flat().filter(Boolean).join(' ') : '';
|
||||
setPerteErr(detail || e.message);
|
||||
} finally { setPerteSaving(false); }
|
||||
};
|
||||
|
||||
const cancelPerteDeclaration = async () => {
|
||||
try {
|
||||
await api.del(`/investissements/${id}/perte-definitive`);
|
||||
setConfirmingPerteCancel(false);
|
||||
await load();
|
||||
} catch (e) { setConfirmingPerteCancel(false); }
|
||||
};
|
||||
|
||||
// Rendement annualisé (XIRR) — calcul exact une fois le prêt intégralement remboursé.
|
||||
// Sinon (en cours / en retard / procédure), estimation basée sur les remboursements déjà
|
||||
// versés + une valorisation du capital restant dû à la date du jour (comme si ce capital
|
||||
@@ -950,6 +1037,11 @@ export default function InvestissementDetail() {
|
||||
<div className="value" style={{ color: capRestant === 0 ? 'var(--success)' : undefined }}>
|
||||
{fmtEUR(capRestant)}
|
||||
</div>
|
||||
{capitalPerdu > 0 && (
|
||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 2, color: '#7c2d12' }}>
|
||||
dont {fmtEUR(capitalPerdu)} déclarés en perte définitive
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Intérêts perçus — {netMode ? 'Net' : 'Brut'}</div>
|
||||
@@ -1116,6 +1208,77 @@ export default function InvestissementDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Perte(s) définitive(s) déclarée(s) ──────────────────────── */}
|
||||
{pertes.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 16, border: '1px solid rgba(239,68,68,0.35)' }}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: 16, color: '#ef4444' }}>Perte définitive</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))',
|
||||
}} />
|
||||
{[...pertes].reverse().map((perte, idx) => {
|
||||
const isLast = idx === 0; // dernière déclaration (liste inversée)
|
||||
const isActive = isLast && inv.statut === 'perte_definitive';
|
||||
const ancienMeta = STATUT_META[perte.ancien_statut] || { label: perte.ancien_statut, color: '#6b7280', bg: 'rgba(107,114,128,0.12)' };
|
||||
return (
|
||||
<div key={perte.id} style={{ position: 'relative', marginBottom: idx < pertes.length - 1 ? 16 : 0 }}>
|
||||
<div style={{
|
||||
position: 'absolute', left: -24, top: 14,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: '#ef4444',
|
||||
border: '2px solid var(--bg-card, #1e293b)',
|
||||
zIndex: 1,
|
||||
}} />
|
||||
<div style={{
|
||||
background: 'rgba(239,68,68,0.07)',
|
||||
border: confirmingPerteCancel ? '1px solid rgba(239,68,68,0.5)' : '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: 8, padding: '10px 14px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 13, color: '#ef4444' }}>⚠ Perte définitive déclarée</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>Effet au {fmtDate(perte.date_effet)}</span>
|
||||
{isActive && !confirmingPerteCancel && (
|
||||
<button
|
||||
title="Annuler cette déclaration de perte"
|
||||
onClick={() => setConfirmingPerteCancel(true)}
|
||||
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>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 130 }}>Montant perdu</span>
|
||||
<span style={{ fontWeight: 700, color: '#ef4444' }}>{fmtEUR(perte.montant_perte)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 130 }}>Statut avant déclaration</span>
|
||||
<span style={{ background: ancienMeta.bg, color: ancienMeta.color, borderRadius: 4, padding: '1px 6px', fontWeight: 600 }}>
|
||||
{ancienMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text)', lineHeight: 1.4 }}>
|
||||
<span className="text-muted">Motif : </span>{perte.motif}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
Déclarée le {fmtDate(perte.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Révisions des conditions de prêt ──────────────────────── */}
|
||||
{revisions.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
@@ -1148,19 +1311,34 @@ export default function InvestissementDetail() {
|
||||
<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 style={{ marginLeft: 'auto', display: 'flex', gap: 2 }}>
|
||||
<button
|
||||
title="Corriger cette révision"
|
||||
onClick={() => openEditRevisionModal(rev)}
|
||||
style={{
|
||||
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 = '#a78bfa'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
title="Supprimer cette révision (rollback)"
|
||||
onClick={() => setConfirmingRevisionDelete(rev.id)}
|
||||
style={{
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1206,6 +1384,26 @@ export default function InvestissementDetail() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rev.nouveau_statut && (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
|
||||
<span style={{ color: 'var(--text-muted)', minWidth: 100 }}>Statut</span>
|
||||
{(() => {
|
||||
const from = STATUT_META[rev.ancien_statut] || { label: rev.ancien_statut, color: '#6b7280', bg: 'rgba(107,114,128,0.12)' };
|
||||
const to = STATUT_META[rev.nouveau_statut] || { label: rev.nouveau_statut, color: '#6b7280', bg: 'rgba(107,114,128,0.12)' };
|
||||
return (
|
||||
<>
|
||||
<span style={{ background: from.bg, color: from.color, borderRadius: 4, padding: '1px 6px', opacity: 0.8 }}>
|
||||
{from.label}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>→</span>
|
||||
<span style={{ background: to.bg, color: to.color, borderRadius: 4, padding: '1px 6px' }}>
|
||||
{to.label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text)', lineHeight: 1.4 }}>
|
||||
<span className="text-muted">Motif : </span>{rev.motif}
|
||||
</div>
|
||||
@@ -1503,7 +1701,11 @@ export default function InvestissementDetail() {
|
||||
? 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 capitalRecu = isPaid
|
||||
? matchedAll.reduce((sum, r) => sum + (r.capital || 0), 0)
|
||||
: null;
|
||||
const isPast = s.date_prevue < todayStr;
|
||||
const capitalDiffers = isPaid && Math.abs(capitalRecu - s.capital_prevu) > 0.01;
|
||||
const interetsDiffers = isPaid && Math.abs(interetsReels - interets) > 0.01;
|
||||
const totalDiffers = isPaid && Math.abs(totalRecu - s.total_prevu) > 0.01;
|
||||
|
||||
@@ -1518,7 +1720,14 @@ export default function InvestissementDetail() {
|
||||
>
|
||||
<td className="num">{s.numero_echeance}</td>
|
||||
<td>{fmtDate(s.date_prevue)}</td>
|
||||
<td className="num">{fmtEUR(s.capital_prevu)}</td>
|
||||
<td className="num">
|
||||
{capitalDiffers ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
|
||||
<span style={{ textDecoration: 'line-through', color: 'var(--text-muted)', fontWeight: 400 }}>{fmtEUR(s.capital_prevu)}</span>
|
||||
<span>{fmtEUR(capitalRecu)}</span>
|
||||
</span>
|
||||
) : fmtEUR(s.capital_prevu)}
|
||||
</td>
|
||||
<td className="num">
|
||||
{interetsDiffers ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
|
||||
@@ -1590,7 +1799,7 @@ export default function InvestissementDetail() {
|
||||
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 statutLabels = { en_cours: 'En cours', rembourse: 'Remboursé', en_retard: 'En retard', procedure: 'Procédure', cloture: 'Clôturé', perte_definitive: 'Perte définitive' };
|
||||
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;
|
||||
@@ -1735,21 +1944,22 @@ export default function InvestissementDetail() {
|
||||
{/* ── Modal révision des conditions de prêt ─────────────────── */}
|
||||
<Modal
|
||||
open={revisionModalOpen}
|
||||
title="Réviser les conditions du prêt"
|
||||
title={revisionEditId ? 'Corriger la révision' : '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'}
|
||||
{revisionSaving ? 'Enregistrement…' : (revisionEditId ? 'Enregistrer la correction' : '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.
|
||||
{revisionEditId
|
||||
? "Corrige la révision existante (l'historique n'ajoute pas une nouvelle entrée). L'échéancier de projection sera régénéré."
|
||||
: "À 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>
|
||||
@@ -1809,6 +2019,15 @@ export default function InvestissementDetail() {
|
||||
<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>
|
||||
{['en_retard', 'procedure'].includes(inv.statut) && (
|
||||
<div style={{
|
||||
fontSize: 11, marginTop: 6, padding: '6px 10px', borderRadius: 6,
|
||||
background: 'rgba(139,92,246,0.12)', color: '#8b5cf6', fontWeight: 600,
|
||||
}}>
|
||||
Le statut passera automatiquement à « Prolongation » — le dossier sortira temporairement
|
||||
du scope « à risque » jusqu'à ce que cette nouvelle date soit à son tour dépassée.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label style={{ marginTop: 10, display: 'block' }}>Motif de la révision</label>
|
||||
@@ -2186,6 +2405,7 @@ export default function InvestissementDetail() {
|
||||
<option value="en_retard">En retard</option>
|
||||
<option value="procedure">Procédure</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -2653,10 +2873,21 @@ 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(); } },
|
||||
['en_retard', 'procedure'].includes(inv.statut) && {
|
||||
label: 'Déclarer une perte définitive',
|
||||
icon: <><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></>,
|
||||
danger: true,
|
||||
action: () => { setCardMenu(null); openPerteModal(); },
|
||||
},
|
||||
inv.statut === 'perte_definitive' && {
|
||||
label: 'Annuler la déclaration de perte',
|
||||
icon: <><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-9.36L1 10"/></>,
|
||||
action: () => { setCardMenu(null); setConfirmingPerteCancel(true); },
|
||||
},
|
||||
{ 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 }) => (
|
||||
].filter(Boolean).map(({ label, icon, action, danger }) => (
|
||||
<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' }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: danger ? 'var(--danger)' : 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => action()}>
|
||||
@@ -2707,6 +2938,67 @@ export default function InvestissementDetail() {
|
||||
onConfirm={rowDeleteConfirm?.onConfirm}
|
||||
onCancel={() => setRowDeleteConfirm(null)}
|
||||
/>
|
||||
|
||||
{/* ── Modal déclaration de perte définitive ─────────────────── */}
|
||||
<Modal
|
||||
open={perteModalOpen}
|
||||
title="Déclarer une perte définitive"
|
||||
onClose={closePerteModal}
|
||||
width={520}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" onClick={closePerteModal}>Annuler</button>
|
||||
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
|
||||
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
|
||||
À utiliser uniquement lorsque le capital est réellement irrécouvrable (procédure
|
||||
collective actée, émetteur radié…) — pas un simple retard de paiement. Une fois
|
||||
déclaré, ce dossier sort du capital investi/en risque et le montant est reporté
|
||||
en case 2TY (pertes en capital) de votre synthèse fiscale, pour l'année de la date
|
||||
d'effet ci-dessous.
|
||||
</p>
|
||||
<form onSubmit={submitPerte}>
|
||||
<label>Date d'effet (constat de la perte)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={perteForm.date_effet}
|
||||
onChange={e => setPerteForm(f => ({ ...f, date_effet: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<label style={{ marginTop: 12, display: 'block' }}>
|
||||
Montant de la perte <span className="text-muted">— capital restant dû : {fmtEUR(capRestant)}</span>
|
||||
</label>
|
||||
<input
|
||||
type="number" step="0.01" min="0" required
|
||||
value={perteForm.montant_perte}
|
||||
onChange={e => setPerteForm(f => ({ ...f, montant_perte: e.target.value }))}
|
||||
/>
|
||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
|
||||
Préfilli avec le capital restant dû — ajustez si une partie a déjà été récupérée
|
||||
(perte partielle).
|
||||
</div>
|
||||
<label style={{ marginTop: 12, display: 'block' }}>Motif</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ex : liquidation judiciaire de l'émetteur, clôture de la procédure de recouvrement…"
|
||||
value={perteForm.motif}
|
||||
onChange={e => setPerteForm(f => ({ ...f, motif: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
{perteErr && <div className="error" style={{ marginTop: 10 }}>{perteErr}</div>}
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmingPerteCancel}
|
||||
message="Annuler la déclaration de perte définitive ? Le dossier reprendra son statut précédent et sortira de la case 2TY."
|
||||
onConfirm={cancelPerteDeclaration}
|
||||
onCancel={() => setConfirmingPerteCancel(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ function invToCSV(rows) {
|
||||
function invToXLS(rows) {
|
||||
const data = rows.map(r => {
|
||||
const capInv = r.capital_total ?? r.montant_investi;
|
||||
const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0));
|
||||
const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0) - (r.capital_perdu || 0));
|
||||
return {
|
||||
'Date souscription': r.date_souscription,
|
||||
'Projet': r.nom_projet,
|
||||
@@ -429,7 +429,11 @@ export default function Investissements() {
|
||||
const decStart = `${yr}-12-01`;
|
||||
if (r.date_souscription > cutoff) return false; // pas encore souscrit
|
||||
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
|
||||
if (ACTIVE.includes(r.statut)) return true; // encore actif aujourd'hui
|
||||
// perte_definitive et prolongation restent "actifs" au sens de ce filtre (comme
|
||||
// en_retard/procedure dont ils découlent) : le dossier ne doit pas disparaître des
|
||||
// vues par année sous prétexte que la perte a été déclarée ou qu'une prolongation a
|
||||
// été négociée — voir project_perte_definitive.md et project_prolongation.md
|
||||
if (ACTIVE.includes(r.statut) || ['perte_definitive', 'prolongation'].includes(r.statut)) return true; // encore actif aujourd'hui
|
||||
const fin = r.date_cible ?? lastRembDateMap[r.id] ?? null;
|
||||
return !!fin && fin >= decStart; // clôturé mais actif en décembre
|
||||
};
|
||||
@@ -490,11 +494,12 @@ export default function Investissements() {
|
||||
acc.cap_remb += capRemb;
|
||||
acc.int_perc += rembParInv[r.id]?.interets_bruts || 0;
|
||||
acc.int_perc_net += rembParInv[r.id]?.interets_nets || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
acc.encours += capRestant;
|
||||
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
|
||||
acc.pertes += (r.capital_perdu || 0);
|
||||
return acc;
|
||||
}, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0 }), [kpiRows, capRembParInv, rembParInv, reinvestCumulParInv]);
|
||||
}, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0, pertes: 0 }), [kpiRows, capRembParInv, rembParInv, reinvestCumulParInv]);
|
||||
|
||||
/* ── Totaux KPI année N-1 (pour TrendBadge) ── */
|
||||
const prevTotals = useMemo(() => {
|
||||
@@ -530,7 +535,7 @@ export default function Investissements() {
|
||||
acc.cap_remb += capRemb;
|
||||
acc.int_perc += prevRembParInv[r.id]?.interets_bruts || 0;
|
||||
acc.int_perc_net += prevRembParInv[r.id]?.interets_nets || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
acc.encours += capRestant;
|
||||
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
|
||||
return acc;
|
||||
@@ -563,7 +568,7 @@ export default function Investissements() {
|
||||
p.cap_remb += capRemb;
|
||||
p.int_perc += rembParInv[r.id]?.interets_bruts || 0;
|
||||
p.int_perc_net += rembParInv[r.id]?.interets_nets || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
// Sans filtre année : les prêts remboursés ont capRestant=0 → pas d'impact
|
||||
// Avec filtre année : on veut le capital déployé à la coupure, quel que soit le statut actuel
|
||||
p.encours += capRestant;
|
||||
@@ -607,7 +612,7 @@ export default function Investissements() {
|
||||
.map(r => {
|
||||
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
|
||||
const capRemb = capRembParInv[r.id] || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
return { plateforme_nom: r.plateforme_nom, montant: capRestant };
|
||||
})
|
||||
.filter(r => r.montant > 0),
|
||||
@@ -847,6 +852,11 @@ export default function Investissements() {
|
||||
{platYear && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />}
|
||||
</div>
|
||||
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.defaut)} en {Number(platYear) - 1}</div>}
|
||||
{totals.pertes > 0 && (
|
||||
<div style={{ fontSize: '0.8em', color: '#7c2d12', marginTop: 4, fontWeight: 600 }}>
|
||||
dont {fmtEUR(totals.pertes)} déjà déclarés en perte définitive (non compris ci-dessus)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3 — Investissements depuis le début */}
|
||||
@@ -1148,6 +1158,8 @@ export default function Investissements() {
|
||||
<option value="en_retard">En retard</option>
|
||||
<option value="procedure">Procédure</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
<option value="prolongation">Prolongation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 120 }}>
|
||||
@@ -1221,7 +1233,7 @@ export default function Investissements() {
|
||||
)}
|
||||
{pagedRows.map(r => {
|
||||
const capInv = r.capital_total ?? r.montant_investi;
|
||||
const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0));
|
||||
const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0) - (r.capital_perdu || 0));
|
||||
return (
|
||||
<tr key={r.id}
|
||||
onClick={() => navigate(`/investissements/${r.id}`, { state: { from: { path: '/investissements', search: location.search, label: 'Investissements' } } })}
|
||||
@@ -1560,6 +1572,7 @@ export default function Investissements() {
|
||||
<option value="en_retard">En retard</option>
|
||||
<option value="procedure">Procédure</option>
|
||||
<option value="cloture">Clôturé</option>
|
||||
<option value="perte_definitive">Perte définitive</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -752,7 +752,11 @@ export default function Plateformes() {
|
||||
const decStart = `${yr}-12-01`;
|
||||
if (r.date_souscription > cutoff) return false;
|
||||
const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
|
||||
if (ACTIVE.includes(r.statut)) return true;
|
||||
// perte_definitive et prolongation restent "actifs" au sens de ce filtre (comme
|
||||
// en_retard/procedure dont ils découlent) : le dossier ne doit pas disparaître des
|
||||
// vues par année sous prétexte que la perte a été déclarée ou qu'une prolongation a
|
||||
// été négociée — voir project_perte_definitive.md et project_prolongation.md
|
||||
if (ACTIVE.includes(r.statut) || ['perte_definitive', 'prolongation'].includes(r.statut)) return true;
|
||||
const fin = r.date_cible ?? lastRembDateMap[r.id] ?? null;
|
||||
return !!fin && fin >= decStart;
|
||||
};
|
||||
@@ -805,11 +809,12 @@ export default function Plateformes() {
|
||||
acc.cap_remb += capRemb;
|
||||
acc.int_perc += rembParInv[r.id]?.interets_bruts || 0;
|
||||
acc.int_perc_net += rembParInv[r.id]?.interets_nets || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
acc.encours += capRestant;
|
||||
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
|
||||
acc.pertes += (r.capital_perdu || 0);
|
||||
return acc;
|
||||
}, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0 }),
|
||||
}, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0, pertes: 0 }),
|
||||
[chartRows, capRembParInv, rembParInv, reinvestCumulParInv]);
|
||||
|
||||
/* ── Pagination onglet Investissements ── */
|
||||
@@ -853,7 +858,7 @@ export default function Plateformes() {
|
||||
acc.cap_remb += capRemb;
|
||||
acc.int_perc += prevRembParInv[r.id]?.interets_bruts || 0;
|
||||
acc.int_perc_net += prevRembParInv[r.id]?.interets_nets || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
acc.encours += capRestant;
|
||||
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
|
||||
return acc;
|
||||
@@ -891,7 +896,7 @@ export default function Plateformes() {
|
||||
for (const r of chartRows) {
|
||||
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
|
||||
const capRemb = capRembParInv[r.id] || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
if (capRestant > 0.01) {
|
||||
estime = true;
|
||||
flowsBrut.push({ amount: capRestant, date: cutoff });
|
||||
@@ -951,7 +956,7 @@ export default function Plateformes() {
|
||||
for (const r of chartRows) {
|
||||
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
|
||||
const capRemb = capRembParInv[r.id] || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
if (capRestant <= 0.01) continue;
|
||||
const echeances = futureSimulParInv[r.id];
|
||||
if (echeances && echeances.length) {
|
||||
@@ -1560,6 +1565,11 @@ export default function Plateformes() {
|
||||
{fmtEUR(prevTotals.defaut)} en {prevYearLabel}
|
||||
</div>
|
||||
)}
|
||||
{totals.pertes > 0 && (
|
||||
<div style={{ fontSize: '0.8em', color: '#7c2d12', marginTop: 4, fontWeight: 600 }}>
|
||||
dont {fmtEUR(totals.pertes)} déjà déclarés en perte définitive (non compris ci-dessus)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3 — Porte-monnaie */}
|
||||
@@ -2557,7 +2567,7 @@ export default function Plateformes() {
|
||||
{pagedChartRows.map(r => {
|
||||
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
|
||||
const capRemb = capRembParInv[r.id] || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
|
||||
const interets = netMode
|
||||
? (rembParInv[r.id]?.interets_nets || 0)
|
||||
: (rembParInv[r.id]?.interets_bruts || 0);
|
||||
|
||||
@@ -266,6 +266,9 @@ export default function TaxReport() {
|
||||
]).then(([d, d2561, d2778]) => {
|
||||
setData(d);
|
||||
const frLignes = (d2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
|
||||
// case_2TY (pertes en capital) n'est pas spécifique FR/étranger contrairement aux autres
|
||||
// cases — une perte définitive sur une plateforme étrangère doit compter aussi.
|
||||
const allLignes = d2561?.lignes ?? [];
|
||||
const platEtr = (d2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
|
||||
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
|
||||
const pfo = 0.128; // taux par défaut — affiné par pfuList si dispo
|
||||
@@ -274,7 +277,7 @@ export default function TaxReport() {
|
||||
case_2TR: frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
|
||||
case_2BH: frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0) + (pfoAssujetti ? Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)) : 0),
|
||||
case_2CK: frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0) + (pfoAssujetti ? Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo) : 0),
|
||||
case_2TY: frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
|
||||
case_2TY: allLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
|
||||
});
|
||||
}).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
--b-en_retard-bg:#fee2e2; --b-en_retard-fg:#991b1b;
|
||||
--b-procedure-bg:#fef3c7;--b-procedure-fg:#92400e;
|
||||
--b-cloture-bg: #e2e8f0; --b-cloture-fg: #475569;
|
||||
--b-perte_definitive-bg:#374151; --b-perte_definitive-fg:#f9fafb;
|
||||
--b-prolongation-bg:#ede9fe; --b-prolongation-fg:#6d28d9;
|
||||
--b-depot-bg: #dcfce7; --b-depot-fg: #166534;
|
||||
--b-retrait-bg: #fee2e2; --b-retrait-fg: #991b1b;
|
||||
--b-paye-bg: #dcfce7; --b-paye-fg: #166534;
|
||||
@@ -89,6 +91,8 @@
|
||||
--b-en_retard-bg:#7f1d1d; --b-en_retard-fg:#fecaca;
|
||||
--b-procedure-bg:#78350f;--b-procedure-fg:#fde68a;
|
||||
--b-cloture-bg: #1e3a6a; --b-cloture-fg: #93b4d8;
|
||||
--b-perte_definitive-bg:#111827; --b-perte_definitive-fg:#e5e7eb;
|
||||
--b-prolongation-bg:#4c1d95; --b-prolongation-fg:#ddd6fe;
|
||||
--b-depot-bg: #14532d; --b-depot-fg: #bbf7d0;
|
||||
--b-retrait-bg: #7f1d1d; --b-retrait-fg: #fecaca;
|
||||
--b-paye-bg: #14532d; --b-paye-fg: #bbf7d0;
|
||||
@@ -630,6 +634,8 @@ tr:hover td { background: var(--surface-2); }
|
||||
.badge.en_retard { background: var(--b-en_retard-bg); color: var(--b-en_retard-fg); }
|
||||
.badge.procedure { background: var(--b-procedure-bg); color: var(--b-procedure-fg); }
|
||||
.badge.cloture { background: var(--b-cloture-bg); color: var(--b-cloture-fg); }
|
||||
.badge.perte_definitive { background: var(--b-perte_definitive-bg); color: var(--b-perte_definitive-fg); }
|
||||
.badge.prolongation { background: var(--b-prolongation-bg); color: var(--b-prolongation-fg); }
|
||||
.badge.depot { background: var(--b-depot-bg); color: var(--b-depot-fg); }
|
||||
.badge.retrait { background: var(--b-retrait-bg); color: var(--b-retrait-fg); }
|
||||
.badge.paye { background: var(--b-paye-bg); color: var(--b-paye-fg); }
|
||||
|
||||
@@ -41,7 +41,14 @@ export function capitalEncoursParInvestissement(investissements, remboursementsC
|
||||
const d = rb.date_remb?.slice(0, 10);
|
||||
if (d && d <= cutoff) capRemb += rb.capital || 0;
|
||||
}
|
||||
map[inv.id] = Math.max(0, capInv - capRemb);
|
||||
// Perte définitive déclarée (capital_perdu/perte_date_effet, cf. GET /api/investissements) :
|
||||
// déduite uniquement à partir du mois de déclaration, pour rester fidèle à l'historique
|
||||
// (le capital comptait normalement avant que la perte ne soit actée).
|
||||
let capPerdu = 0;
|
||||
if (inv.capital_perdu && inv.perte_date_effet && inv.perte_date_effet <= cutoff) {
|
||||
capPerdu = inv.capital_perdu;
|
||||
}
|
||||
map[inv.id] = Math.max(0, capInv - capRemb - capPerdu);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -65,11 +65,13 @@ export const memberLabel = (m) => {
|
||||
/* ── Statuts investissements ───────────────────────────────── */
|
||||
|
||||
export const STATUT_LABELS = {
|
||||
en_cours: 'En cours',
|
||||
rembourse: 'Remboursé',
|
||||
en_retard: 'En retard',
|
||||
procedure: 'Procédure',
|
||||
cloture: 'Clôturé',
|
||||
en_cours: 'En cours',
|
||||
rembourse: 'Remboursé',
|
||||
en_retard: 'En retard',
|
||||
procedure: 'Procédure',
|
||||
cloture: 'Clôturé',
|
||||
perte_definitive: 'Perte définitive',
|
||||
prolongation: 'Prolongation',
|
||||
};
|
||||
|
||||
/** Retourne le libellé affiché d'un statut (première lettre majuscule). */
|
||||
|
||||
Reference in new issue
Block a user