Build & Deploy / build (push) Successful in 22s
Statistiques : le type Stats attendait cir/sccs/users alors que /admin/stats renvoie cir_documents/sccs_documents/total_users. Trois cartes sur quatre affichaient « - » et les deux barres restaient à zéro. Contrat aligné sur l'API. Style : la feuille n'utilisait aucune couleur du site — palette bleu ardoise, unités en px, cartes sans bordure. Reprise de la DA des autres pages : accent violet #7c3aed (celui de la Sidebar), échelle de gris et rayons 0.5/0.75rem de DocumentsPage, cartes blanches bordées, unités rem. Responsive : - onglets en bande défilante au lieu d'un retour à la ligne qui cassait l'entête ; - les tableaux deviennent des cartes sous 768px, chaque cellule portant son intitulé de colonne via data-label + ::before — l'ancien display:block empilait les valeurs sans dire à quoi elles correspondaient ; - cartes de stats en grille auto-fit, plus de paliers codés en dur ; - panneau scrapers en deux colonnes, empilé sous 1024px, boutons pleine largeur sous 480px ; - modale contrainte à la largeur de l'écran, focus visible au clavier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
299 lines
15 KiB
TypeScript
299 lines
15 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
|
import styles from '../styles/Admin.module.css'
|
|
import { API_URL } from '../services/theapi'
|
|
import { FileText, Database, Users } from 'lucide-react'
|
|
|
|
type User = { id: string; email: string; full_name?: string; role: 'admin' | 'user' }
|
|
type Document = { id: string; title: string; source: 'CIR' | 'SCCS'; ingredient?: string; date?: string }
|
|
|
|
type ScraperLog = { id: string; when: string; source: string; status: 'pending' | 'running' | 'success' | 'error' }
|
|
type Stats = { total_documents?: number; cir_documents?: number; sccs_documents?: number; total_users?: number }
|
|
|
|
export default function Admin() {
|
|
const [tab, setTab] = useState<'users'|'documents'|'stats'|'scrapers'>('users')
|
|
|
|
const [users, setUsers] = useState<User[]>([])
|
|
const [docs, setDocs] = useState<Document[]>([])
|
|
const [stats, setStats] = useState<Stats | null>(null)
|
|
const [logs, setLogs] = useState<ScraperLog[]>([])
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
const [confirmAction, setConfirmAction] = useState<() => void>(()=>{})
|
|
const [confirmText, setConfirmText] = useState('')
|
|
|
|
const [search, setSearch] = useState('')
|
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
|
const [sortDir, setSortDir] = useState<'asc'|'desc'>('asc')
|
|
|
|
// Jeton posé par App.tsx au login (handleAuthSuccess).
|
|
const token = localStorage.getItem('authToken') ?? ''
|
|
|
|
useEffect(() => {
|
|
const fetchTab = async () => {
|
|
setError(null)
|
|
setLoading(true)
|
|
try{
|
|
if(tab==='users'){
|
|
const res = await fetch(`${API_URL}/admin/users?token=${token}`)
|
|
if(!res.ok) throw new Error('Impossible de charger les utilisateurs')
|
|
const data = await res.json()
|
|
setUsers(data || [])
|
|
} else if(tab==='documents'){
|
|
const res = await fetch(`${API_URL}/documents`)
|
|
if(!res.ok) throw new Error('Impossible de charger les documents')
|
|
const data = await res.json()
|
|
setDocs(data || [])
|
|
} else if(tab==='stats'){
|
|
const res = await fetch(`${API_URL}/admin/stats?token=${token}`)
|
|
if(!res.ok) throw new Error('Impossible de charger les statistiques')
|
|
const data = await res.json()
|
|
setStats(data)
|
|
} else if(tab==='scrapers'){
|
|
// logs endpoint (fallback)
|
|
const res = await fetch(`${API_URL}/admin/scrapers/logs?token=${token}`)
|
|
if(!res.ok) throw new Error('Impossible de charger les logs de scrapers')
|
|
const data = await res.json()
|
|
setLogs(data || [])
|
|
}
|
|
}catch(e: unknown){
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
setError(msg || 'Erreur réseau')
|
|
}finally{setLoading(false)}
|
|
}
|
|
|
|
fetchTab()
|
|
}, [tab])
|
|
|
|
function openConfirm(text: string, action: ()=>void){
|
|
setConfirmText(text)
|
|
setConfirmAction(()=>action)
|
|
setConfirmOpen(true)
|
|
}
|
|
|
|
async function deleteUser(id: string){
|
|
try{
|
|
setLoading(true)
|
|
const res = await fetch(`${API_URL}/admin/users/${id}?token=${token}`, { method: 'DELETE' })
|
|
if(!res.ok) throw new Error('Suppression impossible')
|
|
setUsers(u=>u.filter(x=>x.id!==id))
|
|
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: 'Utilisateur supprimé', type: 'success' } }))
|
|
}catch(e: unknown){
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
setError(msg||'Erreur')
|
|
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } }))
|
|
}finally{setLoading(false)}
|
|
}
|
|
|
|
async function toggleRole(u: User){
|
|
try{
|
|
setLoading(true)
|
|
const newRole = u.role === 'admin' ? 'user' : 'admin'
|
|
const res = await fetch(`${API_URL}/admin/users/${u.id}/role?token=${token}`, { method: 'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ role: newRole }) })
|
|
if(!res.ok) throw new Error('Impossible de modifier le rôle')
|
|
setUsers(list=>list.map(x=> x.id===u.id ? {...x, role:newRole} : x))
|
|
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `Rôle mis à jour: ${newRole}`, type: 'success' } }))
|
|
}catch(e: unknown){ const msg = e instanceof Error ? e.message : String(e); setError(msg||'Erreur'); window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) }finally{ setLoading(false) }
|
|
}
|
|
|
|
async function deleteDocument(id:string){
|
|
try{
|
|
setLoading(true)
|
|
const res = await fetch(`${API_URL}/admin/documents/${id}?token=${token}`, { method: 'DELETE' })
|
|
if(!res.ok) throw new Error('Suppression impossible')
|
|
setDocs(d=>d.filter(x=>x.id!==id))
|
|
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: 'Document supprimé', type: 'success' } }))
|
|
}catch(e: unknown){ const msg = e instanceof Error ? e.message : String(e); setError(msg||'Erreur'); window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) }finally{ setLoading(false) }
|
|
}
|
|
|
|
async function runScraper(source: 'CIR'|'SCCS'){
|
|
try{
|
|
setLoading(true)
|
|
const res = await fetch(`${API_URL}/admin/scrapers/run?source=${source}&token=${token}`, { method: 'POST' })
|
|
if(!res.ok) throw new Error('Erreur lors du lancement')
|
|
const entry = await res.json()
|
|
setLogs(l=>[entry, ...l].slice(0,5))
|
|
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `Scraper ${source} lancé`, type: 'success' } }))
|
|
}catch(e: unknown){ const msg = e instanceof Error ? e.message : String(e); setError(msg||'Erreur'); window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: msg||'Erreur', type: 'error' } })) }finally{ setLoading(false) }
|
|
}
|
|
|
|
const filteredDocs = docs.filter(d => d.title.toLowerCase().includes(search.toLowerCase()) || (d.ingredient||'').toLowerCase().includes(search.toLowerCase()))
|
|
|
|
const sortedUsers = React.useMemo(()=>{
|
|
if(!sortKey) return users
|
|
return [...users].sort((a: User, b: User) =>{
|
|
const ra = a as unknown as Record<string, unknown>
|
|
const rb = b as unknown as Record<string, unknown>
|
|
const va = typeof ra[sortKey as string] === 'string' ? (ra[sortKey as string] as string) : ''
|
|
const vb = typeof rb[sortKey as string] === 'string' ? (rb[sortKey as string] as string) : ''
|
|
if(typeof va === 'string' && typeof vb === 'string'){
|
|
return sortDir==='asc' ? va.localeCompare(vb) : vb.localeCompare(va)
|
|
}
|
|
return 0
|
|
})
|
|
},[users, sortKey, sortDir])
|
|
|
|
const sortedDocs = React.useMemo(()=>{
|
|
if(!sortKey) return filteredDocs
|
|
return [...filteredDocs].sort((a: Document, b: Document) =>{
|
|
const ra = a as unknown as Record<string, unknown>
|
|
const rb = b as unknown as Record<string, unknown>
|
|
const va = typeof ra[sortKey as string] === 'string' ? (ra[sortKey as string] as string) : ''
|
|
const vb = typeof rb[sortKey as string] === 'string' ? (rb[sortKey as string] as string) : ''
|
|
if(typeof va === 'string' && typeof vb === 'string'){
|
|
return sortDir==='asc' ? va.localeCompare(vb) : vb.localeCompare(va)
|
|
}
|
|
return 0
|
|
})
|
|
},[filteredDocs, sortKey, sortDir])
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<header className={styles.header}>
|
|
<h1>Administration</h1>
|
|
<nav className={styles.tabs}>
|
|
<button className={tab==='users'?styles.active:''} onClick={()=>setTab('users')}>Utilisateurs</button>
|
|
<button className={tab==='documents'?styles.active:''} onClick={()=>setTab('documents')}>Documents</button>
|
|
<button className={tab==='stats'?styles.active:''} onClick={()=>setTab('stats')}>Statistiques</button>
|
|
<button className={tab==='scrapers'?styles.active:''} onClick={()=>setTab('scrapers')}>Scrapers</button>
|
|
</nav>
|
|
</header>
|
|
|
|
<main className={styles.content}>
|
|
{loading && <div className={styles.spinner}>Chargement...</div>}
|
|
{error && <div className={styles.error}>{error}</div>}
|
|
|
|
{tab==='users' && (
|
|
<section>
|
|
<div className={styles.panelHeader}>
|
|
<div>{users.length} utilisateurs au total</div>
|
|
</div>
|
|
<div className={styles.tableWrap}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th onClick={()=>{ setSortKey('id'); setSortDir(sortDir==='asc'?'desc':'asc') }}>ID</th>
|
|
<th onClick={()=>{ setSortKey('email'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Email</th>
|
|
<th onClick={()=>{ setSortKey('full_name'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Nom complet</th>
|
|
<th onClick={()=>{ setSortKey('role'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Rôle</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedUsers.map(u=> (
|
|
<tr key={u.id}>
|
|
<td data-label="ID">{u.id}</td>
|
|
<td data-label="Email">{u.email}</td>
|
|
<td data-label="Nom complet">{u.full_name||'-'}</td>
|
|
<td data-label="Rôle"><span className={u.role==='admin'?styles.badgeAdmin:styles.badgeUser}>{u.role}</span></td>
|
|
<td className={styles.rowActions}>
|
|
<button className={styles.danger} onClick={()=>openConfirm('Supprimer cet utilisateur ?', ()=>deleteUser(u.id))}>Supprimer</button>
|
|
<button onClick={()=>openConfirm(u.role==='admin'? 'Rétrograder cet utilisateur ?' : 'Passer admin ?', ()=>toggleRole(u))}>{u.role==='admin'?'Rétrograder':'Passer admin'}</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{tab==='documents' && (
|
|
<section>
|
|
<div className={styles.panelHeader}>
|
|
<div>{docs.length} documents au total</div>
|
|
<input className={styles.search} placeholder="Rechercher..." value={search} onChange={e=>setSearch(e.target.value)} />
|
|
</div>
|
|
|
|
<div className={styles.tableWrap}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th onClick={()=>{ setSortKey('id'); setSortDir(sortDir==='asc'?'desc':'asc') }}>ID</th>
|
|
<th onClick={()=>{ setSortKey('title'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Titre</th>
|
|
<th onClick={()=>{ setSortKey('source'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Source</th>
|
|
<th onClick={()=>{ setSortKey('ingredient'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Ingrédient</th>
|
|
<th onClick={()=>{ setSortKey('date'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Date</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedDocs.map(d=> (
|
|
<tr key={d.id}>
|
|
<td data-label="ID">{d.id}</td>
|
|
<td data-label="Titre">{d.title}</td>
|
|
<td data-label="Source"><span className={d.source==='CIR'?styles.badgeCIR:styles.badgeSCCS}>{d.source}</span></td>
|
|
<td data-label="Ingrédient">{d.ingredient||'-'}</td>
|
|
<td data-label="Date">{d.date||'-'}</td>
|
|
<td className={styles.rowActions}><button className={styles.danger} onClick={()=>openConfirm('Supprimer ce document ?', ()=>deleteDocument(d.id))}>Supprimer</button></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{filteredDocs.length===0 && <div className={styles.empty}>Aucun document trouvé.</div>}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{tab==='stats' && (
|
|
<section>
|
|
<div className={styles.cards}>
|
|
<div className={styles.card}><FileText/><div className={styles.cardLabel}>Total documents</div><div className={styles.cardValue}>{stats?.total_documents ?? '-'}</div></div>
|
|
<div className={styles.card}><Database/><div className={styles.cardLabel}>Documents CIR</div><div className={styles.cardValue}>{stats?.cir_documents ?? '-'}</div></div>
|
|
<div className={styles.card}><Database/><div className={styles.cardLabel}>Documents SCCS</div><div className={styles.cardValue}>{stats?.sccs_documents ?? '-'}</div></div>
|
|
<div className={styles.card}><Users/><div className={styles.cardLabel}>Total utilisateurs</div><div className={styles.cardValue}>{stats?.total_users ?? '-'}</div></div>
|
|
</div>
|
|
|
|
<div className={styles.chartWrap}>
|
|
<div className={styles.chartLabel}>CIR vs SCCS</div>
|
|
<div className={styles.bars}>
|
|
<div className={styles.barRow}><div className={styles.barTitle}>CIR</div><div className={styles.barFill} style={{width:`${(stats?.cir_documents||0)/(Math.max(1, (stats?.cir_documents||0)+(stats?.sccs_documents||0))) * 100}%`}}></div><div className={styles.barVal}>{stats?.cir_documents||0}</div></div>
|
|
<div className={styles.barRow}><div className={styles.barTitle}>SCCS</div><div className={`${styles.barFill} ${styles.barFillAlt}`} style={{width:`${(stats?.sccs_documents||0)/(Math.max(1, (stats?.cir_documents||0)+(stats?.sccs_documents||0))) * 100}%`}}></div><div className={styles.barVal}>{stats?.sccs_documents||0}</div></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{tab==='scrapers' && (
|
|
<section>
|
|
<div className={styles.panelHeader}>
|
|
<div>Scrapers</div>
|
|
</div>
|
|
<div className={styles.scraperControls}>
|
|
<div>
|
|
<button onClick={()=>runScraper('CIR')}>Lancer scraper CIR</button>
|
|
<button onClick={()=>runScraper('SCCS')}>Lancer scraper SCCS</button>
|
|
</div>
|
|
<div className={styles.logList}>
|
|
<h4>Dernières exécutions</h4>
|
|
<ul>
|
|
{logs.map(l=> (
|
|
<li key={l.id}><strong>{l.when}</strong> — {l.source} — <span className={styles.status}>{l.status}</span></li>
|
|
))}
|
|
{logs.length===0 && <li>Aucune exécution.</li>}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
|
|
{confirmOpen && (
|
|
<div className={styles.modalBackdrop}>
|
|
<div className={styles.modal}>
|
|
<p>{confirmText}</p>
|
|
<div className={styles.modalActions}>
|
|
<button onClick={()=>{setConfirmOpen(false)}}>Annuler</button>
|
|
<button className={styles.danger} onClick={()=>{ setConfirmOpen(false); confirmAction(); }}>Confirmer</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
)
|
|
}
|