Files
crowdlending-app/frontend/src/pages/AdminFiscalite.jsx
T
ocroguennec 837b016cb9 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
2026-06-13 20:29:49 +00:00

1171 lines
63 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconTax() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
);
}
function IconChevronLeft() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>;
}
function IconPercent() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/></svg>;
}
/* ═══════════════════════════════════════════════════════════════
HELPERS — taux crédit d'impôt
═══════════════════════════════════════════════════════════════ */
function flagEmoji(code) {
if (!code) return '🌐';
if (code === 'XK') return '🇽🇰';
return code.toUpperCase().replace(/./g, c =>
String.fromCodePoint(c.charCodeAt(0) + 127397)
);
}
function fmtTaux(taux, exclusif) {
if (exclusif) return <span style={{ color: 'var(--text-muted)', fontStyle: 'italic', fontSize: 12 }}>excl. résidence</span>;
if (taux == null) return <span style={{ color: 'var(--text-muted)' }}></span>;
return <>{taux.toFixed(1).replace('.', ',')} %</>;
}
function StatutBadge({ statut }) {
const map = {
active: { label: 'Active', bg: 'rgba(34,197,94,.12)', color: '#16a34a', border: 'rgba(34,197,94,.3)' },
suspendue: { label: 'Suspendue', bg: 'rgba(234,179,8,.12)', color: '#ca8a04', border: 'rgba(234,179,8,.3)' },
caduque: { label: 'Caduque', bg: 'rgba(239,68,68,.12)', color: '#dc2626', border: 'rgba(239,68,68,.3)' },
};
const s = map[statut] || map.active;
return (
<span style={{
display: 'inline-block', padding: '1px 8px', borderRadius: 10,
fontSize: 11, fontWeight: 600,
background: s.bg, color: s.color, border: `1px solid ${s.border}`,
}}>{s.label}</span>
);
}
const EMPTY_TCI = {
nom_pays: '', code_pays: '',
div_taux: '', div_taux_alt: '', div_taux_alt_label: '', div_exclusif_residence: false,
int_taux: '', int_taux_alt: '', int_taux_alt_label: '', int_exclusif_residence: false,
notice: '', statut_convention: 'active', date_suspension: '', ref_boi: '',
};
/* ── Modal création / édition pays ───────────────────────────── */
function TciModal({ row, onClose, onSaved }) {
const isNew = !row?.id;
const [form, setForm] = useState(isNew ? EMPTY_TCI : {
nom_pays: row.nom_pays || '',
code_pays: row.code_pays || '',
div_taux: row.div_taux != null ? String(row.div_taux) : '',
div_taux_alt: row.div_taux_alt != null ? String(row.div_taux_alt) : '',
div_taux_alt_label: row.div_taux_alt_label || '',
div_exclusif_residence: !!row.div_exclusif_residence,
int_taux: row.int_taux != null ? String(row.int_taux) : '',
int_taux_alt: row.int_taux_alt != null ? String(row.int_taux_alt) : '',
int_taux_alt_label: row.int_taux_alt_label || '',
int_exclusif_residence: !!row.int_exclusif_residence,
notice: row.notice || '',
statut_convention: row.statut_convention || 'active',
date_suspension: row.date_suspension || '',
ref_boi: row.ref_boi || '',
});
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.nom_pays.trim()) { setErr('Le nom du pays est requis.'); return; }
setSaving(true); setErr(null);
try {
const payload = {
nom_pays: form.nom_pays.trim(),
code_pays: form.code_pays.trim().toUpperCase() || null,
div_taux: form.div_exclusif_residence ? null : (form.div_taux !== '' ? Number(form.div_taux) : null),
div_taux_alt: form.div_exclusif_residence ? null : (form.div_taux_alt !== '' ? Number(form.div_taux_alt) : null),
div_taux_alt_label: form.div_taux_alt_label.trim() || null,
div_exclusif_residence: form.div_exclusif_residence,
int_taux: form.int_exclusif_residence ? null : (form.int_taux !== '' ? Number(form.int_taux) : null),
int_taux_alt: form.int_exclusif_residence ? null : (form.int_taux_alt !== '' ? Number(form.int_taux_alt) : null),
int_taux_alt_label: form.int_taux_alt_label.trim() || null,
int_exclusif_residence: form.int_exclusif_residence,
notice: form.notice.trim() || null,
statut_convention: form.statut_convention,
date_suspension: form.date_suspension || null,
ref_boi: form.ref_boi.trim() || null,
};
if (isNew) { await api.post('/taux-credit-impot', payload); }
else { await api.put(`/taux-credit-impot/${row.id}`, payload); }
onSaved(); onClose();
} catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
const inp = { width: '100%', boxSizing: 'border-box' };
const grid = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 };
return (
<Modal open title={isNew ? 'Ajouter un pays' : `Modifier — ${row.nom_pays}`}
onClose={onClose} width={680}
footer={<>
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
<button className="primary" form="tci-form" type="submit" disabled={saving}>
{saving ? '…' : isNew ? 'Créer' : 'Enregistrer'}
</button>
</>}>
<form id="tci-form" onSubmit={handleSubmit}>
<div style={grid}>
<div><label>Nom du pays *</label><input style={inp} value={form.nom_pays} onChange={e => set('nom_pays', e.target.value)} /></div>
<div>
<label>Code ISO alpha-2</label>
<CountrySelect
showCode
value={form.code_pays || null}
onChange={code => {
const country = COUNTRIES.find(c => c.code === code);
setForm(f => ({
...f,
code_pays: code,
nom_pays: f.nom_pays.trim() ? f.nom_pays : (country?.name || f.nom_pays),
}));
}}
/>
</div>
</div>
<div style={{ ...grid, marginTop: 14 }}>
<div>
<label>Statut convention</label>
<select style={inp} value={form.statut_convention} onChange={e => set('statut_convention', e.target.value)}>
<option value="active">Active</option>
<option value="suspendue">Suspendue</option>
<option value="caduque">Caduque</option>
</select>
</div>
<div><label>Date suspension / caducité</label><input type="date" style={inp} value={form.date_suspension} onChange={e => set('date_suspension', e.target.value)} /></div>
</div>
<div style={{ marginTop: 10 }}>
<label>Référence BOI</label>
<input style={inp} value={form.ref_boi} onChange={e => set('ref_boi', e.target.value)} placeholder="Ex : BOI-INT-CVB-DEU" />
</div>
{['div', 'int'].map(cat => {
const excl = form[`${cat}_exclusif_residence`];
return (
<fieldset key={cat} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginTop: 14, minWidth: 0 }}>
<legend style={{ fontSize: 12, fontWeight: 600, padding: '0 6px', color: 'var(--text-muted)' }}>
{cat === 'div' ? 'Dividendes' : 'Intérêts'}
</legend>
<div style={{ marginBottom: 10, cursor: 'pointer' }} onClick={() => set(`${cat}_exclusif_residence`, !excl)}>
<input type="checkbox" checked={excl} onChange={() => {}} style={{ width: 'auto', verticalAlign: 'middle', marginRight: 15, pointerEvents: 'none' }} />
<span style={{ fontSize: 13, verticalAlign: 'middle', color: 'var(--text)' }}>
Imposable exclusivement au lieu de résidence aucun crédit d'impôt
</span>
</div>
{!excl && (
<div style={grid}>
<div><label>Taux principal (%)</label><input type="number" step="0.1" min="0" max="100" style={inp} value={form[`${cat}_taux`]} onChange={e => set(`${cat}_taux`, e.target.value)} placeholder="Ex : 17.6" /></div>
<div><label>Taux alternatif (%)</label><input type="number" step="0.1" min="0" max="100" style={inp} value={form[`${cat}_taux_alt`]} onChange={e => set(`${cat}_taux_alt`, e.target.value)} placeholder="Optionnel" /></div>
{form[`${cat}_taux_alt`] !== '' && (
<div style={{ gridColumn: '1 / -1' }}>
<label>Condition du taux alternatif</label>
<input style={inp} value={form[`${cat}_taux_alt_label`]} onChange={e => set(`${cat}_taux_alt_label`, e.target.value)} placeholder="Ex : Société exonérée IS" />
</div>
)}
</div>
)}
</fieldset>
);
})}
<div style={{ marginTop: 14 }}>
<label>Notice / renseignements spécifiques</label>
<textarea style={{ ...inp, minHeight: 80, resize: 'vertical' }} value={form.notice} onChange={e => set('notice', e.target.value)} placeholder="Conditions particulières, formules de calcul…" />
</div>
{err && <div className="error" style={{ marginTop: 10 }}>{err}</div>}
</form>
</Modal>
);
}
/* ── Section principale ───────────────────────────────────────── */
function TauxCreditImpotSection() {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('tous');
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
const [openMenu, setOpenMenu] = useState(null);
const openRowMenu = (e, row) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ row, x: rect.right, y: rect.bottom });
};
useEffect(() => {
if (!openMenu) return;
const close = () => setOpenMenu(null);
window.addEventListener('scroll', close, true);
return () => window.removeEventListener('scroll', close, true);
}, [openMenu]);
const load = useCallback(async () => {
try { setLoading(true); const data = await api.get('/taux-credit-impot'); setRows(data); }
catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleDelete = (row) => {
setDeleting({
title: 'Supprimer ce pays ?',
message: `Supprimer ${row.nom_pays} du référentiel ? Cette action est irréversible.`,
confirmLabel: 'Supprimer', danger: true,
onConfirm: async () => {
try { await api.del(`/taux-credit-impot/${row.id}`); load(); }
catch (e) { setErr(e.message); }
finally { setDeleting(null); }
},
});
};
const visible = rows.filter(r => {
const q = search.toLowerCase();
return (!q || r.nom_pays.toLowerCase().includes(q) || (r.code_pays || '').toLowerCase().includes(q))
&& (filter === 'tous' || r.statut_convention === filter);
});
const counts = {
tous: rows.length,
active: rows.filter(r => r.statut_convention === 'active').length,
suspendue: rows.filter(r => r.statut_convention === 'suspendue').length,
caduque: rows.filter(r => r.statut_convention === 'caduque').length,
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>2047 — Taux crédit d'impôts</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Référentiel des taux de crédit d'impôt sur dividendes et intérêts étrangers (notice DGFiP 2047).
</p>
</div>
<button className="primary" style={{ flexShrink: 0 }} onClick={() => setEditing('new')}>+ Ajouter un pays</button>
</div>
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
<input placeholder="Rechercher un pays ou un code ISO…" value={search} onChange={e => setSearch(e.target.value)} style={{ flex: 1, minWidth: 220 }} />
<div className="dr-tabs" style={{ flexShrink: 0 }}>
{[
{ key: 'tous', label: `Tous (${counts.tous})` },
{ key: 'active', label: `Actives (${counts.active})` },
{ key: 'suspendue', label: `Suspendues (${counts.suspendue})` },
{ key: 'caduque', label: `Caduques (${counts.caduque})` },
].map(({ key, label }) => (
<button key={key} className={`dr-tab${filter === key ? ' active' : ''}`} onClick={() => setFilter(key)}>{label}</button>
))}
</div>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{loading ? (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 16px', width: 36 }}></th>
<th style={{ padding: '10px 8px', textAlign: 'left', fontWeight: 600 }}>Pays</th>
<th style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 600 }}>Convention</th>
<th style={{ padding: '10px 8px', textAlign: 'right', fontWeight: 600 }}>Dividendes</th>
<th style={{ padding: '10px 8px', textAlign: 'right', fontWeight: 600 }}>Intérêts</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{visible.length === 0 ? (
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
{search ? 'Aucun pays correspondant.' : 'Aucune donnée.'}
</td></tr>
) : visible.map((row, i) => (
<tr key={row.id} style={{
borderBottom: i < visible.length - 1 ? '1px solid var(--border)' : 'none',
background: i % 2 === 0 ? 'transparent' : 'rgba(0,0,0,.018)',
}}>
<td style={{ padding: '8px 16px', fontSize: 20, lineHeight: 1 }}>{flagEmoji(row.code_pays)}</td>
<td style={{ padding: '8px 8px' }}>
<div style={{ fontWeight: 500 }}>{row.nom_pays}</div>
{row.code_pays && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{row.code_pays}</div>}
</td>
<td style={{ padding: '8px 8px', textAlign: 'center' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<StatutBadge statut={row.statut_convention} />
{row.notice && <span title={row.notice} style={{ cursor: 'help', color: 'var(--text-muted)', fontSize: 16, lineHeight: 1 }}></span>}
</div>
{row.date_suspension && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>depuis {new Date(row.date_suspension).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}</div>}
{row.ref_boi && <div style={{ fontSize: 10, color: 'var(--primary)', marginTop: 1 }}>{row.ref_boi}</div>}
</td>
<td style={{ padding: '8px 8px', textAlign: 'right' }}>
<div style={{ fontWeight: 500 }}>{fmtTaux(row.div_taux, row.div_exclusif_residence)}</div>
{!row.div_exclusif_residence && row.div_taux_alt != null && (
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
ou {row.div_taux_alt.toFixed(1).replace('.', ',')} %
{row.div_taux_alt_label && <span style={{ display: 'block' }}>{row.div_taux_alt_label}</span>}
</div>
)}
</td>
<td style={{ padding: '8px 8px', textAlign: 'right' }}>
<div style={{ fontWeight: 500 }}>{fmtTaux(row.int_taux, row.int_exclusif_residence)}</div>
{!row.int_exclusif_residence && row.int_taux_alt != null && (
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
ou {row.int_taux_alt.toFixed(1).replace('.', ',')} %
{row.int_taux_alt_label && <span style={{ display: 'block' }}>{row.int_taux_alt_label}</span>}
</div>
)}
</td>
<td style={{ padding: '8px 16px', textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={e => openRowMenu(e, row)}
>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', flexWrap: 'wrap', gap: 8 }}>
<span>{visible.length} / {rows.length} pays · Source : notice DGFiP 2047</span>
<span><em>excl. résidence</em> — revenus imposables exclusivement dans le pays de résidence du bénéficiaire, aucun crédit d'impôt français applicable</span>
</div>
<TciImportBlock rows={rows} onApplied={load} />
<TciPromptBlock rows={rows} />
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{
position: 'fixed', left: openMenu.x, top: openMenu.y,
transform: 'translateX(-100%) translateY(4px)',
zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: '4px 0', minWidth: 140,
}}>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setOpenMenu(null); setEditing(openMenu.row); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Modifier
</button>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setOpenMenu(null); handleDelete(openMenu.row); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Supprimer
</button>
</div>
</>
)}
{editing && <TciModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onClose={() => setDeleting(null)} />}
</div>
);
}
/* ── Helpers diff ────────────────────────────────────────────── */
const CHAMP_LABELS = {
div_taux: 'Div. taux principal',
div_taux_alt: 'Div. taux alternatif',
div_taux_alt_label: 'Div. cond. taux alt.',
div_exclusif_residence:'Div. excl. résidence',
int_taux: 'Int. taux principal',
int_taux_alt: 'Int. taux alternatif',
int_taux_alt_label: 'Int. cond. taux alt.',
int_exclusif_residence:'Int. excl. résidence',
notice: 'Notice',
statut_convention: 'Statut convention',
date_suspension: 'Date suspension',
ref_boi: 'Référence BOI',
nom_pays: 'Nom du pays',
code_pays: 'Code ISO',
};
function fmtVal(v) {
if (v === null || v === undefined) return <em style={{ color: 'var(--text-muted)' }}>vide</em>;
if (v === true || v === 1) return <span style={{ color: 'var(--success)' }}>Oui</span>;
if (v === false || v === 0) return <span style={{ color: 'var(--text-muted)' }}>Non</span>;
if (typeof v === 'number') return v + ' %';
return String(v);
}
function DiffRow({ label, before, after }) {
const changed = String(before ?? '') !== String(after ?? '');
return (
<tr style={{ background: changed ? 'rgba(234,179,8,.06)' : 'transparent' }}>
<td style={{ padding: '3px 8px', fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{label}</td>
<td style={{ padding: '3px 8px', fontSize: 12, textDecoration: changed ? 'line-through' : 'none', color: changed ? 'var(--danger)' : 'var(--text)' }}>{fmtVal(before)}</td>
<td style={{ padding: '3px 8px', fontSize: 12, fontWeight: changed ? 600 : 400, color: changed ? 'var(--success)' : 'var(--text)' }}>{fmtVal(after)}</td>
</tr>
);
}
/* ── Composant import IA ─────────────────────────────────────── */
function TciImportBlock({ rows, onApplied }) {
const [open, setOpen] = useState(false);
const [json, setJson] = useState('');
const [ops, setOps] = useState(null);
const [parseErr, setParseErr] = useState(null);
const [selected, setSelected] = useState({});
const [applying, setApplying] = useState(false);
const [result, setResult] = useState(null);
const rowsByCode = Object.fromEntries(rows.map(r => [(r.code_pays || '').toUpperCase(), r]));
const rowsByName = Object.fromEntries(rows.map(r => [r.nom_pays.toLowerCase(), r]));
const findExisting = (op) =>
rowsByCode[(op.code_pays || '').toUpperCase()] ||
rowsByName[(op.nom_pays || '').toLowerCase()] || null;
const parse = () => {
setParseErr(null); setOps(null); setSelected({}); setResult(null);
let parsed;
try {
const cleaned = json.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
parsed = JSON.parse(cleaned);
} catch (e) { setParseErr('JSON invalide : ' + e.message); return; }
if (!Array.isArray(parsed)) { setParseErr("Le JSON doit être un tableau d'opérations."); return; }
if (parsed.length === 0) { setParseErr('Aucune opération dans ce tableau.'); return; }
const enriched = parsed.map((op, i) => ({ ...op, _id: i, _existing: findExisting(op) }));
setOps(enriched);
const sel = {};
enriched.forEach(op => { if (op.operation !== 'verify_removal') sel[op._id] = true; });
setSelected(sel);
};
const toggle = (id) => setSelected(s => ({ ...s, [id]: !s[id] }));
const toggleAll = (val) => {
const sel = {};
(ops || []).forEach(op => { if (op.operation !== 'verify_removal') sel[op._id] = val; });
setSelected(sel);
};
const apply = async () => {
setApplying(true); setResult(null);
const toApply = (ops || []).filter(op => selected[op._id] && op.operation !== 'verify_removal');
const ok = []; const errors = [];
for (const op of toApply) {
try {
if (op.operation === 'add') {
await api.post('/taux-credit-impot', {
nom_pays: op.nom_pays, code_pays: op.code_pays || null,
div_exclusif_residence: false, int_exclusif_residence: false,
statut_convention: 'active', ...(op.champs_modifies || {}),
});
ok.push({ nom: op.nom_pays, op: 'ajouté' });
} else if (op.operation === 'update' && op._existing) {
const e = op._existing;
await api.put('/taux-credit-impot/' + e.id, {
nom_pays: e.nom_pays, code_pays: e.code_pays,
div_taux: e.div_taux, div_taux_alt: e.div_taux_alt, div_taux_alt_label: e.div_taux_alt_label, div_exclusif_residence: e.div_exclusif_residence,
int_taux: e.int_taux, int_taux_alt: e.int_taux_alt, int_taux_alt_label: e.int_taux_alt_label, int_exclusif_residence: e.int_exclusif_residence,
notice: e.notice, statut_convention: e.statut_convention, date_suspension: e.date_suspension, ref_boi: e.ref_boi,
...(op.champs_modifies || {}),
});
ok.push({ nom: op.nom_pays, op: 'modifié' });
}
} catch (ex) { errors.push({ nom: op.nom_pays, err: ex.message }); }
}
setResult({ ok, errors });
setApplying(false);
if (ok.length > 0) { onApplied(); setOps(null); setJson(''); setSelected({}); }
};
const nbSelectable = (ops || []).filter(op => op.operation !== 'verify_removal').length;
const nbSelected = Object.values(selected).filter(Boolean).length;
const opColor = { add: '#16a34a', update: '#2563eb', verify_removal: '#ca8a04' };
const opLabel = { add: '+ Nouveau', update: '✎ Modifier', verify_removal: '? À vérifier' };
return (
<div style={{ marginTop: 12, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '12px 16px', background: 'var(--surface-2)',
border: 'none', cursor: 'pointer', textAlign: 'left',
borderBottom: open ? '1px solid var(--border)' : 'none',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<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>
Importer les mises à jour IA
{ops && <span style={{ marginLeft: 6, fontSize: 11, fontWeight: 400, color: 'var(--text-muted)' }}> {ops.length} opération{ops.length > 1 ? 's' : ''} analysée{ops.length > 1 ? 's' : ''}</span>}
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{open && (
<div style={{ padding: 16 }}>
{!ops ? (
<>
<p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--text-muted)' }}>
Collez ici le JSON retourné par l'IA (avec ou sans bloc <code>```json```</code>).
</p>
<textarea value={json} onChange={e => { setJson(e.target.value); setParseErr(null); }}
placeholder={'[\n {\n "operation": "update",\n "nom_pays": "Allemagne",\n "code_pays": "DE",\n "champs_modifies": { "int_taux": 12.5 },\n "raison": "Taux mis à jour"\n }\n]'}
style={{ width: '100%', minHeight: 160, fontFamily: 'monospace', fontSize: 12, boxSizing: 'border-box',
resize: 'vertical', background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, padding: 10, color: 'var(--text)' }} />
{parseErr && <div className="error" style={{ marginTop: 8 }}>{parseErr}</div>}
<button className="primary" onClick={parse} disabled={!json.trim()} style={{ marginTop: 10 }}>
Analyser les changements →
</button>
</>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13 }}><strong>{nbSelected}</strong> / {nbSelectable} opération{nbSelectable > 1 ? 's' : ''} sélectionnée{nbSelected > 1 ? 's' : ''}</span>
<button style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => toggleAll(true)}>Tout sélectionner</button>
<button style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => toggleAll(false)}>Tout désélectionner</button>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => { setOps(null); setJson(''); setSelected({}); setResult(null); }}>← Recommencer</button>
<button className="primary" style={{ fontSize: 12, padding: '6px 14px' }} disabled={nbSelected === 0 || applying} onClick={apply}>
{applying ? 'Application' : `Appliquer (${nbSelected})`}
</button>
</div>
</div>
{result && (
<div style={{ marginBottom: 14 }}>
{result.ok.length > 0 && <div className="success-msg" style={{ marginBottom: 6 }}>✔ {result.ok.length} opération{result.ok.length > 1 ? 's' : ''} appliquée{result.ok.length > 1 ? 's' : ''} : {result.ok.map(r => `${r.nom} (${r.op})`).join(', ')}</div>}
{result.errors.length > 0 && <div className="error">✗ Erreurs : {result.errors.map(r => `${r.nom} — ${r.err}`).join(' | ')}</div>}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{ops.map(op => {
const isVerify = op.operation === 'verify_removal';
const existing = op._existing;
const color = opColor[op.operation] || '#64748b';
const changedFields = Object.keys(op.champs_modifies || {});
return (
<div key={op._id} style={{ border: `1px solid ${selected[op._id] ? color : 'var(--border)'}`, borderRadius: 8, overflow: 'hidden', opacity: isVerify ? 0.8 : 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', background: selected[op._id] ? `${color}10` : 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
{!isVerify && <input type="checkbox" checked={!!selected[op._id]} onChange={() => toggle(op._id)} style={{ cursor: 'pointer', flexShrink: 0 }} />}
<span style={{ fontSize: 18, lineHeight: 1 }}>{flagEmoji(op.code_pays)}</span>
<span style={{ fontWeight: 600, fontSize: 13 }}>{op.nom_pays}</span>
{op.code_pays && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{op.code_pays}</span>}
<span style={{ marginLeft: 4, fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 10, background: `${color}18`, color }}>{opLabel[op.operation]}</span>
{op.operation === 'update' && !existing && <span style={{ fontSize: 11, color: 'var(--danger)' }}>⚠ Pays introuvable en base</span>}
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)', fontStyle: 'italic' }}>{op.raison}</span>
</div>
<div style={{ padding: '8px 12px' }}>
{isVerify ? (
<p style={{ margin: 0, fontSize: 12, color: 'var(--text-muted)' }}>Ce pays figure en base mais n'apparaît plus dans la notice. Vérifiez manuellement si l'entrée doit être supprimée ou conservée.</p>
) : op.operation === 'add' ? (
<table style={{ fontSize: 12, borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ padding: '2px 8px', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 500 }}>Champ</th>
<th style={{ padding: '2px 8px', textAlign: 'left', color: '#16a34a', fontWeight: 600 }}>Valeur</th>
</tr></thead>
<tbody>{Object.entries(op.champs_modifies || {}).map(([k, v]) => (
<tr key={k}>
<td style={{ padding: '2px 8px', color: 'var(--text-muted)' }}>{CHAMP_LABELS[k] || k}</td>
<td style={{ padding: '2px 8px', color: '#16a34a', fontWeight: 600 }}>{fmtVal(v)}</td>
</tr>
))}</tbody>
</table>
) : (
<table style={{ fontSize: 12, borderCollapse: 'collapse', width: '100%' }}>
<thead><tr>
<th style={{ padding: '2px 8px', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 500, width: 180 }}>Champ</th>
<th style={{ padding: '2px 8px', textAlign: 'left', color: 'var(--danger)', fontWeight: 600 }}>Avant</th>
<th style={{ padding: '2px 8px', textAlign: 'left', color: '#2563eb', fontWeight: 600 }}>Après</th>
</tr></thead>
<tbody>{changedFields.map(k => (
<DiffRow key={k} label={CHAMP_LABELS[k] || k} before={existing ? existing[k] : undefined} after={(op.champs_modifies || {})[k]} />
))}</tbody>
</table>
)}
</div>
</div>
);
})}
</div>
</>
)}
</div>
)}
</div>
);
}
/* ── Prompt IA par défaut ─────────────────────────────────────── */
const DEFAULT_PROMPT = `Tu es un expert fiscal. Je vais te fournir les pages 5 et 6 de la notice DGFiP 2047 (taux applicables aux revenus nets de l'impôt prélevé à la source). Ta mission est de comparer le contenu de cette notice avec les données actuellement en base et de me proposer les mises à jour nécessaires.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DONNÉES ACTUELLES EN BASE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{{CURRENT_DATA}}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MODÈLE DE STOCKAGE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Chaque pays est stocké avec les champs suivants :
- nom_pays : nom français du pays (ex : "Allemagne")
- code_pays : code ISO 3166-1 alpha-2 (ex : "DE"). Kosovo = "XK".
- div_taux : taux de crédit d'impôt principal sur dividendes en % (ex : 17.6). null si formule complexe ou exclusif_residence.
- div_taux_alt : taux alternatif sur dividendes. Utilisé quand deux taux s'appliquent selon des conditions différentes.
- div_taux_alt_label : courte description de la condition du taux alternatif.
- div_exclusif_residence : booléen. Vaut 1 (true) quand la notice indique "c/" pour les dividendes imposables EXCLUSIVEMENT au lieu de résidence, AUCUN crédit d'impôt en France.
- int_taux : taux de crédit d'impôt principal sur intérêts en %.
- int_taux_alt : taux alternatif sur intérêts.
- int_taux_alt_label : condition du taux alternatif intérêts.
- int_exclusif_residence : booléen. Même logique pour les intérêts.
- notice : texte libre reprenant les renseignements spécifiques (formules particulières, plafonds, conditions). Null si aucune particularité.
- statut_convention : "active" | "suspendue" | "caduque".
- date_suspension : date ISO 8601 de suspension ou caducité (ex : "2024-06-01").
- ref_boi : référence doctrine BOFiP (ex : "BOI-INT-CVB-BFA").
Règles d'interprétation de la notice DGFiP 2047 :
- "c/" = exclusif_residence true (aucun CI) pour la catégorie concernée.
- Deux taux listés (ex : "div. 22 % ou 17,6 %") : le plus courant = div_taux, l'autre = div_taux_alt avec label explicatif.
- Les taux sont en % du montant net après déduction de l'impôt étranger.
- Formule complexe sans taux fixe : taux = null, formule dans notice.
- Conventions suspendues ou caduques : statut_convention à jour, date et ref_boi renseignées.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
INSTRUCTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Lis attentivement les pages 5 et 6 de la notice 2047 fournie.
2. Pour chaque pays dans la notice, compare avec les données en base.
3. Identifie :
a. Pays dans la notice mais ABSENTS de la base (opération "add").
b. Pays dont les taux, statuts ou notices ont CHANGÉ (opération "update").
c. Pays en base dont la convention est devenue suspendue ou caduque (opération "update").
d. Pays en base qui N'APPARAISSENT PLUS dans la notice (opération "verify_removal").
4. Retourne UNIQUEMENT un tableau JSON avec ce format exact :
[
{
"operation": "update",
"nom_pays": "Allemagne",
"code_pays": "DE",
"champs_modifies": {
"int_taux": 12.5
},
"raison": "Taux intérêts porté de 11,1 % à 12,5 %"
}
]
5. Pour "update" : ne liste dans "champs_modifies" QUE les champs qui changent.
6. Si aucune modification n'est nécessaire pour un pays, ne l'inclus pas.
7. Termine par un résumé : X ajoutés / Y modifiés / Z à vérifier.
Fournis-moi maintenant les pages 5 et 6 de la notice 2047.`;
/* ── Bloc prompt IA ───────────────────────────────────────────── */
function TciPromptBlock({ rows }) {
const LS_KEY = 'cl_tci_prompt';
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [prompt, setPrompt] = useState(() => localStorage.getItem(LS_KEY) || DEFAULT_PROMPT);
const [draft, setDraft] = useState('');
const [copied, setCopied] = useState(false);
const resolvedPrompt = () => {
const lines = rows.map(r => {
const d = r.div_exclusif_residence ? 'div: excl.résidence'
: 'div: ' + (r.div_taux != null ? r.div_taux + '%' : '—') + (r.div_taux_alt != null ? ' ou ' + r.div_taux_alt + '%' : '');
const it = r.int_exclusif_residence ? 'int: excl.résidence'
: 'int: ' + (r.int_taux != null ? r.int_taux + '%' : '—') + (r.int_taux_alt != null ? ' ou ' + r.int_taux_alt + '%' : '');
const conv = r.statut_convention !== 'active' ? ` [${r.statut_convention.toUpperCase()} ${r.date_suspension || ''}]` : '';
return `- ${r.nom_pays} (${r.code_pays || '?'}) : ${d} | ${it}${conv}`;
}).join('\n');
return prompt.replace('{{CURRENT_DATA}}', lines);
};
const startEdit = () => { setDraft(prompt); setEditing(true); };
const saveEdit = () => { setPrompt(draft); localStorage.setItem(LS_KEY, draft); setEditing(false); };
const reset = () => { setPrompt(DEFAULT_PROMPT); localStorage.removeItem(LS_KEY); setEditing(false); };
const copy = async () => {
try { await navigator.clipboard.writeText(resolvedPrompt()); }
catch { const t = document.createElement('textarea'); t.value = resolvedPrompt(); document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); }
setCopied(true); setTimeout(() => setCopied(false), 2000);
};
return (
<div style={{ marginTop: 12, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '12px 16px', background: 'var(--surface-2)',
border: 'none', cursor: 'pointer', textAlign: 'left',
borderBottom: open ? '1px solid var(--border)' : 'none',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600, fontSize: 13 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-1H1a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"/>
<circle cx="7.5" cy="14.5" r="1.5"/><circle cx="16.5" cy="14.5" r="1.5"/>
</svg>
PROMPT IA — Mise à jour annuelle des taux 2047
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{open && (
<div style={{ padding: 16 }}>
<p style={{ margin: '0 0 12px', fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
Ce prompt contient le modèle de stockage et les données actuelles ({rows.length} pays).
Le marqueur <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>{'{{CURRENT_DATA}}'}</code> est remplacé en temps réel par l'export de la base.
</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button className="primary" onClick={copy} style={{ fontSize: 12, padding: '6px 14px' }}>
{copied ? '✓ Copie !' : '📋 Copier le prompt (avec données)'}
</button>
{!editing ? (
<button onClick={startEdit} style={{ fontSize: 12, padding: '6px 14px' }}>✏️ Modifier</button>
) : (
<>
<button className="primary" onClick={saveEdit} style={{ fontSize: 12, padding: '6px 14px' }}>Enregistrer</button>
<button onClick={() => setEditing(false)} style={{ fontSize: 12, padding: '6px 14px' }}>Annuler</button>
<button onClick={reset} style={{ fontSize: 12, padding: '6px 14px', color: 'var(--text-muted)', marginLeft: 'auto' }}>Remettre par défaut</button>
</>
)}
</div>
{editing ? (
<textarea value={draft} onChange={e => setDraft(e.target.value)}
style={{ width: '100%', minHeight: 400, fontFamily: 'monospace', fontSize: 12, lineHeight: 1.5,
resize: 'vertical', boxSizing: 'border-box', background: 'var(--surface-2)',
border: '1px solid var(--border)', borderRadius: 6, padding: 12, color: 'var(--text)' }} />
) : (
<pre style={{ margin: 0, padding: 14, background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, fontSize: 12, fontFamily: 'monospace', lineHeight: 1.6,
whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--text)', maxHeight: 400, overflowY: 'auto' }}>
{resolvedPrompt()}
</pre>
)}
</div>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
SECTION PFU — Flat Tax
═══════════════════════════════════════════════════════════════ */
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 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: '' };
function fmtPct(v) {
if (v == null) return '—';
return `${Number(v).toFixed(1).replace('.', ',')} %`;
}
function PfuDetailPanel({ row, onEdit }) {
if (!row) return (
<div className="dr-detail dr-detail-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
style={{ opacity: 0.25, marginBottom: 8 }}>
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
<span>Sélectionnez une année</span>
</div>
);
const fields = [
{ label: 'Année', value: <strong>{row.annee}</strong> },
{ label: 'PFU total', value: <strong style={{ color: 'var(--primary)' }}>{fmtPct(row.pfu_total)}</strong> },
{ label: 'Impôt sur le revenu', value: fmtPct(row.impot_revenu) },
{ label: 'Prélèvements sociaux', value: fmtPct(row.prelev_sociaux) },
{ label: '└> CSG', value: fmtPct(row.csg ?? 9.2) },
{ label: '└> CRDS', value: fmtPct(row.crds ?? 0.5) },
{ label: '└> Solidarité', value: fmtPct(row.solidarite ?? 7.5) },
];
return (
<div className="dr-detail">
<div className="dr-detail-title">Taux — {row.annee}</div>
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
<span className="dr-detail-label">{f.label}</span>
<span className="dr-detail-value">{f.value}</span>
</div>
))}
</div>
<div className="dr-detail-footer">
<button className="dr-detail-edit-btn" onClick={() => onEdit(row)}>Modifier</button>
</div>
</div>
);
}
function PfuSection() {
const [pfuRows, setPfuRows] = useState([]);
const [selectedPfu, setSelectedPfu] = useState(null);
const [editPfu, setEditPfu] = useState(null);
const [newPfu, setNewPfu] = useState(emptyPfu);
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');
setPfuRows(rows);
setSelectedPfu(prev => prev ? (rows.find(r => r.id === prev.id) ?? rows[0] ?? null) : (rows[0] ?? null));
};
useEffect(() => { load(); }, []);
const savePfu = async (e) => {
e.preventDefault(); setErr(null);
try {
if (editPfu.id) { await api.put(`/pfu/${editPfu.id}`, editPfu); }
else { await api.post('/pfu', editPfu); }
setEditPfu(null);
await load();
} catch (e) { setErr(e.message); }
};
const delPfu = (id) => {
setConfirmDel({
message: 'Supprimer cette année ?',
onConfirm: async () => {
try {
await api.del(`/pfu/${id}`);
if (selectedPfu?.id === id) setSelectedPfu(null);
await load();
} catch (e) { setErr(e.message); }
finally { setConfirmDel(null); }
},
});
};
const addNewPfu = async (e) => {
e.preventDefault(); setErr(null);
try {
await api.post('/pfu', newPfu);
setNewPfu(emptyPfu);
setShowNewPfu(false);
await load();
} 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">
{/* Colonne gauche — liste */}
<div className="dr-mouvements-list">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 0 12px' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 'var(--fs-md)' }}>Flat Tax (PFU)</div>
<div className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginTop: 2 }}>
Taux de référence pour les calculs fiscaux
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<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>
<th>Année</th>
<th className="num">PFU total</th>
<th className="num">Impôt sur le revenu</th>
<th className="num">Prélèvements sociaux</th>
<th className="num" style={{ background: 'rgba(99,102,241,0.06)' }}>└&gt; dont CSG</th>
<th className="num" style={{ background: 'rgba(99,102,241,0.06)' }}>└&gt; dont CRDS</th>
<th className="num" style={{ background: 'rgba(99,102,241,0.06)' }}>└&gt; dont Solidarité</th>
</tr>
</thead>
<tbody>
{pfuRows.length === 0 && (
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune donnée</td></tr>
)}
{pfuRows.map(r => (
<tr key={r.id}
className={`dr-row${selectedPfu?.id === r.id ? ' dr-row-selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setSelectedPfu(selectedPfu?.id === r.id ? null : r)}>
<td><strong>{r.annee}</strong></td>
<td className="num" style={{ color: 'var(--primary)', fontWeight: 600 }}>{fmtPct(r.pfu_total)}</td>
<td className="num">{fmtPct(r.impot_revenu)}</td>
<td className="num">{fmtPct(r.prelev_sociaux)}</td>
<td className="num" style={{ background: 'rgba(99,102,241,0.06)', color: 'var(--text-muted)' }}>{fmtPct(r.csg ?? 9.2)}</td>
<td className="num" style={{ background: 'rgba(99,102,241,0.06)', color: 'var(--text-muted)' }}>{fmtPct(r.crds ?? 0.5)}</td>
<td className="num" style={{ background: 'rgba(99,102,241,0.06)', color: 'var(--text-muted)' }}>{fmtPct(r.solidarite ?? 7.5)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Colonne droite — détail */}
<div className="dr-mouvements-detail">
<PfuDetailPanel
row={selectedPfu}
onEdit={r => { setEditPfu({ ...r }); setErr(null); }}
/>
</div>
</div>
{/* Modal ajout */}
<Modal
open={showNewPfu}
title="Ajouter une année PFU"
onClose={() => { setShowNewPfu(false); setNewPfu(emptyPfu); }}
footer={
<>
<button className="ghost" onClick={() => { setShowNewPfu(false); setNewPfu(emptyPfu); }}>Annuler</button>
<button className="primary" form="form-new-pfu" type="submit">Ajouter</button>
</>
}
>
<form id="form-new-pfu" onSubmit={addNewPfu} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Année *</label>
<input type="number" required min="2000" max="2100" value={newPfu.annee}
onChange={e => setNewPfu({ ...newPfu, annee: e.target.value })} placeholder="2027" />
</div>
<div>
<label>IR — Prélèvement forfaitaire (%) *</label>
<input type="number" required step="0.1" min="0" max="100" value={newPfu.impot_revenu}
onChange={e => setNewPfu({ ...newPfu, impot_revenu: e.target.value })} placeholder="12.8" />
</div>
<div>
<label>CSG (%) *</label>
<input type="number" required step="0.1" min="0" max="100" value={newPfu.csg}
onChange={e => setNewPfu({ ...newPfu, csg: e.target.value })} placeholder="9.2" />
</div>
<div>
<label>CRDS (%) *</label>
<input type="number" required step="0.1" min="0" max="100" value={newPfu.crds}
onChange={e => setNewPfu({ ...newPfu, crds: e.target.value })} placeholder="0.5" />
</div>
<div>
<label>Prélèvement solidarité (%) *</label>
<input type="number" required step="0.1" min="0" max="100" value={newPfu.solidarite}
onChange={e => setNewPfu({ ...newPfu, solidarite: e.target.value })} placeholder="7.5" />
</div>
</div>
{(newPfu.impot_revenu || newPfu.csg || newPfu.crds || newPfu.solidarite) && (
<div style={{ padding: '8px 12px', background: 'var(--surface-2)', borderRadius: 8, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
PS total = {(+(newPfu.csg||0) + +(newPfu.crds||0) + +(newPfu.solidarite||0)).toFixed(1)} %
&nbsp;·&nbsp;
PFU total = {(+(newPfu.impot_revenu||0) + +(newPfu.csg||0) + +(newPfu.crds||0) + +(newPfu.solidarite||0)).toFixed(1)} %
</div>
)}
</form>
</Modal>
{/* Modal édition */}
<Modal
open={!!editPfu}
title={`Modifier — ${editPfu?.annee}`}
onClose={() => setEditPfu(null)}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button className="danger" type="button" onClick={async () => {
if (!editPfu?.id) return;
await delPfu(editPfu.id);
setEditPfu(null);
}}>Supprimer</button>
<div style={{ display: 'flex', gap: 8 }}>
<button className="ghost" onClick={() => setEditPfu(null)}>Annuler</button>
<button className="primary" form="form-edit-pfu" type="submit">Enregistrer</button>
</div>
</div>
}
>
{editPfu && (
<form id="form-edit-pfu" onSubmit={savePfu} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<div className="form-grid">
<div>
<label>Année *</label>
<input type="number" required min="2000" max="2100" value={editPfu.annee}
onChange={e => setEditPfu({ ...editPfu, annee: e.target.value })} />
</div>
<div>
<label>IR — Prélèvement forfaitaire (%) *</label>
<input type="number" required step="0.1" min="0" max="100" value={editPfu.impot_revenu}
onChange={e => setEditPfu({ ...editPfu, impot_revenu: e.target.value })} />
</div>
<div>
<label>CSG (%) *</label>
<input type="number" required step="0.1" min="0" max="100" value={editPfu.csg ?? 9.2}
onChange={e => setEditPfu({ ...editPfu, csg: e.target.value })} />
</div>
<div>
<label>CRDS (%) *</label>
<input type="number" required step="0.1" min="0" max="100" value={editPfu.crds ?? 0.5}
onChange={e => setEditPfu({ ...editPfu, crds: e.target.value })} />
</div>
<div>
<label>Prélèvement solidarité (%) *</label>
<input type="number" required step="0.1" min="0" max="100" value={editPfu.solidarite ?? 7.5}
onChange={e => setEditPfu({ ...editPfu, solidarite: e.target.value })} />
</div>
</div>
<div style={{ padding: '8px 12px', background: 'var(--surface-2)', borderRadius: 8, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
PS total = {(+(editPfu.csg??9.2) + +(editPfu.crds??0.5) + +(editPfu.solidarite??7.5)).toFixed(1)} %
&nbsp;·&nbsp;
PFU total = {(+(editPfu.impot_revenu||0) + +(editPfu.csg??9.2) + +(editPfu.crds??0.5) + +(editPfu.solidarite??7.5)).toFixed(1)} %
</div>
</form>
)}
</Modal>
<ConfirmModal
open={!!confirmDel}
message={confirmDel?.message}
onConfirm={confirmDel?.onConfirm}
onCancel={() => setConfirmDel(null)}
/>
</>
);
}
/* ── Navigation ───────────────────────────────────────────────── */
const NAV = [
{ id: 'pfu', label: 'Flat Tax (PFU)', icon: <IconPercent /> },
{ id: 'taux-ci', label: "2047 Crédit d'impôts", icon: <IconTax /> },
];
/* ── Page principale ──────────────────────────────────────────── */
export default function AdminFiscalite() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'pfu';
return (
<div className="account-layout">
<aside className="account-sidebar">
<div style={{ marginBottom: 4 }}>
<button
onClick={() => navigate('/admin')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12,
color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer',
padding: '4px 0', marginBottom: 8 }}>
<IconChevronLeft /> Administration
</button>
</div>
<h1 className="account-title">Fiscalité</h1>
<div className="account-nav-group">
{NAV.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => navigate(`/admin/fiscalite?section=${item.id}`, { replace: true })}
>
{item.icon}
{item.label}
</button>
))}
</div>
</aside>
<div className="account-content">
{section === 'pfu' && <PfuSection />}
{section === 'taux-ci' && <TauxCreditImpotSection />}
</div>
</div>
);
}