Admin : page admin + responsive + CookieBanner + Toast + BackToTop
Build & Deploy / build (push) Successful in 32s
Build & Deploy / build (push) Successful in 32s
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import styles from '../styles/Admin.module.css'
|
||||
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?: number; sccs?: number; 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')
|
||||
|
||||
const token = '' // token should be passed / read from auth in real app
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTab = async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try{
|
||||
if(tab==='users'){
|
||||
const res = await fetch(`/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('/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(`/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(`/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(`/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(`/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(`/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(`/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>{u.id}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>{u.full_name||'-'}</td>
|
||||
<td><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') }}>Title</th>
|
||||
<th onClick={()=>{ setSortKey('source'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Source</th>
|
||||
<th onClick={()=>{ setSortKey('ingredient'); setSortDir(sortDir==='asc'?'desc':'asc') }}>Ingredient</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>{d.id}</td>
|
||||
<td>{d.title}</td>
|
||||
<td><span className={d.source==='CIR'?styles.badgeCIR:styles.badgeSCCS}>{d.source}</span></td>
|
||||
<td>{d.ingredient||'-'}</td>
|
||||
<td>{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 ?? '-'}</div></div>
|
||||
<div className={styles.card}><Database/><div className={styles.cardLabel}>Documents SCCS</div><div className={styles.cardValue}>{stats?.sccs ?? '-'}</div></div>
|
||||
<div className={styles.card}><Users/><div className={styles.cardLabel}>Total utilisateurs</div><div className={styles.cardValue}>{stats?.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||0)/(Math.max(1, (stats?.cir||0)+(stats?.sccs||0))) * 100}%`}}></div><div className={styles.barVal}>{stats?.cir||0}</div></div>
|
||||
<div className={styles.barRow}><div className={styles.barTitle}>SCCS</div><div className={`${styles.barFill} ${styles.barFillAlt}`} style={{width:`${(stats?.sccs||0)/(Math.max(1, (stats?.cir||0)+(stats?.sccs||0))) * 100}%`}}></div><div className={styles.barVal}>{stats?.sccs||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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user