feat(fiscal): export/import ZIP référentiel fiscal

- Remplace le dropdown CSV/XLS/JSON de PfuSection par Exporter/Importer ZIP
- GET /api/pfu/export-zip : ZIP avec manifest, pfu.json, taux_credit_impot.json
- POST /api/pfu/import-zip : upsert PFU (par annee) et TCI (par nom_pays/code_pays)
- ResultBanner pour le résultat de l'import dans PfuSection
This commit is contained in:
2026-06-13 20:29:49 +00:00
parent 81a57dc0b2
commit 837b016cb9
2 changed files with 203 additions and 90 deletions
+53 -77
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import ConfirmModal from '../components/ConfirmModal.jsx';
import ResultBanner from '../components/ResultBanner.jsx';
import Modal from '../components/Modal.jsx';
import CountrySelect, { COUNTRIES } from '../components/CountrySelect.jsx';
@@ -790,50 +791,11 @@ function TciPromptBlock({ rows }) {
/* ═══════════════════════════════════════════════════════════════
SECTION PFU — Flat Tax
═══════════════════════════════════════════════════════════════ */
function 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);
function IconDownload() {
return <svg width="13" height="13" 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>;
}
function pfuToCSV(rows) {
const BOM = '\uFEFF'; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const headers = ['Année', 'PFU total (%)', 'IR (%)', 'PS total (%)', 'CSG (%)', 'CRDS (%)', 'Solidarité (%)'];
const data = rows.map(r => [r.annee, r.pfu_total, r.impot_revenu, r.prelev_sociaux, r.csg ?? 9.2, r.crds ?? 0.5, r.solidarite ?? 7.5]);
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function pfuToXLS(rows) {
const esc = v => String(v ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const cell = (v, t = 'String') => `<Cell><Data ss:Type="${t}">${esc(v)}</Data></Cell>`;
const mkRow = cells => ` <Row>${cells.join('')}</Row>`;
const header = mkRow(['Année','PFU total (%)','IR (%)','PS total (%)','CSG (%)','CRDS (%)','Solidarité (%)'].map(h => cell(h)));
const dataRows = rows.map(r => mkRow([
cell(r.annee, 'Number'), cell(r.pfu_total, 'Number'), cell(r.impot_revenu, 'Number'),
cell(r.prelev_sociaux, 'Number'), cell(r.csg ?? 9.2, 'Number'), cell(r.crds ?? 0.5, 'Number'), cell(r.solidarite ?? 7.5, 'Number'),
])).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles><Style ss:ID="h"><Font ss:Bold="1"/></Style></Styles>
<Worksheet ss:Name="Flat Tax PFU">
<Table>
${header}
${dataRows}
</Table>
</Worksheet>
</Workbook>`;
}
function pfuToJSON(rows) {
return JSON.stringify(rows.map(r => ({
annee: r.annee, pfu_total: r.pfu_total, impot_revenu: r.impot_revenu,
prelev_sociaux: r.prelev_sociaux, csg: r.csg ?? 9.2, crds: r.crds ?? 0.5, solidarite: r.solidarite ?? 7.5,
})), null, 2);
function IconUpload() {
return <svg width="13" height="13" 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="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
}
const emptyPfu = { annee: '', impot_revenu: '', csg: '', crds: '', solidarite: '' };
@@ -843,33 +805,6 @@ function fmtPct(v) {
return `${Number(v).toFixed(1).replace('.', ',')} %`;
}
function PfuExportDropdown({ disabled, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
return (
<div style={{ position: 'relative' }}>
<button disabled={disabled} onClick={() => setOpen(o => !o)}
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" 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>
Exporter
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{open && (
<>
<div onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 999 }} />
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 1000, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,.12)', minWidth: 120, overflow: 'hidden' }}>
{[['CSV', onCSV], ['XLS', onXLS], ['JSON', onJSON]].map(([label, fn]) => (
<button key={label} onClick={() => { fn(); setOpen(false); }}
style={{ display: 'block', width: '100%', padding: '9px 14px', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', fontSize: 13 }}>
{label}
</button>
))}
</div>
</>
)}
</div>
);
}
function PfuDetailPanel({ row, onEdit }) {
if (!row) return (
<div className="dr-detail dr-detail-empty">
@@ -917,6 +852,8 @@ function PfuSection() {
const [showNewPfu, setShowNewPfu] = useState(false);
const [confirmDel, setConfirmDel] = useState(null);
const [err, setErr] = useState(null);
const [zipResult, setZipResult] = useState(null);
const zipImportRef = useRef(null);
const load = async () => {
const rows = await api.get('/pfu');
@@ -959,6 +896,36 @@ function PfuSection() {
} catch (e) { setErr(e.message); }
};
const handleExportZip = async () => {
try {
const blob = await api.blob('/pfu/export-zip');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fiscal-referentiel-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setZipResult({ ok: false, msg: e.message }); }
};
const handleImportZip = async (file) => {
if (!file) return;
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/pfu/import-zip', fd);
const pfu = r.pfu?.created || r.pfu?.updated
? `PFU : ${r.pfu.created} créé(s), ${r.pfu.updated} mis à jour`
: null;
const tci = r.tci?.created || r.tci?.updated
? `Crédit d'impôt : ${r.tci.created} créé(s), ${r.tci.updated} mis à jour`
: null;
setZipResult({ ok: true, msg: [pfu, tci].filter(Boolean).join(' ') || 'Import terminé.' });
await load();
} catch (e) { setZipResult({ ok: false, msg: e.message }); }
finally { if (zipImportRef.current) zipImportRef.current.value = ''; }
};
return (
<>
<div className="dr-mouvements-layout">
@@ -972,18 +939,27 @@ function PfuSection() {
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<PfuExportDropdown
disabled={pfuRows.length === 0}
onCSV={() => dlBlob(pfuToCSV(pfuRows), 'flat-tax-pfu.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(pfuToXLS(pfuRows), 'flat-tax-pfu.xls', 'application/vnd.ms-excel')}
onJSON={() => dlBlob(pfuToJSON(pfuRows), 'flat-tax-pfu.json', 'application/json')}
/>
<button
onClick={handleExportZip} disabled={pfuRows.length === 0}
title="Exporter le référentiel fiscal (PFU + crédit d'impôt) en ZIP"
style={{ padding: '7px 14px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 6 }}>
<IconDownload /> Exporter
</button>
<button
onClick={() => zipImportRef.current?.click()}
title="Importer un fichier ZIP de référentiel fiscal"
style={{ padding: '7px 14px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 6 }}>
<IconUpload /> Importer
</button>
<input ref={zipImportRef} type="file" accept=".zip" style={{ display: 'none' }}
onChange={e => handleImportZip(e.target.files?.[0])} />
<button className="primary" onClick={() => { setShowNewPfu(true); setErr(null); }}>
+ Ajouter
</button>
</div>
</div>
{zipResult && <ResultBanner result={zipResult} onDismiss={() => setZipResult(null)} style={{ marginBottom: 12 }} />}
<table>
<thead>
<tr>