317 lines
15 KiB
React
317 lines
15 KiB
React
import { useEffect, useRef, useState } from 'react';
|
|
import PageIcon from '../components/PageIcon.jsx';
|
|
import EmptyState from '../components/EmptyState.jsx';
|
|
import Pagination from '../components/Pagination.jsx';
|
|
import { usePagination } from '../hooks/usePagination.js';
|
|
import { api } from '../api.js';
|
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
|
import { useUi } from '../context/UiContext.jsx';
|
|
import { fmtEUR, fmtStatut } from '../utils/format.js';
|
|
import Cerfa2561Preview from '../components/Cerfa2561Preview.jsx';
|
|
import CerfaRecapTable from '../components/CerfaRecapTable.jsx';
|
|
import Cerfa2778Preview from '../components/Cerfa2778Preview.jsx';
|
|
import Cerfa2042Preview from '../components/Cerfa2042Preview.jsx';
|
|
|
|
/* ── YearSelector ────────────────────────────────────────────── */
|
|
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, #7c3aed 0%, #4f46e5 100%)',
|
|
borderRadius: 10,
|
|
padding: '12px 16px',
|
|
boxShadow: open
|
|
? '0 6px 28px rgba(109,40,217,0.45)'
|
|
: '0 4px 20px rgba(109,40,217,0.30)',
|
|
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, i) => {
|
|
const isActive = String(yr) === String(annee);
|
|
return (
|
|
<div
|
|
key={yr}
|
|
onClick={() => { setAnnee(String(yr)); setOpen(false); }}
|
|
style={{
|
|
padding: '10px 16px', cursor: 'pointer',
|
|
background: isActive ? 'rgba(109,40,217,0.08)' : 'transparent',
|
|
color: isActive ? '#7c3aed' : 'var(--text)',
|
|
fontWeight: isActive ? 700 : 400,
|
|
fontSize: 'var(--fs-sm)',
|
|
borderBottom: i < availableYears.length - 1 ? '1px solid var(--border)' : 'none',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
transition: 'background .1s',
|
|
}}
|
|
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
|
>
|
|
<span>{yr}</span>
|
|
{isActive && (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
|
stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── ExportDropdown ──────────────────────────────────────────── */
|
|
function ExportDropdown({ disabled, onCSV, onJSON }) {
|
|
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]);
|
|
const choose = fn => { setOpen(false); fn(); };
|
|
return (
|
|
<div ref={ref} style={{ position: 'relative' }}>
|
|
<button type="button" className="icon-btn" disabled={disabled}
|
|
onClick={() => setOpen(o => !o)} title="Exporter">
|
|
<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>
|
|
{open && (
|
|
<div className="export-dropdown" role="menu">
|
|
<button role="menuitem" onClick={() => choose(onCSV)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
|
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
|
|
</svg>
|
|
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
|
|
</button>
|
|
<button role="menuitem" onClick={() => choose(onJSON)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
|
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
|
|
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
|
|
</svg>
|
|
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Composant principal ─────────────────────────────────────── */
|
|
export default function TaxReport() {
|
|
const { activeId, activeView } = useInvestisseur();
|
|
const { pfoAssujetti } = useUi();
|
|
const [annee, setAnnee] = useState(String(new Date().getFullYear()));
|
|
const [availableYears, setAvailableYears] = useState([]);
|
|
const [data, setData] = useState(null);
|
|
const [data2042, setData2042] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeTab, setActiveTab] = useState('2042');
|
|
const [detailExpanded, setDetailExpanded] = useState(false);
|
|
const [cerfaExpanded, setCerfaExpanded] = useState(false);
|
|
|
|
/* ── Chargement des années disponibles ── */
|
|
useEffect(() => {
|
|
if (!activeId && activeView !== 'all') return;
|
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
|
api.get('/taxreport/years', scopeParams).then(years => {
|
|
setAvailableYears(years);
|
|
// Si l'année courante n'est pas dans la liste, prendre la première disponible
|
|
if (years.length > 0 && !years.map(String).includes(annee)) {
|
|
setAnnee(String(years[0]));
|
|
}
|
|
});
|
|
}, [activeId, activeView]); // eslint-disable-line
|
|
|
|
const load = () => {
|
|
if (!activeId && activeView !== 'all') return;
|
|
setData(null);
|
|
setLoading(true);
|
|
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
|
|
const LS_EXCL = 'cl_2778_excluded_plats';
|
|
const excluded = new Set(JSON.parse(localStorage.getItem(LS_EXCL) ?? '[]'));
|
|
Promise.all([
|
|
api.get('/taxreport', { annee, ...scopeParams }),
|
|
api.get('/taxreport/cerfa2561', { annee, ...scopeParams }),
|
|
api.get('/taxreport/2778', { annee, ...scopeParams }),
|
|
]).then(([d, d2561, d2778]) => {
|
|
setData(d);
|
|
const frLignes = (d2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
|
|
const platEtr = (d2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
|
|
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
|
|
const pfo = 0.128; // taux par défaut — affiné par pfuList si dispo
|
|
setData2042({
|
|
case_2TT: frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0),
|
|
case_2TR: frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
|
|
case_2BH: frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
|
|
case_2CK: frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo),
|
|
case_2TY: frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
|
|
});
|
|
}).finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(load, [activeId, activeView, annee]); // eslint-disable-line
|
|
|
|
/* ── Pagination détail ── */
|
|
const detail = data?.detail ?? [];
|
|
const {
|
|
pagedItems: pagedDetail, page: detPage, setPage: setDetPage,
|
|
pageSize: detPageSize, setPageSize: setDetPageSize,
|
|
totalPages: detTotalPages, totalItems: detTotalItems, PAGE_SIZES,
|
|
} = usePagination(detail, 'cl_pagesize_fiscal_detail', [activeTab]);
|
|
|
|
/* ── Exports ── */
|
|
const dlBlob = (content, filename, type) => {
|
|
const blob = new Blob([content], { type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
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/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 = `2778-SD-${annee}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
};
|
|
|
|
const downloadJson = () => {
|
|
if (!data) return;
|
|
const payload = {
|
|
annee: data.annee,
|
|
recap: data.recap,
|
|
cases: data.cases,
|
|
detail: data.detail,
|
|
pertes: data.pertes,
|
|
pertesTotales: data.pertesTotales,
|
|
};
|
|
dlBlob(JSON.stringify(payload, null, 2), `2778-SD-${annee}.json`, 'application/json');
|
|
};
|
|
|
|
if (!loading && data && availableYears.length === 0) return (
|
|
<>
|
|
<div className="topbar"><h2>Fiscalité</h2></div>
|
|
<EmptyState
|
|
icon="📋"
|
|
title="Aucune donnée fiscale disponible"
|
|
message="Vous n'avez pas encore de données fiscales à déclarer. Ajoutez des plateformes et des remboursements pour voir apparaître votre récapitulatif fiscal."
|
|
/>
|
|
</>
|
|
);
|
|
return (
|
|
<>
|
|
<div className="topbar">
|
|
<h2><PageIcon name="tax" />Fiscalité — Récapitulatif fiscal</h2>
|
|
<YearSelector annee={annee} setAnnee={setAnnee} availableYears={availableYears} />
|
|
</div>
|
|
|
|
{loading || !data ? (
|
|
<div className="card text-muted">Chargement…</div>
|
|
) : (
|
|
<>
|
|
{!detailExpanded && !cerfaExpanded && data2042 && <div className="card">
|
|
<h3 style={{ marginTop: 0 }}>Cases fiscales 2042 — synthèse {annee}</h3>
|
|
<div className="kpi-grid">
|
|
{data2042.case_2TT > 0 && <div className="kpi"><div className="label">Case 2TT — Prêts participatifs (FR)</div><div className="value">{fmtEUR(data2042.case_2TT)}</div></div>}
|
|
{data2042.case_2TR > 0 && <div className="kpi"><div className="label">Case 2TR — Revenus fixes (FR + étranger)</div><div className="value">{fmtEUR(data2042.case_2TR)}</div></div>}
|
|
<div className="kpi"><div className="label">Case 2BH — Base CSG/CRDS</div><div className="value">{fmtEUR(data2042.case_2BH)}</div></div>
|
|
<div className="kpi"><div className="label">Case 2CK — Crédit d'impôt</div><div className="value" style={{ color: 'var(--success)' }}>{fmtEUR(data2042.case_2CK)}</div></div>
|
|
{data2042.case_2TY > 0 && <div className="kpi"><div className="label">Case 2TY — Pertes en capital</div><div className="value danger">{fmtEUR(data2042.case_2TY)}</div></div>}
|
|
</div>
|
|
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
|
|
⚠ Cases indicatives combinant plateformes françaises (IFU automatique) et étrangères. Référez-vous à la notice 2041-GFI.
|
|
</p>
|
|
</div>}
|
|
|
|
{!detailExpanded && !cerfaExpanded && (
|
|
<div className="dr-tabs">
|
|
<button className={`dr-tab${activeTab === '2042' ? ' active' : ''}`} onClick={() => setActiveTab('2042')}>CERFA 2042</button>
|
|
<button className={`dr-tab${activeTab === 'cerfa' ? ' active' : ''}`} onClick={() => setActiveTab('cerfa')}>CERFA 2561 (IFU)</button>
|
|
{pfoAssujetti && <button className={`dr-tab${activeTab === '2778' ? ' active' : ''}`} onClick={() => setActiveTab('2778')}>CERFA 2778-SD</button>}
|
|
</div>
|
|
)}
|
|
|
|
</>
|
|
)}
|
|
|
|
{activeTab === '2042' && !detailExpanded && !cerfaExpanded && data && (
|
|
<Cerfa2042Preview annee={annee} activeView={activeView} pfoAssujetti={pfoAssujetti} />
|
|
)}
|
|
|
|
{(activeTab === 'cerfa' || cerfaExpanded) && !detailExpanded && data && (
|
|
<Cerfa2561Preview
|
|
annee={annee} activeView={activeView} inline
|
|
expanded={cerfaExpanded}
|
|
onToggleExpand={() => { setCerfaExpanded(e => !e); setActiveTab('cerfa'); }}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === '2778' && pfoAssujetti && !detailExpanded && !cerfaExpanded && (
|
|
<Cerfa2778Preview annee={annee} activeView={activeView} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|