maj
This commit is contained in:
1 parent
1e3994f17f
commit
624d32ff7c
17 files changed
+2192
-1
No files matched your search
@@ -30,6 +30,7 @@ import Admin from './pages/Admin.jsx';
|
||||
import AdminPlateformes from './pages/AdminPlateformes.jsx';
|
||||
import AdminFiscalite from './pages/AdminFiscalite.jsx';
|
||||
import AdminInstitutions from './pages/AdminInstitutions.jsx';
|
||||
import AdminEnveloppes from './pages/AdminEnveloppes.jsx';
|
||||
import AdminIA from './pages/AdminIA.jsx';
|
||||
import Aide from './pages/Aide.jsx';
|
||||
import PlatformeProfile from './pages/PlatformeProfile.jsx';
|
||||
@@ -242,6 +243,7 @@ export default function App() {
|
||||
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
|
||||
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
|
||||
<Route path="admin/institutions" element={<AdminOnly><AdminInstitutions /></AdminOnly>} />
|
||||
<Route path="admin/enveloppes" element={<AdminOnly><AdminEnveloppes /></AdminOnly>} />
|
||||
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
|
||||
<Route path="aide" element={<Aide />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
|
||||
@@ -17,6 +17,7 @@ function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
||||
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 IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
|
||||
function IconBank() { 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="3" y1="22" x2="21" y2="22"/><line x1="6" y1="18" x2="6" y2="11"/><line x1="10" y1="18" x2="10" y2="11"/><line x1="14" y1="18" x2="14" y2="11"/><line x1="18" y1="18" x2="18" y2="11"/><polygon points="12 2 21 8 3 8"/></svg>; }
|
||||
function IconWallet() { 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="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>; }
|
||||
function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; }
|
||||
function IconMail() { 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="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
|
||||
function IconDownload() { 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="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>; }
|
||||
@@ -43,6 +44,7 @@ const NAV = [
|
||||
{ id: 'plateformes', label: 'Plateformes', icon: <IconDatabase />, href: '/admin/plateformes' },
|
||||
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
|
||||
{ id: 'institutions', label: 'Institutions financières', icon: <IconBank />, href: '/admin/institutions' },
|
||||
{ id: 'enveloppes', label: 'Enveloppes & supports', icon: <IconWallet />, href: '/admin/enveloppes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
|
||||
/**
|
||||
* pages/AdminEnveloppes.jsx — Référentiel admin "Enveloppes & Supports d'épargne"
|
||||
* (Livret A, Assurance-vie, PEA, PER... / fonds euros, ETF, SCPI...) — chantier du 28/09/26
|
||||
* (demande Olivier, cf. claude/plan_referentiel_enveloppes_supports.md).
|
||||
*
|
||||
* Catalogue seul pour l'instant (cadrage validé par Olivier via AskUserQuestion) — aucun
|
||||
* rattachement à l'existant (comptes, investissements...), en préparation du futur module
|
||||
* patrimoine. Même esprit que AdminInstitutions.jsx : sous-page admin à onglets, montée sous
|
||||
* /admin/enveloppes, avec son propre bouton retour vers /admin.
|
||||
*
|
||||
* Deux référentiels reliés (et non un seul catalogue plat) :
|
||||
* - enveloppes_referentiel : les enveloppes fiscales/juridiques (Livret A, Assurance-vie...).
|
||||
* - supports_referentiel : les supports d'investissement sous-jacents (fonds euros, ETF...).
|
||||
* - enveloppes_supports_disponibles (N-N) : quels supports sont disponibles dans quelle
|
||||
* enveloppe. Une enveloppe "cash" (Livret A, Compte courant...) n'a aucune ligne ici — le
|
||||
* formulaire EnveloppeModal n'en présuppose donc jamais la présence.
|
||||
* Catégories (familles) en N-N via referentiel_categories_enveloppes, propre à ce référentiel
|
||||
* (categories_enveloppes), sur le même patron que categories_institutions.
|
||||
*/
|
||||
|
||||
/* ── Icônes ───────────────────────────────────────────────────── */
|
||||
function IconWallet() {
|
||||
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="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>;
|
||||
}
|
||||
function IconLayers() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>;
|
||||
}
|
||||
function IconTag() {
|
||||
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="M20.59 13.41L13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>;
|
||||
}
|
||||
function IconEdit() {
|
||||
return <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="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>;
|
||||
}
|
||||
function IconTrash() {
|
||||
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="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></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>;
|
||||
}
|
||||
|
||||
const eur = n => (n == null ? '—' : `${Number(n).toLocaleString('fr-FR')} €`);
|
||||
|
||||
/* ── Modale de création/édition d'une enveloppe ──────────────────
|
||||
Formulaire simple à un seul écran : nom, description, plafond, régime fiscal, catégories,
|
||||
et supports disponibles (checkboxes — laissées vides pour une enveloppe "cash" comme le
|
||||
Livret A ou un compte courant, qui n'offre aucun choix de support). */
|
||||
function EnveloppeModal({ row, onClose, onSaved }) {
|
||||
const isNew = !row?.id;
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [supports, setSupports] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
nom: row?.nom || '',
|
||||
description: row?.description || '',
|
||||
plafond_eur: row?.plafond_eur != null ? String(row.plafond_eur) : '',
|
||||
regime_fiscal: row?.regime_fiscal || '',
|
||||
categories_ids: (row?.categories || []).map(c => c.id),
|
||||
supports_ids: (row?.supports || []).map(s => s.id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/ref-categories-enveloppes').then(setCategories).catch(() => {});
|
||||
api.get('/supports-referentiel').then(setSupports).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
|
||||
const plafond = form.plafond_eur.trim() ? Number(form.plafond_eur.replace(',', '.')) : null;
|
||||
if (plafond != null && (!Number.isFinite(plafond) || plafond <= 0)) { setErr('Le plafond doit être un nombre positif.'); return; }
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
const payload = {
|
||||
nom: form.nom.trim(),
|
||||
description: form.description.trim() || null,
|
||||
plafond_eur: plafond,
|
||||
regime_fiscal: form.regime_fiscal.trim() || null,
|
||||
categories_ids: form.categories_ids || [],
|
||||
supports_ids: form.supports_ids || [],
|
||||
};
|
||||
if (isNew) await api.post('/enveloppes-referentiel', payload);
|
||||
else await api.put(`/enveloppes-referentiel/${row.id}`, payload);
|
||||
onSaved(); onClose();
|
||||
} catch (ex) { setErr(ex.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? 'Ajouter une enveloppe' : `Modifier — ${row.nom}`}
|
||||
onClose={onClose}
|
||||
width={600}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
|
||||
<button type="submit" form="enveloppe-form" className="primary" disabled={saving}>
|
||||
{saving ? '…' : (isNew ? 'Créer' : 'Enregistrer')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="enveloppe-form" onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div>
|
||||
<label>Nom *</label>
|
||||
<input value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : Livret A, Assurance-vie, PEA…" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Plafond (€)</label>
|
||||
<input value={form.plafond_eur} onChange={e => set('plafond_eur', e.target.value)} placeholder="Ex : 22950 — laisser vide si sans plafond" />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Régime fiscal</label>
|
||||
<input value={form.regime_fiscal} onChange={e => set('regime_fiscal', e.target.value)} placeholder="Ex : Exonéré IR/PS, Flat tax 30%…" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Catégories</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{categories.length === 0 && <span className="text-muted" style={{ fontSize: 12 }}>Aucune catégorie — gérez-les depuis l'onglet "Catégories".</span>}
|
||||
{categories.map(c => {
|
||||
const checked = (form.categories_ids || []).includes(c.id);
|
||||
return (
|
||||
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => set('categories_ids', e.target.checked
|
||||
? [...(form.categories_ids || []), c.id]
|
||||
: (form.categories_ids || []).filter(id => id !== c.id))} />
|
||||
<span className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Supports disponibles</label>
|
||||
<p style={{ margin: '0 0 6px', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
Laisser vide pour une enveloppe "cash" sans choix de support (Livret A, Compte courant…).
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{supports.length === 0 && <span className="text-muted" style={{ fontSize: 12 }}>Aucun support — gérez-les depuis l'onglet "Supports".</span>}
|
||||
{supports.map(s => {
|
||||
const checked = (form.supports_ids || []).includes(s.id);
|
||||
return (
|
||||
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => set('supports_ids', e.target.checked
|
||||
? [...(form.supports_ids || []), s.id]
|
||||
: (form.supports_ids || []).filter(id => id !== s.id))} />
|
||||
<span className="chip-cat" style={{ fontSize: 11 }}>{s.nom}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Description</label>
|
||||
<textarea value={form.description} onChange={e => set('description', e.target.value)}
|
||||
style={{ minHeight: 90, resize: 'vertical' }} placeholder="Informations générales sur l'enveloppe…" />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section principale : liste des enveloppes ───────────────────── */
|
||||
function EnveloppesSection() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterCategorie, setFilterCategorie] = useState('');
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const set = new Set();
|
||||
rows.forEach(r => (r.categories || []).forEach(c => set.add(c.nom)));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||
}, [rows]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
let r = rows;
|
||||
if (filterCategorie) r = r.filter(x => (x.categories || []).some(c => c.nom === filterCategorie));
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
r = r.filter(x => x.nom.toLowerCase().includes(q));
|
||||
}
|
||||
return r;
|
||||
}, [rows, search, filterCategorie]);
|
||||
|
||||
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
|
||||
usePagination(filteredRows, 'cl_pagesize_ref_enveloppes', [search, filterCategorie]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setLoading(true); setRows(await api.get('/enveloppes-referentiel')); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const close = () => setOpenMenu(null);
|
||||
window.addEventListener('scroll', close, true);
|
||||
return () => window.removeEventListener('scroll', close, true);
|
||||
}, [openMenu]);
|
||||
|
||||
const handleDelete = (row) => {
|
||||
setDeleting({
|
||||
title: 'Supprimer cette enveloppe ?',
|
||||
message: `Supprimer "${row.nom}" du référentiel ? Cette action est définitive.`,
|
||||
confirmLabel: 'Supprimer', danger: true,
|
||||
onConfirm: async () => {
|
||||
try { await api.del(`/enveloppes-referentiel/${row.id}`); load(); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setDeleting(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Référentiel des enveloppes</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Livret A, Assurance-vie, PEA, PER… — catalogue commun, transverse à tous les espaces de travail.
|
||||
</p>
|
||||
</div>
|
||||
<button className="primary" onClick={() => setEditing('new')}>+ Ajouter</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Rechercher par nom…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 14px', fontSize: 14, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select
|
||||
value={filterCategorie}
|
||||
onChange={e => setFilterCategorie(e.target.value)}
|
||||
style={{ flex: 1, padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
|
||||
>
|
||||
<option value="">Toutes les catégories</option>
|
||||
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{(search || filterCategorie) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setFilterCategorie(''); }}
|
||||
style={{ padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}
|
||||
>Effacer les filtres</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<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', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Plafond</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Régime fiscal</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Supports</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
{rows.length === 0
|
||||
? 'Aucune enveloppe dans le référentiel. Créez-en une pour commencer.'
|
||||
: 'Aucun résultat pour ces critères.'}
|
||||
</td></tr>
|
||||
) : pagedItems.map((row, i) => (
|
||||
<tr key={row.id}
|
||||
style={{ borderBottom: i < pagedItems.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}
|
||||
onClick={() => setEditing(row)}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<div style={{ fontWeight: 600 }}>{row.nom}</div>
|
||||
{row.description && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{row.description}</div>}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>{eur(row.plafond_eur)}</td>
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>{row.regime_fiscal || '—'}</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{(row.categories || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
|
||||
{(row.categories || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories.length - 2}</span>}
|
||||
{(row.categories || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{(row.supports || []).length === 0 ? 'Cash (aucun)' : `${row.supports.length} support${row.supports.length > 1 ? 's' : ''}`}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<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 => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setOpenMenu({ row, x: rect.right, y: rect.bottom }); }}
|
||||
>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={page} setPage={setPage}
|
||||
pageSize={pageSize} setPageSize={setPageSize}
|
||||
totalPages={totalPages} totalItems={totalItems}
|
||||
PAGE_SIZES={PAGE_SIZES}
|
||||
/>
|
||||
|
||||
{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: 160 }}>
|
||||
{[
|
||||
{ icon: <IconEdit />, label: 'Modifier', onClick: () => { setOpenMenu(null); setEditing(openMenu.row); } },
|
||||
{ icon: <IconTrash />, label: 'Supprimer', onClick: () => { const r = openMenu.row; setOpenMenu(null); handleDelete(r); }, color: 'var(--danger)' },
|
||||
].map(({ icon, label, onClick, color }) => (
|
||||
<button key={label}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={onClick}>
|
||||
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editing && <EnveloppeModal 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} onCancel={() => setDeleting(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Modale de création/édition d'un support ─────────────────────── */
|
||||
function SupportModal({ row, onClose, onSaved }) {
|
||||
const isNew = !row?.id;
|
||||
const [err, setErr] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
nom: row?.nom || '',
|
||||
description: row?.description || '',
|
||||
});
|
||||
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
const payload = { nom: form.nom.trim(), description: form.description.trim() || null };
|
||||
if (isNew) await api.post('/supports-referentiel', payload);
|
||||
else await api.put(`/supports-referentiel/${row.id}`, payload);
|
||||
onSaved(); onClose();
|
||||
} catch (ex) { setErr(ex.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? 'Ajouter un support' : `Modifier — ${row.nom}`}
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
|
||||
<button type="submit" form="support-form" className="primary" disabled={saving}>
|
||||
{saving ? '…' : (isNew ? 'Créer' : 'Enregistrer')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="support-form" onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div>
|
||||
<label>Nom *</label>
|
||||
<input value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : Fonds euros, ETF, SCPI…" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Description</label>
|
||||
<textarea value={form.description} onChange={e => set('description', e.target.value)}
|
||||
style={{ minHeight: 90, resize: 'vertical' }} placeholder="Informations générales sur ce support…" />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section : supports d'investissement ─────────────────────────── */
|
||||
function SupportsSection() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setLoading(true); setRows(await api.get('/supports-referentiel')); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const close = () => setOpenMenu(null);
|
||||
window.addEventListener('scroll', close, true);
|
||||
return () => window.removeEventListener('scroll', close, true);
|
||||
}, [openMenu]);
|
||||
|
||||
const handleDelete = (row) => {
|
||||
setDeleting({
|
||||
title: 'Supprimer ce support ?',
|
||||
message: row.nb_enveloppes > 0
|
||||
? `Supprimer "${row.nom}" ? Il sera retiré des ${row.nb_enveloppes} enveloppe(s) qui le proposent. Cette action est définitive.`
|
||||
: `Supprimer "${row.nom}" du référentiel ? Cette action est définitive.`,
|
||||
confirmLabel: 'Supprimer', danger: true,
|
||||
onConfirm: async () => {
|
||||
try { await api.del(`/supports-referentiel/${row.id}`); load(); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setDeleting(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Référentiel des supports</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Fonds euros, OPCVM, ETF, SCPI, titres vifs… — supports d'investissement disponibles au sein des enveloppes.
|
||||
</p>
|
||||
</div>
|
||||
<button className="primary" onClick={() => setEditing('new')}>+ Ajouter</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<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', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Enveloppes</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={3} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Aucun support dans le référentiel. Créez-en un pour commencer.</td></tr>
|
||||
) : rows.map((row, i) => (
|
||||
<tr key={row.id}
|
||||
style={{ borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}
|
||||
onClick={() => setEditing(row)}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<div style={{ fontWeight: 600 }}>{row.nom}</div>
|
||||
{row.description && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{row.description}</div>}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
|
||||
<span style={{ display: 'inline-block', minWidth: 28, padding: '2px 8px', borderRadius: 12, fontSize: 12, fontWeight: 700,
|
||||
background: row.nb_enveloppes > 0 ? 'rgba(99,102,241,.12)' : 'var(--surface-2)',
|
||||
color: row.nb_enveloppes > 0 ? '#6366f1' : 'var(--text-muted)' }}>
|
||||
{row.nb_enveloppes}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<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 => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setOpenMenu({ row, x: rect.right, y: rect.bottom }); }}
|
||||
>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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: 160 }}>
|
||||
{[
|
||||
{ icon: <IconEdit />, label: 'Modifier', onClick: () => { setOpenMenu(null); setEditing(openMenu.row); } },
|
||||
{ icon: <IconTrash />, label: 'Supprimer', onClick: () => { const r = openMenu.row; setOpenMenu(null); handleDelete(r); }, color: 'var(--danger)' },
|
||||
].map(({ icon, label, onClick, color }) => (
|
||||
<button key={label}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={onClick}>
|
||||
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editing && <SupportModal 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} onCancel={() => setDeleting(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section : catégories (familles) ──────────────────────────────
|
||||
Reprise quasi à l'identique de CategoriesInstitutionsSection (AdminInstitutions.jsx),
|
||||
pointée sur /ref-categories-enveloppes. */
|
||||
function CategoriesEnveloppesSection() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [newNom, setNewNom] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [editNom, setEditNom] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const apiPath = '/ref-categories-enveloppes';
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api.get(apiPath)
|
||||
.then(data => { setItems(data); setErr(null); })
|
||||
.catch(e => setErr(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const close = () => setOpenMenu(null);
|
||||
window.addEventListener('scroll', close, true);
|
||||
return () => window.removeEventListener('scroll', close, true);
|
||||
}, [openMenu]);
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newNom.trim()) return;
|
||||
setAdding(true); setErr(null);
|
||||
try { await api.post(apiPath, { nom: newNom.trim() }); setNewNom(''); load(); }
|
||||
catch (ex) { setErr(ex.message); }
|
||||
finally { setAdding(false); }
|
||||
};
|
||||
|
||||
const startEdit = (item) => { setEditId(item.id); setEditNom(item.nom); };
|
||||
const cancelEdit = () => { setEditId(null); setEditNom(''); };
|
||||
|
||||
const handleRename = async (e, id) => {
|
||||
e.preventDefault();
|
||||
if (!editNom.trim()) return;
|
||||
setSaving(true); setErr(null);
|
||||
try { await api.put(`${apiPath}/${id}`, { nom: editNom.trim() }); cancelEdit(); load(); }
|
||||
catch (ex) { setErr(ex.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
setConfirm({
|
||||
title: 'Confirmer la suppression',
|
||||
message: `Supprimer "${item.nom}" ?`,
|
||||
confirmLabel: 'Supprimer',
|
||||
onConfirm: async () => {
|
||||
setConfirm(null);
|
||||
try { await api.del(`${apiPath}/${item.id}`); load(); }
|
||||
catch (ex) { setErr(ex.message); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openContextMenu = (e, item) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setOpenMenu({ item, x: rect.right - 160, y: rect.bottom + 4 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Catégories (familles)</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Liste globale utilisée pour classer les enveloppes du référentiel (Épargne réglementée, Assurance-vie, Épargne retraite…).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, padding: '16px 20px' }}>
|
||||
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
value={newNom}
|
||||
onChange={e => setNewNom(e.target.value)}
|
||||
placeholder="Nouvelle catégorie…"
|
||||
style={{ flex: 1, padding: '7px 12px', borderRadius: 7, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}
|
||||
/>
|
||||
<button type="submit" className="primary" disabled={adding || !newNom.trim()}>{adding ? '…' : 'Ajouter'}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<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', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Utilisée</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan={3} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Aucune catégorie.</td></tr>
|
||||
) : items.map((item, i) => (
|
||||
<tr key={item.id} style={{ borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
{editId === item.id ? (
|
||||
<form onSubmit={e => handleRename(e, item.id)} style={{ display: 'flex', gap: 6 }}>
|
||||
<input autoFocus value={editNom} onChange={e => setEditNom(e.target.value)}
|
||||
style={{ flex: 1, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }} />
|
||||
<button type="submit" className="primary" disabled={saving} style={{ fontSize: 12, padding: '5px 10px' }}>OK</button>
|
||||
<button type="button" onClick={cancelEdit} style={{ fontSize: 12, padding: '5px 10px' }}>Annuler</button>
|
||||
</form>
|
||||
) : <span style={{ fontWeight: 600 }}>{item.nom}</span>}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
|
||||
<span style={{ display: 'inline-block', minWidth: 28, padding: '2px 8px', borderRadius: 12, fontSize: 12, fontWeight: 700,
|
||||
background: item.nb_utilises > 0 ? 'rgba(99,102,241,.12)' : 'var(--surface-2)',
|
||||
color: item.nb_utilises > 0 ? '#6366f1' : 'var(--text-muted)' }}>
|
||||
{item.nb_utilises}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
|
||||
onClick={e => openContextMenu(e, item)}
|
||||
>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openMenu && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
|
||||
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, 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: 160 }}>
|
||||
<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={() => { const it = openMenu.item; setOpenMenu(null); startEdit(it); }}>
|
||||
<IconEdit /> Renommer
|
||||
</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={() => { const it = openMenu.item; setOpenMenu(null); handleDelete(it); }}>
|
||||
<IconTrash /> Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmModal open={!!confirm} title={confirm?.title} message={confirm?.message} confirmLabel={confirm?.confirmLabel}
|
||||
onConfirm={confirm?.onConfirm} onCancel={() => setConfirm(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ id: 'enveloppes', label: 'Enveloppes', icon: <IconWallet /> },
|
||||
{ id: 'supports', label: 'Supports', icon: <IconLayers /> },
|
||||
{ id: 'categories', label: 'Catégories', icon: <IconTag /> },
|
||||
];
|
||||
|
||||
/* ── Page principale ──────────────────────────────────────────── */
|
||||
export default function AdminEnveloppes() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const section = new URLSearchParams(search).get('section') || 'enveloppes';
|
||||
|
||||
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">Enveloppes & supports</h1>
|
||||
<div className="account-nav-group">
|
||||
{NAV.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`account-nav-item${section === item.id ? ' active' : ''}`}
|
||||
onClick={() => navigate(`/admin/enveloppes?section=${item.id}`, { replace: true })}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="account-content">
|
||||
{section === 'enveloppes' && <EnveloppesSection />}
|
||||
{section === 'supports' && <SupportsSection />}
|
||||
{section === 'categories' && <CategoriesEnveloppesSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,6 +203,118 @@ function InstitutionModal({ row, onClose, onSaved }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Recherche IA globale (demande Olivier 28/09/26) ──────────────
|
||||
Applique la même route POST /institutions-referentiel/ai-lookup que la baguette magique de
|
||||
InstitutionModal, mais en boucle séquentielle sur toutes les institutions "incomplètes"
|
||||
(sans description et/ou sans catégorie). Choix de conception validés par Olivier :
|
||||
- Périmètre : seulement les institutions incomplètes, pas tout le référentiel (évite de
|
||||
relancer inutilement des appels IA — donc des tokens — sur ce qui est déjà complété).
|
||||
- Écrasement : jamais — seuls les champs actuellement VIDES sont complétés (description, URL,
|
||||
catégories). La Domiciliation n'est donc jamais touchée par cette action, même si elle porte
|
||||
encore la valeur par défaut 'FR' : elle a toujours une valeur, on ne peut pas distinguer un
|
||||
'FR' par défaut d'un 'FR' vérifié à la main.
|
||||
Pas de nouvelle route backend : orchestration entièrement côté client (boucle séquentielle,
|
||||
un appel ai-lookup puis un PUT par institution), sur le modèle de ComptesOrphelinsSection
|
||||
(pas d'infrastructure de job en arrière-plan dans ce projet). */
|
||||
const AI_STATUS_LABEL = { pending: 'En attente', running: 'En cours…', ok: 'Mis à jour', error: 'Échec', skipped: 'Ignoré' };
|
||||
|
||||
function GlobalAiLookupModal({ targets, onClose, onDone }) {
|
||||
const [items, setItems] = useState(() => targets.map(t => ({ ...t, status: 'pending', error: null })));
|
||||
const [phase, setPhase] = useState('confirm'); // 'confirm' | 'running' | 'done'
|
||||
const stopRef = useRef(false);
|
||||
|
||||
const updateItem = (idx, patch) => setItems(prev => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)));
|
||||
|
||||
const run = async () => {
|
||||
setPhase('running');
|
||||
stopRef.current = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (stopRef.current) { updateItem(i, { status: 'skipped' }); continue; }
|
||||
updateItem(i, { status: 'running' });
|
||||
const inst = items[i];
|
||||
try {
|
||||
const r = await api.post('/institutions-referentiel/ai-lookup', { nom: inst.nom, url: inst.url || undefined });
|
||||
if (!r.ok) { updateItem(i, { status: 'error', error: r.error }); continue; }
|
||||
const payload = {
|
||||
nom: inst.nom,
|
||||
url: inst.url || r.url || null,
|
||||
domiciliation: inst.domiciliation,
|
||||
description: (inst.description && inst.description.trim()) ? inst.description : (r.description || null),
|
||||
categories_ids: (inst.categories && inst.categories.length) ? inst.categories.map(c => c.id) : (r.categories_ids || []),
|
||||
};
|
||||
await api.put(`/institutions-referentiel/${inst.id}`, payload);
|
||||
updateItem(i, { status: 'ok' });
|
||||
} catch (ex) {
|
||||
updateItem(i, { status: 'error', error: ex.message });
|
||||
}
|
||||
}
|
||||
setPhase('done');
|
||||
};
|
||||
|
||||
const okCount = items.filter(it => it.status === 'ok').length;
|
||||
const errorCount = items.filter(it => it.status === 'error').length;
|
||||
|
||||
return (
|
||||
<Modal open title="Recherche IA globale" onClose={onClose} busy={phase === 'running'} width={560}
|
||||
footer={
|
||||
phase === 'confirm' ? (
|
||||
<>
|
||||
<button type="button" onClick={onClose}>Annuler</button>
|
||||
<button type="button" className="primary" onClick={run} disabled={items.length === 0}>Lancer ({items.length})</button>
|
||||
</>
|
||||
) : phase === 'running' ? (
|
||||
<button type="button" onClick={() => { stopRef.current = true; }}>Arrêter après l'institution en cours</button>
|
||||
) : (
|
||||
<button type="button" className="primary" onClick={() => { onDone(); onClose(); }}>Fermer</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{phase === 'confirm' && (
|
||||
items.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Toutes les institutions du référentiel sont déjà complètes (description et catégories renseignées).</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 0 }}>
|
||||
{items.length} institution{items.length > 1 ? 's' : ''} sans description et/ou sans catégorie {items.length > 1 ? 'seront recherchées' : 'sera recherchée'} sur le web via l'Assistant IA.
|
||||
Seuls les champs vides sont complétés (description, URL, catégories) — un champ déjà renseigné, y compris la Domiciliation, n'est jamais modifié.
|
||||
</p>
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6 }}>
|
||||
{items.map(it => (
|
||||
<li key={it.id} style={{ padding: '6px 12px', fontSize: 13, borderBottom: '1px solid var(--border)' }}>{it.nom}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{(phase === 'running' || phase === 'done') && (
|
||||
<>
|
||||
{phase === 'done' && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 0 }}>
|
||||
Terminé — {okCount} mise{okCount > 1 ? 's' : ''} à jour, {errorCount} échec{errorCount > 1 ? 's' : ''}.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', maxHeight: 320, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6 }}>
|
||||
{items.map(it => (
|
||||
<li key={it.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '6px 12px', fontSize: 13, borderBottom: '1px solid var(--border)' }}>
|
||||
<span>{it.nom}</span>
|
||||
<span
|
||||
title={it.error || ''}
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
||||
color: it.status === 'ok' ? '#16a34a' : it.status === 'error' ? '#dc2626' : it.status === 'running' ? '#b45309' : 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{AI_STATUS_LABEL[it.status] || it.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section principale : liste des institutions ─────────────────── */
|
||||
function InstitutionsSection() {
|
||||
const navigate = useNavigate();
|
||||
@@ -214,6 +326,8 @@ function InstitutionsSection() {
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterCategorie, setFilterCategorie] = useState('');
|
||||
const [aiAvailable, setAiAvailable] = useState(false);
|
||||
const [globalAi, setGlobalAi] = useState(null); // null | { targets: [...] }
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const set = new Set();
|
||||
@@ -242,6 +356,10 @@ function InstitutionsSection() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/admin/ai/config').then(cfg => setAiAvailable(!!cfg?.actif)).catch(() => setAiAvailable(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const close = () => setOpenMenu(null);
|
||||
@@ -249,6 +367,11 @@ function InstitutionsSection() {
|
||||
return () => window.removeEventListener('scroll', close, true);
|
||||
}, [openMenu]);
|
||||
|
||||
const openGlobalAiLookup = () => {
|
||||
const targets = rows.filter(r => !(r.description && r.description.trim()) || !(r.categories && r.categories.length));
|
||||
setGlobalAi({ targets });
|
||||
};
|
||||
|
||||
const handleDelete = (row) => {
|
||||
setDeleting({
|
||||
title: 'Supprimer cette institution ?',
|
||||
@@ -273,7 +396,14 @@ function InstitutionsSection() {
|
||||
Banques, courtiers, assureurs… — catalogue commun, transverse à tous les espaces de travail.
|
||||
</p>
|
||||
</div>
|
||||
<button className="primary" style={{ flexShrink: 0 }} onClick={() => setEditing('new')}>+ Ajouter</button>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{aiAvailable && (
|
||||
<button style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} onClick={openGlobalAiLookup} title="Compléter les institutions incomplètes via l'Assistant IA">
|
||||
<IconWand /> Recherche IA globale
|
||||
</button>
|
||||
)}
|
||||
<button className="primary" onClick={() => setEditing('new')}>+ Ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
@@ -402,6 +532,7 @@ function InstitutionsSection() {
|
||||
|
||||
{editing && <InstitutionModal 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} onCancel={() => setDeleting(null)} />}
|
||||
{globalAi && <GlobalAiLookupModal targets={globalAi.targets} onClose={() => setGlobalAi(null)} onDone={load} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user