Files
crowdlending-app/frontend/src/pages/AdminPlateformes.jsx
T
Olivier CROGUENNEC 48ed7fe65e Initial commit
2026-06-13 14:57:15 +02:00

2028 lines
104 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import InvSelect from '../components/InvSelect.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import Modal from '../components/Modal.jsx';
import ResultBanner from '../components/ResultBanner.jsx';
import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
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 IconImage() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>;
}
function IconLink() {
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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></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 IconEdit() {
return <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>;
}
function IconTrash() {
return <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>;
}
function IconPushSoft() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/><line x1="5" y1="21" x2="19" y2="21"/></svg>;
}
function IconPushForce() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="17 8 12 13 7 8"/><polyline points="17 13 12 18 7 13"/><line x1="5" y1="21" x2="19" y2="21"/></svg>;
}
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>;
}
/* ── Constantes ───────────────────────────────────────────────── */
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = { flat_tax: 'Flat tax (30%)', sans_fiscalite_locale: 'Sans fiscalité locale', avec_fiscalite_locale: 'Avec fiscalité locale' };
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [] };
/* ── Modal création / édition référentiel ─────────────────────── */
function RefModal({ row, onClose, onSaved }) {
const isNew = !row?.id;
const [catsInv, setCatsInv] = useState([]);
const [secteursInv, setSecteursInv] = useState([]);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState(isNew ? { ...EMPTY_REF } : {
nom: row.nom || '', url: row.url || '', domiciliation: row.domiciliation || 'france',
fiscalite: row.fiscalite || 'flat_tax',
taux_fiscalite_locale: row.taux_fiscalite_locale != null ? String(row.taux_fiscalite_locale) : '',
type_produit_fiscal: row.type_produit_fiscal || '2TT',
methode_remboursement: row.methode_remboursement || 'portefeuille',
type_pret_defaut: row.type_pret_defaut || '',
freq_interets_defaut: row.freq_interets_defaut || '',
description: row.description || '',
categories: [],
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
});
useEffect(() => {
Promise.all([
api.get('/ref-categories'),
api.get('/ref-secteurs'),
]).then(([cats, sects]) => {
setCatsInv(cats);
setSecteursInv(sects);
}).catch(() => {});
}, []); // eslint-disable-line
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const fiscalite = form.domiciliation === 'FR' ? 'flat_tax' : form.fiscalite;
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
setSaving(true); setErr(null);
try {
// Champs gérés par RefModal
const ownFields = {
nom: form.nom.trim(), url: form.url.trim() || null,
domiciliation: form.domiciliation, fiscalite,
taux_fiscalite_locale: fiscalite === 'avec_fiscalite_locale' && form.taux_fiscalite_locale !== ''
? Number(form.taux_fiscalite_locale) : null,
type_produit_fiscal: form.type_produit_fiscal,
methode_remboursement: form.methode_remboursement || 'portefeuille',
type_pret_defaut: form.type_pret_defaut || null,
freq_interets_defaut: form.freq_interets_defaut || null,
description: form.description.trim() || null,
categories_inv_ids: form.categories_inv_ids || [],
secteurs_inv_ids: form.secteurs_inv_ids || [],
};
if (isNew) {
await api.post('/referentiel', { ...ownFields, categories: [], notation: [] });
} else {
// Récupérer l'entrée courante pour préserver les champs du profil
const current = await api.get(`/referentiel/${row.id}`);
const payload = {
...ownFields,
// Champs préservés — gérés uniquement par PlatformeProfile
categories: current.categories ?? [],
notation: current.notation ?? [],
logo_filename: current.logo_filename ?? null,
annee_creation: current.annee_creation ?? null,
investisseurs_types: current.investisseurs_types ?? null,
regulateur: current.regulateur ?? null,
numero_licence: current.numero_licence ?? null,
is_regule: current.is_regule ?? false,
pays_inscription: current.pays_inscription ?? null,
pays_siege: current.pays_siege ?? null,
pays_operation: current.pays_operation ?? [],
investissement_minimum: current.investissement_minimum ?? null,
rendement_annonce: current.rendement_annonce ?? null,
nb_investisseurs: current.nb_investisseurs ?? null,
volume_total_finance: current.volume_total_finance ?? null,
duree_moyenne_pret: current.duree_moyenne_pret ?? null,
garantie_rachat: current.garantie_rachat ?? false,
statistiques_publiques: current.statistiques_publiques ?? false,
bonus_inscription: current.bonus_inscription ?? false,
marche_secondaire: current.marche_secondaire ?? false,
investissement_auto: current.investissement_auto ?? false,
url_trustpilot: current.url_trustpilot ?? null,
url_linkedin: current.url_linkedin ?? null,
};
await api.put(`/referentiel/${row.id}`, payload);
}
onSaved(); onClose();
} catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
return (
<Modal open title={isNew ? 'Ajouter au référentiel' : `Modifier — ${row.nom}`}
onClose={onClose} width={640}
footer={<>
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
<button className="primary" form="ref-form" type="submit" disabled={saving}>{saving ? '…' : isNew ? 'Créer' : 'Enregistrer'}</button>
</>}>
<form id="ref-form" onSubmit={handleSubmit}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div><label>Nom *</label><input value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : October, Lendosphere…" /></div>
<div><label>URL</label><input value={form.url} onChange={e => set('url', e.target.value)} placeholder="https://…" /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label>Domiciliation</label>
<CountrySelect
value={form.domiciliation}
onChange={code => set('domiciliation', code)}
showCode
/>
</div>
<div>
<label>Fiscalité</label>
{form.domiciliation === 'FR'
? <input value="Flat tax (automatique)" disabled />
: <select value={form.fiscalite} onChange={e => set('fiscalite', e.target.value)}>
<option value="sans_fiscalite_locale">Sans fiscalité locale</option>
<option value="avec_fiscalite_locale">Avec fiscalité locale</option>
</select>}
</div>
</div>
{fiscalite === 'avec_fiscalite_locale' && (
<div><label>Taux fiscalité locale (%)</label>
<input type="number" min="0" max="100" step="0.1" value={form.taux_fiscalite_locale} onChange={e => set('taux_fiscalite_locale', e.target.value)} placeholder="Ex : 15" /></div>
)}
<div>
<label>Type produit fiscal</label>
<select value={form.type_produit_fiscal} onChange={e => set('type_produit_fiscal', e.target.value)}>
<option value="2TT">2TT Intérêts</option>
<option value="2TR">2TR Revenus assimilés</option>
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<label>Méthode de remboursement</label>
<select value={form.methode_remboursement} onChange={e => set('methode_remboursement', e.target.value)}>
<option value="portefeuille">Porte-monnaie</option>
<option value="compte_courant">Compte courant</option>
<option value="choix_investisseur">Choix investisseur</option>
</select>
</div>
<div>
<label>Type de prêt (défaut)</label>
<select value={form.type_pret_defaut} onChange={e => {
const t = e.target.value;
set('type_pret_defaut', t);
if (t === 'differe') set('freq_interets_defaut', 'in_fine');
else if (form.freq_interets_defaut === 'in_fine') set('freq_interets_defaut', 'mensuel');
}}>
<option value=""> Non défini </option>
<option value="in_fine">In fine</option>
<option value="amortissable">Amortissable</option>
<option value="differe">Différé</option>
</select>
</div>
<div>
<label>Périodicité (défaut)</label>
<select value={form.type_pret_defaut === 'differe' ? 'in_fine' : form.freq_interets_defaut}
disabled={form.type_pret_defaut === 'differe'}
onChange={e => set('freq_interets_defaut', e.target.value)}>
{form.type_pret_defaut === 'differe'
? <option value="in_fine">In fine (unique)</option>
: <>
<option value=""> Non définie </option>
<option value="mensuel">Mensuelle</option>
<option value="trimestriel">Trimestrielle</option>
</>
}
</select>
</div>
</div>
<div><label>Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
style={{ minHeight: 60, resize: 'vertical' }} placeholder="Informations générales sur la plateforme…" /></div>
<div>
<label>Catégories d'investissement</label>
<InvSelect
items={catsInv}
selected={form.categories_inv_ids || []}
onChange={ids => set('categories_inv_ids', ids)}
emptyLabel="Aucune catégorie d'investissement"
/>
</div>
<div>
<label>Secteurs d'investissement</label>
<InvSelect
items={secteursInv}
selected={form.secteurs_inv_ids || []}
onChange={ids => set('secteurs_inv_ids', ids)}
emptyLabel="Aucun secteur d'investissement"
/>
</div>
</div>
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
</form>
</Modal>
);
}
/* ── Section référentiel plateformes ─────────────────────────── */
function ReferentielSection() {
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [orphelineCount, setOrphelineCount] = useState(0);
const [err, setErr] = useState(null);
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
const [pushing, setPushing] = useState(null);
const [pushResult, setPushResult] = useState(null);
const [openMenu, setOpenMenu] = useState(null);
const [forcePushConfirm, setForcePushConfirm] = useState(null);
const [exporting, setExporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const importRef = useRef(null);
const { search: urlSearch } = useLocation();
const [search, setSearch] = useState('');
const [filterDomicil, setFilterDomicil] = useState('');
const [filterCategorie, setFilterCategorie] = useState(
() => new URLSearchParams(urlSearch).get('filterCat') || ''
);
const allCategories = useMemo(() => {
const set = new Set();
rows.forEach(r => (r.categories_inv || []).forEach(c => set.add(c.nom)));
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [rows]);
const allDomiciliations = useMemo(() => {
const set = new Set();
rows.forEach(r => { if (r.domiciliation) set.add(r.domiciliation); });
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [rows]);
const filteredRows = useMemo(() => {
let r = rows;
if (filterDomicil) r = r.filter(x => x.domiciliation === filterDomicil);
if (filterCategorie) r = r.filter(x => (x.categories_inv || []).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, filterDomicil, filterCategorie]);
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterCategorie]);
const load = useCallback(async () => {
try {
setLoading(true);
const [ref, orphelines] = await Promise.all([
api.get('/referentiel'),
api.get('/admin/plateformes-orphelines').catch(() => []),
]);
setRows(ref);
setOrphelineCount(orphelines.length);
} 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 handlePush = async (row, force = false) => {
setPushing(row.id); setPushResult(null);
try {
const r = await api.post(`/referentiel/${row.id}/push`, { force });
const label = force ? 'Poussée forcée' : 'Poussée douce';
setPushResult({ ok: true, msg: `${label} — mis à jour : ${r.nb_updated} / ${r.nb_plateformes} plateforme(s)` });
load();
} catch (e) { setPushResult({ ok: false, msg: e.message }); }
finally { setPushing(null); }
};
const handleDelete = (row) => {
setDeleting({
title: 'Supprimer ce référentiel ?',
message: `Supprimer "${row.nom}" ? Les ${row.nb_plateformes_liees} plateforme(s) liée(s) seront détachées mais leurs données conservées.`,
confirmLabel: 'Supprimer', danger: true,
onConfirm: async () => {
try { await api.del(`/referentiel/${row.id}`); load(); }
catch (e) { setErr(e.message); }
finally { setDeleting(null); }
},
});
};
const handleExportAll = async () => {
try {
setExporting(true);
const blob = await api.blob('/referentiel/export');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `referentiel-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setImportResult({ ok: false, msg: e.message }); }
finally { setExporting(false); }
};
const handleExportOne = async (row) => {
try {
const blob = await api.blob(`/referentiel/${row.id}/export`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${row.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setImportResult({ ok: false, msg: e.message }); }
};
const handleImportZip = async (file) => {
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/referentiel/import-zip', fd);
setImportResult({ ok: true, msg: `Import terminé : ${r.created} créée(s), ${r.updated} mise(s) à jour sur ${r.total} entrée(s).` });
load();
} catch (e) { setImportResult({ ok: false, msg: e.message }); }
};
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 plateformes</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Données communes héritées par les utilisateurs lors de la création de leurs plateformes.
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
onClick={() => navigate('/admin/plateformes?section=orphelines')}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid',
fontSize: 13, fontWeight: 600, cursor: 'pointer',
background: orphelineCount > 0 ? 'rgba(239,68,68,.08)' : 'var(--surface-2)',
color: orphelineCount > 0 ? '#dc2626' : 'var(--text-muted)',
borderColor: orphelineCount > 0 ? 'rgba(239,68,68,.3)' : 'var(--border)',
}}>
{orphelineCount} plateforme{orphelineCount !== 1 ? 's' : ''} suggérée{orphelineCount !== 1 ? 's' : ''}
</button>
<button
onClick={handleExportAll} disabled={exporting || rows.length === 0}
title="Exporter tout le référentiel 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 /> {exporting ? '' : 'Exporter tout'}
</button>
<button
onClick={() => importRef.current?.click()}
title="Importer un fichier ZIP de référentiel"
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>
<button className="primary" style={{ flexShrink: 0 }} onClick={() => setEditing('new')}>+ Ajouter</button>
</div>
</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={filterDomicil}
onChange={e => setFilterDomicil(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 domiciliations</option>
{allDomiciliations.map(v => (
<option key={v} value={v}>{countryLabel(v)}</option>
))}
</select>
<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 || filterDomicil || filterCategorie) && (
<button
onClick={() => { setSearch(''); setFilterDomicil(''); 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>}
{pushResult && (
<div style={{ marginBottom: 12, padding: '8px 14px', borderRadius: 8, fontSize: 13,
background: pushResult.ok ? 'rgba(34,197,94,.1)' : 'rgba(239,68,68,.1)',
color: pushResult.ok ? '#16a34a' : '#dc2626',
border: `1px solid ${pushResult.ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}` }}>
{pushResult.msg}
<button onClick={() => setPushResult(null)} style={{ marginLeft: 12, background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', fontSize: 16, lineHeight: 1 }}>×</button>
</div>
)}
{importResult && <ResultBanner result={importResult} onDismiss={() => setImportResult(null)} style={{ marginBottom: 12 }} />}
<input ref={importRef} type="file" accept=".zip" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; e.target.value = ''; if (f) handleImportZip(f); }} />
{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 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Héritage</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
{rows.length === 0
? 'Aucune entrée dans le référentiel. Créez-en une ou importez une plateforme orpheline.'
: 'Aucun résultat pour ces critères.'}
</td></tr>
) : pagedItems.map((row, i) => (
<tr key={row.id}
onClick={() => navigate(`/referentiel/${row.id}`)}
style={{ borderBottom: i < pagedItems.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<td style={{ padding: '8px 8px 8px 16px', width: 40 }}>
{row.icone_filename
? <img src={`${(import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '')}/api/logos/${row.icone_filename}`}
alt="" title="Icône bibliothèque" style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 4, display: 'block' }} />
: row.logo_filename
? <img src={`${(import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '')}/api/logos/${row.logo_filename}`}
alt="" title="Logo uploadé" style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 4, display: 'block' }} />
: <div title="Aucune image" style={{ width: 32, height: 32, borderRadius: 4, background: 'var(--surface-2)', border: '1px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontSize: 16 }}>×</div>
}
</td>
<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 }}><span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={row.domiciliation} size={15} />{countryLabel(row.domiciliation)}</span></td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.categories_inv || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{(row.categories_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories_inv.length - 2}</span>}
{(row.categories_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.secteurs_inv || []).slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{(row.secteurs_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.secteurs_inv.length - 2}</span>}
{(row.secteurs_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</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_plateformes_liees > 0 ? 'rgba(99,102,241,.12)' : 'var(--surface-2)',
color: row.nb_plateformes_liees > 0 ? '#6366f1' : 'var(--text-muted)' }}>
{row.nb_plateformes_liees}
</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>
)}
<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: 180 }}>
{[
{ icon: <IconEdit />, label: 'Modifier', onClick: () => { setOpenMenu(null); setEditing(openMenu.row); } },
{ icon: <IconPushSoft />, label: pushing === openMenu.row.id ? 'Poussée' : `Pousser (douce) — ${openMenu.row.nb_plateformes_liees} pl.`,
title: "Pousse uniquement les champs que l'utilisateur n'a pas modifiés",
onClick: () => { const r = openMenu.row; setOpenMenu(null); handlePush(r, false); },
disabled: openMenu.row.nb_plateformes_liees === 0 || pushing === openMenu.row.id, color: '#2563eb' },
{ icon: <IconPushForce />, label: pushing === openMenu.row.id ? 'Poussée' : `Pousser (forcée) — ${openMenu.row.nb_plateformes_liees} pl.`,
title: 'Écrase tous les champs, y compris les modifications des utilisateurs',
onClick: () => { const r = openMenu.row; setOpenMenu(null); setForcePushConfirm(r); },
disabled: openMenu.row.nb_plateformes_liees === 0 || pushing === openMenu.row.id, color: '#d97706' },
{ icon: <IconDownload />, label: 'Exporter', onClick: () => { const r = openMenu.row; setOpenMenu(null); handleExportOne(r); } },
{ icon: <IconTrash />, label: 'Supprimer', onClick: () => { const r = openMenu.row; setOpenMenu(null); handleDelete(r); }, color: 'var(--danger)' },
].map(({ icon, label, title, onClick, disabled, color }) => (
<button key={label} disabled={disabled} title={title}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
background: 'none', border: 'none', cursor: disabled ? 'default' : 'pointer',
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left', opacity: disabled ? 0.5 : 1 }}
onMouseEnter={e => { if (!disabled) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={disabled ? undefined : onClick}>
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
{label}
</button>
))}
</div>
</>
)}
{editing && <RefModal 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)} />}
{forcePushConfirm && (
<ConfirmModal open
title="Poussée forcée — confirmer ?"
message={`Cette opération va écraser tous les champs des ${forcePushConfirm.nb_plateformes_liees} plateforme(s) liée(s) à "${forcePushConfirm.nom}", y compris les modifications apportées par les utilisateurs. Leurs overrides seront réinitialisés.`}
confirmLabel="Forcer la poussée"
danger
onConfirm={() => { const r = forcePushConfirm; setForcePushConfirm(null); handlePush(r, true); }}
onCancel={() => setForcePushConfirm(null)}
/>
)}
</div>
);
}
/* ── Bibliothèque de logos/icones du référentiel ────────────────── */
function LogosBibliothequeSection() {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [uploading, setUploading] = useState(null);
const [deleting, setDeleting] = useState(null);
const [result, setResult] = useState(null);
const [type, setType] = useState('logo'); // 'logo' | 'icone'
const fileInputRef = useRef(null);
const [targetId, setTargetId] = useState(null);
const API_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '');
const load = async () => {
try {
setLoading(true);
const data = await api.get('/referentiel');
setRows(data);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const handleFileChange = async (e) => {
const file = e.target.files?.[0];
if (!file || !targetId) return;
e.target.value = '';
const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp'];
const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0];
if (!allowed.includes(ext)) {
setResult({ ok: false, msg: 'Format non supporté SVG, PNG, JPG ou WebP uniquement' });
return;
}
setUploading(targetId);
setResult(null);
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('cl_token');
const res = await fetch(`${API_BASE}/api/referentiel/${targetId}/${type}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'Erreur upload');
const label = type === 'logo' ? 'Logo' : 'Icône';
const countKey = json.nb_plateformes_updated;
setResult({ ok: true, msg: `${label} importé — mis à jour sur ${countKey} plateforme(s) liée(s).` });
load();
} catch (ex) { setResult({ ok: false, msg: ex.message }); }
finally { setUploading(null); setTargetId(null); }
};
const triggerUpload = (id) => {
setTargetId(id);
setTimeout(() => fileInputRef.current?.click(), 50);
};
const handleDelete = (row) => {
const label = type === 'logo' ? 'logo' : 'icône';
setDeleting({
title: `Supprimer le ${label} de "${row.nom}" ?`,
message: `Le ${label} sera supprimé du référentiel et retiré de toutes les plateformes liées non-overridées.`,
confirmLabel: 'Supprimer', danger: true,
onConfirm: async () => {
try {
await api.del(`/referentiel/${row.id}/${type}`);
setResult({ ok: true, msg: `${label.charAt(0).toUpperCase() + label.slice(1)} de "${row.nom}" supprimé.` });
load();
} catch (ex) { setResult({ ok: false, msg: ex.message }); }
finally { setDeleting(null); }
},
});
};
const getImageUrl = (row) => {
const filename = type === 'logo' ? row.logo_filename : row.icone_filename;
return filename ? `${API_BASE}/api/logos/${filename}` : null;
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>Bibliothèque d'images Plateformes</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
{type === 'logo'
? 'Logos associés au référentiel. Une fois importé, le logo est poussé automatiquement vers les plateformes liées.'
: 'Icônes associées au référentiel. Une fois importée, l\'icône est poussée automatiquement vers les plateformes liées.'}
</p>
</div>
</div>
<div className="dr-tabs" style={{ marginBottom: 20 }}>
<button
className={`dr-tab${type === 'logo' ? ' active' : ''}`}
onClick={() => { setType('logo'); setResult(null); }}>
Logos
</button>
<button
className={`dr-tab${type === 'icone' ? ' active' : ''}`}
onClick={() => { setType('icone'); setResult(null); }}>
Icônes
</button>
</div>
<input ref={fileInputRef} type="file" accept=".svg,.png,.jpg,.jpeg,.webp" style={{ display: 'none' }} onChange={handleFileChange} />
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginBottom: 16 }} />
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement</p> : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 16 }}>
{rows.length === 0 && (
<p style={{ color: 'var(--text-muted)', gridColumn: '1 / -1' }}>Aucune entrée dans le référentiel.</p>
)}
{rows.map(row => {
const imageUrl = getImageUrl(row);
const isUploading = uploading === row.id;
const missingLabel = type === 'logo' ? 'Logo manquant' : 'Icône manquante';
return (
<div key={row.id} className="card" style={{ padding: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{
height: 110,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--surface-2)',
borderBottom: '1px solid var(--border)',
position: 'relative',
}}>
{isUploading ? (
<span style={{ color: 'var(--text-muted)', fontSize: 13 }}>Import</span>
) : imageUrl ? (
<img src={imageUrl} alt={row.nom}
style={{ maxWidth: 120, maxHeight: 80, objectFit: 'contain' }}
onError={e => { e.currentTarget.style.display = 'none'; e.currentTarget.nextSibling.style.display = 'flex'; }} />
) : null}
<div style={{
display: imageUrl && !isUploading ? 'none' : 'flex',
flexDirection: 'column', alignItems: 'center', gap: 6,
color: 'var(--text-muted)',
}}>
{!isUploading && (
<>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.45">
<circle cx="12" cy="12" r="10"/>
<line x1="8" y1="8" x2="16" y2="16"/>
<line x1="16" y1="8" x2="8" y2="16"/>
</svg>
<span style={{ fontSize: 11 }}>{missingLabel}</span>
</>
)}
</div>
</div>
<div style={{ padding: '10px 12px', flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 600, fontSize: 13, lineHeight: 1.3 }}>{row.nom}</div>
{row.nb_plateformes_liees > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{row.nb_plateformes_liees} plateforme(s) liée(s)</div>
)}
<div style={{ marginTop: 'auto', paddingTop: 8, display: 'flex', gap: 6 }}>
<button
onClick={() => triggerUpload(row.id)}
disabled={isUploading}
style={{ flex: 1, fontSize: 12, padding: '5px 8px', cursor: 'pointer' }}>
{imageUrl ? 'Remplacer' : 'Importer'}
</button>
{imageUrl && (
<button
onClick={() => handleDelete(row)}
disabled={isUploading}
style={{ fontSize: 12, padding: '5px 8px', cursor: 'pointer', color: 'var(--danger)', background: 'rgba(239,68,68,.08)', border: '1px solid rgba(239,68,68,.25)', borderRadius: 6 }}>
×
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
</div>
);
}
/* ── Section plateformes orphelines ──────────────────────────── */
function OrphelinesSection() {
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [acting, setActing] = useState(null);
const [confirm, setConfirm] = useState(null);
const [result, setResult] = useState(null);
const load = useCallback(async () => {
try { setLoading(true); setRows(await api.get('/admin/plateformes-orphelines')); }
catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleLier = (p) => {
setConfirm({
title: 'Lier au référentiel existant',
message: `Lier "${p.nom}" au référentiel "${p.suggestion.referentiel_nom}" (similarité ${p.suggestion.score}%) sans modifier les données ? Les champs ne seront pas écrasés.`,
confirmLabel: 'Lier',
onConfirm: async () => {
setConfirm(null); setActing(p.id);
try {
await api.post(`/admin/plateformes-orphelines/${p.id}/lier`, { referentiel_id: p.suggestion.referentiel_id });
setResult({ ok: true, msg: `"${p.nom}" lié à "${p.suggestion.referentiel_nom}".` });
load();
} catch (e) { setResult({ ok: false, msg: e.message }); }
finally { setActing(null); }
},
});
};
const handleImport = (p) => {
setConfirm({
title: 'Importer dans le référentiel',
message: `Créer une nouvelle entrée "${p.nom}" dans le référentiel commun et lier cette plateforme automatiquement ?`,
confirmLabel: 'Importer',
onConfirm: async () => {
setConfirm(null); setActing(p.id);
try {
const r = await api.post(`/admin/plateformes-orphelines/${p.id}/importer`, {});
setResult({ ok: true, msg: r.created ? `"${r.referentiel.nom}" créé dans le référentiel.` : `"${r.referentiel.nom}" existait déjà — plateforme liée.` });
load();
} catch (e) { setResult({ ok: false, msg: e.message }); }
finally { setActing(null); }
},
});
};
const byUser = rows.reduce((acc, r) => { const k = r.user_email; if (!acc[k]) acc[k] = { display_name: r.user_display_name, plats: [] }; acc[k].plats.push(r); return acc; }, {});
const totalSuggestions = rows.filter(r => r.suggestion).length;
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>Plateformes suggérées</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Plateformes créées par les utilisateurs non présentes dans le référentiel des plateformes connues.
{totalSuggestions > 0 && <span style={{ color: '#f59e0b', marginLeft: 6 }}> {totalSuggestions} correspondance(s) suggérée(s) avec le référentiel existant.</span>}
</p>
</div>
<button
onClick={() => navigate('/admin/plateformes?section=referentiel')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '7px 14px',
borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--text-muted)', fontSize: 13, fontWeight: 500, cursor: 'pointer', flexShrink: 0 }}>
Retour aux plateformes
</button>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => { setResult(null); navigate('/admin/plateformes?section=referentiel'); }} style={{ marginBottom: 12 }} />
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement</p>
: Object.keys(byUser).length === 0
? <div className="card" style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Aucune plateforme détectée.</div>
: Object.entries(byUser).map(([email, { display_name, plats }]) => (
<div key={email} className="card" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 12, fontSize: 13, color: 'var(--text-muted)' }}>
Plateforme déclarée par <strong style={{ color: 'var(--text)' }}>{display_name || email}</strong> ({email}) non présente dans le référentiel :
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '6px 12px', textAlign: 'left' }}>Plateforme</th>
<th style={{ padding: '6px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '6px 8px', textAlign: 'left' }}>Suggestion</th>
<th style={{ padding: '6px 12px', textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{plats.map((p, i) => (
<tr key={p.id} style={{ borderBottom: i < plats.length - 1 ? '1px solid var(--border)' : 'none',
background: p.suggestion ? 'rgba(245,158,11,.04)' : 'transparent' }}>
<td style={{ padding: '10px 12px', fontWeight: 500 }}>{p.nom}</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}><span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={p.domiciliation} size={15} />{countryLabel(p.domiciliation)}</span></td>
<td style={{ padding: '10px 8px' }}>
{p.suggestion ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '2px 8px', borderRadius: 10,
background: 'rgba(245,158,11,.15)', color: '#b45309', fontWeight: 600 }}>
{p.suggestion.referentiel_nom} ({p.suggestion.score}%)
</span>
) : <span style={{ color: 'var(--text-muted)', fontSize: 12 }}></span>}
</td>
<td style={{ padding: '10px 12px', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
{p.suggestion && (
<button
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, padding: '4px 10px',
background: 'rgba(245,158,11,.12)', border: '1px solid rgba(245,158,11,.4)',
borderRadius: 6, color: '#b45309', cursor: 'pointer', whiteSpace: 'nowrap' }}
disabled={acting === p.id}
onClick={() => handleLier(p)}>
<IconLink /> {acting === p.id ? '…' : 'Lier'}
</button>
)}
<button
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, padding: '4px 10px',
border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer',
background: 'var(--surface)', color: 'var(--text)', whiteSpace: 'nowrap' }}
disabled={acting === p.id}
onClick={() => handleImport(p)}>
{acting === p.id ? '…' : 'Importer'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
))
}
{confirm && <ConfirmModal open title={confirm.title} message={confirm.message} confirmLabel={confirm.confirmLabel} onConfirm={confirm.onConfirm} onCancel={() => setConfirm(null)} />}
</div>
);
}
/* ── Icônes catégories / secteurs ────────────────────────────── */
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.41l-7.17 7.17a2 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 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 IconMerge() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 6l4 4-4 4"/><path d="M16 6l-4 4 4 4"/><line x1="12" y1="10" x2="12" y2="20"/><line x1="8" y1="20" x2="16" y2="20"/></svg>;
}
/* ── Section générique catégories / secteurs ─────────────────── */
function RefListSection({ title, subtitle, apiPath, onRowClick, suggestType }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [suggestCount, setSuggestCount] = useState(0);
// Création
const [newNom, setNewNom] = useState('');
const [adding, setAdding] = useState(false);
// Édition inline
const [editId, setEditId] = useState(null);
const [editNom, setEditNom] = useState('');
const [saving, setSaving] = useState(false);
// Menu ⋮
const [openMenu, setOpenMenu] = useState(null); // { item, x, y }
// Suppression
const [confirm, setConfirm] = useState(null);
// Fusion
const [mergeSource, setMergeSource] = useState(null);
const navigate = useNavigate();
const load = useCallback(() => {
setLoading(true);
api.get(apiPath)
.then(data => { setItems(data); setErr(null); })
.catch(e => setErr(e.message))
.finally(() => setLoading(false));
}, [apiPath]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!suggestType) return;
api.get('/admin/inv-suggestions-count')
.then(d => setSuggestCount(suggestType === 'categories' ? d.cats : d.sects))
.catch(() => {});
}, [suggestType]);
// Fermeture menu au scroll
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={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>{title}</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>{subtitle}</p>
</div>
{suggestType && (
<button
onClick={() => navigate('/admin/plateformes?section=inv-suggestions')}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid',
fontSize: 13, fontWeight: 600, cursor: 'pointer', flexShrink: 0,
background: suggestCount > 0 ? 'rgba(239,68,68,.08)' : 'var(--surface-2)',
color: suggestCount > 0 ? '#dc2626' : 'var(--text-muted)',
borderColor: suggestCount > 0 ? 'rgba(239,68,68,.3)' : 'var(--border)',
}}>
{suggestCount} {suggestType === 'categories'
? `catégorie${suggestCount !== 1 ? 's' : ''} suggérée${suggestCount !== 1 ? 's' : ''}`
: `secteur${suggestCount !== 1 ? 's' : ''} suggéré${suggestCount !== 1 ? 's' : ''}`}
</button>
)}
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Formulaire ajout */}
<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="Nouveau nom…"
style={{ flex: 1, padding: '7px 12px', borderRadius: 7, border: '1px solid var(--border)',
background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}
/>
<button type="submit" disabled={adding || !newNom.trim()}
style={{ padding: '7px 16px', borderRadius: 7, border: 'none', cursor: 'pointer',
background: 'var(--primary)', color: '#fff', fontSize: 13, fontWeight: 600,
opacity: adding || !newNom.trim() ? 0.6 : 1 }}>
{adding ? '…' : 'Ajouter'}
</button>
</form>
</div>
{/* Liste */}
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement</p>
: items.length === 0
? <div className="card" style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>
Aucun élément commencez par en ajouter un.
</div>
: (
<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: '8px 16px', textAlign: 'left', fontWeight: 600, fontSize: 12, color: 'var(--text-muted)' }}>Nom</th>
<th style={{ padding: '8px 12px', textAlign: 'center', fontWeight: 600, fontSize: 12, color: 'var(--text-muted)', width: 140 }}>Utilisé par</th>
<th style={{ padding: '8px 12px', textAlign: 'right', width: 48 }}></th>
</tr>
</thead>
<tbody>
{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(--primary)',
background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}
/>
<button type="submit" disabled={saving}
style={{ padding: '5px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: 'var(--primary)', color: '#fff', fontSize: 12, fontWeight: 600 }}>
{saving ? '…' : 'OK'}
</button>
<button type="button" onClick={cancelEdit}
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface)', color: 'var(--text)', fontSize: 12, cursor: 'pointer' }}>
</button>
</form>
) : (
<span
onClick={onRowClick ? () => onRowClick(item) : undefined}
style={{ fontWeight: 500,
cursor: onRowClick ? 'pointer' : 'default',
color: onRowClick ? 'var(--primary)' : 'inherit',
textDecoration: onRowClick ? 'underline' : 'none',
textDecorationColor: 'transparent',
}}
onMouseEnter={onRowClick ? e => { e.currentTarget.style.textDecorationColor = 'var(--primary)'; } : undefined}
onMouseLeave={onRowClick ? e => { e.currentTarget.style.textDecorationColor = 'transparent'; } : undefined}
>{item.nom}</span>
)}
</td>
<td style={{ padding: '10px 12px', textAlign: 'center' }}>
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 10, fontSize: 11, fontWeight: 600,
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} plateforme{item.nb_utilises !== 1 ? 's' : ''}
</span>
</td>
<td style={{ padding: '10px 12px', textAlign: 'right' }}>
{editId !== item.id && (
<button
onClick={e => openContextMenu(e, item)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 6px',
borderRadius: 6, color: 'var(--text-muted)', fontSize: 18, lineHeight: 1,
display: 'inline-flex', alignItems: 'center' }}
title="Actions">
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
{/* Menu contextuel */}
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 999 }} onClick={() => setOpenMenu(null)} />
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, zIndex: 1000,
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 10,
boxShadow: '0 4px 20px rgba(0,0,0,.15)', minWidth: 160, overflow: 'hidden' }}>
{[
{ icon: <IconEdit />, label: 'Renommer', onClick: () => { setOpenMenu(null); startEdit(openMenu.item); } },
{
icon: <IconMerge />,
label: 'Fusionner avec…',
disabled: items.length < 2,
title: items.length < 2 ? 'Aucune autre entrée disponible' : '',
onClick: () => { const it = openMenu.item; setOpenMenu(null); setMergeSource(it); },
color: '#7c3aed',
},
{
icon: <IconTrash />,
label: 'Supprimer',
disabled: openMenu.item.nb_utilises > 0,
title: openMenu.item.nb_utilises > 0 ? 'Retirez-le des plateformes avant de le supprimer' : '',
onClick: () => { const it = openMenu.item; setOpenMenu(null); handleDelete(it); },
color: 'var(--danger)',
},
].map(action => (
<button key={action.label}
disabled={action.disabled}
title={action.title || ''}
onClick={action.onClick}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', padding: '8px 14px',
background: 'none', border: 'none', cursor: action.disabled ? 'not-allowed' : 'pointer',
fontSize: 13, color: action.disabled ? 'var(--text-muted)' : (action.color || 'var(--text)'),
opacity: action.disabled ? 0.5 : 1 }}
onMouseEnter={e => { if (!action.disabled) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{action.icon}</span>
{action.label}
</button>
))}
</div>
</>
)}
{confirm && <ConfirmModal open title={confirm.title} message={confirm.message} confirmLabel={confirm.confirmLabel} onConfirm={confirm.onConfirm} onCancel={() => setConfirm(null)} />}
{mergeSource && (
<MergeModal
source={mergeSource}
items={items}
apiPath={apiPath}
onClose={() => setMergeSource(null)}
onDone={() => { setMergeSource(null); load(); }}
/>
)}
</div>
);
}
/* ── Modal de fusion catégorie/secteur ───────────────────────── */
function MergeModal({ source, items, apiPath, onClose, onDone }) {
const others = items.filter(i => i.id !== source.id);
const [targetId, setTargetId] = useState(others[0]?.id ?? '');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [err, setErr] = useState(null);
const target = others.find(i => i.id === Number(targetId));
const handleMerge = async () => {
if (!targetId) return;
setLoading(true); setErr(null);
try {
const res = await api.post(`${apiPath}/${source.id}/merge`, { target_id: Number(targetId) });
setResult(res.message);
} catch (ex) { setErr(ex.message); }
finally { setLoading(false); }
};
return (
<Modal open title={`Fusionner — ${source.nom}`} onClose={result ? onDone : onClose} width={480}
footer={result ? (
<button className="primary" onClick={onDone}>Fermer</button>
) : (
<>
<button onClick={onClose} disabled={loading}>Annuler</button>
<button className="primary" onClick={handleMerge} disabled={loading || !targetId}
style={{ background: '#7c3aed' }}>
{loading ? 'Fusion…' : 'Fusionner'}
</button>
</>
)}>
{result ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: '12px 16px', borderRadius: 8, background: 'rgba(34,197,94,.1)',
color: '#16a34a', border: '1px solid rgba(34,197,94,.3)', fontSize: 13 }}>
{result}
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
Les plateformes du référentiel qui ont <strong style={{ color: 'var(--text)' }}>{source.nom}</strong> recevront
la catégorie cible si elles ne l'ont pas déjà. La catégorie source sera ensuite supprimée.
</p>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 12, fontWeight: 600,
textTransform: 'uppercase', color: 'var(--text-muted)', letterSpacing: '.05em' }}>
Fusionner dans
</label>
<select value={targetId} onChange={e => setTargetId(e.target.value)}
style={{ width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--border)',
background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}>
{others.map(o => (
<option key={o.id} value={o.id}>
{o.nom}{o.nb_utilises > 0 ? ` — ${o.nb_utilises} plateforme(s)` : ''}
</option>
))}
</select>
</div>
{target && (
<div style={{ padding: '10px 14px', borderRadius: 8, background: 'rgba(124,58,237,.06)',
border: '1px solid rgba(124,58,237,.2)', fontSize: 12, color: 'var(--text-muted)' }}>
<strong style={{ color: '#7c3aed' }}>{source.nom}</strong>
{' '}({source.nb_utilises} plateforme(s)) →{' '}
<strong style={{ color: '#7c3aed' }}>{target.nom}</strong>
{' '}({target.nb_utilises} plateforme(s))
</div>
)}
{err && <div className="error">{err}</div>}
</div>
)}
</Modal>
);
}
/* ── Navigation ───────────────────────────────────────────────── */
/* ── Catégories & secteurs suggérés ─────────────────────────── */
function InvSuggestionsSection() {
const navigate = useNavigate();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('categories');
const [result, setResult] = useState(null);
const [confirming, setConfirming] = useState(null);
const load = async () => {
setLoading(true);
try {
const d = await api.get('/admin/inv-suggestions');
setData(d);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const promouvoir = async (type, row) => {
try {
const r = await api.post(`/admin/inv-suggestions/${type}/${row.id}/promouvoir`);
setResult({ ok: true, msg: r.msg });
load();
} catch (e) { setResult({ ok: false, msg: e.message }); }
};
const supprimer = async (type, row) => {
try {
const r = await api.del(`/admin/inv-suggestions/${type}/${row.id}`);
setResult({ ok: true, msg: r.msg });
load();
} catch (e) { setResult({ ok: false, msg: e.message }); }
};
const rows = tab === 'categories' ? (data?.categories || []) : (data?.secteurs || []);
const typeKey = tab === 'categories' ? 'categories' : 'secteurs';
const chipClass = tab === 'categories' ? 'chip-cat' : 'chip-sect';
const emptyLabel = tab === 'categories' ? 'Aucune catégorie suggérée' : 'Aucun secteur suggéré';
const byUser = rows.reduce((acc, r) => {
const k = r.email;
if (!acc[k]) acc[k] = { display_name: r.display_name, email: r.email, items: [] };
acc[k].items.push(r);
return acc;
}, {});
return (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>Tags suggérés</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Catégories et secteurs créés par les utilisateurs, non présents dans le référentiel global.
</p>
</div>
<button onClick={() => navigate('/admin/plateformes?section=categories')}
style={{ fontSize: 12, padding: '5px 12px', borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', flexShrink: 0 }}>
← Retour aux catégories
</button>
</div>
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginBottom: 16 }} />
<div className="dr-tabs" style={{ marginBottom: 20 }}>
<button className={`dr-tab${tab === 'categories' ? ' active' : ''}`} onClick={() => setTab('categories')}>
Catégories
{data && data.categories.length > 0 && (
<span style={{ marginLeft: 6, background: 'rgba(239,68,68,.15)', color: '#dc2626',
borderRadius: 10, padding: '1px 7px', fontSize: 11, fontWeight: 600 }}>
{data.categories.length}
</span>
)}
</button>
<button className={`dr-tab${tab === 'secteurs' ? ' active' : ''}`} onClick={() => setTab('secteurs')}>
Secteurs
{data && data.secteurs.length > 0 && (
<span style={{ marginLeft: 6, background: 'rgba(239,68,68,.15)', color: '#dc2626',
borderRadius: 10, padding: '1px 7px', fontSize: 11, fontWeight: 600 }}>
{data.secteurs.length}
</span>
)}
</button>
</div>
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Chargement…</p>
) : rows.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>
{emptyLabel}
</div>
) : (
Object.entries(byUser).map(([email, { display_name, items }]) => (
<div key={email} className="card" style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12, fontWeight: 500 }}>
{display_name ? `Suggéré par ${display_name} (${email}) :` : `Suggéré par ${email} :`}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'left', padding: '6px 8px', color: 'var(--text-muted)', fontWeight: 500 }}>Nom</th>
<th style={{ textAlign: 'center', padding: '6px 8px', color: 'var(--text-muted)', fontWeight: 500 }}>Plateformes</th>
<th style={{ textAlign: 'center', padding: '6px 8px', color: 'var(--text-muted)', fontWeight: 500 }}>Investissements</th>
<th style={{ textAlign: 'right', padding: '6px 8px' }}></th>
</tr>
</thead>
<tbody>
{items.map(row => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px' }}><span className={chipClass}>{row.nom}</span></td>
<td style={{ textAlign: 'center', padding: '8px', color: 'var(--text-muted)' }}>{row.nb_plateformes}</td>
<td style={{ textAlign: 'center', padding: '8px', color: 'var(--text-muted)' }}>{row.nb_investissements}</td>
<td style={{ textAlign: 'right', padding: '8px' }}>
<button onClick={() => setConfirming({ type: 'promouvoir', typeKey, row })}
style={{ fontSize: 12, padding: '4px 10px', marginRight: 6,
background: 'rgba(99,102,241,.1)', color: 'var(--primary)',
border: '1px solid rgba(99,102,241,.25)', borderRadius: 6, cursor: 'pointer' }}>
Promouvoir
</button>
<button onClick={() => setConfirming({ type: 'supprimer', typeKey, row })}
style={{ fontSize: 12, padding: '4px 10px',
background: 'rgba(239,68,68,.08)', color: '#dc2626',
border: '1px solid rgba(239,68,68,.2)', borderRadius: 6, cursor: 'pointer' }}>
Supprimer
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
))
)}
{confirming?.type === 'promouvoir' && (
<ConfirmModal open
title="Promouvoir en global"
message={`Rendre "${confirming.row.nom}" visible par tous les utilisateurs ?`}
confirmLabel="Promouvoir"
onConfirm={() => { promouvoir(confirming.typeKey, confirming.row); setConfirming(null); }}
onClose={() => setConfirming(null)} />
)}
{confirming?.type === 'supprimer' && (
<ConfirmModal open danger
title="Supprimer ce tag"
message={`Supprimer définitivement "${confirming.row.nom}" ?${confirming.row.nb_plateformes + confirming.row.nb_investissements > 0 ? ' Ce tag est déjà utilisé.' : ''}`}
confirmLabel="Supprimer"
onConfirm={() => { supprimer(confirming.typeKey, confirming.row); setConfirming(null); }}
onClose={() => setConfirming(null)} />
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
ICÔNES GARANTIES / NOTATION
═══════════════════════════════════════════════════════════════ */
function IconShield() {
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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>;
}
function IconStar() {
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 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>;
}
function IconImportGar() {
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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
}
function IconExportBtn() {
return <svg width="16" height="16" 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>;
}
/* ── Export helpers ───────────────────────────────────────────── */
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function garantiesToCSV(rows) {
const BOM = '\uFEFF';
const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '\"\"')}"`;
const headers = ['ID', 'Ordre', 'Libellé', 'Description'];
const data = rows.map(r => [r.id, r.ordre, r.libelle, r.description || '']);
return BOM + [headers, ...data].map(row => row.map(q).join(sep)).join('\r\n');
}
function garantiesToJSON(rows) {
return JSON.stringify({
exported_at: new Date().toISOString(),
garanties: rows.map(({ id, ordre, libelle, description }) => ({ id, ordre, libelle, description })),
}, null, 2);
}
function GarExportDropdown({ disabled, rows }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title="Exporter">
<IconExportBtn />
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => { setOpen(false); dlBlob(garantiesToCSV(rows), 'garanties.csv', 'text/csv;charset=utf-8'); }}>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => { setOpen(false); dlBlob(garantiesToJSON(rows), 'garanties.json', 'application/json'); }}>
<span><strong>Format JSON</strong><small>Données structurées</small></span>
</button>
</div>
)}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
SECTION GARANTIES
═══════════════════════════════════════════════════════════════ */
const EMPTY_GAR = { libelle: '', description: '', ordre: '' };
function GarantiesSection() {
const [garanties, setGaranties] = useState([]);
const [loading, setLoading] = useState(true);
const [newRow, setNewRow] = useState(EMPTY_GAR);
const [editId, setEditId] = useState(null);
const [editRow, setEditRow] = useState({});
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
const [importResult, setImportResult] = useState(null);
const [importing, setImporting] = useState(false);
const [importErr, setImportErr] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const importRef = useRef(null);
const load = useCallback(async () => {
try { setLoading(true); setGaranties(await api.get('/garanties')); }
catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleImport = async (e) => {
const file = e.target.files[0];
if (!importRef.current) return;
importRef.current.value = '';
if (!file) return;
setImporting(true); setImportErr(null); setImportResult(null);
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/garanties/import', fd);
setImportResult(r);
load();
} catch (ex) { setImportErr(ex.message); }
finally { setImporting(false); }
};
const startEdit = (g) => { setEditId(g.id); setEditRow({ libelle: g.libelle, description: g.description || '', ordre: String(g.ordre ?? 0) }); setErr(null); };
const cancelEdit = () => { setEditId(null); setEditRow({}); setErr(null); };
const saveEdit = async (g) => {
if (!editRow.libelle?.trim()) { setErr('Le libellé est requis.'); return; }
setSaving(true); setErr(null);
try {
await api.put(`/garanties/${g.id}`, { libelle: editRow.libelle.trim(), description: editRow.description?.trim() || null, ordre: Number(editRow.ordre || 0) });
cancelEdit(); load();
} catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
const saveNew = async () => {
if (!newRow.libelle?.trim()) { setErr('Le libellé est requis.'); return; }
setSaving(true); setErr(null);
try {
await api.post('/garanties', { libelle: newRow.libelle.trim(), description: newRow.description?.trim() || null, ordre: Number(newRow.ordre || 0) });
setNewRow(EMPTY_GAR); load();
} catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
const del = (id) => {
setDeleteConfirm({
message: 'Supprimer ce type de garantie ?',
onConfirm: async () => {
try { await api.del(`/garanties/${id}`); load(); }
catch (ex) { setErr(ex.message); }
finally { setDeleteConfirm(null); }
},
});
};
return (
<>
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>Types de garanties</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Référentiel global des garanties associables à un investissement.
</p>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label title="Importer (.xlsx, .csv, .json)" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 32, height: 32, borderRadius: 6, cursor: 'pointer',
border: '1px solid var(--border)', background: 'var(--surface)',
color: 'var(--text-muted)',
}}>
{importing ? '…' : <IconImportGar />}
<input ref={importRef} type="file" accept=".xlsx,.xls,.csv,.json"
style={{ display: 'none' }} disabled={importing} onChange={handleImport} />
</label>
<GarExportDropdown disabled={garanties.length === 0} rows={garanties} />
</div>
</div>
{err && <div className="error" style={{ marginBottom: 10 }}>{err}</div>}
{importErr && <div className="error" style={{ marginBottom: 10 }}>{importErr}</div>}
{importResult && (
<div className="success-msg" style={{ marginBottom: 10 }}>
✔ Import terminé — {importResult.inserted} créée(s), {importResult.updated} mise(s) à jour
{importResult.skipped > 0 && `, ${importResult.skipped} ignorée(s)`}.
</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 12px', width: 48, textAlign: 'center', fontWeight: 600 }}>#</th>
<th style={{ padding: '10px 8px', textAlign: 'left', fontWeight: 600, width: '24%' }}>Libellé</th>
<th style={{ padding: '10px 8px', textAlign: 'left', fontWeight: 600 }}>Description</th>
<th style={{ padding: '10px 12px', width: 100 }} />
</tr>
</thead>
<tbody>
{garanties.length === 0 && editId === null && (
<tr><td colSpan={4} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)', fontStyle: 'italic' }}>Aucun type de garantie défini.</td></tr>
)}
{garanties.map((g, i) => (
<tr key={g.id} style={{ borderBottom: i < garanties.length - 1 ? '1px solid var(--border)' : 'none', verticalAlign: 'top' }}>
{editId === g.id ? (
<>
<td style={{ padding: '8px 12px' }}><input type="number" min="0" value={editRow.ordre} style={{ width: 52 }} onChange={e => setEditRow({ ...editRow, ordre: e.target.value })} /></td>
<td style={{ padding: '8px 8px' }}><input required autoFocus value={editRow.libelle} placeholder="Libellé *" onChange={e => setEditRow({ ...editRow, libelle: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') saveEdit(g); if (e.key === 'Escape') cancelEdit(); }} /></td>
<td style={{ padding: '8px 8px' }}><textarea rows={2} value={editRow.description} placeholder="Description…" style={{ width: '100%', resize: 'vertical', fontSize: 13 }} onChange={e => setEditRow({ ...editRow, description: e.target.value })} /></td>
<td style={{ padding: '8px 12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<button className="primary" style={{ padding: '3px 10px', fontSize: 11 }} disabled={saving} onClick={() => saveEdit(g)}>{saving ? '…' : '✓ OK'}</button>
<button style={{ padding: '3px 10px', fontSize: 11 }} onClick={cancelEdit}>Annuler</button>
</div>
</td>
</>
) : (
<>
<td style={{ padding: '8px 12px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{g.ordre}</td>
<td style={{ padding: '8px 8px', fontWeight: 500 }}>{g.libelle}</td>
<td style={{ padding: '8px 8px', fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'pre-wrap' }}>{g.description || <em style={{ opacity: .5 }}>—</em>}</td>
<td style={{ padding: '8px 12px' }}>
<div style={{ display: 'flex', gap: 4 }}>
<button style={{ padding: '3px 8px', fontSize: 11 }} onClick={() => { cancelEdit(); startEdit(g); }}>Éditer</button>
<button style={{ padding: '3px 8px', fontSize: 11, background: 'rgba(239,68,68,.08)', color: '#dc2626', border: '1px solid rgba(239,68,68,.2)', borderRadius: 4, cursor: 'pointer' }} onClick={() => del(g.id)}>✕</button>
</div>
</td>
</>
)}
</tr>
))}
{/* Ligne d'ajout */}
<tr style={{ borderTop: '2px solid var(--border)', verticalAlign: 'top' }}>
<td style={{ padding: '8px 12px' }}><input type="number" min="0" value={newRow.ordre} style={{ width: 52 }} placeholder="0" onChange={e => setNewRow({ ...newRow, ordre: e.target.value })} /></td>
<td style={{ padding: '8px 8px' }}><input value={newRow.libelle} placeholder="Nouveau libellé *" onChange={e => setNewRow({ ...newRow, libelle: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') saveNew(); }} /></td>
<td style={{ padding: '8px 8px' }}><textarea rows={2} value={newRow.description} placeholder="Description (optionnelle)…" style={{ width: '100%', resize: 'vertical', fontSize: 13 }} onChange={e => setNewRow({ ...newRow, description: e.target.value })} /></td>
<td style={{ padding: '8px 12px', paddingTop: 12 }}>
<button className="primary" style={{ padding: '3px 10px', fontSize: 11, whiteSpace: 'nowrap' }} disabled={saving || !newRow.libelle.trim()} onClick={saveNew}>
{saving ? '…' : '+ Ajouter'}
</button>
</td>
</tr>
</tbody>
</table>
)}
</div>
</div>
<ConfirmModal
open={!!deleteConfirm}
message={deleteConfirm?.message}
onConfirm={deleteConfirm?.onConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
</>
);
}
/* ═══════════════════════════════════════════════════════════════
SECTION NOTATION — référentiel
═══════════════════════════════════════════════════════════════ */
const TYPE_LABELS = {
etoiles: '⭐ Étoiles (1 5)',
lettres: '🔤 Lettres (ex. A, B, C…)',
score: '🔢 Score numérique',
custom: '🏷️ Valeurs personnalisées',
};
const EMPTY_NOTATION_FORM = {
nom: '', type: 'etoiles', valeurs: '', min_val: '0', max_val: '10', description: '', ordre: '0',
};
function NotationRefSection() {
const [refs, setRefs] = useState([]); // plateformes référentiel
const [selRef, setSelRef] = useState(''); // id sélectionné
const [criteres, setCriteres] = useState([]);
const [busy, setBusy] = useState(false);
const [form, setForm] = useState(EMPTY_NOTATION_FORM);
const [editing, setEditing] = useState(null);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
// Charger la liste des plateformes référentiel
useEffect(() => {
api.get('/referentiel').then(data => setRefs(data)).catch(() => {});
}, []);
// Charger les critères quand la plateforme change
const loadCriteres = useCallback(async (refId) => {
if (!refId) { setCriteres([]); return; }
setBusy(true);
try { setCriteres(await api.get(`/referentiel/${refId}/notation`)); }
catch (e) { setErr(e.message); }
finally { setBusy(false); }
}, []);
useEffect(() => { loadCriteres(selRef); }, [selRef, loadCriteres]);
const resetForm = () => { setForm(EMPTY_NOTATION_FORM); setEditing(null); setErr(null); };
const openEdit = (c) => {
setEditing(c);
setForm({
nom: c.nom, type: c.type,
valeurs: Array.isArray(c.valeurs) ? c.valeurs.join(', ') : '',
min_val: String(c.min_val ?? 0),
max_val: String(c.max_val ?? 10),
description: c.description || '',
ordre: String(c.ordre ?? 0),
});
setErr(null);
};
const buildPayload = () => ({
nom: form.nom.trim(),
type: form.type,
valeurs: (form.type === 'lettres' || form.type === 'custom')
? form.valeurs.split(',').map(v => v.trim()).filter(Boolean)
: undefined,
min_val: form.type === 'score' ? Number(form.min_val) : undefined,
max_val: form.type === 'score' ? Number(form.max_val) : undefined,
description: form.description || undefined,
ordre: Number(form.ordre || 0),
});
const save = async (e) => {
e.preventDefault(); setSaving(true); setErr(null);
try {
if (editing) {
await api.put(`/referentiel/notation/${editing.id}`, buildPayload());
} else {
await api.post(`/referentiel/${selRef}/notation`, buildPayload());
}
resetForm();
loadCriteres(selRef);
} catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
const del = (id) => {
setDeleteConfirm({
message: 'Supprimer ce critère ?',
onConfirm: async () => {
try { await api.del(`/referentiel/notation/${id}`); loadCriteres(selRef); }
catch (ex) { setErr(ex.message); }
finally { setDeleteConfirm(null); }
},
});
};
return (
<>
<div>
<div style={{ marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Notation des plateformes</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Définissez les critères de notation propres à chaque plateforme du référentiel (risque, qualité emprunteur, rendement, etc.).
</p>
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500 }}>Plateforme</label>
<select value={selRef} onChange={e => { setSelRef(e.target.value); resetForm(); }} style={{ maxWidth: 340 }}>
<option value="">— Choisir une plateforme —</option>
{refs.map(r => <option key={r.id} value={r.id}>{r.nom}</option>)}
</select>
</div>
{!selRef && (
<p style={{ color: 'var(--text-muted)', fontSize: 13, fontStyle: 'italic' }}>
Sélectionnez une plateforme pour gérer ses critères de notation.
</p>
)}
{selRef && (
<>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{busy ? (
<p style={{ color: 'var(--text-muted)' }}>Chargement…</p>
) : criteres.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13, fontStyle: 'italic' }}>
Aucun critère défini pour cette plateforme.
</p>
) : (
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 20 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 12px', width: 32, fontWeight: 600 }}>#</th>
<th style={{ padding: '10px 8px', textAlign: 'left', fontWeight: 600 }}>Critère</th>
<th style={{ padding: '10px 8px', textAlign: 'left', fontWeight: 600 }}>Type</th>
<th style={{ padding: '10px 8px', textAlign: 'left', fontWeight: 600 }}>Barème</th>
<th style={{ padding: '10px 8px', textAlign: 'left', fontWeight: 600 }}>Description</th>
<th style={{ padding: '10px 12px', width: 80 }} />
</tr>
</thead>
<tbody>
{criteres.map((c, i) => (
<tr key={c.id} style={{
borderBottom: i < criteres.length - 1 ? '1px solid var(--border)' : 'none',
background: editing?.id === c.id ? 'rgba(59,130,246,.04)' : 'transparent',
}}>
<td style={{ padding: '8px 12px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{c.ordre}</td>
<td style={{ padding: '8px 8px', fontWeight: 500 }}>{c.nom}</td>
<td style={{ padding: '8px 8px' }}>
<span className="badge en_cours" style={{ fontSize: 11 }}>{TYPE_LABELS[c.type] ?? c.type}</span>
</td>
<td style={{ padding: '8px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
{c.type === 'etoiles' && '1 — 5 ⭐'}
{c.type === 'score' && `${c.min_val} ${c.max_val}`}
{(c.type === 'lettres' || c.type === 'custom') && Array.isArray(c.valeurs) && c.valeurs.join(' · ')}
</td>
<td style={{ padding: '8px 8px', fontSize: 12, color: 'var(--text-muted)' }}>{c.description}</td>
<td style={{ padding: '8px 12px' }}>
<div style={{ display: 'flex', gap: 4 }}>
<button style={{ padding: '3px 8px', fontSize: 11 }} onClick={() => openEdit(c)}>Éditer</button>
<button style={{ padding: '3px 8px', fontSize: 11, background: 'rgba(239,68,68,.08)', color: '#dc2626', border: '1px solid rgba(239,68,68,.2)', borderRadius: 4, cursor: 'pointer' }} onClick={() => del(c.id)}>✕</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="card" style={{ marginTop: 8 }}>
<h4 style={{ margin: '0 0 12px', fontSize: 14 }}>
{editing ? `Modifier le critère « ${editing.nom} »` : 'Ajouter un critère'}
</h4>
{err && !criteres.length && <div className="error" style={{ marginBottom: 10 }}>{err}</div>}
<form onSubmit={save}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<div style={{ gridColumn: 'span 2' }}>
<label>Nom du critère *</label>
<input required value={form.nom} placeholder="ex. Risque emprunteur, Qualité projet…"
onChange={e => setForm({ ...form, nom: e.target.value })} />
</div>
<div>
<label>Ordre</label>
<input type="number" min="0" value={form.ordre} onChange={e => setForm({ ...form, ordre: e.target.value })} />
</div>
<div>
<label>Type de notation *</label>
<select value={form.type} onChange={e => setForm({ ...form, type: e.target.value })}>
{Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
{form.type === 'score' && (
<>
<div><label>Valeur min *</label><input type="number" step="any" required value={form.min_val} onChange={e => setForm({ ...form, min_val: e.target.value })} /></div>
<div><label>Valeur max *</label><input type="number" step="any" required value={form.max_val} onChange={e => setForm({ ...form, max_val: e.target.value })} /></div>
</>
)}
{(form.type === 'lettres' || form.type === 'custom') && (
<div style={{ gridColumn: 'span 2' }}>
<label>Valeurs possibles * <span style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)' }}>(séparées par des virgules)</span></label>
<input required value={form.valeurs}
placeholder={form.type === 'lettres' ? 'ex. A+, A, B+, B, C, D' : 'ex. Excellent, Bon, Moyen, Faible'}
onChange={e => setForm({ ...form, valeurs: e.target.value })} />
</div>
)}
<div style={{ gridColumn: 'span 3' }}>
<label>Description <span style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)' }}>(optionnelle)</span></label>
<input value={form.description} placeholder="ex. Évalue la solidité financière de l'emprunteur"
onChange={e => setForm({ ...form, description: e.target.value })} />
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button className="primary" type="submit" disabled={saving}>
{editing ? 'Enregistrer' : 'Ajouter le critère'}
</button>
{editing && <button type="button" onClick={resetForm}>Annuler</button>}
</div>
</form>
</div>
</>
)}
</div>
<ConfirmModal
open={!!deleteConfirm}
message={deleteConfirm?.message}
onConfirm={deleteConfirm?.onConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
</>
);
}
const NAV = [
{ id: 'referentiel', label: 'Plateformes', icon: <IconDatabase /> },
{ id: 'logos-ref', label: 'Logos des plateformes', icon: <IconImage /> },
{ id: 'categories', label: "Catégories d'investissement", icon: <IconTag /> },
{ id: 'secteurs', label: "Secteurs d'investissement", icon: <IconLayers /> },
{ id: 'garanties', label: 'Types de garanties', icon: <IconShield /> },
{ id: 'notation', label: 'Notation', icon: <IconStar /> },
];
/* ── Page principale ──────────────────────────────────────────── */
export default function AdminPlateformes() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'referentiel';
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">Plateformes</h1>
<div className="account-nav-group">
{NAV.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => navigate(`/admin/plateformes?section=${item.id}`, { replace: true })}
>
{item.icon}
{item.label}
</button>
))}
</div>
</aside>
<div className="account-content">
{section === 'referentiel' && <ReferentielSection />}
{section === 'logos-ref' && <LogosBibliothequeSection />}
{section === 'orphelines' && <OrphelinesSection />}
{section === 'categories' && (
<RefListSection
title="Catégories d'investissement"
subtitle="Liste globale des catégories disponibles sur les profils du référentiel. Cliquez sur un nom pour filtrer les plateformes."
apiPath="/ref-categories"
suggestType="categories"
onRowClick={item => navigate(`/admin/plateformes?section=referentiel&filterCat=${encodeURIComponent(item.nom)}`, { replace: true })}
/>
)}
{section === 'inv-suggestions' && <InvSuggestionsSection />}
{section === 'secteurs' && (
<RefListSection
title="Secteurs d'investissement"
subtitle="Liste globale des secteurs disponibles sur les profils du référentiel."
apiPath="/ref-secteurs"
suggestType="secteurs"
/>
)}
{section === 'garanties' && <GarantiesSection />}
{section === 'notation' && <NotationRefSection />}
</div>
</div>
);
}