Files
regwatch-frontend/src/pages/Admin.tsx
T
neckfireandClaude Opus 5 02d56d90bc
Build & Deploy / build (push) Successful in 22s
Page admin : statistiques réparées et refonte responsive dans la DA du site
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>
2026-08-15 16:34:00 +02:00

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>
)
}