Amélioration de la fonctionnalité Fiscalité

This commit is contained in:
ocroguennec committed 2026-08-22 11:58:52 +02:00
1 parent 5437091884
commit 5dc6f21e61
7 files changed
+1206 -189

No files matched your search

@@ -0,0 +1,447 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
import { useUi } from '../context/UiContext.jsx';
import {
SuiviMensuelMatrix, Report2042Block,
computeCases, getRatesForYear, MOIS_NUMS, r,
} from './Cerfa2778Preview.jsx';
const fmtPct = (v, nonPlafonne) => v == null
? (nonPlafonne
? <span title="Convention active sans taux forfaitaire chiffré : crédit égal à l'impôt réellement prélevé, sans plafond" style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Non plafonné</span>
: <span style={{ color: 'var(--text-muted)' }}>—</span>)
: `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} %`;
// Cases CERFA 2047 : montants arrondis à l'euro le plus proche (règle fiscale), sans décimales —
// contrairement au Suivi mensuel (intermédiaire, non déclaré tel quel) qui garde 2 décimales.
const fmt0 = v => {
if (v == null || v === 0) return <span style={{ color: 'var(--text-muted)' }}>- €</span>;
return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €`;
};
/* ── Badge de case CERFA — même habillage que Case2042Row / Case2042 (Report 2042) ── */
function CaseBadge({ code, tone }) {
const success = tone === 'success';
return (
<span style={{
display: 'inline-block', fontFamily: 'monospace', fontWeight: 800, fontSize: 'var(--fs-xs)',
color: success ? 'var(--success)' : 'var(--primary)',
background: success ? 'rgba(16,185,129,0.12)' : 'rgba(99,102,241,0.07)',
borderRadius: 6, padding: '2px 6px', letterSpacing: '.04em', whiteSpace: 'nowrap',
}}>{code}</span>
);
}
function CaseHeader({ code, label, tone }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<CaseBadge code={code} tone={tone} />
<span>{label}</span>
</div>
);
}
/* ── Composant principal ── */
export default function Cerfa2047Preview({ annee, activeView }) {
const LS_KEY = 'cl_2778_excluded_plats'; // partagé avec le 2778-SD (mêmes plateformes étrangères)
const { pfoAssujetti } = useUi();
const [data2778, setData2778] = useState(null);
const [data2047, setData2047] = useState(null);
const [loading, setLoading] = useState(false);
const [pfuList, setPfuList] = useState([]);
const [excluded, setExcluded] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem(LS_KEY)) ?? []); }
catch { return new Set(); }
});
const [view, setView] = useState('matrice'); // 'matrice' | 'donnees' | 'report2042'
useEffect(() => {
setLoading(true);
setData2778(null);
setData2047(null);
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
Promise.all([
api.get('/taxreport/2778', { annee, ...scopeParams }),
api.get('/taxreport/2047', { annee, ...scopeParams }),
pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null),
]).then(([d2778, d2047, pfu]) => {
setData2778(d2778);
setData2047(d2047);
if (pfu) setPfuList(pfu);
}).finally(() => setLoading(false));
}, [annee, activeView]); // eslint-disable-line
if (loading || !data2778 || !data2047) return <div className="card text-muted">Chargement…</div>;
const plateformes = data2778.plateformes;
const rates = getRatesForYear(annee, pfuList);
const TOTAL_TAX = rates.pfo + rates.csg + rates.crds + rates.solidarite;
if (plateformes.length === 0) {
return (
<div className="card">
<p className="text-muted" style={{ margin: 0 }}>
Aucun revenu de plateforme étrangère pour {annee}.
</p>
</div>
);
}
const togglePlat = (id) => {
setExcluded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
localStorage.setItem(LS_KEY, JSON.stringify([...next]));
return next;
});
};
const totauxMois = MOIS_NUMS.map(m => {
let sum = 0;
for (const plat of plateformes) {
if (!excluded.has(plat.id)) sum += plat.mois[m] ?? 0;
}
return r(sum);
});
const totalBA = r(totauxMois.reduce((a, b) => a + b, 0));
const totalIA = computeCases(totalBA, rates).IA;
// Lignes 2047 filtrées sur les plateformes non exclues (cohérence avec le Suivi mensuel)
const lignes2047 = data2047.lignes.filter(l => !excluded.has(`${l.plateforme_id}_${l.investisseur_id}`));
const lignesEligibles = lignes2047.filter(l => l.eligible_ci);
const lignesNonEligibles = lignes2047.filter(l => !l.eligible_ci);
// Regroupement par pays pour faciliter le report dans la déclaration 2047
// (le formulaire officiel attend une ligne par pays, pas par plateforme)
const groupesEligibles = Object.values(
lignesEligibles.reduce((acc, l) => {
const key = l.domiciliation || l.pays_nom || '?';
if (!acc[key]) acc[key] = { pays_nom: l.pays_nom, domiciliation: l.domiciliation, lignes: [] };
acc[key].lignes.push(l);
return acc;
}, {})
).sort((a, b) => (a.pays_nom ?? '').localeCompare(b.pays_nom ?? ''));
const t = lignes2047.reduce((acc, l) => ({
case_233: acc.case_233 + l.case_233,
case_236: acc.case_236 + l.case_236,
case_237: acc.case_237 + l.case_237,
case_238: acc.case_238 + l.case_238,
case_250: acc.case_250 + l.case_250,
}), { case_233: 0, case_236: 0, case_237: 0, case_238: 0, case_250: 0 });
t.case_251 = r(t.case_238 + t.case_250);
t.case_8VL = r(t.case_237);
t.case_8PL = r(t.case_233);
const platLabel = (l) => {
const n = l.investisseur_nom ?? '';
const p = l.investisseur_prenom ?? '';
const who = p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' ');
return (
<>
<span style={{ fontWeight: 500 }}>{l.plateforme_nom}</span>
{who && <span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginLeft: 6 }}>— {who}</span>}
</>
);
};
const cellStyle = {
textAlign: 'right', padding: '7px 10px', fontSize: 'var(--fs-sm)',
whiteSpace: 'nowrap', borderBottom: '1px solid var(--border)',
};
const headStyle = {
textAlign: 'center', padding: '7px 10px', fontSize: 'var(--fs-xs)', fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--surface-2)',
borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap',
};
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 12h6m-6 4h6m2 5H7a2 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 2047
</button>
<button
className={`account-nav-item${view === 'report2042' ? ' active' : ''}`}
onClick={() => setView('report2042')}
>
<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'/>
</svg>
Report 2042
</button>
<div style={{ margin: '10px 12px 6px', borderTop: '1px solid var(--border)' }} />
<div style={{ padding: '4px 12px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.5 }}>
Plateformes non-françaises<br/>Cadre 2, rubrique 230 — Intérêts
</div>
</nav>
<div style={{ flex: 1, minWidth: 0 }}>
{/* ── Vue Matrice (partagée avec le 2778-SD) ── */}
{view === 'matrice' && (
<SuiviMensuelMatrix
annee={annee}
plateformes={plateformes}
excluded={excluded}
togglePlat={togglePlat}
totauxMois={totauxMois}
rates={rates}
TOTAL_TAX={TOTAL_TAX}
/>
)}
{/* ── Vue Données 2047 ── */}
{view === 'donnees' && (
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<div style={{ padding: '14px 14px 10px' }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Formulaire 2047 — Rubrique 230 (Intérêts)</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>Vision annuelle {annee} — plateformes étrangères</span>
</div>
{/* Lignes 231-238 : éligibles au crédit d'impôt */}
<div style={{ padding: '0 14px 6px' }}>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="231-238" /> Intérêts ouvrant droit à un crédit d'impôt égal à l'impôt payé à l'étranger
</div>
</div>
{lignesEligibles.length === 0 ? (
<div style={{ padding: '4px 14px 14px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Aucune plateforme éligible pour {annee}.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr>
<th style={{ ...headStyle, textAlign: 'left' }}>Plateforme — Détenteur</th>
<th style={headStyle}>Pays</th>
<th style={headStyle}><CaseHeader code="233" label="Montant net encaissé" /></th>
<th style={headStyle}><CaseHeader code="234" label="Taux applicable" /></th>
<th style={headStyle}><CaseHeader code="235" label="Résultat" /></th>
<th style={headStyle}><CaseHeader code="236" label="Impôt supporté à l'étranger" /></th>
<th style={{ ...headStyle, background: 'rgba(16,185,129,0.10)' }}><CaseHeader code="237" label="Crédit d'impôt retenu" tone="success" /></th>
<th style={headStyle}><CaseHeader code="238" label="Intérêts crédit d'impôt inclus" /></th>
</tr>
</thead>
<tbody>
{groupesEligibles.flatMap(g => {
const sub = g.lignes.reduce((acc, l) => ({
case_233: acc.case_233 + l.case_233,
case_235: acc.case_235 + l.case_235,
case_236: acc.case_236 + l.case_236,
case_237: acc.case_237 + l.case_237,
case_238: acc.case_238 + l.case_238,
}), { case_233: 0, case_235: 0, case_236: 0, case_237: 0, case_238: 0 });
const rows = g.lignes.map(l => (
<tr key={`${l.plateforme_id}_${l.investisseur_id}`}>
<td style={{ ...cellStyle, textAlign: 'left' }}>{platLabel(l)}</td>
<td style={{ ...cellStyle, textAlign: 'center' }}>{l.pays_nom}</td>
<td style={cellStyle}>{fmt0(l.case_233)}</td>
<td style={{ ...cellStyle, textAlign: 'center' }}>{fmtPct(l.case_234, !l.taux_plafonne)}</td>
<td style={cellStyle}>{fmt0(l.case_235)}</td>
<td style={cellStyle}>{fmt0(l.case_236)}</td>
<td style={{ ...cellStyle, fontWeight: 600, color: 'var(--success)', background: 'rgba(16,185,129,0.05)' }}>{fmt0(l.case_237)}</td>
<td style={{ ...cellStyle, fontWeight: 600 }}>{fmt0(l.case_238)}</td>
</tr>
));
if (g.lignes.length > 1) {
rows.push(
<tr key={`sous-total-${g.domiciliation}`} style={{ background: 'rgba(99,102,241,0.06)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontStyle: 'italic', color: 'var(--text-muted)' }} colSpan={2}>
Sous-total {g.pays_nom} ({g.lignes.length} plateformes)
</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(sub.case_233)}</td>
<td style={cellStyle}></td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(sub.case_235)}</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(sub.case_236)}</td>
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>{fmt0(sub.case_237)}</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(sub.case_238)}</td>
</tr>
);
}
return rows;
})}
<tr style={{ background: 'var(--surface-2)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 700 }} colSpan={2}>Total</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(t.case_233)}</td>
<td style={cellStyle}></td>
<td style={cellStyle}></td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(t.case_236)}</td>
<td style={{ ...cellStyle, fontWeight: 700, color: 'var(--success)' }}>{fmt0(t.case_237)}</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(t.case_238)}</td>
</tr>
</tbody>
</table>
)}
{/* Ligne 250 : n'ouvrant pas droit à crédit d'impôt */}
<div style={{ padding: '18px 14px 6px', borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="250" /> Intérêts n'ouvrant pas droit à crédit d'impôt
</div>
</div>
{lignesNonEligibles.length === 0 ? (
<div style={{ padding: '4px 14px 14px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Aucune plateforme dans ce cas pour {annee}.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr>
<th style={{ ...headStyle, textAlign: 'left' }}>Plateforme — Détenteur</th>
<th style={headStyle}>Pays</th>
<th style={{ ...headStyle, textAlign: 'left' }}>Motif</th>
<th style={headStyle}><CaseHeader code="250" label="Montant" /></th>
</tr>
</thead>
<tbody>
{lignesNonEligibles.map(l => (
<tr key={`${l.plateforme_id}_${l.investisseur_id}`}>
<td style={{ ...cellStyle, textAlign: 'left' }}>{platLabel(l)}</td>
<td style={{ ...cellStyle, textAlign: 'center' }}>{l.domiciliation}</td>
<td style={{ ...cellStyle, textAlign: 'left', color: 'var(--text-muted)', fontSize: 'var(--fs-xs)' }}>{l.motif}</td>
<td style={cellStyle}>{fmt0(l.case_250)}</td>
</tr>
))}
<tr style={{ background: 'var(--surface-2)' }}>
<td style={{ ...cellStyle, textAlign: 'left', fontWeight: 700 }} colSpan={3}>Total</td>
<td style={{ ...cellStyle, fontWeight: 700 }}>{fmt0(t.case_250)}</td>
</tr>
</tbody>
</table>
)}
{/* Totaux 251 / 252 */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 14px', marginTop: 4, borderTop: '2px solid var(--border)',
background: 'rgba(99,102,241,0.05)',
}}>
<div>
<div style={{ fontWeight: 700, fontSize: 'var(--fs-sm)', display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="251" /> Total des intérêts imposables (238 + 250)
</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="252" /> dont intérêts et autres produits de placement à revenu fixe → reporté en case <strong>2TR</strong> de la 2042 (voir onglet « Report 2042 »)
</div>
</div>
<div style={{ fontWeight: 800, fontSize: '1.2rem' }}>{fmt0(t.case_251)}</div>
</div>
<div style={{ padding: '10px 14px 14px', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.6 }}>
L'éligibilité au crédit d'impôt (231-238 vs 250) est déterminée automatiquement à partir du pays de domiciliation
de chaque plateforme et du référentiel <strong>« 2047 — Taux crédit d'impôts »</strong> (Admin&nbsp;&gt;&nbsp;Fiscalité).
Le crédit d'impôt retenu (237) est plafonné au plus petit des deux montants : taux conventionnel × montant net (235),
ou impôt réellement prélevé à l'étranger (236).
</div>
{/* Rubrique 9 : revenus déjà soumis aux prélèvements sociaux (2778-SD) */}
<div style={{ padding: '18px 14px 6px', borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<CaseBadge code="272-273" /> Rubrique 9 — Revenus déjà soumis aux prélèvements sociaux (2778-SD)
</div>
</div>
{pfoAssujetti ? (
<div style={{ padding: '0 14px 14px' }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<Case2042Row
code="272"
label="Revenus déjà soumis aux prélèvements sociaux (avec CSG déductible si option barème)"
note="Même montant que la case 2BH — intérêts bruts totaux avant retenue locale, déclarés via vos 2778-SD"
value={totalBA}
/>
<Case2042Row
code="273"
label="Prélèvement forfaitaire non libératoire déjà versé"
note="Même montant que la case 2CK — somme des acomptes PFO (case IA) versés via vos 2778-SD"
value={totalIA}
/>
</div>
</div>
) : (
<div style={{
margin: '0 14px 14px', padding: '10px 14px', borderRadius: 8,
background: 'var(--surface-2)', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', lineHeight: 1.6,
}}>
Les cases 272 et 273 ne s'appliquent que si l'option de déclaration 2778-SD est activée
(Paramétrage&nbsp;&gt;&nbsp;Ma fiscalité).
</div>
)}
</div>
)}
{/* ── Vue Report 2042 ── */}
{view === 'report2042' && (
<>
<Report2042Block annee={annee} totauxMois={totauxMois} rates={rates} plateformes={plateformes} excluded={excluded} source="2047" />
<div className="card" style={{ marginTop: 12 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 14 }}>
<h4 style={{ margin: 0, fontWeight: 700 }}>Crédit d'impôt étranger (cadre 7 du 2047)</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>À reporter sur la 2042C</span>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<Case2042Row
code="8VL"
label="Crédit d'impôt sur revenus de valeurs mobilières et assimilés"
note="Somme des crédits d'impôt retenus (case 237) — plateformes dont le pays ouvre droit à un crédit d'impôt conventionnel"
value={t.case_8VL}
/>
<Case2042Row
code="8PL"
label="Revenus nets ouvrant droit à un crédit d'impôt étranger"
note="Somme des montants nets encaissés (case 233) sur ces mêmes plateformes"
value={t.case_8PL}
/>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}
/* ── Ligne de case CERFA 2047 → report 2042/2042C ── */
function Case2042Row({ code, label, note, value }) {
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 }}>{label}</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)' }}>
{Number(value ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} €
</div>
</div>
</div>
);
}