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 (
onChange(e.target.checked)} style={{ display: 'none' }} />
);
}
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 (
Annuler
{saving ? '…' : isNew ? 'Créer' : 'Enregistrer'}
>}>
);
}
/* ── 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
setEditing({ new: true })}>+ Ajouter
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é.
) : (
Nom
Type
Modèle
Clé API
Statut
Défaut
{providers.map(p => (
{p.nom}
{TYPE_LABELS[p.type] || p.type}
{p.modele}
{p.hasApiKey ? '••••••••' : non renseignée }
toggleActif(p)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }} title="Basculer l'activation">
testProvider(p)} disabled={testingId === p.id || !p.hasApiKey}
title={p.hasApiKey ? 'Tester la connexion' : 'Aucune clé API enregistrée'}
style={{ background: 'none', border: 'none', cursor: p.hasApiKey ? 'pointer' : 'not-allowed', color: testingId === p.id ? 'var(--primary)' : 'var(--text-muted)', padding: 4, opacity: p.hasApiKey ? 1 : 0.4 }}>
setEditing(p)} title="Modifier"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 4 }}>
delProvider(p)} title="Supprimer"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 4 }}>
))}
)}
{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 ;
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 (
);
}
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 => (
setDays(p.value)}
style={{
fontSize: 12, padding: '4px 10px',
background: days === p.value ? 'var(--primary)' : undefined,
color: days === p.value ? '#fff' : undefined,
borderColor: days === p.value ? 'var(--primary)' : undefined,
}}>
{p.label}
))}
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.
) : (
Utilisateur
Requêtes
Tokens entrée
Tokens sortie
Tokens total
Erreurs
Dernière utilisation
{summary.byUser.map(u => (
{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é
setLogUserId(e.target.value)}>
Tous les utilisateurs
{(summary?.byUser || []).map(u => (
{u.displayName || u.email}
))}
{loadingLog ? (
Chargement…
) : logRows.length === 0 ? (
Aucune requête trouvée.
) : (
<>
Date
Utilisateur
Investisseur
Modèle
Tokens (in / out)
Outils
Statut
{logRows.map(r => (
{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 && (
setLogPage(p => p - 1)}>Précédent
Page {logPage + 1} / {logPages}
= logPages - 1} onClick={() => setLogPage(p => p + 1)}>Suivant
)}
>
)}
);
}
/* ── 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 (
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 }}>
Administration
Assistant IA
{NAV.map(item => (
navigate(`/admin/ia?section=${item.id}`, { replace: true })}
>
{item.icon}
{item.label}
))}
{section === 'providers' &&
}
{section === 'usage' &&
}
{section === 'config' &&
}
);
}