import { useState, useEffect, useRef, useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import Modal from '../components/Modal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx'; import ResultBanner from '../components/ResultBanner.jsx'; import { fmt, StatusBadge as LogStatusBadge } from './admin/adminHelpers.jsx'; /* ── Icônes ───────────────────────────────────────────────────── */ function IconChevronLeft() { return ; } function IconSparkle() { return ; } function IconServer() { return ; } function IconSettings() { return ; } function IconChart() { return ; } function IconEye({ open }) { return open ? : ; } const IconEdit = () => ; const IconTrash = () => ; const IconStar = ({ filled }) => ; const IconZap = () => ; /* ── Toggle switch (copié du pattern SmtpSection.jsx) ────────────── */ function Toggle({ checked, onChange, id }) { return ( ); } function StatusBadge({ actif }) { return actif ? Actif : Inactif; } const TYPE_LABELS = { anthropic: 'Anthropic (natif)', openai_compatible: 'Compatible OpenAI', }; /* ── Modal création / édition d'un fournisseur ───────────────────── */ const EMPTY_PROVIDER = { nom: '', type: 'anthropic', baseUrl: '', modele: '', apiKey: '', actif: true, isDefault: false }; function ProviderModal({ provider, onClose, onSaved }) { const isNew = !provider?.id; const [form, setForm] = useState(isNew ? EMPTY_PROVIDER : { nom: provider.nom, type: provider.type, baseUrl: provider.baseUrl || '', modele: provider.modele, apiKey: '', actif: provider.actif, isDefault: provider.isDefault, }); const [showKey, setShowKey] = useState(false); const [err, setErr] = useState(null); const [saving, setSaving] = useState(false); const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const handleSubmit = async (e) => { e.preventDefault(); if (!form.nom.trim()) { setErr('Le nom est requis.'); return; } if (!form.modele.trim()) { setErr('Le modèle est requis.'); return; } if (form.type === 'openai_compatible' && !form.baseUrl.trim()) { setErr("L'URL de base est requise pour un déploiement compatible OpenAI (ex : https://openrouter.ai/api/v1)."); return; } if (isNew && !form.apiKey.trim()) { setErr('La clé API est requise.'); return; } setSaving(true); setErr(null); try { const payload = { nom: form.nom.trim(), type: form.type, baseUrl: form.baseUrl.trim() || undefined, modele: form.modele.trim(), actif: form.actif, isDefault: form.isDefault, }; if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim(); if (isNew) { await api.post('/admin/ai/providers', payload); } else { await api.put(`/admin/ai/providers/${provider.id}`, payload); } onSaved(); onClose(); } catch (ex) { setErr(ex.message); } finally { setSaving(false); } }; const inp = { width: '100%', boxSizing: 'border-box' }; return ( }>
{err && (
{err}
)}
set('nom', e.target.value)} placeholder="Ex : Anthropic (production)" />
{form.type === 'openai_compatible' && (
set('baseUrl', e.target.value)} placeholder="https://openrouter.ai/api/v1" />
)}
set('modele', e.target.value)} placeholder={form.type === 'anthropic' ? 'claude-sonnet-4-5-20250929' : 'anthropic/claude-sonnet-4.5'} />
set('apiKey', e.target.value)} placeholder={!isNew && provider.hasApiKey ? '•••••••• (laisser vide pour conserver)' : 'sk-ant-…'} autoComplete="new-password" />
{!isNew && provider.hasApiKey && (
Une clé est déjà enregistrée. Laissez ce champ vide pour la conserver.
)}
); } /* ── Section : fournisseurs ───────────────────────────────────────── */ function ProvidersSection() { const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); // provider | { new: true } | null const [confirmDel, setConfirmDel] = useState(null); const [banner, setBanner] = useState(null); const [testingId, setTestingId] = useState(null); const load = async () => { setLoading(true); try { setProviders(await api.get('/admin/ai/providers')); } catch (e) { setBanner({ ok: false, msg: e.message }); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const delProvider = (p) => { setConfirmDel({ message: `Supprimer le fournisseur « ${p.nom} » ? Cette action est irréversible.`, onConfirm: async () => { try { await api.del(`/admin/ai/providers/${p.id}`); await load(); } catch (e) { setBanner({ ok: false, msg: e.message }); } finally { setConfirmDel(null); } }, }); }; const toggleActif = async (p) => { try { await api.put(`/admin/ai/providers/${p.id}`, { actif: !p.actif }); await load(); } catch (e) { setBanner({ ok: false, msg: e.message }); } }; const testProvider = async (p) => { setTestingId(p.id); setBanner(null); try { const r = await api.post(`/admin/ai/providers/${p.id}/test`, {}); if (r.ok) { setBanner({ ok: true, msg: `${p.nom} — connexion réussie en ${r.dureeMs} ms (réponse du modèle : « ${r.reponse} »).` }); } else { setBanner({ ok: false, msg: `${p.nom} — échec : ${r.error}` }); } } catch (e) { setBanner({ ok: false, msg: `${p.nom} — échec : ${e.message}` }); } finally { setTestingId(null); } }; return (

Fournisseurs IA

Adaptateur natif Anthropic ou compatible OpenAI (OpenRouter, OpenAI, Mistral, LLM local…). Plusieurs fournisseurs peuvent être configurés ; le fournisseur par défaut actif est utilisé pour les conversations.

{banner && setBanner(null)} style={{ marginBottom: 16 }} />}
{loading ? (

Chargement…

) : providers.length === 0 ? (

Aucun fournisseur configuré.

) : ( {providers.map(p => ( ))}
Nom Type Modèle Clé API Statut Défaut
{p.nom} {TYPE_LABELS[p.type] || p.type} {p.modele} {p.hasApiKey ? '••••••••' : non renseignée}
)}
{editing && ( setEditing(null)} onSaved={() => { setBanner({ ok: true, msg: editing.new ? 'Fournisseur créé.' : 'Fournisseur mis à jour.' }); load(); }} /> )} setConfirmDel(null)} />
); } /* ── Ligne de paramètre (copié du pattern SmtpSection.jsx) ───────── */ function SettingRow({ label, description, children }) { return (
{label}
{description &&
{description}
}
{children}
); } /* ── Section : configuration globale ─────────────────────────────── */ function ConfigSection() { const [config, setConfig] = useState({ actif: false, quotaQuotidien: null }); const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [banner, setBanner] = useState(null); const load = async () => { setLoading(true); try { const [cfg, provs] = await Promise.all([api.get('/admin/ai/config'), api.get('/admin/ai/providers')]); setConfig({ actif: cfg.actif, quotaQuotidien: cfg.quotaQuotidien }); setProviders(provs); } catch (e) { setBanner({ ok: false, msg: e.message }); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const activeDefault = providers.find(p => p.actif && p.isDefault) || providers.find(p => p.actif); const save = async (next) => { setSaving(true); try { const result = await api.patch('/admin/ai/config', next); const msg = result?.usersReset ? `Configuration enregistrée. Accès IA retiré à ${result.usersReset} utilisateur${result.usersReset > 1 ? 's' : ''} (Assistant IA désactivé globalement).` : 'Configuration enregistrée.'; setBanner({ ok: true, msg }); await load(); } catch (e) { setBanner({ ok: false, msg: e.message }); } finally { setSaving(false); } }; const toggleActif = (v) => { if (v && !activeDefault) { setBanner({ ok: false, msg: "Impossible d'activer l'Assistant IA : configurez d'abord au moins un fournisseur actif." }); return; } save({ actif: v }); }; if (loading) return

Chargement…

; return (

Configuration

Interrupteur global de l'Assistant IA embarqué. L'accès reste également soumis à l'autorisation individuelle de chaque utilisateur (page Utilisateurs) et à la disponibilité d'un fournisseur actif.

{banner && setBanner(null)} style={{ marginBottom: 16 }} />}
setConfig(c => ({ ...c, quotaQuotidien: e.target.value === '' ? null : Number(e.target.value) }))} onBlur={() => save({ quotaQuotidien: config.quotaQuotidien })} placeholder="Illimité" disabled={saving} />
); } /* ── Carte de synthèse chiffrée ──────────────────────────────────── */ function StatCard({ label, value, accent }) { return (
{label}
{value}
); } const PERIODS = [ { value: 7, label: '7 jours' }, { value: 30, label: '30 jours' }, { value: 90, label: '90 jours' }, { value: 365, label: '1 an' }, ]; /* ── Section : usage (requêtes & tokens consommés) ────────────────── */ function UsageSection() { const [days, setDays] = useState(30); const [summary, setSummary] = useState(null); const [loadingSum, setLoadingSum] = useState(true); const [err, setErr] = useState(null); const [logRows, setLogRows] = useState([]); const [logTotal, setLogTotal] = useState(0); const [logPage, setLogPage] = useState(0); const [logUserId, setLogUserId] = useState(''); const [loadingLog, setLoadingLog] = useState(true); const LOG_PER_PAGE = 20; const loadSummary = useCallback(async () => { setLoadingSum(true); try { setSummary(await api.get('/admin/ai/usage/summary', { days })); } catch (e) { setErr(e.message); } finally { setLoadingSum(false); } }, [days]); const loadLog = useCallback(async () => { setLoadingLog(true); try { const params = { limit: LOG_PER_PAGE, offset: logPage * LOG_PER_PAGE }; if (logUserId) params.userId = logUserId; const data = await api.get('/admin/ai/usage/log', params); setLogRows(data.rows); setLogTotal(data.total); } catch (e) { setErr(e.message); } finally { setLoadingLog(false); } }, [logPage, logUserId]); useEffect(() => { loadSummary(); }, [loadSummary]); useEffect(() => { loadLog(); }, [loadLog]); useEffect(() => { setLogPage(0); }, [logUserId]); const logPages = Math.ceil(logTotal / LOG_PER_PAGE); const n = (v) => (v ?? 0).toLocaleString('fr-FR'); return (

Usage

{PERIODS.map(p => ( ))}

Nombre de requêtes envoyées à l'Assistant IA et tokens consommés (entrée / sortie), au global, par utilisateur, et requête par requête, sur la période sélectionnée.

{err && setErr(null)} style={{ marginBottom: 16 }} />}
0 ? '#dc2626' : undefined} />

Par utilisateur

{loadingSum ? (

Chargement…

) : !summary?.byUser.length ? (

Aucune requête sur cette période.

) : ( {summary.byUser.map(u => ( ))}
Utilisateur Requêtes Tokens entrée Tokens sortie Tokens total Erreurs Dernière utilisation
{u.displayName || u.email}
{u.displayName &&
{u.email}
}
{n(u.requests)} {n(u.tokensIn)} {n(u.tokensOut)} {n(u.tokensTotal)} 0 ? '#dc2626' : undefined }}>{u.errors} {fmt(u.lastUsed)}
)}

Journal détaillé

{loadingLog ? (

Chargement…

) : logRows.length === 0 ? (

Aucune requête trouvée.

) : ( <> {logRows.map(r => ( ))}
Date Utilisateur Investisseur Modèle Tokens (in / out) Outils Statut
{fmt(r.createdAt)} {r.displayName || r.email} {r.investisseurNom || '—'} {r.modele || '—'} {n(r.tokensIn)} / {n(r.tokensOut)} {r.outilsAppeles.length ? r.outilsAppeles.join(', ') : '—'}
{logPages > 1 && (
Page {logPage + 1} / {logPages}
)} )}
); } /* ── Navigation ───────────────────────────────────────────────── */ const NAV = [ { id: 'providers', label: 'Fournisseurs', icon: }, { id: 'usage', label: 'Usage', icon: }, { id: 'config', label: 'Configuration', icon: }, ]; /* ── Page principale ──────────────────────────────────────────── */ export default function AdminIA() { const { search } = useLocation(); const navigate = useNavigate(); const section = new URLSearchParams(search).get('section') || 'providers'; return (
{section === 'providers' && } {section === 'usage' && } {section === 'config' && }
); }