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') => `
-${header}
-${dataRows}
-
-