Initial commit
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
export default function CreateUserSection({ onCreated }) {
|
||||
const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true); setErr(null); setSuccess(null);
|
||||
try {
|
||||
const created = await api.post('/admin/users', {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
displayName: form.displayName || undefined,
|
||||
role: form.role,
|
||||
});
|
||||
setSuccess(`Utilisateur "${created.display_name || created.email}" créé avec succès.`);
|
||||
setForm({ email: '', password: '', displayName: '', role: 'user' });
|
||||
onCreated?.();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Créer un utilisateur</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
Créez un nouveau compte manuellement sur la plateforme.
|
||||
</p>
|
||||
|
||||
{success && <div className="success-msg" style={{ marginBottom: 16 }}>{success}</div>}
|
||||
{err && <div className="error" style={{ marginBottom: 16 }}>{err}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ maxWidth: 480 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label>Nom affiché</label>
|
||||
<input value={form.displayName} onChange={e => set('displayName', e.target.value)} placeholder="Prénom Nom" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Email *</label>
|
||||
<input type="email" required value={form.email} onChange={e => set('email', e.target.value)} placeholder="utilisateur@exemple.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Mot de passe *</label>
|
||||
<input type="password" required minLength={8} value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Rôle</label>
|
||||
<select value={form.role} onChange={e => set('role', e.target.value)}>
|
||||
<option value="user">Utilisateur</option>
|
||||
<option value="admin">Administrateur</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20 }}>
|
||||
<button type="submit" className="primary" disabled={loading}>
|
||||
{loading ? 'Création…' : 'Créer l\'utilisateur'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
|
||||
function IconPlus() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>;
|
||||
}
|
||||
function IconUpload() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
|
||||
}
|
||||
function IconDownload() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
|
||||
}
|
||||
function IconHistory() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.95"/></svg>;
|
||||
}
|
||||
|
||||
export default function IconsSection() {
|
||||
const [icons, setIcons] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [uploading, setUploading] = useState(null);
|
||||
const [history, setHistory] = useState(null); // { name, rows } | null
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newFile, setNewFile] = useState(null);
|
||||
const [createErr, setCreateErr] = useState(null);
|
||||
const [createOk, setCreateOk] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setIcons(await api.get('/icons')); }
|
||||
catch { setErr('Erreur de chargement'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleReplace(name) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.svg,.png,.jpg,.jpeg,.webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
setUploading(name);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = localStorage.getItem('cl_token');
|
||||
const res = await fetch(`/api/icons/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) { const j = await res.json(); throw new Error(j.error); }
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setUploading(null); }
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function loadHistory(name) {
|
||||
try {
|
||||
const rows = await api.get(`/icons/${name}/history`);
|
||||
setHistory({ name, rows });
|
||||
} catch { setErr('Erreur historique'); }
|
||||
}
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault();
|
||||
setCreateErr(null); setCreateOk(null);
|
||||
if (!newName.trim()) return setCreateErr('Nom requis');
|
||||
if (!newFile) return setCreateErr('Fichier requis');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('name', newName.trim().toLowerCase());
|
||||
fd.append('description', newDesc.trim());
|
||||
fd.append('file', newFile);
|
||||
const token = localStorage.getItem('cl_token');
|
||||
const res = await fetch('/api/icons', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error);
|
||||
setCreateOk(`Icône "${json.name}" créée.`);
|
||||
setNewName(''); setNewDesc(''); setNewFile(null);
|
||||
setCreating(false);
|
||||
await load();
|
||||
} catch (e) { setCreateErr(e.message); }
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-muted" style={{ padding: 24 }}>Chargement…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="topbar" style={{ marginBottom: 20 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>Bibliothèque d'icônes</h2>
|
||||
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
|
||||
{icons.length} icône{icons.length !== 1 ? 's' : ''} — les noms sont les clés utilisées par l'application.
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => { setCreating(v => !v); setCreateErr(null); setCreateOk(null); }}>
|
||||
<IconPlus /> Nouvelle icône
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{creating && (
|
||||
<div className="card" style={{ marginBottom: 20 }}>
|
||||
<h3 style={{ margin: '0 0 14px' }}>Nouvelle association nom / image</h3>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Nom (slug) *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="ex: taux-defaut"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>lettres minuscules, chiffres, tirets</span>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="ex: Taux de défaut"
|
||||
value={newDesc}
|
||||
onChange={e => setNewDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Fichier image * (SVG, PNG, JPG, WebP — 2 Mo max)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".svg,.png,.jpg,.jpeg,.webp"
|
||||
onChange={e => setNewFile(e.target.files[0] || null)}
|
||||
/>
|
||||
{newFile && <span style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-muted)' }}>{newFile.name}</span>}
|
||||
</div>
|
||||
{createErr && <div className="error" style={{ marginBottom: 8 }}>{createErr}</div>}
|
||||
{createOk && <div className="success" style={{ marginBottom: 8 }}>{createOk}</div>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary">Créer</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="icons-grid">
|
||||
{icons.map(icon => (
|
||||
<div key={icon.name} className="icon-card">
|
||||
<div className="icon-card-preview">
|
||||
<img
|
||||
src={`${ICONS_BASE}${icon.filename}`}
|
||||
alt={icon.name}
|
||||
style={{ width: 48, height: 48, objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="icon-card-body">
|
||||
<span className="icon-card-name">{icon.name}</span>
|
||||
{icon.description && (
|
||||
<span className="icon-card-desc">{icon.description}</span>
|
||||
)}
|
||||
<span className="icon-card-file">{icon.filename}</span>
|
||||
</div>
|
||||
<div className="icon-card-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleReplace(icon.name)}
|
||||
disabled={uploading === icon.name}
|
||||
title="Remplacer l'image"
|
||||
>
|
||||
{uploading === icon.name ? '…' : <><IconUpload /> Remplacer</>}
|
||||
</button>
|
||||
<a
|
||||
className="btn btn-sm btn-ghost"
|
||||
href={`${ICONS_BASE}${icon.filename}`}
|
||||
download={icon.filename}
|
||||
title="Télécharger le fichier nettoyé"
|
||||
>
|
||||
<IconDownload />
|
||||
</a>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => history?.name === icon.name ? setHistory(null) : loadHistory(icon.name)}
|
||||
title="Historique des versions"
|
||||
>
|
||||
<IconHistory />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{history?.name === icon.name && (
|
||||
<div className="icon-history">
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||
Historique ({history.rows.length} version{history.rows.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
{history.rows.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Aucune version précédente</span>
|
||||
) : (
|
||||
<div className="icon-history-list">
|
||||
{history.rows.map(h => (
|
||||
<div key={h.id} className="icon-history-row">
|
||||
<img
|
||||
src={`${ICONS_BASE}${h.filename}`}
|
||||
alt="prev"
|
||||
style={{ width: 28, height: 28, objectFit: 'contain', opacity: .7 }}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', flex: 1 }}>{h.filename}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{new Date(h.replaced_at).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import { fmt, StatusBadge } from './adminHelpers.jsx';
|
||||
|
||||
const KNOWN_JOBS = [
|
||||
{ name: 'auto_statut_retard', label: 'Passage automatique en retard' },
|
||||
];
|
||||
|
||||
export default function JobLogsSection() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(null);
|
||||
const [runResult, setRunResult] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const PER_PAGE = 20;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.get('/admin/job-logs', { limit: PER_PAGE, offset: page * PER_PAGE });
|
||||
setLogs(data.rows);
|
||||
setTotal(data.total);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const runJob = async (jobName) => {
|
||||
setRunning(jobName); setRunResult(null);
|
||||
try {
|
||||
const r = await api.post(`/admin/jobs/${jobName}/run`, {});
|
||||
setRunResult({ ok: true, msg: `Exécution terminée — ${r.nb_changes} modification(s)` });
|
||||
load();
|
||||
} catch (e) {
|
||||
setRunResult({ ok: false, msg: e.message });
|
||||
} finally { setRunning(null); }
|
||||
};
|
||||
|
||||
const pages = Math.ceil(total / PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Logs des jobs automatiques</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
Historique d'exécution des tâches planifiées et lancement manuel.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
{KNOWN_JOBS.map(j => (
|
||||
<button
|
||||
key={j.name}
|
||||
className="btn btn-outline"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 7 }}
|
||||
disabled={running === j.name}
|
||||
onClick={() => runJob(j.name)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="4,2 14,8 4,14"/>
|
||||
</svg>
|
||||
{running === j.name ? 'Exécution…' : `Lancer : ${j.label}`}
|
||||
</button>
|
||||
))}
|
||||
{runResult && (
|
||||
<span style={{
|
||||
fontSize: 13, padding: '4px 12px', borderRadius: 6,
|
||||
background: runResult.ok ? 'rgba(34,197,94,.1)' : 'rgba(239,68,68,.1)',
|
||||
color: runResult.ok ? '#16a34a' : '#dc2626',
|
||||
border: `1px solid ${runResult.ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
|
||||
}}>
|
||||
{runResult.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>}
|
||||
{err && <p style={{ color: '#ef4444' }}>{err}</p>}
|
||||
{!loading && !err && !logs.length && <p style={{ color: 'var(--text-muted)' }}>Aucun log disponible.</p>}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
{total} entrée{total > 1 ? 's' : ''} au total
|
||||
</p>
|
||||
<table style={{ fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Job</th><th>Statut</th>
|
||||
<th className="num">Modifs</th><th>Détails</th><th>Erreur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map(l => (
|
||||
<tr key={l.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>{fmt(l.run_at)}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{l.job_name}</td>
|
||||
<td><StatusBadge status={l.status} /></td>
|
||||
<td className="num">
|
||||
{l.nb_changes > 0
|
||||
? <span style={{ fontWeight: 700, color: '#f97316' }}>{l.nb_changes}</span>
|
||||
: <span style={{ color: 'var(--text-muted)' }}>0</span>
|
||||
}
|
||||
</td>
|
||||
<td style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={l.details || ''}>
|
||||
{l.details || <span style={{ color: 'var(--text-muted)' }}>—</span>}
|
||||
</td>
|
||||
<td style={{ color: '#ef4444', fontSize: 12 }}>
|
||||
{l.error_msg || <span style={{ color: 'var(--text-muted)' }}>—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{pages > 1 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'center' }}>
|
||||
<button className="btn btn-sm btn-outline" disabled={page === 0} onClick={() => setPage(p => p - 1)}>← Précédent</button>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Page {page + 1} / {pages}</span>
|
||||
<button className="btn btn-sm btn-outline" disabled={page >= pages - 1} onClick={() => setPage(p => p + 1)}>Suivant →</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import { fmt, Badge } from './adminHelpers.jsx';
|
||||
|
||||
export default function UsersSection({ currentUserId }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [confirmAction, setConfirmAction] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.get('/admin/users');
|
||||
setUsers(data);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const toggleRole = (u) => {
|
||||
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
||||
setConfirmAction({
|
||||
title: 'Changer le rôle',
|
||||
message: `Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
|
||||
confirmLabel: 'Confirmer',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.patch(`/admin/users/${u.id}/role`, { role: newRole });
|
||||
load();
|
||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = (u) => {
|
||||
setConfirmAction({
|
||||
title: 'Supprimer l\'utilisateur',
|
||||
message: `Supprimer définitivement ${u.display_name || u.email} ? Toutes ses données seront effacées.`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.del(`/admin/users/${u.id}`);
|
||||
load();
|
||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
|
||||
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Comptes utilisateurs</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
{users.length} utilisateur{users.length !== 1 ? 's' : ''} enregistré{users.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}>ID</th>
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Rôle</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
|
||||
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}>—</em>}</td>
|
||||
<td>{u.email}</td>
|
||||
<td><Badge role={u.role} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => toggleRole(u)}
|
||||
disabled={u.id === currentUserId && u.role === 'admin'}
|
||||
title={u.id === currentUserId ? 'Vous ne pouvez pas vous rétrograder' : ''}
|
||||
>
|
||||
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
|
||||
</button>
|
||||
{u.id !== currentUserId && (
|
||||
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={!!confirmAction}
|
||||
title={confirmAction?.title}
|
||||
message={confirmAction?.message}
|
||||
confirmLabel={confirmAction?.confirmLabel}
|
||||
onConfirm={confirmAction?.onConfirm}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* ── Helpers partagés Admin ───────────────────────────────────── */
|
||||
export function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
const utc = iso.includes('T') || iso.endsWith('Z')
|
||||
? iso
|
||||
: iso.replace(' ', 'T') + 'Z';
|
||||
return new Date(utc).toLocaleString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function Badge({ role }) {
|
||||
const isAdmin = role === 'admin';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '.04em',
|
||||
background: isAdmin ? 'rgba(234,179,8,.15)' : 'rgba(100,116,139,.15)',
|
||||
color: isAdmin ? '#ca8a04' : '#64748b',
|
||||
border: `1px solid ${isAdmin ? 'rgba(234,179,8,.35)' : 'rgba(100,116,139,.25)'}`,
|
||||
}}>
|
||||
{isAdmin ? 'Admin' : 'Utilisateur'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }) {
|
||||
const ok = status === 'ok';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 10px', borderRadius: 12,
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: ok ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
||||
color: ok ? '#16a34a' : '#dc2626',
|
||||
border: `1px solid ${ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)'}`,
|
||||
}}>
|
||||
{ok ? 'OK' : 'Erreur'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user