Files
crowdlending-app/frontend/src/pages/TaxReport.jsx
T

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} />
)}
</>
);
}