feat(ui): implement full UI layer for all modules

Add interactive island components and server partials for notes,
students, and admin modules, following the Figma prototype design.

- static/styles/ui.css: shared component library (buttons, tables,
  chips, cards, filters, tabs, form inputs)
- notes: NotesView (student grade view with UE cards, promo tabs,
  weighted averages), AdminConsultNotes, AdminUEs islands + partials
- students: ConsultStudents (list/filter/delete), AdminPromotions
  (CRUD) islands + partials
- admin: AdminModules, AdminUsers, AdminRoles islands + partials
- All partials use State type with unknown cast for session access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 22:54:10 +02:00
parent 34b7ac0231
commit 5ba8b8cb68
25 changed files with 2059 additions and 77 deletions
@@ -0,0 +1,204 @@
import { useEffect, useState } from "preact/hooks";
type Module = { id: string; nom: string };
export default function AdminModules() {
const [modules, setModules] = useState<Module[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newId, setNewId] = useState("");
const [newNom, setNewNom] = useState("");
const [creating, setCreating] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editNom, setEditNom] = useState("");
async function load() {
try {
const res = await fetch("/admin/api/modules");
if (!res.ok) throw new Error("Impossible de charger les modules");
setModules(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createModule() {
if (!newId.trim() || !newNom.trim()) return;
setCreating(true);
try {
const res = await fetch("/admin/api/modules", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: newId.trim(), nom: newNom.trim() }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setNewId("");
setNewNom("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreating(false);
}
}
async function saveEdit(id: string) {
try {
const res = await fetch(`/admin/api/modules/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: editNom.trim() }),
});
if (!res.ok) throw new Error("Modification échouée");
setEditId(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function deleteModule(id: string) {
if (!confirm(`Supprimer le module ${id} ?`)) return;
try {
const res = await fetch(
`/admin/api/modules/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
return (
<div class="page-content">
<h2 class="page-title">Gestion des Modules</h2>
{error && <p class="state-error">{error}</p>}
<div class="form-row">
<input
class="form-input"
placeholder="Identifiant (ex: JIA3)"
value={newId}
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
style="min-width: 10rem"
/>
<input
class="form-input"
placeholder="Nom du module"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="btn btn-primary"
onClick={createModule}
disabled={creating}
>
+ Ajouter
</button>
</div>
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Identifiant</th>
<th>Nom</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{modules.length === 0
? (
<tr>
<td colspan={3} class="state-empty">
Aucun module enregistré
</td>
</tr>
)
: modules.map((m) => (
<tr key={m.id}>
<td class="col-dim">{m.id}</td>
<td>
{editId === m.id
? (
<input
class="form-input"
value={editNom}
onInput={(e) =>
setEditNom(
(e.target as HTMLInputElement).value,
)}
style="min-width: 0; width: 100%"
/>
)
: m.nom}
</td>
<td>
<div class="col-actions">
{editId === m.id
? (
<>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => saveEdit(m.id)}
>
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => setEditId(null)}
>
</button>
</>
)
: (
<>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => {
setEditId(m.id);
setEditNom(m.nom);
}}
>
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteModule(m.id)}
>
🗑
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,225 @@
import { useEffect, useState } from "preact/hooks";
type Role = { id: number; nom: string };
type Permission = { id: string; nom: string };
export default function AdminRoles() {
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newNom, setNewNom] = useState("");
const [creating, setCreating] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [editNom, setEditNom] = useState("");
async function load() {
try {
const [rRes, pRes] = await Promise.all([
fetch("/admin/api/roles"),
fetch("/admin/api/permissions"),
]);
if (!rRes.ok) throw new Error("Impossible de charger les rôles");
setRoles(await rRes.json());
if (pRes.ok) setPermissions(await pRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createRole() {
if (!newNom.trim()) return;
setCreating(true);
try {
const res = await fetch("/admin/api/roles", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: newNom.trim() }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setNewNom("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreating(false);
}
}
async function saveEdit(id: number) {
try {
const res = await fetch(`/admin/api/roles/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: editNom.trim() }),
});
if (!res.ok) throw new Error("Modification échouée");
setEditId(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function deleteRole(id: number) {
if (!confirm("Supprimer ce rôle ?")) return;
try {
const res = await fetch(`/admin/api/roles/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
return (
<div class="page-content">
<h2 class="page-title">Gestion des Rôles</h2>
{error && <p class="state-error">{error}</p>}
<div class="form-row">
<input
class="form-input"
placeholder="Nom du rôle"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && createRole()}
/>
<button
type="button"
class="btn btn-primary"
onClick={createRole}
disabled={creating}
>
+ Ajouter
</button>
</div>
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{roles.length === 0
? (
<tr>
<td colspan={3} class="state-empty">
Aucun rôle enregistré
</td>
</tr>
)
: roles.map((r) => (
<tr key={r.id}>
<td class="col-dim">{r.id}</td>
<td>
{editId === r.id
? (
<input
class="form-input"
value={editNom}
onInput={(e) =>
setEditNom(
(e.target as HTMLInputElement).value,
)}
style="min-width: 0; width: 100%"
/>
)
: r.nom}
</td>
<td>
<div class="col-actions">
{editId === r.id
? (
<>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => saveEdit(r.id)}
>
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => setEditId(null)}
>
</button>
</>
)
: (
<>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => {
setEditId(r.id);
setEditNom(r.nom);
}}
>
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteRole(r.id)}
>
🗑
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{permissions.length > 0 && (
<div style="margin-top: 2rem">
<h3 style="font-size: 0.9rem; font-weight: var(--font-weight-bold); margin-bottom: 0.75rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
Permissions disponibles
</h3>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={p.id}>
<td class="col-dim">{p.id}</td>
<td>{p.nom}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,201 @@
import { useEffect, useState } from "preact/hooks";
type User = { id: string; nom: string; prenom: string; idRole: number | null };
type Role = { id: number; nom: string };
export default function AdminUsers() {
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newId, setNewId] = useState("");
const [newNom, setNewNom] = useState("");
const [newPrenom, setNewPrenom] = useState("");
const [newIdRole, setNewIdRole] = useState("");
const [creating, setCreating] = useState(false);
const [filterNom, setFilterNom] = useState("");
async function load() {
try {
const [uRes, rRes] = await Promise.all([
fetch("/admin/api/users"),
fetch("/admin/api/roles"),
]);
if (!uRes.ok) throw new Error("Impossible de charger les utilisateurs");
setUsers(await uRes.json());
if (rRes.ok) setRoles(await rRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createUser() {
if (!newId.trim() || !newNom.trim() || !newPrenom.trim()) return;
setCreating(true);
try {
const res = await fetch("/admin/api/users", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: newId.trim(),
nom: newNom.trim(),
prenom: newPrenom.trim(),
idRole: newIdRole ? Number(newIdRole) : null,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setNewId("");
setNewNom("");
setNewPrenom("");
setNewIdRole("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreating(false);
}
}
async function deleteUser(id: string) {
if (!confirm(`Supprimer l'utilisateur ${id} ?`)) return;
try {
const res = await fetch(`/admin/api/users/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom]));
const filtered = users.filter((u) =>
!filterNom ||
`${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes(
filterNom.toLowerCase(),
)
);
return (
<div class="page-content">
<h2 class="page-title">Gestion des Utilisateurs</h2>
{error && <p class="state-error">{error}</p>}
<div class="form-row">
<input
class="form-input"
placeholder="Login (uid)"
value={newId}
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
style="min-width: 9rem"
/>
<input
class="form-input"
placeholder="Nom"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/>
<input
class="form-input"
placeholder="Prénom"
value={newPrenom}
onInput={(e) => setNewPrenom((e.target as HTMLInputElement).value)}
/>
<select
class="filter-select"
value={newIdRole}
onChange={(e) => setNewIdRole((e.target as HTMLSelectElement).value)}
>
<option value="">Aucun rôle</option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}</option>)}
</select>
<button
type="button"
class="btn btn-primary"
onClick={createUser}
disabled={creating}
>
+ Ajouter
</button>
</div>
<div class="filters">
<input
class="filter-input"
placeholder="Rechercher…"
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
</div>
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Login</th>
<th>Nom</th>
<th>Prénom</th>
<th>Rôle</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0
? (
<tr>
<td colspan={5} class="state-empty">
Aucun utilisateur trouvé
</td>
</tr>
)
: filtered.map((u) => (
<tr key={u.id}>
<td class="col-dim">{u.id}</td>
<td>{u.nom}</td>
<td>{u.prenom}</td>
<td>
{u.idRole ? (roleMap[u.idRole] ?? `#${u.idRole}`) : "—"}
</td>
<td>
<div class="col-actions">
<a
class="btn btn-sm btn-secondary"
href={`/admin/users/${encodeURIComponent(u.id)}`}
f-client-nav={false}
>
</a>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteUser(u.id)}
>
🗑
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+5 -2
View File
@@ -4,9 +4,12 @@ const properties: AppProperties = {
name: "Admin",
icon: "school",
pages: {
index: "Homepage",
index: "Accueil",
modules: "Modules",
users: "Utilisateurs",
roles: "Rôles",
},
adminOnly: [],
adminOnly: ["modules", "users", "roles"],
hint: "PolyMPR module",
};
+34 -3
View File
@@ -3,10 +3,41 @@ import {
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
import { State } from "$root/defaults/interfaces.ts";
export function Index(_request: Request, _context: FreshContext<State>) {
return <h2>Welcome to Admin.</h2>;
// deno-lint-ignore require-await
export async function Index(
_request: Request,
context: FreshContext<State>,
) {
return (
<div class="page-content">
<h2 class="page-title">Administration</h2>
<p>
Bienvenue{" "}
<strong>
{(context.state as unknown as { session: Record<string, string> })
.session.displayName}
</strong>
.
</p>
<p>
Gérez les{" "}
<a href="/admin/modules" f-partial="/admin/partials/modules">
modules
</a>
,{" "}
<a href="/admin/users" f-partial="/admin/partials/users">
utilisateurs
</a>
,{" "}
<a href="/admin/roles" f-partial="/admin/partials/roles">
rôles
</a>{" "}
depuis la barre de navigation.
</p>
</div>
);
}
export const config = getPartialsConfig();
+18
View File
@@ -0,0 +1,18 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import AdminModules from "../(_islands)/AdminModules.tsx";
// deno-lint-ignore require-await
async function Modules(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminModules />;
}
export const config = getPartialsConfig();
export default makePartials(Modules);
+18
View File
@@ -0,0 +1,18 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import AdminRoles from "../(_islands)/AdminRoles.tsx";
// deno-lint-ignore require-await
async function Roles(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminRoles />;
}
export const config = getPartialsConfig();
export default makePartials(Roles);
+18
View File
@@ -0,0 +1,18 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import AdminUsers from "../(_islands)/AdminUsers.tsx";
// deno-lint-ignore require-await
async function Users(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminUsers />;
}
export const config = getPartialsConfig();
export default makePartials(Users);