diff --git a/backend/src/routes/pfu.js b/backend/src/routes/pfu.js index 7daacf0..38ab785 100644 --- a/backend/src/routes/pfu.js +++ b/backend/src/routes/pfu.js @@ -1,15 +1,158 @@ import { Router } from 'express'; +import multer from 'multer'; import db from '../db/index.js'; +import { createZip, readZip } from '../utils/zip.js'; const router = Router(); -/* GET /api/pfu — liste triée par année */ +const zipStorage = multer.memoryStorage(); +const zipUpload = multer({ storage: zipStorage, limits: { fileSize: 5 * 1024 * 1024 } }); + +/* GET /api/pfu */ router.get('/', (req, res) => { const rows = db.prepare('SELECT * FROM taux_pfu ORDER BY annee ASC').all(); res.json(rows); }); -/* POST /api/pfu — ajouter une année */ +/* GET /api/pfu/export-zip — export ZIP fiscal (PFU + crédit d'impôt) */ +router.get('/export-zip', (req, res, next) => { + try { + const pfu = db.prepare('SELECT * FROM taux_pfu ORDER BY annee ASC').all(); + const tci = db.prepare('SELECT * FROM taux_credit_impot ORDER BY nom_pays ASC').all(); + + const manifest = { + version: '1.0', + app: 'crowdlending', + exported_at: new Date().toISOString(), + type: 'fiscal-referentiel', + }; + + const pfuData = pfu.map(r => ({ + annee: r.annee, + impot_revenu: r.impot_revenu, + csg: r.csg, + crds: r.crds, + solidarite: r.solidarite, + })); + + const tciData = tci.map(r => ({ + nom_pays: r.nom_pays, + code_pays: r.code_pays, + div_taux: r.div_taux, + div_taux_alt: r.div_taux_alt, + div_taux_alt_label: r.div_taux_alt_label, + div_exclusif_residence: r.div_exclusif_residence, + int_taux: r.int_taux, + int_taux_alt: r.int_taux_alt, + int_taux_alt_label: r.int_taux_alt_label, + int_exclusif_residence: r.int_exclusif_residence, + notice: r.notice, + statut_convention: r.statut_convention, + date_suspension: r.date_suspension, + ref_boi: r.ref_boi, + })); + + const entries = [ + { name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }, + { name: 'pfu.json', data: JSON.stringify(pfuData, null, 2) }, + { name: 'taux_credit_impot.json', data: JSON.stringify(tciData, null, 2) }, + ]; + + const zipBuf = createZip(entries); + const slug = 'fiscal-referentiel-' + new Date().toISOString().slice(0, 10); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`); + res.send(zipBuf); + } catch (e) { next(e); } +}); + +/* POST /api/pfu/import-zip — import ZIP fiscal (PFU + crédit d'impôt) */ +router.post('/import-zip', zipUpload.single('file'), (req, res, next) => { + try { + if (!req.file) return res.status(400).json({ error: 'Fichier ZIP manquant' }); + const zipEntries = readZip(req.file.buffer); + + const pfuEntry = zipEntries.find(e => e.name === 'pfu.json'); + const tciEntry = zipEntries.find(e => e.name === 'taux_credit_impot.json'); + if (!pfuEntry && !tciEntry) { + return res.status(400).json({ error: 'ZIP invalide : aucun fichier reconnu' }); + } + + let pfuCreated = 0, pfuUpdated = 0; + let tciCreated = 0, tciUpdated = 0; + + const tx = db.transaction(() => { + if (pfuEntry) { + const rows = JSON.parse(pfuEntry.data.toString('utf8')); + for (const r of rows) { + if (!r.annee) continue; + const ps = Number(r.csg || 0) + Number(r.crds || 0) + Number(r.solidarite || 0); + const total = Number(r.impot_revenu || 0) + ps; + const ex = db.prepare('SELECT id FROM taux_pfu WHERE annee = ?').get(Number(r.annee)); + if (ex) { + db.prepare( + `UPDATE taux_pfu SET pfu_total=?, impot_revenu=?, prelev_sociaux=?, csg=?, crds=?, solidarite=?, updated_at=datetime('now') WHERE id=?` + ).run(total, Number(r.impot_revenu), ps, Number(r.csg), Number(r.crds), Number(r.solidarite), ex.id); + pfuUpdated++; + } else { + db.prepare( + 'INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux, csg, crds, solidarite) VALUES (?,?,?,?,?,?,?)' + ).run(Number(r.annee), total, Number(r.impot_revenu), ps, Number(r.csg), Number(r.crds), Number(r.solidarite)); + pfuCreated++; + } + } + } + + if (tciEntry) { + const rows = JSON.parse(tciEntry.data.toString('utf8')); + for (const r of rows) { + if (!r.nom_pays) continue; + const ex = db.prepare( + 'SELECT id FROM taux_credit_impot WHERE LOWER(nom_pays) = LOWER(?) OR (code_pays IS NOT NULL AND code_pays = ?)' + ).get(r.nom_pays, r.code_pays || ''); + const vals = [ + r.nom_pays, r.code_pays || null, + r.div_taux ?? null, r.div_taux_alt ?? null, r.div_taux_alt_label || null, + r.div_exclusif_residence ? 1 : 0, + r.int_taux ?? null, r.int_taux_alt ?? null, r.int_taux_alt_label || null, + r.int_exclusif_residence ? 1 : 0, + r.notice || null, r.statut_convention || 'active', + r.date_suspension || null, r.ref_boi || null, + ]; + if (ex) { + db.prepare( + `UPDATE taux_credit_impot SET + nom_pays=?, code_pays=?, + div_taux=?, div_taux_alt=?, div_taux_alt_label=?, div_exclusif_residence=?, + int_taux=?, int_taux_alt=?, int_taux_alt_label=?, int_exclusif_residence=?, + notice=?, statut_convention=?, date_suspension=?, ref_boi=?, + updated_at=datetime('now') WHERE id=?` + ).run(...vals, ex.id); + tciUpdated++; + } else { + db.prepare( + `INSERT INTO taux_credit_impot + (nom_pays, code_pays, div_taux, div_taux_alt, div_taux_alt_label, div_exclusif_residence, + int_taux, int_taux_alt, int_taux_alt_label, int_exclusif_residence, + notice, statut_convention, date_suspension, ref_boi) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)` + ).run(...vals); + tciCreated++; + } + } + } + }); + tx(); + + res.json({ + ok: true, + pfu: { created: pfuCreated, updated: pfuUpdated }, + tci: { created: tciCreated, updated: tciUpdated }, + }); + } catch (e) { next(e); } +}); + +/* POST /api/pfu */ router.post('/', (req, res) => { const { annee, impot_revenu, csg, crds, solidarite } = req.body; if (!annee || impot_revenu == null || csg == null || crds == null || solidarite == null) { @@ -18,13 +161,9 @@ router.post('/', (req, res) => { const prelev_sociaux = Number(csg) + Number(crds) + Number(solidarite); const pfu_total = Number(impot_revenu) + prelev_sociaux; try { - const stmt = db.prepare( + const result = db.prepare( 'INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux, csg, crds, solidarite) VALUES (?, ?, ?, ?, ?, ?, ?)' - ); - const result = stmt.run( - Number(annee), pfu_total, Number(impot_revenu), prelev_sociaux, - Number(csg), Number(crds), Number(solidarite) - ); + ).run(Number(annee), pfu_total, Number(impot_revenu), prelev_sociaux, Number(csg), Number(crds), Number(solidarite)); res.status(201).json(db.prepare('SELECT * FROM taux_pfu WHERE id = ?').get(result.lastInsertRowid)); } catch (e) { if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: `L'année ${annee} existe déjà.` }); @@ -32,7 +171,7 @@ router.post('/', (req, res) => { } }); -/* PUT /api/pfu/:id — modifier */ +/* PUT /api/pfu/:id */ router.put('/:id', (req, res) => { const { annee, impot_revenu, csg, crds, solidarite } = req.body; if (!annee || impot_revenu == null || csg == null || crds == null || solidarite == null) { @@ -44,10 +183,8 @@ router.put('/:id', (req, res) => { db.prepare( `UPDATE taux_pfu SET annee=?, pfu_total=?, impot_revenu=?, prelev_sociaux=?, csg=?, crds=?, solidarite=?, updated_at=datetime('now') WHERE id=?` - ).run( - Number(annee), pfu_total, Number(impot_revenu), prelev_sociaux, - Number(csg), Number(crds), Number(solidarite), Number(req.params.id) - ); + ).run(Number(annee), pfu_total, Number(impot_revenu), prelev_sociaux, + Number(csg), Number(crds), Number(solidarite), Number(req.params.id)); const row = db.prepare('SELECT * FROM taux_pfu WHERE id = ?').get(Number(req.params.id)); if (!row) return res.status(404).json({ error: 'Introuvable' }); res.json(row); diff --git a/frontend/src/pages/AdminFiscalite.jsx b/frontend/src/pages/AdminFiscalite.jsx index eafefd3..8273267 100644 --- a/frontend/src/pages/AdminFiscalite.jsx +++ b/frontend/src/pages/AdminFiscalite.jsx @@ -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 ; } - -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,'&').replace(//g,'>').replace(/"/g,'"'); - const cell = (v, t = 'String') => `${esc(v)}`; - const mkRow = cells => ` ${cells.join('')}`; - 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 ` - - - - - -${header} -${dataRows} -
-
-
`; -} - -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 ; } 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 ( -
- - {open && ( - <> -
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 999 }} /> -
- {[['CSV', onCSV], ['XLS', onXLS], ['JSON', onJSON]].map(([label, fn]) => ( - - ))} -
- - )} -
- ); -} - function PfuDetailPanel({ row, onEdit }) { if (!row) return (
@@ -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 ( <>
@@ -972,18 +939,27 @@ function PfuSection() {
- 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')} - /> + + + handleImportZip(e.target.files?.[0])} />
+ {zipResult && setZipResult(null)} style={{ marginBottom: 12 }} />}