837b016cb9
- 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
1171 lines
63 KiB
React
1171 lines
63 KiB
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';
|
||
|
||
/* ── 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)' }}>└> dont CSG</th>
|
||
<th className="num" style={{ background: 'rgba(99,102,241,0.06)' }}>└> dont CRDS</th>
|
||
<th className="num" style={{ background: 'rgba(99,102,241,0.06)' }}>└> 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)} %
|
||
·
|
||
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)} %
|
||
·
|
||
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>
|
||
);
|
||
}
|