MAJ
This commit is contained in:
1 parent
14cf4e5a19
commit
26197fc411
15 files changed
+1776
-29
No files matched your search
+12
-1
@@ -23,6 +23,7 @@ import Frais from './pages/Frais.jsx';
|
||||
import FraisPe from './pages/FraisPe.jsx';
|
||||
import SimulRemboursements from './pages/SimulRemboursements.jsx';
|
||||
import TaxReport from './pages/TaxReport.jsx';
|
||||
import TaxReportPe from './pages/TaxReportPe.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
import MonCompte from './pages/MonCompte.jsx';
|
||||
import Admin from './pages/Admin.jsx';
|
||||
@@ -164,6 +165,16 @@ function PlateformesRouter() {
|
||||
return <Plateformes />;
|
||||
}
|
||||
|
||||
// Même principe (chantier "Fiscalité PE", 20/09/26, demande Olivier : "Nous n'avons pas fait la
|
||||
// partie Fiscalité pour les PE") : la page Fiscalité bascule sur TaxReportPe.jsx pour tout
|
||||
// workspace autre que crowdlending — la route n'est donc plus réservée au crowdlending via
|
||||
// RequireWorkspace (elle l'était jusqu'ici, cf. route "taxreport" plus bas).
|
||||
function TaxReportRouter() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.type !== 'crowdlending') return <TaxReportPe />;
|
||||
return <TaxReport />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
|
||||
@@ -215,7 +226,7 @@ export default function App() {
|
||||
<Route path="remboursements" element={<RemboursementsRouter />} />
|
||||
<Route path="frais" element={<FraisRouter />} />
|
||||
<Route path="simul" element={<RequireWorkspace type="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
|
||||
<Route path="taxreport" element={<RequireWorkspace type="crowdlending"><TaxReport /></RequireWorkspace>} />
|
||||
<Route path="taxreport" element={<TaxReportRouter />} />
|
||||
<Route path="assistant-ia" element={<RequireWorkspace type="crowdlending"><AssistantIA /></RequireWorkspace>} />
|
||||
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
|
||||
<Route path="imports" element={<Navigate to="/settings?section=imports" replace />} />
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
/* ── CERFA 2042 (PE, détention directe) ──────────────────────────────────
|
||||
* Pendant du composant Cerfa2042Preview.jsx (crowdlending), demande Olivier du
|
||||
* 20/09/26 : "il faut que tu reprenne l'onglet CERFA 2042 [...] pour les
|
||||
* plateforme en flat Tax (direct)". Reprend le même format visuel (pivot
|
||||
* mensuel + report annuel par case), mais le contenu est adapté :
|
||||
* - Pas de distinction FR/étranger (hors sujet pour le PE dans ce chantier).
|
||||
* - Deux pivots mensuels séparés au lieu d'un seul : l'intérêt sur capital
|
||||
* non déployé (case 2TR) et la plus-value réalisée (case 3VG) ne sont
|
||||
* JAMAIS fusionnés, car ils empruntent des circuits déclaratifs différents
|
||||
* (2TR est transmis par l'IFU/2561 ; 3VG/3VH passent par le 2074, cf.
|
||||
* Cerfa2561Pe.jsx et le commentaire de taxreportPe.js).
|
||||
* - Consomme le même endpoint que Cerfa2561Pe.jsx (/taxreport-pe/cerfa2561,
|
||||
* déjà filtré côté backend sur mode_detention = 'direct').
|
||||
*/
|
||||
|
||||
const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12'];
|
||||
const r2 = v => Math.round((v ?? 0) * 100) / 100;
|
||||
const fmtN = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const fmtI = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); };
|
||||
|
||||
/* ── Ligne de case 2042 (repris du format Case2042 de Cerfa2042Preview.jsx) ── */
|
||||
function Case2042({ code, label, note, value, breakdown, hors2561 }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr auto', gap: '0 12px', alignItems: 'center', padding: '10px 14px' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-base)', color: 'var(--primary)', background: 'rgba(99,102,241,0.07)', borderRadius: 6, padding: '2px 6px', textAlign: 'center', letterSpacing: '.04em' }}>{code}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600, display: 'flex', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{label}
|
||||
{hors2561 && (
|
||||
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: 'rgba(245,158,11,0.12)', color: '#b45309', fontWeight: 600, marginLeft: 8, whiteSpace: 'nowrap' }}>
|
||||
hors IFU — formulaire 2074
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{note && <div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2 }}>{note}</div>}
|
||||
</div>
|
||||
<div style={{ fontWeight: 800, fontSize: 'var(--fs-lg)', whiteSpace: 'nowrap', color: 'var(--text)' }}>{fmtI(value)} €</div>
|
||||
</div>
|
||||
{breakdown && breakdown.length > 0 && (
|
||||
<div style={{ padding: '0 14px 10px 118px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{breakdown.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
<span>└ {p.nom}</span>
|
||||
<span style={{ fontWeight: 500, marginLeft: 12 }}>{fmtI(p.val)} €</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Pivot mensuel générique (repris du format matriceView de Cerfa2042Preview.jsx) ── */
|
||||
function PivotTable({ title, subtitle, lignes, getMonthVal, footerBg, footerColor }) {
|
||||
const totalByMonth = m => r2(lignes.reduce((s, l) => s + getMonthVal(l, m), 0));
|
||||
const rowTotal = l => r2(MOIS_NUMS.reduce((s, m) => s + getMonthVal(l, m), 0));
|
||||
const grandTotal = r2(MOIS_NUMS.reduce((s, m) => s + totalByMonth(m), 0));
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '14px 14px 10px' }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>{title}</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>{subtitle}</span>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme — Détenteur</th>
|
||||
{MOIS_LABELS.map(m => (
|
||||
<th key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{m}</th>
|
||||
))}
|
||||
<th style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-muted)' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignes.map(l => {
|
||||
const total = rowTotal(l);
|
||||
return (
|
||||
<tr key={`${l.plateforme_id}_${l.investisseur_id}`} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{l.plateforme_nom}
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>— {fullName(l)}</span>
|
||||
</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = getMonthVal(l, m);
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', color: v > 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700 }}>{total > 0 ? fmtN(total) + ' €' : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', background: footerBg }}>
|
||||
<td style={{ padding: '6px 12px', fontWeight: 700, color: footerColor }}>Total</td>
|
||||
{MOIS_NUMS.map(m => {
|
||||
const v = totalByMonth(m);
|
||||
return <td key={m} style={{ padding: '6px 8px', textAlign: 'right', fontWeight: 700, color: v > 0 ? footerColor : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}</td>;
|
||||
})}
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 700, color: footerColor }}>{fmtN(grandTotal)} €</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Cerfa2042Pe({ annee, activeView }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState('matrice');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport-pe/cerfa2561', { annee, ...scopeParams })
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
}, [annee, activeView]);
|
||||
|
||||
const lignes = data?.lignes ?? [];
|
||||
|
||||
const matriceView = (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
{loading && <div className="card text-muted">Chargement…</div>}
|
||||
{!loading && lignes.length === 0 && (
|
||||
<div className="card"><p className="text-muted" style={{ margin: 0 }}>Aucune donnée pour {annee} (deals en détention directe).</p></div>
|
||||
)}
|
||||
{!loading && lignes.length > 0 && (
|
||||
<>
|
||||
<PivotTable
|
||||
title="Suivi mensuel — Intérêt sur capital non déployé"
|
||||
subtitle={`Revenus ${annee} — deals en détention directe (case 2TR)`}
|
||||
lignes={lignes}
|
||||
getMonthVal={(l, m) => l.mois?.[m]?.interets_bruts ?? 0}
|
||||
footerBg="rgba(99,102,241,0.06)"
|
||||
footerColor="var(--primary)"
|
||||
/>
|
||||
<PivotTable
|
||||
title="Suivi mensuel — Plus-values réalisées"
|
||||
subtitle={`Cessions ${annee} — deals en détention directe (case 3VG, hors IFU — formulaire 2074)`}
|
||||
lignes={lignes}
|
||||
getMonthVal={(l, m) => l.mois?.[m]?.plus_values ?? 0}
|
||||
footerBg="rgba(16,185,129,0.06)"
|
||||
footerColor="var(--success)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const donneesView = (() => {
|
||||
if (loading) return <div className="card text-muted">Chargement…</div>;
|
||||
|
||||
const total2TR = lignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0);
|
||||
const total3VG = lignes.reduce((s, l) => s + (l.case_3VG ?? 0), 0);
|
||||
const total3VH = lignes.reduce((s, l) => s + (l.case_3VH ?? 0), 0);
|
||||
const bd2TR = lignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TR }));
|
||||
const bd3VG = lignes.filter(l => l.case_3VG > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_3VG }));
|
||||
const bd3VH = lignes.filter(l => l.case_3VH > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_3VH }));
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||
<h4 style={{ margin: 0, fontWeight: 700 }}>Report annuel — Déclaration 2042</h4>
|
||||
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1} — deals en détention directe</span>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
|
||||
<Case2042 code="2TR" label="Intérêt sur capital non déployé" note="Revenus de capitaux mobiliers — transmis par vos plateformes via l'IFU (onglet CERFA 2561)" value={total2TR || undefined} breakdown={bd2TR} />
|
||||
<Case2042 code="3VG" label="Plus-value réalisée" note="Case du formulaire 2074 — jamais transmise via l'IFU, à reporter vous-même" value={total3VG || undefined} breakdown={bd3VG} hors2561 />
|
||||
<Case2042 code="3VH" label="Moins-value de cession (perte définitive)" note="Case du formulaire 2074 — imputable uniquement sur des plus-values mobilières de l'année ou des dix années suivantes" value={total3VH || undefined} breakdown={bd3VH} hors2561 />
|
||||
</div>
|
||||
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.15)', fontSize: 'var(--fs-xs)', lineHeight: 1.6, color: 'var(--text-muted)' }}>
|
||||
<strong style={{ color: 'var(--text)', display: 'block', marginBottom: 4 }}>Synthèse fiscale</strong>
|
||||
La case <strong>2TR</strong> est normalement pré-remplie par l'administration à partir de l'IFU transmis par vos plateformes.
|
||||
Les cases <strong>3VG</strong> et <strong>3VH</strong> (formulaire 2074) ne sont jamais transmises automatiquement — reportez-les
|
||||
vous-même après compensation entre les plus et moins-values de cession de l'année.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 0, alignItems: 'flex-start' }}>
|
||||
<nav style={{ width: 200, flexShrink: 0, marginRight: 20, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 0', boxSizing: 'border-box' }}>
|
||||
<div style={{ padding: '6px 12px 8px', fontSize: 'var(--fs-xs)', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.06em' }}>Sections</div>
|
||||
<button className={`account-nav-item${view === 'matrice' ? ' active' : ''}`} onClick={() => setView('matrice')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3h18v2H3V3zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
||||
Suivi mensuel
|
||||
</button>
|
||||
<button className={`account-nav-item${view === 'donnees' ? ' active' : ''}`} onClick={() => setView('donnees')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
Données 2042
|
||||
</button>
|
||||
</nav>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{view === 'matrice' && matriceView}
|
||||
{view === 'donnees' && donneesView}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
/* ── CERFA 2561 / IFU (PE, détention directe) ────────────────────────────
|
||||
* Pendant du composant Cerfa2561Preview.jsx (crowdlending), demande Olivier
|
||||
* du 20/09/26 : "il faut que tu reprenne l'onglet CERFA 2042 et 2551 [2561]
|
||||
* pour les plateforme en flat Tax (direct)". Reprend le même format visuel
|
||||
* (formulaire papier simulé — Cell/SectionTitle/FieldRow), mais le contenu
|
||||
* est adapté à la fiscalité réelle d'un deal PE :
|
||||
* - Case AR → 2TR (produits de placement à revenu fixe) uniquement — jamais
|
||||
* KR → 2TT (produits des prêts participatifs, régime spécifique aux prêts
|
||||
* de financement participatif, art. 125-00 A du CGI, qui ne s'applique
|
||||
* PAS à une part de fonds PE). Cf. commentaire détaillé dans
|
||||
* taxreportPe.js sur la correction 2TY → 3VH de ce chantier.
|
||||
* - Les plus-values réalisées (case 3VG) et les moins-values de cession /
|
||||
* pertes définitives (case 3VH) sont présentées à part, clairement
|
||||
* signalées comme "hors IFU" : aucune case du formulaire 2561 ne couvre
|
||||
* les cessions de valeurs mobilières — elles se déclarent via le
|
||||
* formulaire 2074, qui reporte ensuite 3VG/3VH sur la 2042.
|
||||
* - Pas de distinction FR/étranger ni de "type_produit_fiscal" (tous les
|
||||
* deals PE en détention directe sont traités en flat tax dans ce
|
||||
* chantier — cf. taxreportPe.js).
|
||||
*/
|
||||
|
||||
const fmtEUR = n => { if (!n && n !== 0) return ''; return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n); };
|
||||
const fmtEURDec = n => { if (!n && n !== 0) return ''; return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); };
|
||||
const r2 = v => Math.round((v ?? 0) * 100) / 100;
|
||||
const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); };
|
||||
|
||||
/* ── Cellule du formulaire (repris du format Cell de Cerfa2561Preview.jsx) ── */
|
||||
/* hors2561 remplace noReport : contrairement au 2TY/AS crowdlending (jamais reporté nulle
|
||||
* part), les cases 3VG/3VH d'un deal PE SONT reportées sur la 2042 — mais via le 2074,
|
||||
* jamais via cet IFU. Le libellé doit donc dire "reporté via 2074", pas "sans report". */
|
||||
function Cell({ label, code2561, code2042, value, filled, hors2561 }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1, background: filled ? '#fffbe6' : '#fff' }}>
|
||||
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRight: '1px solid #aaa', background: '#f5f3ff', fontSize: 11, fontWeight: 700, color: '#7c3aed' }}>{code2561}</div>
|
||||
<div style={{ flex: 1, padding: '5px 8px', fontSize: 11, color: '#222', lineHeight: 1.4, borderRight: '1px solid #aaa' }}>
|
||||
<div>{label}</div>
|
||||
<div style={{ fontSize: 10, color: hors2561 ? '#9a3412' : '#555', marginTop: 2 }}>
|
||||
{hors2561 ? 'Reporté sur la 2042 via le formulaire 2074 — jamais via cet IFU' : 'Montant à reporter sur votre déclaration 2042'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 48, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRight: '1px solid #aaa', background: filled ? '#111' : '#555', fontSize: 11, fontWeight: 700, color: '#fff' }}>{code2042 ?? '—'}</div>
|
||||
<div style={{ width: 90, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', padding: '0 10px', fontSize: 12, fontWeight: filled ? 700 : 400, color: filled ? '#14532d' : '#bbb', background: filled ? '#f0fdf4' : '#fafafa' }}>
|
||||
{filled ? fmtEUR(value) + ' €' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div style={{ background: '#5b21b6', color: '#fff', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', fontWeight: 700, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.04em', padding: '4px 8px', marginTop: 10, marginBottom: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ label, code, value }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
|
||||
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
|
||||
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '3px 8px', fontSize: 11, color: value ? '#111' : 'transparent', background: '#fff' }}>{value || ''}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Cellule éditable (repris tel quel de Cerfa2561Preview.jsx — même /api/plateforme-tax,
|
||||
* route générique non spécifique au crowdlending, réutilisable ici sans modification). ── */
|
||||
function EditableFieldRow({ label, code, value, onSave }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const startEdit = () => { setDraft(value ?? ''); setEditing(true); };
|
||||
const confirm = () => { onSave(draft.trim() || null); setEditing(false); };
|
||||
const onKey = e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') setEditing(false); };
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', border: '1px solid #aaa', marginBottom: -1 }}>
|
||||
<div style={{ width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3px 4px', fontSize: 10, fontWeight: 600, color: '#7c3aed', borderRight: '1px solid #aaa', background: '#f5f3ff' }}>{code}</div>
|
||||
<div style={{ width: 120, flexShrink: 0, display: 'flex', alignItems: 'center', padding: '3px 6px', fontSize: 11, color: '#222', borderRight: '1px solid #aaa' }}>{label}</div>
|
||||
<div
|
||||
onClick={!editing ? startEdit : undefined}
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', background: editing ? '#f5f3ff' : '#fff', cursor: editing ? 'default' : 'text', position: 'relative' }}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 11, padding: '3px 8px', color: '#111' }}
|
||||
placeholder="Saisir…"
|
||||
/>
|
||||
<button onClick={confirm} title="Valider" style={{ flexShrink: 0, border: 'none', background: 'none', cursor: 'pointer', padding: '0 8px', color: '#7c3aed', fontSize: 14, fontWeight: 700 }}>✓</button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ padding: '3px 8px', fontSize: 11, color: value ? '#111' : '#bbb', fontStyle: value ? 'normal' : 'italic' }}>
|
||||
{value || 'Cliquer pour saisir…'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Formulaire d'une ligne (plateforme × investisseur, détention directe) ── */
|
||||
function Cerfa2561FormPe({ ligne, index, total }) {
|
||||
const [taxDetails, setTaxDetails] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/plateforme-tax/${ligne.plateforme_id}/${ligne.annee}`)
|
||||
.then(setTaxDetails)
|
||||
.catch(() => setTaxDetails({ raison_sociale: ligne.plateforme_nom, siret_n: null, siret_n1: null }));
|
||||
}, [ligne.plateforme_id, ligne.annee]);
|
||||
|
||||
const save = field => val => {
|
||||
if (!taxDetails) return;
|
||||
const updated = { ...taxDetails, [field]: val };
|
||||
setTaxDetails(updated);
|
||||
api.patch(`/plateforme-tax/${taxDetails.id}`, { [field]: val });
|
||||
};
|
||||
|
||||
const interetsNets = r2(ligne.interets_bruts - ligne.prelev_sociaux - ligne.prelev_forfaitaire);
|
||||
const hasCession = ligne.case_3VG > 0 || ligne.case_3VH > 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
fontFamily: 'Arial, sans-serif', maxWidth: 900, margin: '0 auto 40px',
|
||||
border: '2px solid #5b21b6', pageBreakAfter: index < total - 1 ? 'always' : 'auto',
|
||||
colorScheme: 'light', background: '#fff', color: '#111',
|
||||
}}>
|
||||
<div style={{ background: '#5b21b6', color: '#fff', padding: '10px 14px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.05em' }}>CERFA N°2561 — N°11428*27</div>
|
||||
<div style={{ fontSize: 11, marginTop: 2, opacity: .85 }}>Déclaration récapitulative des opérations sur valeurs mobilières et revenus de capitaux mobiliers</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>{ligne.annee}</div>
|
||||
<div style={{ fontSize: 10, opacity: .8 }}>Simulation — non officielle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 10 }}>
|
||||
<div style={{ background: '#fff8e1', border: '1px solid #f59e0b', borderRadius: 4, padding: '6px 10px', fontSize: 10, color: '#92400e', marginBottom: 10 }}>
|
||||
⚠ Ce document est une simulation générée par votre outil de suivi, pour vos deals Private Equity en détention directe.
|
||||
Il ne remplace pas le formulaire officiel émis par {ligne.plateforme_nom}. Il ne couvre que l'intérêt sur capital non
|
||||
déployé — les plus ou moins-values de cession ne figurent jamais sur ce formulaire (cf. bandeau ci-dessous).
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
<SectionTitle>Désignation du payeur (plateforme)</SectionTitle>
|
||||
<EditableFieldRow label="Raison sociale" code="ZM"
|
||||
value={taxDetails?.raison_sociale ?? ligne.plateforme_nom}
|
||||
onSave={save('raison_sociale')} />
|
||||
<EditableFieldRow label={`N° SIRET au 31-12-${Number(ligne.annee) - 1}`} code="ZT"
|
||||
value={taxDetails?.siret_n1 ?? null}
|
||||
onSave={save('siret_n1')} />
|
||||
<EditableFieldRow label={`N° SIRET au 31-12-${ligne.annee}`} code="ZS"
|
||||
value={taxDetails?.siret_n ?? null}
|
||||
onSave={save('siret_n')} />
|
||||
|
||||
<SectionTitle>Désignation du bénéficiaire (investisseur)</SectionTitle>
|
||||
<FieldRow label="Nom de famille" code="ZC" value={ligne.investisseur_nom} />
|
||||
<FieldRow label="Prénoms" code="ZD" value={ligne.investisseur_prenom} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionTitle>Informations générales</SectionTitle>
|
||||
<FieldRow label="Période de référence" code="AQ" value="0101 – 1231" />
|
||||
<FieldRow label="Année fiscale" code="—" value={ligne.annee} />
|
||||
|
||||
<SectionTitle>Récapitulatif fiscal — intérêt non déployé</SectionTitle>
|
||||
<div style={{ border: '1px solid #aaa', padding: '6px 8px', background: '#f8faff', fontSize: 11 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<span>Intérêts bruts</span><strong>{fmtEURDec(ligne.interets_bruts)} €</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
|
||||
<span>− Prélèvements sociaux</span><span>−{fmtEURDec(ligne.prelev_sociaux)} €</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3, color: '#dc2626' }}>
|
||||
<span>− PFNL (12,8%)</span><span>−{fmtEURDec(ligne.prelev_forfaitaire)} €</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #ccc', paddingTop: 3, fontWeight: 700 }}>
|
||||
<span>Intérêts nets</span><span>{fmtEURDec(interetsNets)} €</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, color: '#666' }}>
|
||||
✓ Détention directe — PS et PFU déjà prélevés à la source par la plateforme
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionTitle>Cases à remplir sur le formulaire 2561</SectionTitle>
|
||||
|
||||
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#6d28d9', borderBottom: '1px solid #aaa', marginTop: 0 }}>
|
||||
PRODUITS DE PLACEMENT À REVENU FIXE
|
||||
</div>
|
||||
<Cell
|
||||
label="Gains — produits de placement à revenu fixe"
|
||||
code2561="AR" code2042="2TR" value={ligne.case_2TR} filled={ligne.case_2TR > 0}
|
||||
/>
|
||||
|
||||
{ligne.case_2BH > 0 && <>
|
||||
<div style={{ background: '#ede9fe', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#5b21b6', borderBottom: '1px solid #aaa', marginTop: 6 }}>
|
||||
PRODUITS POUR LESQUELS LES PRÉLÈVEMENTS SOCIAUX ONT DÉJÀ ÉTÉ APPLIQUÉS
|
||||
</div>
|
||||
<Cell
|
||||
label="Produits susceptibles d'ouvrir droit à CSG déductible en cas d'option pour le barème progressif"
|
||||
code2561="DQ" code2042="2BH" value={ligne.case_2BH} filled={ligne.case_2BH > 0}
|
||||
/>
|
||||
</>}
|
||||
|
||||
{ligne.case_2CK > 0 && <>
|
||||
<div style={{ background: '#fef9c3', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#854d0e', borderBottom: '1px solid #aaa', marginTop: 6 }}>
|
||||
CRÉDIT D'IMPÔT PRÉLÈVEMENT
|
||||
</div>
|
||||
<Cell
|
||||
label="Crédit d'impôt prélèvement — PFNL déjà versé (12,8%)"
|
||||
code2561="AD" code2042="2CK" value={ligne.case_2CK} filled={ligne.case_2CK > 0}
|
||||
/>
|
||||
</>}
|
||||
|
||||
{hasCession && (
|
||||
<>
|
||||
<div style={{ background: '#fef3c7', padding: '4px 8px', fontSize: 10, fontWeight: 700, color: '#92400e', borderBottom: '1px solid #aaa', marginTop: 10 }}>
|
||||
HORS FORMULAIRE 2561 — PLUS OU MOINS-VALUES DE CESSION (FORMULAIRE 2074)
|
||||
</div>
|
||||
<div style={{ border: '1px solid #aaa', borderTop: 'none', padding: '8px 10px', fontSize: 11, color: '#78350f', background: '#fffbeb' }}>
|
||||
Aucune case du formulaire 2561 ne couvre les cessions de parts de fonds : la plus-value réalisée et la moins-value
|
||||
définitive constatées cette année ne figurent jamais sur l'IFU. Déclarez-les vous-même via le formulaire 2074, qui
|
||||
reporte ensuite le résultat net sur les cases 3VG / 3VH de votre 2042.
|
||||
</div>
|
||||
{ligne.case_3VG > 0 && (
|
||||
<Cell label="Plus-value réalisée sur cession" code2561="—" code2042="3VG" value={ligne.case_3VG} filled hors2561 />
|
||||
)}
|
||||
{ligne.case_3VH > 0 && (
|
||||
<Cell label="Moins-value de cession (perte définitive)" code2561="—" code2042="3VH" value={ligne.case_3VH} filled hors2561 />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Cerfa2561Pe({ annee, activeView }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterKey, setFilterKey] = useState('all');
|
||||
const contentRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport-pe/cerfa2561', { annee, ...scopeParams })
|
||||
.then(d => {
|
||||
setData(d);
|
||||
if (d?.lignes?.[0]) setFilterKey(`${d.lignes[0].plateforme_id}_${d.lignes[0].investisseur_id}`);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [annee, activeView]);
|
||||
|
||||
const lignes = data?.lignes ?? [];
|
||||
|
||||
const handlePrint = () => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
const platName = lignes.find(l => `${l.plateforme_id}_${l.investisseur_id}` === filterKey)?.plateforme_nom ?? 'Plateforme';
|
||||
const printWin = window.open('', '_blank', 'width=900,height=700');
|
||||
printWin.document.write(`<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>CERFA 2561 — ${annee} — ${platName}</title>
|
||||
<style>
|
||||
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
||||
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; background: white; color: #111; }
|
||||
@media print { body { padding: 0; } }
|
||||
.no-print { display: none !important; }
|
||||
</style>
|
||||
</head><body>${content.innerHTML}</body></html>`);
|
||||
printWin.document.close();
|
||||
printWin.focus();
|
||||
setTimeout(() => { printWin.print(); printWin.close(); }, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="no-print" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
{lignes.length > 0 && (
|
||||
<select
|
||||
value={filterKey}
|
||||
onChange={e => setFilterKey(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-sm)', padding: '4px 8px', width: 240 }}
|
||||
>
|
||||
{lignes.map(l => (
|
||||
<option key={`${l.plateforme_id}_${l.investisseur_id}`} value={`${l.plateforme_id}_${l.investisseur_id}`}>
|
||||
{l.plateforme_nom} — {fullName(l)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="icon-btn" onClick={handlePrint} disabled={loading || !lignes.length} title="Imprimer / Enregistrer en PDF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 6 2 18 2 18 9"/>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
|
||||
<rect x="6" y="14" width="12" height="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ textAlign: 'center', color: 'var(--text-muted)', paddingTop: 20 }}>Chargement…</div>}
|
||||
{!loading && lignes.length === 0 && (
|
||||
<p className="text-muted" style={{ margin: 0 }}>Aucun deal en détention directe avec des distributions pour {annee}.</p>
|
||||
)}
|
||||
|
||||
<div ref={contentRef}>
|
||||
{!loading && lignes
|
||||
.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterKey)
|
||||
.map((ligne, i, arr) => (
|
||||
<Cerfa2561FormPe key={`${ligne.plateforme_id}_${ligne.investisseur_id}`} ligne={{ ...ligne, annee }} index={i} total={arr.length} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -368,11 +368,17 @@ export default function Layout() {
|
||||
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Fiscalité (20/09/26, chantier "Fiscalité PE") a désormais son équivalent PE
|
||||
(TaxReportPe.jsx, TaxReportRouter dans App.jsx) — sortie du bloc isCrowdlendingActive
|
||||
pour devenir visible quel que soit le workspace actif, même principe que
|
||||
Plateformes/Investissements/Dépôts-Retraits/Remboursements ci-dessus. Assistant IA
|
||||
reste, lui, réservé au crowdlending (aucun équivalent PE, cf. RequireWorkspace sur
|
||||
sa route dans App.jsx). */}
|
||||
<NavLink to="/taxreport" title="Fiscalité">
|
||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||
</NavLink>
|
||||
{isCrowdlendingActive && (
|
||||
<>
|
||||
<NavLink to="/taxreport" title="Fiscalité">
|
||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||
</NavLink>
|
||||
{aiEnabled && (
|
||||
<NavLink to="/assistant-ia" title="Assistant IA">
|
||||
<NavIcon libFilename={navIcons.assistantIa} Fallback={IconAssistantIa} /><span className="nav-label">Assistant IA</span>
|
||||
|
||||
@@ -214,6 +214,10 @@ export default function RembFormModal({
|
||||
<option value="">— Non précisé —</option>
|
||||
<option value="capital">Restitution de capital déployé</option>
|
||||
<option value="interet_non_deploye">Intérêt sur capital non déployé</option>
|
||||
{/* Plus-value réalisée (20/09/26, chantier "Fiscalité PE") — cf. distribution_nature
|
||||
dans db/index.js : le fonds distribue le produit de ses propres cessions au fil de
|
||||
l'eau plutôt qu'en une seule vente, donc chaque distribution porte sa propre nature. */}
|
||||
<option value="plus_value">Plus-value réalisée</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import InvestissementPeFormModal from '../components/InvestissementPeFormModal.j
|
||||
import FraisFormModal from '../components/FraisFormModal.jsx';
|
||||
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import { fmtEUR, fmtDate, fmtOctets, today } from '../utils/format.js';
|
||||
import { fmtEUR, fmtDate, fmtOctets, today, MODE_DETENTION_LABELS } from '../utils/format.js';
|
||||
import { emptyDealForm, AUTO_VAL_NOTE, AUTO_VAL_NOTE_LEGACY } from './InvestissementsPe.jsx';
|
||||
|
||||
/**
|
||||
@@ -44,12 +44,6 @@ const STATUT_LABELS = {
|
||||
perte_definitive: 'Perte définitive',
|
||||
};
|
||||
|
||||
const MODE_DETENTION_LABELS = {
|
||||
direct: 'Direct',
|
||||
pea_pme: 'PEA / PEA-PME',
|
||||
assurance_vie: 'Assurance-vie',
|
||||
};
|
||||
|
||||
const FRAIS_MODE_LABELS = {
|
||||
source: 'À la source',
|
||||
portefeuille: 'Porte-monnaie',
|
||||
@@ -356,6 +350,63 @@ export default function InvestissementPeDetail() {
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
/* ── Déclaration de perte définitive (20/09/26, chantier "Fiscalité PE") ──────────
|
||||
* Même principe que InvestissementDetail.jsx côté crowdlending (openPerteModal/submitPerte/
|
||||
* cancelPerteDeclaration) mais adapté : un deal PE n'a pas de statut intermédiaire de
|
||||
* recouvrement (en_retard/procedure), donc l'action est proposée pour tout statut autre que
|
||||
* 'perte_definitive' plutôt que réservée à un sous-ensemble (cf. POST /:id/perte-definitive
|
||||
* côté backend, même garde-fou). Montant préfilli avec le capital pas encore restitué
|
||||
* (montant investi/investissable − distributions déjà classées "capital"), mais reste éditable
|
||||
* (perte partielle possible si une partie a déjà été récupérée).
|
||||
*/
|
||||
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 openPerteModal = () => {
|
||||
const capInvestissable = deal?.capital_investissable ?? deal?.montant_investi ?? 0;
|
||||
const capNonRestitue = Math.max(0, capInvestissable - (deal?.capital_restitue_cumule || 0));
|
||||
setPerteForm({ date_effet: today(), montant_perte: String(Math.round(capNonRestitue * 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-pe/${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-pe/${id}/perte-definitive`);
|
||||
setConfirmingPerteCancel(false);
|
||||
await load();
|
||||
} catch (e) { setConfirmingPerteCancel(false); }
|
||||
};
|
||||
|
||||
/* ── Valorisations ── */
|
||||
const [valForm, setValForm] = useState(emptyValForm);
|
||||
const [valErr, setValErr] = useState(null);
|
||||
@@ -1010,6 +1061,72 @@ export default function InvestissementPeDetail() {
|
||||
onCancel={() => setDocDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{/* ── Modal déclaration de perte définitive (20/09/26, chantier "Fiscalité PE") ──
|
||||
Même contenu que la modale équivalente côté crowdlending (InvestissementDetail.jsx),
|
||||
adapté au vocabulaire PE ("deal" plutôt que "dossier", case 2TY via taxreport-pe). */}
|
||||
<Modal
|
||||
open={perteModalOpen}
|
||||
title="Déclarer une perte définitive"
|
||||
onClose={closePerteModal}
|
||||
width={520}
|
||||
busy={perteSaving}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" disabled={perteSaving} 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 (fonds liquidé
|
||||
sans distribution, société sous-jacente radiée…) — pas une simple baisse de
|
||||
valorisation latente. Une fois déclaré, ce deal sort du capital investi/en risque et
|
||||
le montant est reporté dans votre synthèse fiscale PE (case 2TY), 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 pas encore restitué : {fmtEUR(Math.max(0, (deal?.capital_investissable ?? deal?.montant_investi ?? 0) - (deal?.capital_restitue_cumule || 0)))}</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 pas encore restitué — 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 du fonds, société sous-jacente radiée…"
|
||||
value={perteForm.motif}
|
||||
onChange={e => setPerteForm(f => ({ ...f, motif: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
{perteErr && <div className="form-error" style={{ marginTop: 10 }}>{perteErr}</div>}
|
||||
</form>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
open={confirmingPerteCancel}
|
||||
title="Annuler la déclaration de perte ?"
|
||||
message="Le deal retrouve son statut précédent et le montant sort de votre synthèse fiscale PE."
|
||||
confirmLabel="Annuler la déclaration"
|
||||
onConfirm={cancelPerteDeclaration}
|
||||
onCancel={() => setConfirmingPerteCancel(false)}
|
||||
/>
|
||||
|
||||
<FraisFormModal
|
||||
open={fraisModalOpen}
|
||||
onClose={closeFraisModal}
|
||||
@@ -1041,6 +1158,21 @@ export default function InvestissementPeDetail() {
|
||||
items={[
|
||||
{ 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); openEditDeal(); } },
|
||||
{ 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); exportDeal(); } },
|
||||
// Déclarer/annuler une perte définitive (20/09/26, chantier "Fiscalité PE") — même
|
||||
// paire d'actions que le menu "⋮" crowdlending (InvestissementDetail.jsx).
|
||||
deal && deal.statut !== 'perte_definitive' && {
|
||||
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,
|
||||
separatorBefore: true,
|
||||
action: () => { setCardMenu(null); openPerteModal(); },
|
||||
},
|
||||
deal && deal.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"/></>,
|
||||
separatorBefore: true,
|
||||
action: () => { setCardMenu(null); setConfirmingPerteCancel(true); },
|
||||
},
|
||||
{ label: 'Supprimer', danger: true, separatorBefore: true, icon: <><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"/></>, action: () => { setCardMenu(null); setDeleteConfirm(true); } },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import { useAsyncAction } from '../hooks/useAsyncAction.js';
|
||||
import { fmtEUR, fmtDate, memberLabel } from '../utils/format.js';
|
||||
import { fmtEUR, fmtDate, memberLabel, DISTRIBUTION_NATURE_LABELS } from '../utils/format.js';
|
||||
import {
|
||||
sortMouvements, computeSoldeIndicatifMap, signedMontant, MOUV_TYPE_LABELS,
|
||||
ExportDropdown, mouvToCSV, mouvToXLS, mouvToJSON, timestampSuffix, dlBlob,
|
||||
@@ -480,7 +480,8 @@ export default function PlateformesPe() {
|
||||
<td>{fmtDate(r.date_remb)}</td>
|
||||
<td>{deals.find(d => d.id === r.investissement_pe_id)?.nom_deal || '—'}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.investisseur_nom || '—'}</td>}
|
||||
<td className="text-muted">{r.distribution_nature === 'interet_non_deploye' ? 'Intérêt sur capital non déployé' : 'Restitution de capital'}</td>
|
||||
{/* 20/09/26, chantier "Fiscalité PE" : ternaire remplacé par la constante partagée DISTRIBUTION_NATURE_LABELS (utils/format.js) pour inclure la nouvelle nature 'plus_value' sans dupliquer les libellés (cf. RemboursementsSimple.jsx). */}
|
||||
<td className="text-muted">{DISTRIBUTION_NATURE_LABELS[r.distribution_nature] || 'Restitution de capital'}</td>
|
||||
<td className="num">{fmtEUR(r.net_recu)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -13,7 +13,7 @@ import Pagination from '../components/Pagination.jsx';
|
||||
import InteretsChart from '../components/InteretsChart.jsx';
|
||||
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
import { fmtEUR, fmtDate, today, DISTRIBUTION_NATURE_LABELS } from '../utils/format.js';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
bonus_plateforme: 'Bonus plateforme',
|
||||
@@ -21,11 +21,9 @@ const TYPE_LABELS = {
|
||||
interets_plateforme: 'Distribution',
|
||||
};
|
||||
|
||||
// Nature de la distribution (18/09/26) — affichée en sous-texte sous le type dans le tableau.
|
||||
const DISTRIBUTION_NATURE_LABELS = {
|
||||
capital: 'Restitution de capital',
|
||||
interet_non_deploye: 'Intérêt sur capital non déployé',
|
||||
};
|
||||
// Nature de la distribution (18/09/26 ; libellés extraits en constante partagée le 20/09/26,
|
||||
// chantier "Fiscalité PE" — cf. utils/format.js, réutilisée aussi par PlateformesPe.jsx).
|
||||
// Affichée en sous-texte sous le type dans le tableau.
|
||||
|
||||
const MOIS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const MOIS_ABBR = ['Jan','Fév','Mar','Avr','Mai','Juin','Juil','Août','Sep','Oct','Nov','Déc'];
|
||||
@@ -395,13 +393,30 @@ export default function RemboursementsSimple() {
|
||||
if (key === 'taxe_locale') {
|
||||
next.interets_bruts = round2(Number(next.interets_bruts_avant_local || 0) - Number(value));
|
||||
}
|
||||
if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc'].includes(key)) {
|
||||
// Recalcul PFU/PS (20/09/26, chantier "Fiscalité PE" — cf. taxreportPe.js) : désormais
|
||||
// dépendant à la fois de la nature de la distribution ET du mode de détention du deal lié
|
||||
// (investissements_pe.mode_detention), pas seulement de l'année comme avant. 'distribution_nature'
|
||||
// et 'investissement_pe_id' redéclenchent donc aussi ce recalcul, contrairement à avant.
|
||||
// - capital (restitution de capital déployé) : jamais imposable, quel que soit le mode.
|
||||
// - direct : PFU complet (IR + PS), comme avant (comportement historique inchangé).
|
||||
// - pea_pme : prélèvements sociaux seuls (IR nativement exonéré dans le plan).
|
||||
// - assurance_vie : aucun taux automatique (dépend du contrat) — laissé à 0, saisie manuelle.
|
||||
if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc', 'distribution_nature', 'investissement_pe_id'].includes(key)) {
|
||||
const dateStr = key === 'date_remb' ? value : next.date_remb;
|
||||
const base = round2((Number(next.interets_bruts) || 0) - (Number(next.frais_ttc) || 0));
|
||||
const rates = getRatesForYearPe(dateStr, pfuRates);
|
||||
if (rates) {
|
||||
const dealSel = deals.find(d => String(d.id) === String(next.investissement_pe_id));
|
||||
const modeDetention = dealSel?.mode_detention || 'direct';
|
||||
|
||||
if (next.distribution_nature === 'capital') {
|
||||
next.prelev_sociaux = 0;
|
||||
next.prelev_forfaitaire = 0;
|
||||
} else if (modeDetention === 'assurance_vie') {
|
||||
next.prelev_sociaux = 0;
|
||||
next.prelev_forfaitaire = 0;
|
||||
} else if (rates) {
|
||||
next.prelev_sociaux = round2(base * rates.prelev_sociaux / 100);
|
||||
next.prelev_forfaitaire = round2(base * rates.impot_revenu / 100);
|
||||
next.prelev_forfaitaire = modeDetention === 'pea_pme' ? 0 : round2(base * rates.impot_revenu / 100);
|
||||
}
|
||||
}
|
||||
setForm(next);
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import { fmtEUR, fmtDate, MODE_DETENTION_LABELS } from '../utils/format.js';
|
||||
import Cerfa2042Pe from '../components/Cerfa2042Pe.jsx';
|
||||
import Cerfa2561Pe from '../components/Cerfa2561Pe.jsx';
|
||||
|
||||
/* ── YearSelector ─────────────────────────────────────────────
|
||||
* Copié de TaxReport.jsx (même comportement — pas d'extraction en composant partagé pour
|
||||
* l'instant afin de ne pas toucher au fichier crowdlending existant dans ce chantier ; la
|
||||
* page Plateformes a son propre AnneeSelector, légèrement différent car il autorise un choix
|
||||
* "Depuis le début" hors de propos ici, une année étant toujours requise pour la fiscalité). */
|
||||
function YearSelector({ annee, setAnnee, availableYears }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0, width: 160 }}>
|
||||
<div
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
height: '100%', boxSizing: 'border-box',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 16px',
|
||||
boxShadow: open
|
||||
? '0 6px 28px color-mix(in srgb, var(--primary) 45%, transparent)'
|
||||
: '0 4px 20px color-mix(in srgb, var(--primary) 30%, transparent)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
userSelect: 'none',
|
||||
transition: 'box-shadow .15s',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--fs-xs)', textTransform: 'uppercase',
|
||||
letterSpacing: '.06em', color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||
}}>Année</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="rgba(255,255,255,0.7)" strokeWidth="2.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s', flexShrink: 0 }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ color: '#fff', fontSize: '1.8rem', fontWeight: 700, lineHeight: 1.1, marginTop: 6 }}>
|
||||
{annee}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 200,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,0.15)',
|
||||
minWidth: 160, overflow: 'hidden',
|
||||
}}>
|
||||
{availableYears.map(yr => {
|
||||
const isActive = String(yr) === String(annee);
|
||||
return (
|
||||
<div
|
||||
key={yr}
|
||||
onClick={() => { setAnnee(String(yr)); setOpen(false); }}
|
||||
style={{
|
||||
padding: '10px 16px', cursor: 'pointer',
|
||||
background: isActive ? 'color-mix(in srgb, var(--primary) 8%, transparent)' : 'transparent',
|
||||
color: isActive ? 'var(--primary)' : 'var(--text)',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
}}
|
||||
>
|
||||
{yr}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Ligne d'information CERFA (repris du format InfoLigneCerfa de TaxReport.jsx) ── */
|
||||
function InfoLigneCerfa({ code, titre, texte }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
flexShrink: 0, minWidth: 62, textAlign: 'center',
|
||||
fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-xs)',
|
||||
color: 'var(--primary)', background: 'rgba(99,102,241,0.07)',
|
||||
borderRadius: 6, padding: '4px 6px', letterSpacing: '.03em', whiteSpace: 'nowrap',
|
||||
}}>{code}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600 }}>{titre}</div>
|
||||
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.5 }}>{texte}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Schéma : quel formulaire alimente quel formulaire (PE, détention directe) ──
|
||||
* Adapté de CerfaFlowDiagram (TaxReport.jsx) : contrairement au crowdlending, deux
|
||||
* formulaires alimentent la 2042 en parallèle et ne sont JAMAIS fusionnés — le 2561
|
||||
* (intérêt sur capital non déployé) et le 2074 (plus ou moins-value de cession). ── */
|
||||
function CerfaFlowDiagramPe() {
|
||||
const boxFill = 'var(--surface-2)';
|
||||
const box = (x, y, w, h, code, label, highlight) => (
|
||||
<g key={code}>
|
||||
<rect x={x} y={y} width={w} height={h} rx={10}
|
||||
fill={highlight ? 'rgba(99,102,241,0.08)' : boxFill}
|
||||
stroke={highlight ? 'var(--primary)' : 'var(--border)'} strokeWidth={highlight ? 1.8 : 1.3} />
|
||||
<text x={x + w / 2} y={y + h / 2 - 6} textAnchor="middle" fontFamily="monospace" fontWeight="800"
|
||||
fontSize="14" fill="var(--primary)">{code}</text>
|
||||
<text x={x + w / 2} y={y + h / 2 + 13} textAnchor="middle" fontSize="10" fill="var(--text-muted)">{label}</text>
|
||||
</g>
|
||||
);
|
||||
const arrow = (x1, y1, x2, y2, key) => (
|
||||
<line key={key} x1={x1} y1={y1} x2={x2} y2={y2}
|
||||
stroke="var(--text-muted)" strokeWidth={1.6} markerEnd="url(#cerfaFlowArrowPe)" />
|
||||
);
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 480 190" style={{ width: '100%', maxWidth: 460, height: 'auto', display: 'block' }}>
|
||||
<defs>
|
||||
<marker id="cerfaFlowArrowPe" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="var(--text-muted)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{arrow(180, 35, 268, 78, 'a1')}
|
||||
{arrow(180, 150, 268, 108, 'a2')}
|
||||
{box(20, 10, 160, 50, '2561', 'IFU (intérêt)')}
|
||||
{box(20, 125, 160, 50, '2074', 'Plus/moins-values')}
|
||||
{box(278, 65, 190, 60, '2042', 'Déclaration revenus', true)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Carte "régime de détention" ── */
|
||||
function RegimeCard({ mode, bucket }) {
|
||||
const hasData = bucket.plus_value_realisee > 0 || bucket.interet_non_deploye > 0
|
||||
|| bucket.capital_restitue > 0 || bucket.non_classe > 0 || bucket.pertes_definitives > 0;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ flex: '1 1 300px' }}>
|
||||
<h3 style={{ marginTop: 0 }}>{MODE_DETENTION_LABELS[mode] || mode}</h3>
|
||||
{!hasData ? (
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>Aucun flux pour cette année.</p>
|
||||
) : (
|
||||
<div className="kpi-grid">
|
||||
{bucket.plus_value_realisee > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Plus-value réalisée</div>
|
||||
<div className="value">{fmtEUR(bucket.plus_value_realisee)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.interet_non_deploye > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Intérêt sur capital non déployé</div>
|
||||
<div className="value">{fmtEUR(bucket.interet_non_deploye)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.capital_restitue > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Capital restitué <span className="text-muted">(non imposable)</span></div>
|
||||
<div className="value">{fmtEUR(bucket.capital_restitue)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.non_classe > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Distributions non classées <span className="text-muted">(nature inconnue)</span></div>
|
||||
<div className="value">{fmtEUR(bucket.non_classe)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.pertes_definitives > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Pertes définitives</div>
|
||||
<div className="value danger">{fmtEUR(bucket.pertes_definitives)}</div>
|
||||
</div>
|
||||
)}
|
||||
{bucket.frais_gestion > 0 && (
|
||||
<div className="kpi">
|
||||
<div className="label">Frais de gestion <span className="text-muted">(informatif)</span></div>
|
||||
<div className="value">{fmtEUR(bucket.frais_gestion)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasData && mode === 'direct' && (
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)' }}>
|
||||
<div className="kpi-grid">
|
||||
<div className="kpi">
|
||||
<div className="label">PFU sur plus-value <span className="text-muted">(IR + PS)</span></div>
|
||||
<div className="value">
|
||||
{bucket.fiscal.prelev_forfaitaire_plus_value != null
|
||||
? fmtEUR((bucket.fiscal.prelev_forfaitaire_plus_value || 0) + (bucket.fiscal.prelev_sociaux_plus_value || 0))
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">PFU sur intérêts <span className="text-muted">(IR + PS)</span></div>
|
||||
<div className="value">
|
||||
{bucket.fiscal.prelev_forfaitaire_interets != null
|
||||
? fmtEUR((bucket.fiscal.prelev_forfaitaire_interets || 0) + (bucket.fiscal.prelev_sociaux_interets || 0))
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasData && mode === 'pea_pme' && (
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)' }}>
|
||||
<div className="kpi-grid">
|
||||
<div className="kpi">
|
||||
<div className="label">Prélèvements sociaux <span className="text-muted">(17,2 %)</span></div>
|
||||
<div className="value">{bucket.fiscal.prelev_sociaux != null ? fmtEUR(bucket.fiscal.prelev_sociaux) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: 12, marginTop: 10, marginBottom: 0 }}>{bucket.fiscal.note}</p>
|
||||
</div>
|
||||
)}
|
||||
{hasData && mode === 'assurance_vie' && (
|
||||
<p className="text-muted" style={{ fontSize: 12, marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)', marginBottom: 0 }}>
|
||||
{bucket.fiscal.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TaxReportPe — Fiscalité Private Equity (chantier du 20/09/26, demande Olivier : "Nous n'avons
|
||||
* pas fait la partie Fiscalité pour les PE"). Pendant PE de TaxReport.jsx (crowdlending), mais
|
||||
* volontairement plus simple : pas d'onglets CERFA/2042/2047/2778 (aucun de ces formulaires ne
|
||||
* s'applique directement au PE — cf. commentaire taxreportPe.js), un récapitulatif par mode de
|
||||
* détention (direct / PEA-PME / assurance-vie, cf. échange avec Olivier : "j'ai besoin de
|
||||
* différencier les 3") avec un calcul PFU/PS indicatif propre à chaque régime.
|
||||
*/
|
||||
export default function TaxReportPe() {
|
||||
const { activeId, activeView } = useInvestisseur();
|
||||
const [annee, setAnnee] = useState(String(new Date().getFullYear()));
|
||||
const [availableYears, setAvailableYears] = useState([]);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('2042');
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport-pe/years', scopeParams).then(years => {
|
||||
setAvailableYears(years);
|
||||
if (years.length > 0 && !years.map(String).includes(annee)) {
|
||||
setAnnee(String(years[0]));
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeId, activeView]);
|
||||
|
||||
const load = () => {
|
||||
if (!activeId && activeView !== 'all') return;
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
||||
api.get('/taxreport-pe', { annee, ...scopeParams })
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(load, [activeId, activeView, annee]); // eslint-disable-line
|
||||
|
||||
const downloadCsv = () => {
|
||||
const token = localStorage.getItem('cl_token');
|
||||
const investisseurId = localStorage.getItem('cl_investisseur_id');
|
||||
const exportParams = activeView === 'all' ? { annee, scope: 'all' } : { annee };
|
||||
fetch(api.exportUrl('/taxreport-pe/export', exportParams), {
|
||||
headers: { Authorization: `Bearer ${token}`, 'X-Investisseur-Id': investisseurId },
|
||||
})
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `fiscalite-pe-${annee}.csv`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
const hasAnyData = data && (
|
||||
['direct', 'pea_pme', 'assurance_vie'].some(m => {
|
||||
const b = data.par_mode_detention[m];
|
||||
return b.plus_value_realisee > 0 || b.interet_non_deploye > 0 || b.capital_restitue > 0
|
||||
|| b.non_classe > 0 || b.pertes_definitives > 0;
|
||||
})
|
||||
|| (data.non_rattachees && (data.non_rattachees.plus_value_realisee > 0 || data.non_rattachees.interet_non_deploye > 0))
|
||||
);
|
||||
|
||||
if (!loading && data && availableYears.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2><PageIcon name="tax" />Fiscalité — Private Equity</h2></div>
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title="Aucune donnée fiscale PE pour le moment"
|
||||
message="Vos deals PE sont configurés, mais aucune distribution n'a encore été enregistrée. Ajoutez une distribution (page Remboursements) pour voir apparaître votre récapitulatif fiscal."
|
||||
cta="Ajouter une distribution"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="tax" />Fiscalité — Private Equity</h2>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'stretch' }}>
|
||||
<button type="button" className="icon-btn" disabled={!data} onClick={downloadCsv} title="Exporter en CSV">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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"/>
|
||||
</svg>
|
||||
</button>
|
||||
<YearSelector annee={annee} setAnnee={setAnnee} availableYears={availableYears} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || !data ? (
|
||||
<div className="card text-muted">Chargement…</div>
|
||||
) : !hasAnyData ? (
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title={`Aucune donnée fiscale PE pour ${annee}`}
|
||||
message="Aucune distribution ni perte définitive enregistrée sur cette année. Changez d'année ou ajoutez des mouvements."
|
||||
cta="Ajouter une distribution"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'stretch' }}>
|
||||
<RegimeCard mode="direct" bucket={data.par_mode_detention.direct} />
|
||||
<RegimeCard mode="pea_pme" bucket={data.par_mode_detention.pea_pme} />
|
||||
<RegimeCard mode="assurance_vie" bucket={data.par_mode_detention.assurance_vie} />
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: 10 }}>À quoi correspond chaque CERFA ?</h3>
|
||||
<div style={{ display: 'flex', gap: 28, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 280, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<InfoLigneCerfa
|
||||
code="2042"
|
||||
titre="Déclaration des revenus"
|
||||
texte="La déclaration principale : reprend la case 2TR (intérêt sur capital non déployé) et, le cas échéant, les cases 3VG / 3VH (plus ou moins-value de cession) reportées depuis la 2074 — pour vos deals en détention directe uniquement."
|
||||
/>
|
||||
<InfoLigneCerfa
|
||||
code="2561"
|
||||
titre="IFU — Imprimé fiscal unique"
|
||||
texte="Récapitulatif annuel transmis par les plateformes pour vos deals en détention directe soumis au PFU. Ne couvre que l'intérêt sur capital non déployé (case AR → 2TR) : les plus-values de cession n'y figurent jamais."
|
||||
/>
|
||||
<InfoLigneCerfa
|
||||
code="2074"
|
||||
titre="Plus ou moins-values de cession de valeurs mobilières"
|
||||
texte="À déclarer vous-même, séparément de l'IFU : la plus-value réalisée (case 3VG) ou la moins-value définitive constatée (case 3VH) sur un deal en détention directe. Le résultat net alimente ensuite la 2042."
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 280, display: 'flex', justifyContent: 'center' }}>
|
||||
<CerfaFlowDiagramPe />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted" style={{ marginTop: 14, marginBottom: 0, fontSize: 12, lineHeight: 1.6 }}>
|
||||
⚠ Ce module est une aide à la préparation de votre déclaration ; il ne la remplace pas. Les onglets et cases
|
||||
ci-dessous ne concernent que vos deals en <strong>détention directe</strong> — le PEA-PME et l'assurance-vie
|
||||
suivent des régimes fiscaux distincts, sans formulaire CERFA dédié (cf. cartes ci-dessus). Vérifiez chaque
|
||||
montant avant de le reporter. Vous restez seul responsable de l'exactitude de votre déclaration fiscale —
|
||||
Crowdlending ne saurait être tenu responsable des données déclarées.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
|
||||
<button className={`dr-tab${activeTab === '2561' ? ' active' : ''}`} onClick={() => setActiveTab('2561')}>CERFA 2561 (IFU)</button>
|
||||
</div>
|
||||
|
||||
{activeTab === '2042' && <Cerfa2042Pe annee={annee} activeView={activeView} />}
|
||||
{activeTab === '2561' && <Cerfa2561Pe annee={annee} activeView={activeView} />}
|
||||
|
||||
{data.non_rattachees && (data.non_rattachees.plus_value_realisee > 0 || data.non_rattachees.interet_non_deploye > 0 || data.non_rattachees.non_classe > 0) && (
|
||||
<div className="card" style={{ marginTop: 16, borderColor: 'var(--warning, #d97706)' }}>
|
||||
<h3 style={{ marginTop: 0 }}>⚠ Distributions non rattachées à un deal</h3>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>
|
||||
Ces distributions n'indiquent pas de deal PE précis (champ « Investissement PE »
|
||||
du formulaire) — leur régime de détention (direct / PEA-PME / assurance-vie) ne
|
||||
peut donc pas être déterminé, et elles ne sont pas comptées dans les cartes
|
||||
ci-dessus. Rattachez-les à leur deal (page Remboursements) pour qu'elles soient
|
||||
prises en compte.
|
||||
</p>
|
||||
<div className="kpi-grid">
|
||||
{data.non_rattachees.plus_value_realisee > 0 && (
|
||||
<div className="kpi"><div className="label">Plus-value réalisée</div><div className="value">{fmtEUR(data.non_rattachees.plus_value_realisee)}</div></div>
|
||||
)}
|
||||
{data.non_rattachees.interet_non_deploye > 0 && (
|
||||
<div className="kpi"><div className="label">Intérêt sur capital non déployé</div><div className="value">{fmtEUR(data.non_rattachees.interet_non_deploye)}</div></div>
|
||||
)}
|
||||
{data.non_rattachees.non_classe > 0 && (
|
||||
<div className="kpi"><div className="label">Non classées</div><div className="value">{fmtEUR(data.non_rattachees.non_classe)}</div></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.pertes_detail && data.pertes_detail.length > 0 && (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Pertes définitives déclarées en {annee}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date d'effet</th>
|
||||
<th>Deal</th>
|
||||
<th>Plateforme</th>
|
||||
<th>Mode de détention</th>
|
||||
<th>Motif</th>
|
||||
<th className="num">Montant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.pertes_detail.map((p, i) => (
|
||||
<tr key={i}>
|
||||
<td>{fmtDate(p.date_effet)}</td>
|
||||
<td>{p.nom_deal}</td>
|
||||
<td>{p.plateforme_nom}</td>
|
||||
<td>{MODE_DETENTION_LABELS[p.mode_detention] || p.mode_detention}</td>
|
||||
<td className="text-muted">{p.motif}</td>
|
||||
<td className="num">{fmtEUR(p.montant_perte)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted" style={{ fontSize: 12, marginTop: 16 }}>
|
||||
{data.cases?.note}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,26 @@ export const STATUT_LABELS = {
|
||||
/** Retourne le libellé affiché d'un statut (première lettre majuscule). */
|
||||
export const fmtStatut = (s) => STATUT_LABELS[s] ?? s;
|
||||
|
||||
/* ── Nature d'une distribution PE ──────────────────────────────
|
||||
* Extrait en constante partagée (20/09/26, chantier "Fiscalité PE") — jusqu'ici dupliquée entre
|
||||
* RemboursementsSimple.jsx (objet local) et PlateformesPe.jsx (ternaire à 2 branches) ; un seul
|
||||
* endroit à mettre à jour quand une nature est ajoutée (comme 'plus_value' ici). */
|
||||
export const DISTRIBUTION_NATURE_LABELS = {
|
||||
capital: 'Restitution de capital',
|
||||
interet_non_deploye: 'Intérêt sur capital non déployé',
|
||||
plus_value: 'Plus-value réalisée',
|
||||
};
|
||||
|
||||
/** Libellés des 3 régimes de détention d'un deal PE (investissements_pe.mode_detention) —
|
||||
* reprend exactement les libellés déjà utilisés dans InvestissementPeDetail.jsx (19/09/26),
|
||||
* extraits ici en constante partagée le 20/09/26 (chantier "Fiscalité PE", réutilisée aussi
|
||||
* par TaxReportPe.jsx). */
|
||||
export const MODE_DETENTION_LABELS = {
|
||||
direct: 'Direct',
|
||||
pea_pme: 'PEA / PEA-PME',
|
||||
assurance_vie: 'Assurance-vie',
|
||||
};
|
||||
|
||||
/** Initiales pour l'avatar (2 caractères max).
|
||||
* - Si prenom est renseigné : 1re lettre du prénom + 1re lettre du reste du nom complet
|
||||
* - Sinon (données migrées) : 1re lettre du 1er mot + 1re lettre du dernier mot du nom complet
|
||||
|
||||
Reference in new issue
Block a user