2028 lines
104 KiB
React
2028 lines
104 KiB
React
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>
|
||
);
|
||
}
|