Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
@@ -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>
);
}
+240
View File
@@ -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>
);
}
+128
View File
@@ -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>
);
}
+114
View File
@@ -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)}
/>
</>
);
}
+41
View File
@@ -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>
);
}