Page admin : statistiques réparées et refonte responsive dans la DA du site
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>
This commit is contained in:
2026-08-15 16:34:00 +02:00
co-authored by Claude Opus 5
parent 4890a29ba3
commit 02d56d90bc
2 changed files with 595 additions and 71 deletions
+17 -17
View File
@@ -7,7 +7,7 @@ type User = { id: string; email: string; full_name?: string; role: 'admin' | 'us
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 }
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')
@@ -184,10 +184,10 @@ export default function Admin() {
<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 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>
@@ -212,9 +212,9 @@ export default function Admin() {
<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('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') }}>Ingredient</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>
@@ -222,11 +222,11 @@ export default function Admin() {
<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 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>
))}
@@ -242,16 +242,16 @@ export default function Admin() {
<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 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||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 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>
+568 -44
View File
@@ -1,58 +1,582 @@
.page{padding:20px;max-width:1200px;margin:0 auto}
.header{display:flex;align-items:center;justify-content:space-between;gap:16px}
.tabs{display:flex;gap:8px}
.tabs button{background:transparent;border:1px solid #e6e9ef;padding:8px 12px;border-radius:8px;cursor:pointer}
.tabs .active{background:#eef2ff;border-color:#c7b3ff}
.content{margin-top:16px}
.spinner{padding:12px;background:#f8fafc;border:1px solid #e6eef6;border-radius:8px}
.error{padding:12px;background:#fff1f2;border:1px solid #fecaca;color:#b91c1c;border-radius:8px}
/* Administration — aligné sur la DA des autres pages :
accent violet #7c3aed (celui de la Sidebar), échelle de gris Tailwind,
cartes blanches bordées, rayons 0.5/0.75rem, unités rem. */
.panelHeader{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;gap:12px}
.search{padding:8px;border:1px solid #e6eef6;border-radius:6px}
.page {
padding: 2rem;
max-width: 80rem;
margin: 0 auto;
}
.tableWrap{overflow:auto;background:#fff;border:1px solid #eef2f7;padding:8px;border-radius:8px}
.table{width:100%;border-collapse:collapse}
.table th,.table td{padding:10px;border-bottom:1px solid #f1f5f9;text-align:left}
.rowActions{display:flex;gap:8px}
.danger{background:#ef4444;color:#fff;border:none;padding:8px 10px;border-radius:6px;cursor:pointer}
/* ── En-tête et onglets ─────────────────────────────────────── */
.badgeAdmin{background:#7c3aed;color:#fff;padding:4px 8px;border-radius:999px;font-weight:600}
.badgeUser{background:#94a3b8;color:#fff;padding:4px 8px;border-radius:999px;font-weight:600}
.badgeCIR{background:#2563eb;color:#fff;padding:4px 8px;border-radius:6px}
.badgeSCCS{background:#10b981;color:#fff;padding:4px 8px;border-radius:6px}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.75rem;
}
.empty{padding:12px;color:#64748b}
.header h1 {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
line-height: 1.1;
margin: 0;
}
.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}
.card{background:#fff;padding:16px;border-radius:8px;display:flex;flex-direction:column;gap:8px;align-items:flex-start}
.cardLabel{font-size:14px;color:#64748b}
.cardValue{font-size:20px;font-weight:700}
.tabs {
display: flex;
gap: 0.5rem;
}
.chartWrap{background:#fff;padding:12px;border-radius:8px}
.bars{display:flex;flex-direction:column;gap:8px}
.barRow{display:flex;align-items:center;gap:8px}
.barTitle{width:60px}
.barFill{height:14px;background:#2563eb;border-radius:6px;flex:1}
.barFillAlt{background:#10b981}
.barVal{width:40px;text-align:right}
.tabs button {
background: transparent;
border: 1px solid rgb(229, 231, 235);
color: rgb(55, 65, 81);
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.scraperControls{display:flex;gap:20px;align-items:flex-start}
.logList{background:#fff;padding:12px;border-radius:8px}
.status{font-weight:700}
.tabs button:hover {
background: rgb(249, 250, 251);
border-color: rgb(209, 213, 219);
}
.modalBackdrop{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(2,6,23,0.45);display:flex;align-items:center;justify-content:center;z-index:9999}
.modal{background:#fff;padding:20px;border-radius:8px;min-width:320px}
.modalActions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
.tabs .active,
.tabs .active:hover {
background: rgba(124, 58, 237, 0.08);
border-color: rgba(124, 58, 237, 0.45);
color: #7c3aed;
}
.tabs button:focus-visible,
.danger:focus-visible,
.search:focus-visible {
outline: 2px solid #7c3aed;
outline-offset: 2px;
}
.content {
margin-top: 0.5rem;
}
/* ── États ──────────────────────────────────────────────────── */
.spinner {
padding: 0.875rem 1rem;
background: rgb(249, 250, 251);
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
color: rgb(75, 85, 99);
margin-bottom: 1rem;
}
.error {
padding: 0.875rem 1rem;
background: rgb(254, 242, 242);
border: 1px solid rgb(254, 202, 202);
color: rgb(185, 28, 28);
border-radius: 0.75rem;
margin-bottom: 1rem;
}
.empty {
padding: 1.5rem;
text-align: center;
color: rgb(107, 114, 128);
}
/* ── Barre de panneau ───────────────────────────────────────── */
.panelHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
color: rgb(75, 85, 99);
font-size: 0.9375rem;
}
.search {
padding: 0.5rem 0.75rem;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.5rem;
font-size: 0.9375rem;
min-width: 14rem;
flex: 1 1 14rem;
max-width: 22rem;
}
/* ── Tableaux ───────────────────────────────────────────────── */
.tableWrap {
background: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid rgb(243, 244, 246);
}
.table th {
background: rgb(249, 250, 251);
color: rgb(55, 65, 81);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.table th:hover {
color: #7c3aed;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: rgb(249, 250, 251);
}
.rowActions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.rowActions button {
background: white;
border: 1px solid rgb(229, 231, 235);
color: rgb(55, 65, 81);
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
.rowActions button:hover {
border-color: rgba(124, 58, 237, 0.45);
color: #7c3aed;
}
.danger,
.rowActions .danger {
background: rgb(239, 68, 68);
color: white;
border: 1px solid rgb(239, 68, 68);
}
.danger:hover,
.rowActions .danger:hover {
background: rgb(220, 38, 38);
border-color: rgb(220, 38, 38);
color: white;
}
/* ── Badges ─────────────────────────────────────────────────── */
.badgeAdmin,
.badgeUser,
.badgeCIR,
.badgeSCCS {
display: inline-block;
padding: 0.1875rem 0.625rem;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.badgeAdmin,
.badgeUser {
border-radius: 999px;
}
.badgeAdmin {
background: rgba(124, 58, 237, 0.1);
color: #6d28d9;
border: 1px solid rgba(124, 58, 237, 0.35);
}
.badgeUser {
background: rgb(243, 244, 246);
color: rgb(75, 85, 99);
border: 1px solid rgb(229, 231, 235);
}
.badgeCIR,
.badgeSCCS {
border-radius: 0.5rem;
}
.badgeCIR {
background: rgba(37, 99, 235, 0.1);
color: rgb(29, 78, 216);
border: 1px solid rgba(37, 99, 235, 0.3);
}
.badgeSCCS {
background: rgba(16, 185, 129, 0.12);
color: rgb(4, 120, 87);
border: 1px solid rgba(16, 185, 129, 0.3);
}
/* ── Statistiques ───────────────────────────────────────────── */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
.card {
background: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
align-items: flex-start;
}
.card svg {
width: 1.25rem;
height: 1.25rem;
color: #7c3aed;
margin-bottom: 0.25rem;
}
.cardLabel {
font-size: 0.875rem;
color: rgb(107, 114, 128);
}
.cardValue {
font-size: 1.75rem;
font-weight: 700;
color: rgb(17, 24, 39);
line-height: 1.1;
}
.chartWrap {
background: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
}
.chartLabel {
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 1rem;
}
.bars {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.barRow {
display: flex;
align-items: center;
gap: 0.75rem;
}
.barTitle {
width: 3.5rem;
flex-shrink: 0;
font-size: 0.875rem;
font-weight: 500;
color: rgb(55, 65, 81);
}
/* La largeur est posée en style inline par le composant : min-width garde une
amorce visible quand la valeur est nulle. */
.barFill {
height: 0.625rem;
min-width: 0.625rem;
background: rgb(37, 99, 235);
border-radius: 999px;
}
.barFillAlt {
background: rgb(16, 185, 129);
}
.barVal {
width: 3rem;
flex-shrink: 0;
text-align: right;
font-variant-numeric: tabular-nums;
color: rgb(75, 85, 99);
}
/* ── Scrapers ───────────────────────────────────────────────── */
.scraperControls {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 1rem;
align-items: start;
}
.scraperControls > div:first-child {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.scraperControls button {
background: #7c3aed;
color: white;
border: 1px solid #7c3aed;
padding: 0.625rem 1rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
}
.scraperControls button:hover {
background: #6d28d9;
border-color: #6d28d9;
}
.logList {
background: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
}
.logList h4 {
margin: 0 0 0.75rem;
font-size: 0.9375rem;
font-weight: 600;
color: rgb(17, 24, 39);
}
.logList ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.logList li {
padding: 0.625rem 0.75rem;
background: rgb(249, 250, 251);
border: 1px solid rgb(243, 244, 246);
border-radius: 0.5rem;
font-size: 0.875rem;
color: rgb(75, 85, 99);
overflow-wrap: anywhere;
}
.status {
font-weight: 600;
color: rgb(17, 24, 39);
}
/* ── Modale ─────────────────────────────────────────────────── */
.modalBackdrop {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 9999;
}
.modal {
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
width: 100%;
max-width: 26rem;
box-shadow: 0 10px 25px rgba(17, 24, 39, 0.15);
}
.modalActions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.25rem;
flex-wrap: wrap;
}
.modalActions button {
background: white;
border: 1px solid rgb(229, 231, 235);
color: rgb(55, 65, 81);
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
cursor: pointer;
}
.modalActions button:last-child {
background: #7c3aed;
border-color: #7c3aed;
color: white;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 1024px) {
.scraperControls {
grid-template-columns: 1fr;
}
.scraperControls > div:first-child {
flex-direction: row;
flex-wrap: wrap;
}
}
@media (max-width: 768px) {
.cards{grid-template-columns:repeat(2,1fr)}
.table th, .table td{display:block}
.table tr{margin-bottom:12px;border-bottom:none}
.tableWrap{padding:0}
.page {
padding: 1.25rem 1rem;
}
.header {
align-items: flex-start;
flex-direction: column;
gap: 0.875rem;
}
.header h1 {
font-size: 1.75rem;
}
/* Onglets en bande défilante plutôt qu'en retour à la ligne. */
.tabs {
width: 100%;
overflow-x: auto;
padding-bottom: 0.25rem;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
/* Chaque ligne devient une carte, avec l'intitulé de colonne en regard. */
.tableWrap {
background: transparent;
border: none;
border-radius: 0;
overflow-x: visible;
}
.table,
.table tbody,
.table tr,
.table td {
display: block;
width: 100%;
}
.table thead {
display: none;
}
.table tr {
background: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 0.25rem 0;
margin-bottom: 0.75rem;
}
.table tbody tr:hover {
background: white;
}
.table td {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 1rem;
border-bottom: none;
text-align: right;
overflow-wrap: anywhere;
}
.table td::before {
content: attr(data-label);
font-weight: 600;
color: rgb(107, 114, 128);
font-size: 0.8125rem;
text-align: left;
flex-shrink: 0;
}
.rowActions {
justify-content: stretch;
padding-top: 0.75rem;
border-top: 1px solid rgb(243, 244, 246);
margin-top: 0.25rem;
}
.rowActions button {
flex: 1;
padding: 0.5rem 0.75rem;
}
.search {
max-width: none;
}
}
@media (max-width: 480px) {
.cards{grid-template-columns:1fr}
.scraperControls{flex-direction:column}
.tabs{flex-wrap:wrap}
.cards {
grid-template-columns: 1fr;
}
.scraperControls > div:first-child {
flex-direction: column;
}
.scraperControls button {
width: 100%;
}
.barTitle {
width: 2.75rem;
}
}
/* NB : le site n'a pas de mode sombre réel — index.css bascule --bg sur le body
mais aucune page n'utilise ces variables et toutes gardent des cartes claires.
Cette page suit la même convention pour rester cohérente avec ses sœurs. */