From 04be659d6b2e29611af66ee94e5f8cdfc5c0cf8d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Wed, 29 Apr 2026 09:12:55 +0200 Subject: [PATCH] feat(app): add studentOnly pages and new routes Add routes for modules, users, notes import, recap, and islands edit. Update middleware to filter pages based on user role. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(admin): add modal for assigning teaching, replace delete icon with SVG refactor(server): rename port variable to uppercase and add env support feat(admin): add enseignants, users, filtering and role colors refactor(AdminRoles): improve role UI and add permission mapping feat(admin-users): add role colors, role filter, and modal for creating users feat(admin): add EditModule component for module editing feat(admin): add EditUser page for editing users and managing enseignements feat(promo-select): display id and name in options for promo dropdown feat: add edit module/user routes, inline coeff editing, UI tweaks refactor: UI – icons, modal overlay, grid, subtitles, import margin --- defaults/interfaces.ts | 1 + fresh.gen.ts | 18 +- routes/(apps)/_middleware.ts | 11 +- .../admin/(_islands)/AdminEnseignements.tsx | 139 ++++--- .../(apps)/admin/(_islands)/AdminModules.tsx | 254 +++++++----- .../admin/(_islands)/AdminPermissions.tsx | 23 +- routes/(apps)/admin/(_islands)/AdminRoles.tsx | 53 ++- routes/(apps)/admin/(_islands)/AdminUsers.tsx | 195 +++++++-- routes/(apps)/admin/(_islands)/EditModule.tsx | 344 +++++++++++++++ routes/(apps)/admin/(_islands)/EditUser.tsx | 391 ++++++++++++++++++ routes/(apps)/admin/modules/[idModule].tsx | 11 + routes/(apps)/admin/users/[id].tsx | 11 + .../notes/(_islands)/AdminConsultNotes.tsx | 12 +- routes/(apps)/notes/(_islands)/AdminUEs.tsx | 103 ++++- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 12 +- routes/(apps)/notes/(_props)/props.ts | 1 + .../(apps)/notes/partials/(admin)/import.tsx | 6 - .../students/(_islands)/AdminPromotions.tsx | 19 +- .../students/(_islands)/ConsultStudents.tsx | 25 +- .../students/(_islands)/EditStudents.tsx | 8 - .../students/partials/(admin)/upload.tsx | 6 - static/styles/ui.css | 57 ++- 22 files changed, 1455 insertions(+), 245 deletions(-) create mode 100644 routes/(apps)/admin/(_islands)/EditModule.tsx create mode 100644 routes/(apps)/admin/(_islands)/EditUser.tsx create mode 100644 routes/(apps)/admin/modules/[idModule].tsx create mode 100644 routes/(apps)/admin/users/[id].tsx diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index f385846..9b65a28 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -19,6 +19,7 @@ export interface AppProperties { icon: string; pages: Record; adminOnly: string[]; + studentOnly?: string[]; hint: string; } diff --git a/fresh.gen.ts b/fresh.gen.ts index 22cab59..ffa7923 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -15,12 +15,14 @@ import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles import * as $_apps_admin_api_users from "./routes/(apps)/admin/api/users.ts"; import * as $_apps_admin_api_users_id_ from "./routes/(apps)/admin/api/users/[id].ts"; import * as $_apps_admin_index from "./routes/(apps)/admin/index.tsx"; +import * as $_apps_admin_modules_idModule_ from "./routes/(apps)/admin/modules/[idModule].tsx"; import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx"; import * as $_apps_admin_partials_index from "./routes/(apps)/admin/partials/index.tsx"; import * as $_apps_admin_partials_modules from "./routes/(apps)/admin/partials/modules.tsx"; import * as $_apps_admin_partials_permissions from "./routes/(apps)/admin/partials/permissions.tsx"; import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; +import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx"; import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx"; @@ -30,18 +32,19 @@ import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustem import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts"; import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts"; import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts"; +import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts"; import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts"; import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts"; import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; -import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx"; import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx"; import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; +import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; import * as $_apps_students_api_promotions from "./routes/(apps)/students/api/promotions.ts"; import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; @@ -70,6 +73,8 @@ import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_isla import * as $_apps_admin_islands_AdminPermissions from "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx"; import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx"; import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx"; +import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx"; +import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx"; import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx"; import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx"; @@ -103,6 +108,8 @@ const manifest = { "./routes/(apps)/admin/api/users.ts": $_apps_admin_api_users, "./routes/(apps)/admin/api/users/[id].ts": $_apps_admin_api_users_id_, "./routes/(apps)/admin/index.tsx": $_apps_admin_index, + "./routes/(apps)/admin/modules/[idModule].tsx": + $_apps_admin_modules_idModule_, "./routes/(apps)/admin/partials/enseignements.tsx": $_apps_admin_partials_enseignements, "./routes/(apps)/admin/partials/index.tsx": $_apps_admin_partials_index, @@ -111,6 +118,7 @@ const manifest = { $_apps_admin_partials_permissions, "./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, + "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, "./routes/(apps)/mobility/api/insert_mobility.ts": $_apps_mobility_api_insert_mobility, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, @@ -126,6 +134,8 @@ const manifest = { "./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes, "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts": $_apps_notes_api_notes_numEtud_idModule_, + "./routes/(apps)/notes/api/notes/import-xlsx.ts": + $_apps_notes_api_notes_import_xlsx, "./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules, "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts": $_apps_notes_api_ue_modules_idModule_idUE_idPromo_, @@ -134,7 +144,6 @@ const manifest = { "./routes/(apps)/notes/edition/[numEtud].tsx": $_apps_notes_edition_numEtud_, "./routes/(apps)/notes/index.tsx": $_apps_notes_index, - "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, "./routes/(apps)/notes/partials/(admin)/courses.tsx": $_apps_notes_partials_admin_courses, "./routes/(apps)/notes/partials/(admin)/import.tsx": @@ -143,6 +152,7 @@ const manifest = { $_apps_notes_partials_admin_ues, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, + "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, "./routes/(apps)/students/api/promotions.ts": $_apps_students_api_promotions, "./routes/(apps)/students/api/promotions/[idPromo].ts": @@ -187,6 +197,10 @@ const manifest = { $_apps_admin_islands_AdminRoles, "./routes/(apps)/admin/(_islands)/AdminUsers.tsx": $_apps_admin_islands_AdminUsers, + "./routes/(apps)/admin/(_islands)/EditModule.tsx": + $_apps_admin_islands_EditModule, + "./routes/(apps)/admin/(_islands)/EditUser.tsx": + $_apps_admin_islands_EditUser, "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx": $_apps_mobility_islands_ConsultMobility, "./routes/(apps)/mobility/(_islands)/EditMobility.tsx": diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index f30f19f..e60886b 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -22,13 +22,18 @@ export const handler: MiddlewareHandler[] = [ )).default; context.state.availablePages = properties.pages; - if ( + const isStudent = context.state.session.eduPersonPrimaryAffiliation == "student" && - Deno.env.get("LOCAL") != "true" - ) { + Deno.env.get("LOCAL") != "true"; + + if (isStudent) { properties.adminOnly.forEach((page) => delete context.state.availablePages[page] ); + } else { + properties.studentOnly?.forEach((page) => + delete context.state.availablePages[page] + ); } return await context.next(); diff --git a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx index 7b158d2..2a0c2af 100644 --- a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -169,55 +169,77 @@ export default function AdminEnseignements() { {showAdd && ( -
- {addError && ( - - {addError} - - )} - - - setAddProf((e.target as HTMLInputElement).value)} - style="min-width: 10rem" - /> - - + )} @@ -266,7 +288,24 @@ export default function AdminEnseignements() { e.idPromo, )} > - 🗑 + + + + +
diff --git a/routes/(apps)/admin/(_islands)/AdminModules.tsx b/routes/(apps)/admin/(_islands)/AdminModules.tsx index df0af41..3a89778 100644 --- a/routes/(apps)/admin/(_islands)/AdminModules.tsx +++ b/routes/(apps)/admin/(_islands)/AdminModules.tsx @@ -1,22 +1,31 @@ import { useEffect, useState } from "preact/hooks"; type Module = { id: string; nom: string }; +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type User = { id: string; nom: string; prenom: string }; export default function AdminModules() { const [modules, setModules] = useState([]); + const [enseignements, setEnseignements] = useState([]); + const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newId, setNewId] = useState(""); const [newNom, setNewNom] = useState(""); const [creating, setCreating] = useState(false); - const [editId, setEditId] = useState(null); - const [editNom, setEditNom] = useState(""); + const [filterNom, setFilterNom] = 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()); + const [mRes, eRes, uRes] = await Promise.all([ + fetch("/admin/api/modules"), + fetch("/admin/api/enseignements"), + fetch("/admin/api/users"), + ]); + if (!mRes.ok) throw new Error("Impossible de charger les modules"); + setModules(await mRes.json()); + if (eRes.ok) setEnseignements(await eRes.json()); + if (uRes.ok) setUsers(await uRes.json()); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -51,21 +60,6 @@ export default function AdminModules() { } } - 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 { @@ -80,125 +74,181 @@ export default function AdminModules() { } } + const userMap = Object.fromEntries( + users.map((u) => [u.id, u]), + ); + + function enseignantsForModule(moduleId: string): string { + const profs = [ + ...new Set( + enseignements + .filter((e) => e.idModule === moduleId) + .map((e) => e.idProf), + ), + ]; + if (profs.length === 0) return ""; + return profs + .map((id) => { + const u = userMap[id]; + return u ? `${u.nom} ${u.prenom.charAt(0)}.` : id; + }) + .join(", "); + } + + const filtered = modules.filter((m) => + !filterNom || + `${m.id} ${m.nom}`.toLowerCase().includes(filterNom.toLowerCase()) + ); + return (

Gestion des Modules

{error &&

{error}

} -
+
setNewId((e.target as HTMLInputElement).value)} - style="min-width: 10rem" - /> - setNewNom((e.target as HTMLInputElement).value)} + class="filter-input" + placeholder="Rechercher..." + value={filterNom} + onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)} />
{loading - ?

Chargement…

+ ?

Chargement...

: (
- - + + + - {modules.length === 0 + {filtered.length === 0 ? ( - ) - : modules.map((m) => ( - - - - + + + - - ))} + : --} + + + + ); + })}
IdentifiantNomid (code)Nom du moduleEnseignants assignes Actions
+ Aucun module enregistré
{m.id} - {editId === m.id - ? ( - - setEditNom( - (e.target as HTMLInputElement).value, - )} - style="min-width: 0; width: 100%" - /> - ) - : m.nom} - -
- {editId === m.id + : filtered.map((m) => { + const profs = enseignantsForModule(m.id); + return ( +
{m.id}{m.nom} + {profs ? ( - <> - - - + + {profs} + ) - : ( - <> - - - - )} - -
+
+ + + + {" "} + edit + + +
+
)} + + {/* Nouveau module */} +
+

Nouveau module

+
+ setNewId((e.target as HTMLInputElement).value)} + style="min-width: 8rem; max-width: 10rem" + /> + setNewNom((e.target as HTMLInputElement).value)} + /> + +
+
); } diff --git a/routes/(apps)/admin/(_islands)/AdminPermissions.tsx b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx index 57c600b..79ed125 100644 --- a/routes/(apps)/admin/(_islands)/AdminPermissions.tsx +++ b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx @@ -3,6 +3,19 @@ import { useEffect, useState } from "preact/hooks"; type Perm = { id: string; nom: string }; type Role = { id: number; nom: string; permissions: string[] }; +const ROLE_COLORS = [ + "#22c55e", + "#d4a017", + "#e07020", + "#8b5cf6", + "#06b6d4", + "#ec4899", +]; + +function roleColor(roleId: number): string { + return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length]; +} + export default function AdminPermissions() { const [permissions, setPermissions] = useState([]); const [roles, setRoles] = useState([]); @@ -80,7 +93,15 @@ export default function AdminPermissions() {
{shown.map((r) => ( - {r.nom} + + {r.nom} + ))} {overflow > 0 && ( {saveError}

} -
+
idRole : {managingRole.id} {managingRole.nom} - + {activeCount} permission{activeCount !== 1 ? "s" : ""} active {activeCount !== 1 ? "s" : ""} @@ -151,7 +148,7 @@ export default function AdminRoles() { onClick={savePerms} disabled={saving} > - {saving ? "…" : "Enregistrer"} + {saving ? "..." : "Enregistrer"}
@@ -192,6 +189,8 @@ export default function AdminRoles() { ); } + const permMap = Object.fromEntries(permissions.map((p) => [p.id, p.nom])); + // ---- Main list view ---- return (
@@ -202,7 +201,7 @@ export default function AdminRoles() {
setNewNom((e.target as HTMLInputElement).value)} onKeyDown={(e) => e.key === "Enter" && createRole()} @@ -219,7 +218,7 @@ export default function AdminRoles() {
{loading - ?

Chargement…

+ ?

Chargement...

: (
@@ -252,7 +251,9 @@ export default function AdminRoles() { diff --git a/routes/(apps)/admin/(_islands)/AdminUsers.tsx b/routes/(apps)/admin/(_islands)/AdminUsers.tsx index eee86f9..0ca35b6 100644 --- a/routes/(apps)/admin/(_islands)/AdminUsers.tsx +++ b/routes/(apps)/admin/(_islands)/AdminUsers.tsx @@ -3,11 +3,25 @@ import { useEffect, useState } from "preact/hooks"; type User = { id: string; nom: string; prenom: string; idRole: number | null }; type Role = { id: number; nom: string }; +const ROLE_COLORS = [ + "#22c55e", + "#d4a017", + "#e07020", + "#8b5cf6", + "#06b6d4", + "#ec4899", +]; + +function roleColor(roleId: number): string { + return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length]; +} + export default function AdminUsers() { const [users, setUsers] = useState([]); const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); const [newId, setNewId] = useState(""); const [newNom, setNewNom] = useState(""); const [newPrenom, setNewPrenom] = useState(""); @@ -15,6 +29,7 @@ export default function AdminUsers() { const [creating, setCreating] = useState(false); const [filterNom, setFilterNom] = useState(""); + const [filterRole, setFilterRole] = useState(""); async function load() { try { @@ -58,6 +73,7 @@ export default function AdminUsers() { setNewNom(""); setNewPrenom(""); setNewIdRole(""); + setShowCreate(false); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); @@ -81,12 +97,14 @@ export default function AdminUsers() { 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(), - ) - ); + const filtered = users.filter((u) => { + const matchNom = !filterNom || + `${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes( + filterNom.toLowerCase(), + ); + const matchRole = !filterRole || String(u.idRole) === filterRole; + return matchNom && matchRole; + }); return (
@@ -94,64 +112,121 @@ export default function AdminUsers() { {error &&

{error}

} -
+
setNewId((e.target as HTMLInputElement).value)} - style="min-width: 9rem" - /> - setNewNom((e.target as HTMLInputElement).value)} - /> - setNewPrenom((e.target as HTMLInputElement).value)} + class="filter-input" + placeholder="Rechercher..." + value={filterNom} + onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)} />
-
- setFilterNom((e.target as HTMLInputElement).value)} - /> -
+ {/* Creation modal */} + {showCreate && ( + + )} {loading - ?

Chargement…

+ ?

Chargement...

: (
{shown.map((p) => ( - {p} + + {permMap[p] ?? p} + ))} {overflow > 0 && (
- + - + @@ -170,7 +245,18 @@ export default function AdminUsers() { diff --git a/routes/(apps)/admin/(_islands)/EditModule.tsx b/routes/(apps)/admin/(_islands)/EditModule.tsx new file mode 100644 index 0000000..a9770ba --- /dev/null +++ b/routes/(apps)/admin/(_islands)/EditModule.tsx @@ -0,0 +1,344 @@ +import { useEffect, useState } from "preact/hooks"; + +type Module = { id: string; nom: string }; +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type User = { id: string; nom: string; prenom: string }; +type Promo = { id: string; annee: string }; + +type Props = { moduleId: string }; + +export default function EditModule({ moduleId }: Props) { + const [mod, setMod] = useState(null); + const [enseignements, setEnseignements] = useState([]); + const [users, setUsers] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saveMsg, setSaveMsg] = useState(null); + const [saving, setSaving] = useState(false); + + const [nom, setNom] = useState(""); + + // Add enseignement + const [addProf, setAddProf] = useState(""); + const [addPromo, setAddPromo] = useState(""); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + async function load() { + try { + const [mRes, eRes, uRes, pRes] = await Promise.all([ + fetch(`/admin/api/modules/${encodeURIComponent(moduleId)}`), + fetch("/admin/api/enseignements"), + fetch("/admin/api/users"), + fetch("/students/api/promotions"), + ]); + if (!mRes.ok) throw new Error("Module introuvable"); + const m: Module = await mRes.json(); + setMod(m); + setNom(m.nom); + if (eRes.ok) { + const all: Enseignement[] = await eRes.json(); + setEnseignements(all.filter((e) => e.idModule === moduleId)); + } + if (uRes.ok) setUsers(await uRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, [moduleId]); + + async function saveInfos() { + if (!mod) return; + setSaving(true); + setSaveMsg(null); + try { + const res = await fetch( + `/admin/api/modules/${encodeURIComponent(moduleId)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: nom.trim() }), + }, + ); + if (!res.ok) throw new Error("Modification échouée"); + const updated: Module = await res.json(); + setMod(updated); + setSaveMsg("Module enregistré."); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + async function deleteModule() { + if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return; + try { + const res = await fetch( + `/admin/api/modules/${encodeURIComponent(moduleId)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + globalThis.location.href = "/admin/modules"; + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function addEnseignement() { + if (!addProf || !addPromo) { + setAddError("Enseignant et Promo sont requis"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/admin/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idProf: addProf, + idModule: moduleId, + idPromo: addPromo, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddProf(""); + setAddPromo(""); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + async function removeEnseignement(idProf: string, idPromo: string) { + if (!confirm("Retirer cet enseignement ?")) return; + try { + const res = await fetch( + `/admin/api/enseignements/${encodeURIComponent(idProf)}/${ + encodeURIComponent(moduleId) + }/${encodeURIComponent(idPromo)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const userMap = Object.fromEntries(users.map((u) => [u.id, u])); + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + + if (error && !mod) { + return ( +
+

{error}

+
+ ); + } + + if (!mod) return null; + + return ( +
+ + ← Retour a la liste + + +

+ Module -- {mod.id} +

+ +
+ {mod.id} + {mod.nom} +
+ + {error &&

{error}

} + {saveMsg && ( +

+ {saveMsg} +

+ )} + + {/* Section 1: Infos */} +
+

Informations

+
+
+ + +
+
+ + setNom((e.target as HTMLInputElement).value)} + /> +
+
+
+ + +
+
+ + {/* Section 2: Enseignements */} +
+

Enseignants assignes

+ + {enseignements.length > 0 + ? ( +
+
Loginid (login) Nom PrénomRôleRôle(s) Actions
{u.nom} {u.prenom} - {u.idRole ? (roleMap[u.idRole] ?? `#${u.idRole}`) : "—"} + {u.idRole + ? ( + + {roleMap[u.idRole] ?? `#${u.idRole}`} + + ) + : --}
@@ -179,14 +265,35 @@ export default function AdminUsers() { href={`/admin/users/${encodeURIComponent(u.id)}`} f-client-nav={false} > - ✏ + + + {" "} + edit
+ + + + + + + + + {enseignements.map((e) => { + const u = userMap[e.idProf]; + return ( + + + + + + ); + })} + +
EnseignantPromoActions
+ {u ? `${u.nom} ${u.prenom.charAt(0)}.` : e.idProf} + + {e.idPromo} + + +
+
+ ) + : ( +

+ Aucun enseignant assigne. +

+ )} + +

+ Ajouter un enseignant +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + +
+
+
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/EditUser.tsx b/routes/(apps)/admin/(_islands)/EditUser.tsx new file mode 100644 index 0000000..c9e45ca --- /dev/null +++ b/routes/(apps)/admin/(_islands)/EditUser.tsx @@ -0,0 +1,391 @@ +import { useEffect, useState } from "preact/hooks"; + +type User = { id: string; nom: string; prenom: string; idRole: number | null }; +type Role = { id: number; nom: string }; +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type Module = { id: string; nom: string }; +type Promo = { id: string; annee: string }; + +type Props = { userId: string }; + +export default function EditUser({ userId }: Props) { + const [user, setUser] = useState(null); + const [roles, setRoles] = useState([]); + const [enseignements, setEnseignements] = useState([]); + const [modules, setModules] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saveMsg, setSaveMsg] = useState(null); + const [saving, setSaving] = useState(false); + + const [nom, setNom] = useState(""); + const [prenom, setPrenom] = useState(""); + const [idRole, setIdRole] = useState(""); + + // Add enseignement form + const [addModule, setAddModule] = useState(""); + const [addPromo, setAddPromo] = useState(""); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + async function load() { + try { + const [uRes, rRes, eRes, mRes, pRes] = await Promise.all([ + fetch(`/admin/api/users/${encodeURIComponent(userId)}`), + fetch("/admin/api/roles"), + fetch("/admin/api/enseignements"), + fetch("/admin/api/modules"), + fetch("/students/api/promotions"), + ]); + if (!uRes.ok) throw new Error("Utilisateur introuvable"); + const u: User = await uRes.json(); + setUser(u); + setNom(u.nom); + setPrenom(u.prenom); + setIdRole(u.idRole !== null ? String(u.idRole) : ""); + if (rRes.ok) setRoles(await rRes.json()); + if (eRes.ok) { + const allEns: Enseignement[] = await eRes.json(); + setEnseignements(allEns.filter((e) => e.idProf === userId)); + } + if (mRes.ok) setModules(await mRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, [userId]); + + async function saveInfos() { + if (!user) return; + setSaving(true); + setSaveMsg(null); + try { + const res = await fetch( + `/admin/api/users/${encodeURIComponent(userId)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: nom.trim(), + prenom: prenom.trim(), + idRole: idRole ? Number(idRole) : null, + }), + }, + ); + if (!res.ok) throw new Error("Modification échouée"); + const updated: User = await res.json(); + setUser(updated); + setSaveMsg("Informations enregistrées."); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + async function deleteUser() { + if (!confirm(`Supprimer définitivement l'utilisateur ${userId} ?`)) return; + try { + const res = await fetch( + `/admin/api/users/${encodeURIComponent(userId)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + globalThis.location.href = "/admin/users"; + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function addEnseignement() { + if (!addModule || !addPromo) { + setAddError("Module et Promo sont requis"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/admin/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idProf: userId, + idModule: addModule, + idPromo: addPromo, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddModule(""); + setAddPromo(""); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + async function removeEnseignement(idModule: string, idPromo: string) { + if (!confirm("Retirer cet enseignement ?")) return; + try { + const res = await fetch( + `/admin/api/enseignements/${encodeURIComponent(userId)}/${ + encodeURIComponent(idModule) + }/${encodeURIComponent(idPromo)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom])); + + if (loading) { + return ( +
+

Chargement...

+
+ ); + } + + if (error && !user) { + return ( +
+

{error}

+
+ ); + } + + if (!user) return null; + + return ( +
+ + ← Retour a la liste + + +

+ Edition -- {user.prenom} {user.nom} +

+ +
+ {user.id} + + {user.idRole + ? (roleMap[user.idRole] ?? `Role #${user.idRole}`) + : "Aucun role"} + +
+ + {error &&

{error}

} + {saveMsg && ( +

+ {saveMsg} +

+ )} + + {/* Section 1: Informations generales */} +
+

Informations generales

+ +
+
+ + setNom((e.target as HTMLInputElement).value)} + /> +
+
+ + setPrenom((e.target as HTMLInputElement).value)} + /> +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {/* Section 2: Enseignements */} +
+

Enseignements

+

+ Modules enseignes par cet utilisateur +

+ + {enseignements.length > 0 + ? ( +
+ + + + + + + + + + {enseignements.map((e) => { + const mod = moduleMap[e.idModule]; + return ( + + + + + + ); + })} + +
ModulePromoActions
+ {mod ? `${mod.id} -- ${mod.nom}` : e.idModule} + + {e.idPromo} + + +
+
+ ) + : ( +

+ Aucun enseignement assigne. +

+ )} + +

+ Ajouter un enseignement +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + +
+
+
+ ); +} diff --git a/routes/(apps)/admin/modules/[idModule].tsx b/routes/(apps)/admin/modules/[idModule].tsx new file mode 100644 index 0000000..858bfa9 --- /dev/null +++ b/routes/(apps)/admin/modules/[idModule].tsx @@ -0,0 +1,11 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditModule from "../(_islands)/EditModule.tsx"; + +// deno-lint-ignore require-await +export default async function EditModulePage( + _request: Request, + context: FreshContext, +) { + return ; +} diff --git a/routes/(apps)/admin/users/[id].tsx b/routes/(apps)/admin/users/[id].tsx new file mode 100644 index 0000000..868db34 --- /dev/null +++ b/routes/(apps)/admin/users/[id].tsx @@ -0,0 +1,11 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditUser from "../(_islands)/EditUser.tsx"; + +// deno-lint-ignore require-await +export default async function EditUserPage( + _request: Request, + context: FreshContext, +) { + return ; +} diff --git a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx index 9ae2c94..dd4abae 100644 --- a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx +++ b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx @@ -130,7 +130,17 @@ export default function AdminConsultNotes() { href={`/notes/edition/${s.numEtud}`} f-client-nav={false} > - ✏ édit + + + {" "} + édit
diff --git a/routes/(apps)/notes/(_islands)/AdminUEs.tsx b/routes/(apps)/notes/(_islands)/AdminUEs.tsx index 8c2ea22..bbed5df 100644 --- a/routes/(apps)/notes/(_islands)/AdminUEs.tsx +++ b/routes/(apps)/notes/(_islands)/AdminUEs.tsx @@ -31,6 +31,10 @@ export default function AdminUEs() { const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); + // Inline coeff editing + const [editingCoeff, setEditingCoeff] = useState(null); + const [editCoeffValue, setEditCoeffValue] = useState(""); + async function load() { try { const [uRes, umRes, mRes, pRes] = await Promise.all([ @@ -137,6 +141,32 @@ export default function AdminUEs() { } } + async function updateCoeff( + idModule: string, + idUE: number, + idPromo: string, + coeff: number, + ) { + try { + const res = await fetch( + `/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ + encodeURIComponent(idPromo) + }`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ coeff }), + }, + ); + if (!res.ok) throw new Error("Modification échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setEditingCoeff(null); + } + } + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); const selectedUeModules = selectedUe @@ -249,7 +279,59 @@ export default function AdminUEs() { {um.idPromo} - {um.coeff} + { + const key = + `${um.idModule}-${um.idUE}-${um.idPromo}`; + setEditingCoeff(key); + setEditCoeffValue(String(um.coeff)); + }} + style="cursor: pointer" + > + {editingCoeff === + `${um.idModule}-${um.idUE}-${um.idPromo}` + ? ( + + setEditCoeffValue( + (e.target as HTMLInputElement) + .value, + )} + onBlur={() => { + const v = parseFloat( + editCoeffValue, + ); + if (!isNaN(v) && v > 0) { + updateCoeff( + um.idModule, + um.idUE, + um.idPromo, + v, + ); + } else { + setEditingCoeff(null); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + (e.target as HTMLInputElement) + .blur(); + } + if (e.key === "Escape") { + setEditingCoeff(null); + } + }} + /> + ) + : um.coeff} + diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index 81918f5..f72bc89 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -326,7 +326,17 @@ export default function NoteRecap({ numEtud }: Props) { : "", })} > - ✏ note + + + {" "} + note
); diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index 2f5be17..2e4dc98 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -11,6 +11,7 @@ const properties: AppProperties = { import: "Import xlsx", }, adminOnly: ["courses", "ues", "import"], + studentOnly: ["notes"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/partials/(admin)/import.tsx b/routes/(apps)/notes/partials/(admin)/import.tsx index 4a92c3d..111edf0 100644 --- a/routes/(apps)/notes/partials/(admin)/import.tsx +++ b/routes/(apps)/notes/partials/(admin)/import.tsx @@ -14,12 +14,6 @@ async function ImportNotesPage( return (

Importer des Notes

-

- POST /notes/api/notes -

); diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/students/(_islands)/AdminPromotions.tsx index 6143972..7f32f91 100644 --- a/routes/(apps)/students/(_islands)/AdminPromotions.tsx +++ b/routes/(apps)/students/(_islands)/AdminPromotions.tsx @@ -25,7 +25,7 @@ export default function AdminPromotions() { const [anneeSco, setAnneeSco] = useState(""); const generatedId = anneeSco.trim() - ? `${selectedAnnee}${selectedFiliere}${anneeSco.trim()}` + ? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}` : ""; async function load() { @@ -101,7 +101,7 @@ export default function AdminPromotions() {

Créer une promotion

- POST /promotions – idPromo est généré automatiquement + idPromo est généré automatiquement

@@ -141,7 +141,7 @@ export default function AdminPromotions() { setAnneeSco((e.target as HTMLInputElement).value)} style="min-width: 9rem" @@ -220,7 +220,18 @@ export default function AdminPromotions() { class="btn btn-sm btn-danger" onClick={() => deletePromo(p.id)} > - 🗑 + + + + + diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index 031bbe9..c55ae51 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -67,6 +67,7 @@ export default function ConsultStudents() { class="btn btn-primary" href="/students/upload" f-partial="/students/partials/upload" + style="margin-left: auto" > Importer xlsx @@ -128,14 +129,34 @@ export default function ConsultStudents() { href={`/students/edit/${s.numEtud}`} f-client-nav={false} > - ✏ + + +
diff --git a/routes/(apps)/students/(_islands)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index f5728c4..a7fc770 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -147,8 +147,6 @@ export default function EditStudents({ numEtud }: Props) { {/* Section 1: Informations générales */}

Informations générales

-

PUT /students/{"{numEtud}"}

-
@@ -212,9 +210,6 @@ export default function EditStudents({ numEtud }: Props) { {/* Section 2: Spécialisations */}

Spécialisations

-

- GET·POST·DELETE /spe5a – plusieurs modules possibles -

Notes (lecture seule)

-

- GET /students/{"{numEtud}"}/notes – voir récap complet -

Voir le récap complet des notes et moyennes de cet étudiant → diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index cdb94fd..578d830 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -11,12 +11,6 @@ async function Students(_request: Request, _context: FreshContext) { return (

Importer des Élèves

-

- POST /students/api/students/import-csv -

); diff --git a/static/styles/ui.css b/static/styles/ui.css index f43bfc8..a4efd9a 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -470,6 +470,18 @@ Permission toggle cards (role management) ------------------------------------------------------- */ +.perm-header-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.85rem; + margin-bottom: 1.25rem; + background: light-dark(#f5f4ff, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; +} + .perm-toggle-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -740,7 +752,7 @@ .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); - gap: 0.5rem; + gap: 0.75rem 1rem; margin-bottom: 0.75rem; } @@ -947,6 +959,49 @@ (end note recap) ------------------------------------------------------- */ +/* ------------------------------------------------------- + Modal overlay +------------------------------------------------------- */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-box { + background: light-dark(white, #1a172d); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 6px; + padding: 1.25rem; + min-width: 22rem; + max-width: 90vw; +} + +.modal-title { + font-size: 0.95rem; + font-weight: var(--font-weight-bold); + margin: 0 0 1rem; +} + +.modal-form { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + .info-note-dim { font-size: 0.7rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));