Files
crowdlending-app/frontend/src/components/Cerfa2047Preview.jsx
T

448 lines
23 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}