From 34b7ac023177387c3754c016bda430707160ee3d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 22:29:10 +0200 Subject: [PATCH 01/11] docs: update CLAUDE.md to reflect completed API layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark all implemented endpoints as ✅, document the 3-level test architecture, and clarify that UI pages are the next priority. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 147 +++++++++++++++++++++++++++++------------------------- 1 file changed, 80 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe5c70d..435d43f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,8 @@ role-based administration. ### Current Status -🚧 **In Progress** - Application is far from complete. The schema below is the -**final/definitive schema** that should guide all development. +🚧 **In Progress** - API layer largely complete, UI pages not yet built. +The schema below is the **final/definitive schema** that guides all development. --- @@ -133,15 +133,11 @@ erDiagram AJUSTEMENT }o--|| UE : "dans" ``` -### Current Schema (Incomplete) +### Current Schema -The current Drizzle ORM schema in `/databases/schema.ts` only implements: - -- `promotions` -- `students` -- `mobility` - -**Migration needed**: Update schema to match the final ER diagram above. +The Drizzle ORM schema in `/databases/schema.ts` implements all tables: +`roles`, `permissions`, `rolePermissions`, `users`, `promotions`, `students`, +`modules`, `enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. --- @@ -186,71 +182,73 @@ The current Drizzle ORM schema in `/databases/schema.ts` only implements: ### API Endpoints +Legend: ✅ implemented & tested | 📋 not yet implemented + **Students API** -- 📋 GET `/students` (#7) -- 📋 POST `/students` (#8) -- 📋 POST `/students/import-csv` (#9) -- 📋 GET `/students/{numEtud}` (#10) -- 📋 PUT `/students/{numEtud}` (#11) -- 📋 DELETE `/students/{numEtud}` (#12) -- 📋 GET `/promotions` (#13) -- 📋 POST `/promotions` (#14) -- 📋 GET `/promotions/{idPromo}` (#15) -- 📋 PUT `/promotions/{idPromo}` (#16) -- 📋 DELETE `/promotions/{idPromo}` (#17) +- ✅ GET `/students` (#7) +- ✅ POST `/students` (#8) +- ✅ POST `/students/import-csv` (#9) +- ✅ GET `/students/{numEtud}` (#10) +- ✅ PUT `/students/{numEtud}` (#11) +- ✅ DELETE `/students/{numEtud}` (#12) +- ✅ GET `/promotions` (#13) +- ✅ POST `/promotions` (#14) +- ✅ GET `/promotions/{idPromo}` (#15) +- ✅ PUT `/promotions/{idPromo}` (#16) +- ✅ DELETE `/promotions/{idPromo}` (#17) **Administration API - Modules & Enseignements** -- 📋 GET `/modules` (#23) -- 📋 POST `/modules` (#24) -- 📋 GET `/modules/{idModule}` (#25) -- 📋 PUT `/modules/{idModule}` (#26) -- 📋 DELETE `/modules/{idModule}` (#27) -- 📋 POST `/enseignements` (#29) -- 📋 GET `/enseignements/{idProf}/{idModule}/{idPromo}` (#30) -- 📋 DELETE `/enseignements/{idProf}/{idModule}/{idPromo}` (#31) +- ✅ GET `/modules` (#23) +- ✅ POST `/modules` (#24) +- ✅ GET `/modules/{idModule}` (#25) +- ✅ PUT `/modules/{idModule}` (#26) +- ✅ DELETE `/modules/{idModule}` (#27) +- ✅ POST `/enseignements` (#29) +- ✅ GET `/enseignements/{idProf}/{idModule}/{idPromo}` (#30) +- ✅ DELETE `/enseignements/{idProf}/{idModule}/{idPromo}` (#31) **Notes API - UEs & UE-Modules** -- 📋 GET `/ues` (#32) -- 📋 POST `/ues` (#33) -- 📋 GET `/ues/{idUE}` (#34) -- 📋 PUT `/ues/{idUE}` (#35) -- 📋 DELETE `/ues/{idUE}` (#36) -- 📋 GET `/ue-modules` (#37) -- 📋 POST `/ue-modules` (#38) -- 📋 GET `/ue-modules/{idModule}/{idUE}/{idPromo}` (#39) -- 📋 PUT `/ue-modules/{idModule}/{idUE}/{idPromo}` (#40) -- 📋 DELETE `/ue-modules/{idModule}/{idUE}/{idPromo}` (#41) +- ✅ GET `/ues` (#32) +- ✅ POST `/ues` (#33) +- ✅ GET `/ues/{idUE}` (#34) +- ✅ PUT `/ues/{idUE}` (#35) +- ✅ DELETE `/ues/{idUE}` (#36) +- ✅ GET `/ue-modules` (#37) +- ✅ POST `/ue-modules` (#38) +- ✅ GET `/ue-modules/{idModule}/{idUE}/{idPromo}` (#39) +- ✅ PUT `/ue-modules/{idModule}/{idUE}/{idPromo}` (#40) +- ✅ DELETE `/ue-modules/{idModule}/{idUE}/{idPromo}` (#41) **Notes API - Notes & Ajustements** -- 📋 GET `/notes` (#42) -- 📋 POST `/notes` (#43) +- ✅ GET `/notes` (#42) +- ✅ POST `/notes` (#43) - 📋 POST `/notes/import-xlsx` (#44) -- 📋 GET `/notes/{numEtud}/{idModule}` (#45) -- 📋 PUT `/notes/{numEtud}/{idModule}` (#46) -- 📋 DELETE `/notes/{numEtud}/{idModule}` (#47) -- 📋 GET `/ajustements` (#48) -- 📋 POST `/ajustements` (#49) -- 📋 GET `/ajustements/{numEtud}/{idUE}` (#50) -- 📋 PUT `/ajustements/{numEtud}/{idUE}` (#51) -- 📋 DELETE `/ajustements/{numEtud}/{idUE}` (#52) +- ✅ GET `/notes/{numEtud}/{idModule}` (#45) +- ✅ PUT `/notes/{numEtud}/{idModule}` (#46) +- ✅ DELETE `/notes/{numEtud}/{idModule}` (#47) +- ✅ GET `/ajustements` (#48) +- ✅ POST `/ajustements` (#49) +- ✅ GET `/ajustements/{numEtud}/{idUE}` (#50) +- ✅ PUT `/ajustements/{numEtud}/{idUE}` (#51) +- ✅ DELETE `/ajustements/{numEtud}/{idUE}` (#52) **Administration API - Users, Roles & Permissions** -- 📋 GET `/users` (#60) -- 📋 POST `/users` (#61) -- 📋 GET `/users/{id}` (#62) -- 📋 PUT `/users/{id}` (#63) -- 📋 DELETE `/users/{id}` (#64) -- 📋 GET `/roles` (#65) -- 📋 POST `/roles` (#66) -- 📋 GET `/roles/{idRole}` (#67) -- 📋 PUT `/roles/{idRole}` (#68) -- 📋 DELETE `/roles/{idRole}` (#69) -- 📋 GET `/permissions` (#70) +- ✅ GET `/users` (#60) +- ✅ POST `/users` (#61) +- ✅ GET `/users/{id}` (#62) +- ✅ PUT `/users/{id}` (#63) +- ✅ DELETE `/users/{id}` (#64) +- ✅ GET `/roles` (#65) +- ✅ POST `/roles` (#66) +- ✅ GET `/roles/{idRole}` (#67) +- ✅ PUT `/roles/{idRole}` (#68) +- ✅ DELETE `/roles/{idRole}` (#69) +- ✅ GET `/permissions` (#70) --- @@ -298,10 +296,24 @@ deno task check ### Testing -- Write unit tests for business logic -- Integration tests for API endpoints -- E2E tests with HappyDOM for UI interactions -- Mock database with provided helpers +3-level architecture — all 149 tests pass: + +- **Unit** (`tests/unit/`) — pure logic with mock DB + mock API, no real DB +- **Integration** (`tests/integration/`) — Drizzle ORM direct on real DB +- **E2E** (`tests/e2e/`) — Fresh handler + real DB (handler-level, not browser) + +Helpers in `tests/helpers/`: +- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, `makeJsonRequest`…) +- `db_integration.ts` — seed functions + `truncateAll()` for test isolation +- `db_mock.ts` / `api_mock.ts` — in-memory mocks for unit tests + +```bash +deno task test # run all tests +deno task test:coverage # coverage report (terminal) +deno task test:coverage:html # coverage report (HTML → coverage/html/index.html) +nix run nixpkgs#act -- -j unit --no-cache-server # unit tests via GitHub Actions +nix run nixpkgs#act -- -j integration --no-cache-server # integration + e2e via GitHub Actions +``` --- @@ -327,12 +339,13 @@ deno task check ## 💡 Important Notes -1. **Current Limitation**: The database schema in `/databases/schema.ts` does - NOT match the final ER diagram. This is a priority migration task. -2. **Design System**: Follow the Figma prototype for all UI work. +1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints are implemented. +2. **Next priority**: UI pages (none built yet) — follow the Figma prototype. 3. **Module Pattern**: Each module should follow the same pattern: routes, API endpoints, components, and tests. 4. **Permissions**: All admin operations should respect the ROLE_PERMISSION system. 5. **Fresh Conventions**: Routes use Fresh's file-based routing convention (e.g., `routes/path/index.tsx`). +6. **Drizzle `.where()` pitfall**: Always wrap multiple conditions with `and()`. + `.where(eq(a), eq(b))` silently ignores the second argument. -- 2.52.0 From 5ba8b8cb68356ab5ff5141c008043c63b02c18fd Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 22:54:10 +0200 Subject: [PATCH 02/11] 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 --- CLAUDE.md | 17 +- .../(apps)/admin/(_islands)/AdminModules.tsx | 204 +++++++++ routes/(apps)/admin/(_islands)/AdminRoles.tsx | 225 ++++++++++ routes/(apps)/admin/(_islands)/AdminUsers.tsx | 201 +++++++++ routes/(apps)/admin/(_props)/props.ts | 7 +- routes/(apps)/admin/partials/index.tsx | 37 +- routes/(apps)/admin/partials/modules.tsx | 18 + routes/(apps)/admin/partials/roles.tsx | 18 + routes/(apps)/admin/partials/users.tsx | 18 + .../notes/(_islands)/AdminConsultNotes.tsx | 145 +++++++ routes/(apps)/notes/(_islands)/AdminUEs.tsx | 124 ++++++ routes/(apps)/notes/(_islands)/NotesView.tsx | 223 ++++++++++ routes/(apps)/notes/(_props)/props.ts | 9 +- .../(apps)/notes/partials/(admin)/courses.tsx | 7 +- routes/(apps)/notes/partials/(admin)/ues.tsx | 18 + routes/(apps)/notes/partials/index.tsx | 48 ++- routes/(apps)/notes/partials/notes.tsx | 33 +- .../students/(_islands)/AdminPromotions.tsx | 143 +++++++ .../students/(_islands)/ConsultStudents.tsx | 171 ++++++-- routes/(apps)/students/(_props)/props.ts | 9 +- .../students/partials/(admin)/consult.tsx | 7 +- .../students/partials/(admin)/promotions.tsx | 18 + routes/(apps)/students/partials/index.tsx | 42 +- routes/_app.tsx | 1 + static/styles/ui.css | 393 ++++++++++++++++++ 25 files changed, 2059 insertions(+), 77 deletions(-) create mode 100644 routes/(apps)/admin/(_islands)/AdminModules.tsx create mode 100644 routes/(apps)/admin/(_islands)/AdminRoles.tsx create mode 100644 routes/(apps)/admin/(_islands)/AdminUsers.tsx create mode 100644 routes/(apps)/admin/partials/modules.tsx create mode 100644 routes/(apps)/admin/partials/roles.tsx create mode 100644 routes/(apps)/admin/partials/users.tsx create mode 100644 routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx create mode 100644 routes/(apps)/notes/(_islands)/AdminUEs.tsx create mode 100644 routes/(apps)/notes/(_islands)/NotesView.tsx create mode 100644 routes/(apps)/notes/partials/(admin)/ues.tsx create mode 100644 routes/(apps)/students/(_islands)/AdminPromotions.tsx create mode 100644 routes/(apps)/students/partials/(admin)/promotions.tsx create mode 100644 static/styles/ui.css diff --git a/CLAUDE.md b/CLAUDE.md index 435d43f..f3f37b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,8 @@ role-based administration. ### Current Status -🚧 **In Progress** - API layer largely complete, UI pages not yet built. -The schema below is the **final/definitive schema** that guides all development. +🚧 **In Progress** - API layer largely complete, UI pages not yet built. The +schema below is the **final/definitive schema** that guides all development. --- @@ -135,9 +135,9 @@ erDiagram ### Current Schema -The Drizzle ORM schema in `/databases/schema.ts` implements all tables: -`roles`, `permissions`, `rolePermissions`, `users`, `promotions`, `students`, -`modules`, `enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. +The Drizzle ORM schema in `/databases/schema.ts` implements all tables: `roles`, +`permissions`, `rolePermissions`, `users`, `promotions`, `students`, `modules`, +`enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. --- @@ -303,7 +303,9 @@ deno task check - **E2E** (`tests/e2e/`) — Fresh handler + real DB (handler-level, not browser) Helpers in `tests/helpers/`: -- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, `makeJsonRequest`…) + +- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, + `makeJsonRequest`…) - `db_integration.ts` — seed functions + `truncateAll()` for test isolation - `db_mock.ts` / `api_mock.ts` — in-memory mocks for unit tests @@ -339,7 +341,8 @@ nix run nixpkgs#act -- -j integration --no-cache-server # integration + e2e via ## 💡 Important Notes -1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints are implemented. +1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints + are implemented. 2. **Next priority**: UI pages (none built yet) — follow the Figma prototype. 3. **Module Pattern**: Each module should follow the same pattern: routes, API endpoints, components, and tests. diff --git a/routes/(apps)/admin/(_islands)/AdminModules.tsx b/routes/(apps)/admin/(_islands)/AdminModules.tsx new file mode 100644 index 0000000..df0af41 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminModules.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from "preact/hooks"; + +type Module = { id: string; nom: string }; + +export default function AdminModules() { + const [modules, setModules] = 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(""); + + 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 ( +
+

Gestion des Modules

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + style="min-width: 10rem" + /> + setNewNom((e.target as HTMLInputElement).value)} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {modules.length === 0 + ? ( + + + + ) + : modules.map((m) => ( + + + + + + ))} + +
IdentifiantNomActions
+ 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 + ? ( + <> + + + + ) + : ( + <> + + + + )} +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx new file mode 100644 index 0000000..9e97fad --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -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([]); + const [permissions, setPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newNom, setNewNom] = useState(""); + const [creating, setCreating] = useState(false); + const [editId, setEditId] = useState(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 ( +
+

Gestion des Rôles

+ + {error &&

{error}

} + +
+ setNewNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createRole()} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {roles.length === 0 + ? ( + + + + ) + : roles.map((r) => ( + + + + + + ))} + +
IDNomActions
+ Aucun rôle enregistré +
{r.id} + {editId === r.id + ? ( + + setEditNom( + (e.target as HTMLInputElement).value, + )} + style="min-width: 0; width: 100%" + /> + ) + : r.nom} + +
+ {editId === r.id + ? ( + <> + + + + ) + : ( + <> + + + + )} +
+
+
+ )} + + {permissions.length > 0 && ( +
+

+ Permissions disponibles +

+
+ + + + + + + + + {permissions.map((p) => ( + + + + + ))} + +
IDNom
{p.id}{p.nom}
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminUsers.tsx b/routes/(apps)/admin/(_islands)/AdminUsers.tsx new file mode 100644 index 0000000..eee86f9 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminUsers.tsx @@ -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([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

Gestion des Utilisateurs

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + style="min-width: 9rem" + /> + setNewNom((e.target as HTMLInputElement).value)} + /> + setNewPrenom((e.target as HTMLInputElement).value)} + /> + + +
+ +
+ setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((u) => ( + + + + + + + + ))} + +
LoginNomPrénomRôleActions
+ Aucun utilisateur trouvé +
{u.id}{u.nom}{u.prenom} + {u.idRole ? (roleMap[u.idRole] ?? `#${u.idRole}`) : "—"} + +
+ + ✏ + + +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index 3ae55a1..a95fb54 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -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", }; diff --git a/routes/(apps)/admin/partials/index.tsx b/routes/(apps)/admin/partials/index.tsx index 4e0c915..bedfc1e 100644 --- a/routes/(apps)/admin/partials/index.tsx +++ b/routes/(apps)/admin/partials/index.tsx @@ -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) { - return

Welcome to Admin.

; +// deno-lint-ignore require-await +export async function Index( + _request: Request, + context: FreshContext, +) { + return ( +
+

Administration

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+

+ Gérez les{" "} + + modules + + ,{" "} + + utilisateurs + + ,{" "} + + rôles + {" "} + depuis la barre de navigation. +

+
+ ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/admin/partials/modules.tsx b/routes/(apps)/admin/partials/modules.tsx new file mode 100644 index 0000000..a36640d --- /dev/null +++ b/routes/(apps)/admin/partials/modules.tsx @@ -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, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Modules); diff --git a/routes/(apps)/admin/partials/roles.tsx b/routes/(apps)/admin/partials/roles.tsx new file mode 100644 index 0000000..b40aeb0 --- /dev/null +++ b/routes/(apps)/admin/partials/roles.tsx @@ -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, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Roles); diff --git a/routes/(apps)/admin/partials/users.tsx b/routes/(apps)/admin/partials/users.tsx new file mode 100644 index 0000000..837d515 --- /dev/null +++ b/routes/(apps)/admin/partials/users.tsx @@ -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, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Users); diff --git a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx new file mode 100644 index 0000000..9ae2c94 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string | null }; + +export default function AdminConsultNotes() { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + const [filterPrenom, setFilterPrenom] = useState(""); + const [applied, setApplied] = useState({ + promo: "", + nom: "", + prenom: "", + }); + + useEffect(() => { + async function load() { + try { + const [sRes, pRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les étudiants"); + setStudents(await sRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + const filtered = students.filter((s) => { + if (applied.promo && s.idPromo !== applied.promo) return false; + if ( + applied.nom && + !s.nom.toLowerCase().includes(applied.nom.toLowerCase()) + ) return false; + if ( + applied.prenom && + !s.prenom.toLowerCase().includes(applied.prenom.toLowerCase()) + ) return false; + return true; + }); + + function applyFilters() { + setApplied({ promo: filterPromo, nom: filterNom, prenom: filterPrenom }); + } + + return ( +
+
+

Consulter les Notes

+
+ + {error &&

{error}

} + +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> + setFilterPrenom((e.target as HTMLInputElement).value)} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((s) => ( + + + + + + + + ))} + +
PromoNomPrénomN° ÉtudiantAction
+ Aucun étudiant trouvé +
{s.idPromo}{s.nom}{s.prenom}{s.numEtud} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/notes/(_islands)/AdminUEs.tsx b/routes/(apps)/notes/(_islands)/AdminUEs.tsx new file mode 100644 index 0000000..d698c34 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/AdminUEs.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "preact/hooks"; + +type UE = { id: number; nom: string }; + +export default function AdminUEs() { + const [ues, setUes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newNom, setNewNom] = useState(""); + const [creating, setCreating] = useState(false); + + async function load() { + try { + const res = await fetch("/notes/api/ues"); + if (!res.ok) throw new Error("Impossible de charger les UEs"); + setUes(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createUE() { + if (!newNom.trim()) return; + setCreating(true); + try { + const res = await fetch("/notes/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: newNom.trim() }), + }); + if (!res.ok) throw new Error("Création échouée"); + setNewNom(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deleteUE(id: number) { + if (!confirm("Supprimer cette UE ?")) return; + try { + const res = await fetch(`/notes/api/ues/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + return ( +
+

Gestion des UEs

+ + {error &&

{error}

} + +
+ setNewNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createUE()} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {ues.length === 0 + ? ( + + + + ) + : ues.map((ue) => ( + + + + + + ))} + +
IDNomAction
+ Aucune UE enregistrée +
{ue.id}{ue.nom} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx new file mode 100644 index 0000000..fd77b87 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "preact/hooks"; + +type Note = { numEtud: number; idModule: string; note: number }; +type UE = { id: number; nom: string }; +type UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; +type Module = { id: string; nom: string }; +type Ajustement = { numEtud: number; idUE: number; valeur: number }; + +type Props = { + numEtud: number | null; + prenom: string; +}; + +function scoreClass(score: number | null): string { + if (score === null) return "score-none"; + return score >= 10 ? "score-good" : "score-warn"; +} + +function avgClass(avg: number | null): string { + if (avg === null) return ""; + return avg >= 10 ? "avg-good" : "avg-warn"; +} + +export default function NotesView({ numEtud, prenom }: Props) { + const [notes, setNotes] = useState([]); + const [ues, setUes] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [modules, setModules] = useState([]); + const [ajustements, setAjustements] = useState([]); + const [promos, setPromos] = useState([]); + const [activePromo, setActivePromo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (numEtud === null) { + setLoading(false); + return; + } + + async function load() { + try { + const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([ + fetch(`/notes/api/notes?numEtud=${numEtud}`), + fetch("/notes/api/ues"), + fetch("/notes/api/ue-modules"), + fetch("/admin/api/modules"), + fetch(`/notes/api/ajustements?numEtud=${numEtud}`), + ]); + + if (!notesRes.ok || !uesRes.ok || !ueModRes.ok) { + throw new Error("Erreur lors du chargement"); + } + + const [notesData, uesData, ueModData, modData, ajData] = await Promise + .all([ + notesRes.json(), + uesRes.json(), + ueModRes.json(), + modRes.ok ? modRes.json() : [], + ajRes.ok ? ajRes.json() : [], + ]); + + setNotes(notesData); + setUes(uesData); + setUeModules(ueModData); + setModules(modData); + setAjustements(ajData); + + // Derive promos from UE-modules for this student's notes + const noteModuleIds = new Set(notesData.map((n: Note) => n.idModule)); + const relevantPromos = [ + ...new Set( + ueModData + .filter((um: UEModule) => noteModuleIds.has(um.idModule)) + .map((um: UEModule) => um.idPromo), + ), + ] as string[]; + + setPromos(relevantPromos); + if (relevantPromos.length > 0) setActivePromo(relevantPromos[0]); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur inconnue"); + } finally { + setLoading(false); + } + } + + load(); + }, [numEtud]); + + if (numEtud === null) { + return ( +
+

+ Bonjour {prenom}{" "} + — aucun dossier étudiant n'est associé à votre compte. +

+
+ ); + } + + if (loading) { + return ( +
+

Chargement…

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + // Filter UE-modules by active promo + const filteredUeModules = activePromo + ? ueModules.filter((um) => um.idPromo === activePromo) + : ueModules; + + // Group UE-modules by UE + const ueIds = [...new Set(filteredUeModules.map((um) => um.idUE))]; + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + const noteMap = Object.fromEntries( + notes.map((n) => [n.idModule, n.note]), + ); + const ajMap = Object.fromEntries( + ajustements.map((a) => [a.idUE, a.valeur]), + ); + + return ( +
+ {promos.length > 1 && ( +
+ {promos.map((p) => ( + + ))} +
+ )} + + {ueIds.length === 0 && ( +

Aucune note disponible pour cette période.

+ )} + + {ueIds.map((ueId) => { + const ue = ues.find((u) => u.id === ueId); + if (!ue) return null; + + const ueModsForUE = filteredUeModules.filter((um) => um.idUE === ueId); + let weightedSum = 0; + let coveredCoeff = 0; + ueModsForUE.forEach((um) => { + const note = noteMap[um.idModule]; + if (note !== undefined) { + weightedSum += note * um.coeff; + coveredCoeff += um.coeff; + } + }); + + const avg = coveredCoeff > 0 ? weightedSum / coveredCoeff : null; + const ajustement = ajMap[ueId] ?? null; + const finalAvg = avg !== null && ajustement !== null + ? avg + ajustement + : avg; + + return ( +
+
+

UE : {ue.nom}

+ {finalAvg !== null && ( +

+ Moyenne : {finalAvg.toFixed(2)}/20 + {ajustement !== null && ajustement !== 0 && ( + + {" "} + (ajustement : {ajustement > 0 ? "+" : ""} + {ajustement}) + + )} +

+ )} + {finalAvg === null && ( +

Notes non disponibles

+ )} +
+ + {ueModsForUE.map((um) => { + const mod = moduleMap[um.idModule]; + const note = noteMap[um.idModule] ?? null; + return ( +
+ + {mod ? mod.id : um.idModule} —{" "} + {mod ? mod.nom : "Module inconnu"} (coef {um.coeff}) + + + {note !== null ? `${note}/20` : "—"} + +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index 36b0f28..fb7f11b 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -4,11 +4,12 @@ const properties: AppProperties = { name: "PolyNotes", icon: "school", pages: { - index: "Homepage", - notes: "Notes", - courses: "Courses management", + index: "Accueil", + notes: "Mes notes", + courses: "Consulter", + ues: "UEs", }, - adminOnly: ["courses", "students"], + adminOnly: ["courses", "ues"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx index 3ac215d..0ec8ebe 100644 --- a/routes/(apps)/notes/partials/(admin)/courses.tsx +++ b/routes/(apps)/notes/partials/(admin)/courses.tsx @@ -3,11 +3,12 @@ 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"; +import AdminConsultNotes from "../../(_islands)/AdminConsultNotes.tsx"; // deno-lint-ignore require-await -async function Courses(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +async function Courses(_request: Request, _context: FreshContext) { + return ; } export const config = getPartialsConfig(); diff --git a/routes/(apps)/notes/partials/(admin)/ues.tsx b/routes/(apps)/notes/partials/(admin)/ues.tsx new file mode 100644 index 0000000..2d6b0e9 --- /dev/null +++ b/routes/(apps)/notes/partials/(admin)/ues.tsx @@ -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 AdminUEs from "../../(_islands)/AdminUEs.tsx"; + +// deno-lint-ignore require-await +async function UEs( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(UEs); diff --git a/routes/(apps)/notes/partials/index.tsx b/routes/(apps)/notes/partials/index.tsx index 2971e0e..e5d80ce 100644 --- a/routes/(apps)/notes/partials/index.tsx +++ b/routes/(apps)/notes/partials/index.tsx @@ -3,11 +3,53 @@ 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"; // deno-lint-ignore require-await -export async function Index(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +export async function Index( + _request: Request, + context: FreshContext, +) { + const isEmployee = + (context.state as unknown as { session: Record }).session + .eduPersonPrimaryAffiliation === "employee"; + + return ( +
+

PolyNotes

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+ {isEmployee + ? ( +

+ Consultez les{" "} + + notes des élèves + {" "} + ou gérez les{" "} + + UEs + + . +

+ ) + : ( +

+ Consultez vos{" "} + + notes + + . +

+ )} +
+ ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index 1e3bbc1..188a05e 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -1,13 +1,36 @@ +import { FreshContext } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { students } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; import { getPartialsConfig, 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"; +import NotesView from "../(_islands)/NotesView.tsx"; -// deno-lint-ignore require-await -async function Notes(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +async function Notes( + _request: Request, + context: FreshContext, +) { + const session = + (context.state as unknown as { session: { sn: string; givenName: string } }) + .session; + const { sn, givenName } = session; + + let numEtud: number | null = null; + try { + const student = await db + .select() + .from(students) + .where(and(eq(students.nom, sn), eq(students.prenom, givenName))) + .then((rows) => rows[0] ?? null); + numEtud = student?.numEtud ?? null; + } catch { + // DB lookup failed — island will show fallback message + } + + return ; } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/students/(_islands)/AdminPromotions.tsx new file mode 100644 index 0000000..5461d9e --- /dev/null +++ b/routes/(apps)/students/(_islands)/AdminPromotions.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "preact/hooks"; + +type Promotion = { id: string; annee: string | null }; + +export default function AdminPromotions() { + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newId, setNewId] = useState(""); + const [newAnnee, setNewAnnee] = useState(""); + const [creating, setCreating] = useState(false); + + async function load() { + try { + const res = await fetch("/students/api/promotions"); + if (!res.ok) throw new Error("Impossible de charger les promotions"); + setPromos(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createPromo() { + if (!newId.trim()) return; + setCreating(true); + try { + const res = await fetch("/students/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idPromo: newId.trim(), + annee: newAnnee.trim() || null, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewId(""); + setNewAnnee(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deletePromo(id: string) { + if (!confirm(`Supprimer la promotion ${id} ?`)) return; + try { + const res = await fetch( + `/students/api/promotions/${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 ( +
+

Gestion des Promotions

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + /> + setNewAnnee((e.target as HTMLInputElement).value)} + style="min-width: 14rem" + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {promos.length === 0 + ? ( + + + + ) + : promos.map((p) => ( + + + + + + ))} + +
IdentifiantAnnéeAction
+ Aucune promotion enregistrée +
{p.id}{p.annee ?? "—"} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index f67036b..031bbe9 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -1,45 +1,150 @@ import { useEffect, useState } from "preact/hooks"; -import Promotion from "$root/routes/(apps)/students/(_components)/Promotion.tsx"; -type SingleUserResponse = { promo: Promotion; student: Student }; -type ManyUsersResponse = { promos: Promotion[]; students: Student[] }; - -type APIResponse = SingleUserResponse | ManyUsersResponse; +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; export default function ConsultStudents() { - const [data, setData] = useState(null); + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + + async function load() { + try { + const [sRes, pRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les élèves"); + setStudents(await sRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } useEffect(() => { - const fetchData = async () => { - const response = await fetch("/students/api/students"); - if (!response.ok) { - setError("Failed to load data. Please try again later."); - } - - const result: APIResponse = await response.json(); - setData(result); - }; - - fetchData(); + load(); }, []); + async function deleteStudent(numEtud: number) { + if (!confirm(`Supprimer l'élève #${numEtud} ?`)) return; + try { + const res = await fetch(`/students/api/students/${numEtud}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const filtered = students.filter((s) => { + const matchPromo = !filterPromo || s.idPromo === filterPromo; + const matchNom = !filterNom || + `${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase()); + return matchPromo && matchNom; + }); + return ( - <> - {error &&

{error}

} - {data && ((Object.hasOwn(data, "student")) - ? ( - - ) - : (data as ManyUsersResponse).promos.map((promo) => ( - - )))} - +
+

Gestion des Élèves

+ + {error &&

{error}

} + + + +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((s) => ( + + + + + + + + ))} + +
N° étud.NomPrénomPromoActions
+ Aucun élève trouvé +
{s.numEtud}{s.nom}{s.prenom}{s.idPromo} +
+ + ✏ + + +
+
+
+ )} +
); } diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index 13bafe9..5483732 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -4,11 +4,12 @@ const properties: AppProperties = { name: "Students", icon: "badge", pages: { - index: "Homepage", - upload: "Upload students", - consult: "Consult students", + index: "Accueil", + consult: "Élèves", + promotions: "Promotions", + upload: "Import xlsx", }, - adminOnly: ["upload", "consult"], + adminOnly: ["consult", "promotions", "upload"], hint: "Create students promotion and see informations", }; diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index b685c5c..4c81c71 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -8,12 +8,7 @@ import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await async function Students(_request: Request, _context: FreshContext) { - return ( - <> -

Consult students

- - - ); + return ; } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/partials/(admin)/promotions.tsx b/routes/(apps)/students/partials/(admin)/promotions.tsx new file mode 100644 index 0000000..003f993 --- /dev/null +++ b/routes/(apps)/students/partials/(admin)/promotions.tsx @@ -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 AdminPromotions from "../../(_islands)/AdminPromotions.tsx"; + +// deno-lint-ignore require-await +async function Promotions( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Promotions); diff --git a/routes/(apps)/students/partials/index.tsx b/routes/(apps)/students/partials/index.tsx index 78931b5..c696b94 100644 --- a/routes/(apps)/students/partials/index.tsx +++ b/routes/(apps)/students/partials/index.tsx @@ -4,16 +4,44 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; -import SelfPortrait from "$root/routes/(apps)/students/(_components)/SelfPortrait.tsx"; // deno-lint-ignore require-await -export async function Index(_request: Request, context: FreshContext) { +export async function Index( + _request: Request, + context: FreshContext, +) { + const isEmployee = + (context.state as unknown as { session: Record }).session + .eduPersonPrimaryAffiliation === "employee"; + return ( - <> -

Welcome {context.state.session?.givenName}!

-

Your amU identity

- - +
+

Étudiants

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+ {isEmployee && ( +

+ Consultez la{" "} + + liste des élèves + {" "} + ou gérez les{" "} + + promotions + + . +

+ )} +
); } diff --git a/routes/_app.tsx b/routes/_app.tsx index 60d03a9..8162820 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -28,6 +28,7 @@ export default async function App( +
diff --git a/static/styles/ui.css b/static/styles/ui.css new file mode 100644 index 0000000..e56daa2 --- /dev/null +++ b/static/styles/ui.css @@ -0,0 +1,393 @@ +/* ui.css — Shared UI components for PolyMPR app pages */ + +/* ------------------------------------------------------- + Page layout +------------------------------------------------------- */ + +.page-content { + padding: 1.5rem; + max-width: 960px; +} + +.page-title { + font-size: 1.2rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.75rem 0; + padding-bottom: 0.75rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +/* ------------------------------------------------------- + Filters bar +------------------------------------------------------- */ + +.filters { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.filter-input, +.filter-select { + padding: 0.3rem 0.5rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + color: light-dark(var(--light-foreground), var(--dark-foreground)); + font-size: 0.8rem; + font-family: inherit; + min-width: 8rem; +} + +.filter-input:focus, +.filter-select:focus { + outline: none; + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); +} + +/* ------------------------------------------------------- + Buttons +------------------------------------------------------- */ + +.btn { + padding: 0.3rem 0.75rem; + border-radius: 3px; + font-size: 0.8rem; + font-family: inherit; + font-weight: var(--font-weight-bold); + cursor: pointer; + border: 1px solid; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.3rem; + line-height: 1.4; + background: transparent; + transition: background 100ms, color 100ms; +} + +.btn::before { + all: unset; +} + +.btn-primary { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.btn-primary:hover { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); +} + +.btn-secondary { + border-color: light-dark( + var(--light-foreground-dimmer), + var(--dark-foreground-dimmer) + ); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.btn-secondary:hover { + border-color: light-dark( + var(--light-foreground-dim), + var(--dark-foreground-dim) + ); + color: light-dark(var(--light-foreground), var(--dark-foreground)); +} + +.btn-danger { + border-color: #933; + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.btn-danger:hover { + background: #933; + color: white; +} + +.btn-sm { + padding: 0.15rem 0.5rem; + font-size: 0.75rem; +} + +/* ------------------------------------------------------- + Data table +------------------------------------------------------- */ + +.data-table-wrap { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + overflow: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.data-table th { + padding: 0.5rem 1rem; + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + text-align: left; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.data-table td { + padding: 0.55rem 1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:nth-child(even) td { + background: light-dark(#f5f4ff, #141229); +} + +.data-table .col-promo { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-weight: var(--font-weight-bold); + font-size: 0.75rem; +} + +.data-table .col-dim { + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.75rem; +} + +.data-table .col-actions { + display: flex; + gap: 0.4rem; + align-items: center; +} + +/* ------------------------------------------------------- + UE card (student notes view) +------------------------------------------------------- */ + +.ue-card { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + margin-bottom: 1rem; + overflow: hidden; + position: relative; + padding-left: 3px; +} + +.ue-card::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + transform: none; + transition: none; + border-radius: 0; +} + +.ue-card-header { + padding: 0.65rem 1rem 0.5rem 1.1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ue-card-title { + font-weight: var(--font-weight-bold); + font-size: 0.85rem; + margin: 0; +} + +.ue-card-avg { + font-size: 0.7rem; + margin: 0.2rem 0 0; +} + +.ue-card-avg.avg-good { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.ue-card-avg.avg-warn { + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.ue-module-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.45rem 1rem 0.45rem 1.1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ue-module-row:last-child { + border-bottom: none; +} + +.ue-module-name { + font-size: 0.8rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* ------------------------------------------------------- + Score chip +------------------------------------------------------- */ + +.score-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 4.5rem; + padding: 0.15rem 0.5rem; + border-radius: 12px; + border: 1px solid; + font-size: 0.75rem; + font-weight: var(--font-weight-bold); + background: light-dark(white, #1a172d); +} + +.score-chip.score-good { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.score-chip.score-warn { + border-color: light-dark( + var(--light-strong-color), + var(--dark-strong-color) + ); + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.score-chip.score-none { + border-color: light-dark( + var(--light-foreground-dimmer), + var(--dark-foreground-dimmer) + ); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* ------------------------------------------------------- + Tabs +------------------------------------------------------- */ + +.tabs { + display: flex; + gap: 0.6rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.tab-btn { + padding: 0.35rem 0.9rem; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + background: transparent; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.82rem; + font-family: inherit; + cursor: pointer; +} + +.tab-btn::before { + all: unset; +} + +.tab-btn.active { + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-weight: var(--font-weight-bold); + border-bottom-width: 2px; +} + +/* ------------------------------------------------------- + Status states +------------------------------------------------------- */ + +.state-loading, +.state-empty { + padding: 2.5rem; + text-align: center; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.9rem; +} + +.state-error { + padding: 1rem; + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); + font-size: 0.85rem; + border: 1px solid #933; + border-radius: 4px; + background: light-dark(#fff0f0, #1a1010); + margin-bottom: 1rem; +} + +/* ------------------------------------------------------- + Inline form row (for inline add/edit) +------------------------------------------------------- */ + +.form-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.form-input { + padding: 0.35rem 0.5rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + color: light-dark(var(--light-foreground), var(--dark-foreground)); + font-size: 0.82rem; + font-family: inherit; + min-width: 12rem; +} + +.form-input:focus { + outline: none; + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); +} + +/* ------------------------------------------------------- + Toolbar (title + action button) +------------------------------------------------------- */ + +.toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + gap: 1rem; +} -- 2.52.0 From fcc9547a30b2ebc8fa3400f6533df868afacb800 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 23:01:59 +0200 Subject: [PATCH 03/11] feat(dev): add compose files and dev-login bypass route - compose.prod.yml: production stack with registry image, healthcheck, migration service - compose.test.yml: local test stack with source mount and LOCAL=true - routes/dev-login.ts: fake admin JWT login, only active when LOCAL=true - routes/_middleware.ts: expose /dev-login as public route Co-Authored-By: Claude Sonnet 4.6 --- compose.prod.yml | 36 +++++++++++++++++++++++++++++++ compose.test.yml | 49 +++++++++++++++++++++++++++++++++++++++++++ routes/_middleware.ts | 1 + routes/dev-login.ts | 48 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 compose.prod.yml create mode 100644 compose.test.yml create mode 100644 routes/dev-login.ts diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..6fcc5bc --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,36 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASS} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-polympr} + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 10 + + migrate: + image: registry.docker.polytech.djalim.fr/polympr:latest + command: node_modules/.bin/drizzle-kit migrate + env_file: .env + depends_on: + db: + condition: service_healthy + + app: + image: registry.docker.polytech.djalim.fr/polympr:latest + restart: unless-stopped + ports: + - "4430:443" + env_file: .env + depends_on: + migrate: + condition: service_completed_successfully + +volumes: + db_data: diff --git a/compose.test.yml b/compose.test.yml new file mode 100644 index 0000000..18478d8 --- /dev/null +++ b/compose.test.yml @@ -0,0 +1,49 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: testpass + POSTGRES_USER: postgres + POSTGRES_DB: polympr_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + + migrate: + image: denoland/deno:alpine + working_dir: /app + volumes: + - .:/app + command: task migrate + environment: + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASS: testpass + POSTGRES_DB: polympr_test + LOCAL: "true" + depends_on: + db: + condition: service_healthy + + app: + image: denoland/deno:alpine + working_dir: /app + volumes: + - .:/app + command: run -A --unstable-ffi main.ts + ports: + - "4430:443" + environment: + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASS: testpass + POSTGRES_DB: polympr_test + LOCAL: "true" + depends_on: + migrate: + condition: service_completed_successfully diff --git a/routes/_middleware.ts b/routes/_middleware.ts index 01b449e..2588e7b 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -10,6 +10,7 @@ const PUBLIC_ROUTES = [ "/about", "/partials/about", "/contact", + "/dev-login", ]; const jwtKeyCache: Record = {}; diff --git a/routes/dev-login.ts b/routes/dev-login.ts new file mode 100644 index 0000000..b50898e --- /dev/null +++ b/routes/dev-login.ts @@ -0,0 +1,48 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { CasContent, LoginJWT, State } from "$root/defaults/interfaces.ts"; +import { createJwt } from "@popov/jwt"; +import { setCookie } from "$std/http/cookie.ts"; +import { getKey } from "$root/routes/_middleware.ts"; + +const FAKE_ADMIN: CasContent = { + amuCampus: "local", + amuComposante: "local", + amuDateValidation: "", + coGroup: "", + eduPersonPrimaryAffiliation: "employee", + eduPersonPrincipalName: "admin@local", + mail: "admin@local", + displayName: "Admin Local", + givenName: "Admin", + memberOf: [], + sn: "Local", + supannCivilite: "", + supannEntiteAffectation: "", + supannEtuAnneeInscription: "", + supannEtuEtape: "", + uid: "admin-local", +}; + +export const handler: Handlers = { + async GET(_request: Request, _context: FreshContext) { + if (Deno.env.get("LOCAL") !== "true") { + return new Response("Not available outside LOCAL mode.", { status: 403 }); + } + + const now = Math.floor(Date.now() / 1000); + const payload: LoginJWT = { + iss: "PolyMPR", + iat: now, + exp: now + 0xe10, + aud: "PolyMPR", + user: FAKE_ADMIN, + }; + + const token = await createJwt(payload, getKey(FAKE_ADMIN.uid)); + const headers = new Headers(); + setCookie(headers, { name: "sessionToken", value: token }); + headers.set("Location", "/apps"); + + return new Response(null, { status: 302, headers }); + }, +}; -- 2.52.0 From 56019ad372078d9744ac55c22ab17b75c82718e2 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 00:04:28 +0200 Subject: [PATCH 04/11] fix: fixed test ci --- compose.test.yml | 35 ++++++--------- databases/docker-init.sh | 10 +++++ fresh.gen.ts | 94 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 21 deletions(-) create mode 100755 databases/docker-init.sh diff --git a/compose.test.yml b/compose.test.yml index 18478d8..37b8e04 100644 --- a/compose.test.yml +++ b/compose.test.yml @@ -6,21 +6,29 @@ services: POSTGRES_PASSWORD: testpass POSTGRES_USER: postgres POSTGRES_DB: polympr_test + volumes: + # Init script strips drizzle-kit markers and applies migrations on first start + - ./databases/docker-init.sh:/docker-entrypoint-initdb.d/01-migrate.sh:ro + - ./databases/migrations:/migrations:ro + - db_data_test:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 10 - migrate: + app: image: denoland/deno:alpine working_dir: /app volumes: - .:/app - command: task migrate + - deno_cache:/deno-dir + command: run -A --unstable-ffi main.ts + ports: + - "4430:443" environment: POSTGRES_HOST: db - POSTGRES_PORT: 5432 + POSTGRES_PORT: "5432" POSTGRES_USER: postgres POSTGRES_PASS: testpass POSTGRES_DB: polympr_test @@ -29,21 +37,6 @@ services: db: condition: service_healthy - app: - image: denoland/deno:alpine - working_dir: /app - volumes: - - .:/app - command: run -A --unstable-ffi main.ts - ports: - - "4430:443" - environment: - POSTGRES_HOST: db - POSTGRES_PORT: 5432 - POSTGRES_USER: postgres - POSTGRES_PASS: testpass - POSTGRES_DB: polympr_test - LOCAL: "true" - depends_on: - migrate: - condition: service_completed_successfully +volumes: + db_data_test: + deno_cache: diff --git a/databases/docker-init.sh b/databases/docker-init.sh new file mode 100755 index 0000000..0db1cf6 --- /dev/null +++ b/databases/docker-init.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Applied by postgres on first container startup via /docker-entrypoint-initdb.d. +# drizzle-kit migration files use "--> statement-breakpoint" markers which are +# not valid SQL — strip them before applying. +set -e +for f in /migrations/*.sql; do + echo "Applying $f..." + sed '/^-->/d' "$f" | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" +done +echo "All migrations applied." diff --git a/fresh.gen.ts b/fresh.gen.ts index eeb5302..2309f78 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,18 +4,47 @@ import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; +import * as $_apps_admin_api_enseignements from "./routes/(apps)/admin/api/enseignements.ts"; +import * as $_apps_admin_api_enseignements_idProf_idModule_idPromo_ from "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts"; +import * as $_apps_admin_api_example from "./routes/(apps)/admin/api/example.ts"; +import * as $_apps_admin_api_modules from "./routes/(apps)/admin/api/modules.ts"; +import * as $_apps_admin_api_modules_idModule_ from "./routes/(apps)/admin/api/modules/[idModule].ts"; +import * as $_apps_admin_api_permissions from "./routes/(apps)/admin/api/permissions.ts"; +import * as $_apps_admin_api_roles from "./routes/(apps)/admin/api/roles.ts"; +import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles/[idRole].ts"; +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_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_roles from "./routes/(apps)/admin/partials/roles.tsx"; +import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.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"; import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx"; import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx"; +import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustements.ts"; +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_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_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.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_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"; +import * as $_apps_students_api_students_numEtud_ from "./routes/(apps)/students/api/students/[numEtud].ts"; +import * as $_apps_students_api_students_import_csv from "./routes/(apps)/students/api/students/import-csv.ts"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; +import * as $_apps_students_partials_admin_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx"; import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts"; @@ -24,14 +53,22 @@ import * as $_app from "./routes/_app.tsx"; import * as $_middleware from "./routes/_middleware.ts"; import * as $about from "./routes/about.tsx"; import * as $apps from "./routes/apps.tsx"; +import * as $dev_login from "./routes/dev-login.ts"; import * as $index from "./routes/index.tsx"; import * as $login from "./routes/login.tsx"; import * as $logout from "./routes/logout.tsx"; import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx"; import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx"; +import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_islands)/AdminModules.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_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"; +import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; +import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx"; +import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; +import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx"; import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx"; @@ -41,6 +78,25 @@ const manifest = { routes: { "./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_middleware.ts": $_apps_middleware, + "./routes/(apps)/admin/api/enseignements.ts": + $_apps_admin_api_enseignements, + "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts": + $_apps_admin_api_enseignements_idProf_idModule_idPromo_, + "./routes/(apps)/admin/api/example.ts": $_apps_admin_api_example, + "./routes/(apps)/admin/api/modules.ts": $_apps_admin_api_modules, + "./routes/(apps)/admin/api/modules/[idModule].ts": + $_apps_admin_api_modules_idModule_, + "./routes/(apps)/admin/api/permissions.ts": $_apps_admin_api_permissions, + "./routes/(apps)/admin/api/roles.ts": $_apps_admin_api_roles, + "./routes/(apps)/admin/api/roles/[idRole].ts": + $_apps_admin_api_roles_idRole_, + "./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/partials/index.tsx": $_apps_admin_partials_index, + "./routes/(apps)/admin/partials/modules.tsx": $_apps_admin_partials_modules, + "./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles, + "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/mobility/api/insert_mobility.ts": $_apps_mobility_api_insert_mobility, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, @@ -50,15 +106,38 @@ const manifest = { $_apps_mobility_partials_index, "./routes/(apps)/mobility/partials/overview.tsx": $_apps_mobility_partials_overview, + "./routes/(apps)/notes/api/ajustements.ts": $_apps_notes_api_ajustements, + "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts": + $_apps_notes_api_ajustements_numEtud_idUE_, + "./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/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_, + "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, + "./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_, "./routes/(apps)/notes/index.tsx": $_apps_notes_index, "./routes/(apps)/notes/partials/(admin)/courses.tsx": $_apps_notes_partials_admin_courses, + "./routes/(apps)/notes/partials/(admin)/ues.tsx": + $_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)/students/api/promotions.ts": + $_apps_students_api_promotions, + "./routes/(apps)/students/api/promotions/[idPromo].ts": + $_apps_students_api_promotions_idPromo_, "./routes/(apps)/students/api/students.ts": $_apps_students_api_students, + "./routes/(apps)/students/api/students/[numEtud].ts": + $_apps_students_api_students_numEtud_, + "./routes/(apps)/students/api/students/import-csv.ts": + $_apps_students_api_students_import_csv, "./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, + "./routes/(apps)/students/partials/(admin)/promotions.tsx": + $_apps_students_partials_admin_promotions, "./routes/(apps)/students/partials/(admin)/upload.tsx": $_apps_students_partials_admin_upload, "./routes/(apps)/students/partials/index.tsx": @@ -69,6 +148,7 @@ const manifest = { "./routes/_middleware.ts": $_middleware, "./routes/about.tsx": $about, "./routes/apps.tsx": $apps, + "./routes/dev-login.ts": $dev_login, "./routes/index.tsx": $index, "./routes/login.tsx": $login, "./routes/logout.tsx": $logout, @@ -76,12 +156,26 @@ const manifest = { islands: { "./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator, "./routes/(_islands)/Navbar.tsx": $_islands_Navbar, + "./routes/(apps)/admin/(_islands)/AdminModules.tsx": + $_apps_admin_islands_AdminModules, + "./routes/(apps)/admin/(_islands)/AdminRoles.tsx": + $_apps_admin_islands_AdminRoles, + "./routes/(apps)/admin/(_islands)/AdminUsers.tsx": + $_apps_admin_islands_AdminUsers, "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx": $_apps_mobility_islands_ConsultMobility, "./routes/(apps)/mobility/(_islands)/EditMobility.tsx": $_apps_mobility_islands_EditMobility, "./routes/(apps)/mobility/(_islands)/ImportFile.tsx": $_apps_mobility_islands_ImportFile, + "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx": + $_apps_notes_islands_AdminConsultNotes, + "./routes/(apps)/notes/(_islands)/AdminUEs.tsx": + $_apps_notes_islands_AdminUEs, + "./routes/(apps)/notes/(_islands)/NotesView.tsx": + $_apps_notes_islands_NotesView, + "./routes/(apps)/students/(_islands)/AdminPromotions.tsx": + $_apps_students_islands_AdminPromotions, "./routes/(apps)/students/(_islands)/ConsultStudents.tsx": $_apps_students_islands_ConsultStudents, "./routes/(apps)/students/(_islands)/EditStudents.tsx": -- 2.52.0 From 733259e317d3ae5da3dc7ca5d7dde2b384d97c47 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 11:21:32 +0200 Subject: [PATCH 05/11] feat : fixed some page not being as described in the figma --- fresh.gen.ts | 15 + .../admin/(_islands)/AdminEnseignements.tsx | 292 +++++++++++++ .../admin/(_islands)/AdminPermissions.tsx | 107 +++++ routes/(apps)/admin/(_islands)/AdminRoles.tsx | 301 ++++++++----- routes/(apps)/admin/(_props)/props.ts | 6 +- routes/(apps)/admin/api/enseignements.ts | 16 + .../(apps)/admin/partials/enseignements.tsx | 18 + routes/(apps)/admin/partials/permissions.tsx | 18 + routes/(apps)/notes/(_islands)/AdminUEs.tsx | 353 ++++++++++++--- .../students/(_islands)/AdminPromotions.tsx | 198 ++++++--- .../students/(_islands)/EditStudents.tsx | 247 +++++++++++ routes/(apps)/students/edit/[numEtud].tsx | 12 + static/styles/ui.css | 409 ++++++++++++++++++ 13 files changed, 1757 insertions(+), 235 deletions(-) create mode 100644 routes/(apps)/admin/(_islands)/AdminEnseignements.tsx create mode 100644 routes/(apps)/admin/(_islands)/AdminPermissions.tsx create mode 100644 routes/(apps)/admin/partials/enseignements.tsx create mode 100644 routes/(apps)/admin/partials/permissions.tsx create mode 100644 routes/(apps)/students/edit/[numEtud].tsx diff --git a/fresh.gen.ts b/fresh.gen.ts index 2309f78..a4a95f9 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -15,8 +15,10 @@ 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_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_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; @@ -42,6 +44,7 @@ import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/studen import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; import * as $_apps_students_api_students_numEtud_ from "./routes/(apps)/students/api/students/[numEtud].ts"; import * as $_apps_students_api_students_import_csv from "./routes/(apps)/students/api/students/import-csv.ts"; +import * as $_apps_students_edit_numEtud_ from "./routes/(apps)/students/edit/[numEtud].tsx"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; import * as $_apps_students_partials_admin_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx"; @@ -59,7 +62,9 @@ import * as $login from "./routes/login.tsx"; import * as $logout from "./routes/logout.tsx"; import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx"; import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx"; +import * as $_apps_admin_islands_AdminEnseignements from "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx"; import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_islands)/AdminModules.tsx"; +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_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; @@ -93,8 +98,12 @@ 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/partials/enseignements.tsx": + $_apps_admin_partials_enseignements, "./routes/(apps)/admin/partials/index.tsx": $_apps_admin_partials_index, "./routes/(apps)/admin/partials/modules.tsx": $_apps_admin_partials_modules, + "./routes/(apps)/admin/partials/permissions.tsx": + $_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)/mobility/api/insert_mobility.ts": @@ -133,6 +142,8 @@ const manifest = { $_apps_students_api_students_numEtud_, "./routes/(apps)/students/api/students/import-csv.ts": $_apps_students_api_students_import_csv, + "./routes/(apps)/students/edit/[numEtud].tsx": + $_apps_students_edit_numEtud_, "./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, @@ -156,8 +167,12 @@ const manifest = { islands: { "./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator, "./routes/(_islands)/Navbar.tsx": $_islands_Navbar, + "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx": + $_apps_admin_islands_AdminEnseignements, "./routes/(apps)/admin/(_islands)/AdminModules.tsx": $_apps_admin_islands_AdminModules, + "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx": + $_apps_admin_islands_AdminPermissions, "./routes/(apps)/admin/(_islands)/AdminRoles.tsx": $_apps_admin_islands_AdminRoles, "./routes/(apps)/admin/(_islands)/AdminUsers.tsx": diff --git a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx new file mode 100644 index 0000000..7b158d2 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -0,0 +1,292 @@ +import { useEffect, useState } from "preact/hooks"; + +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type Module = { id: string; nom: string }; +type Promo = { id: string; annee: string }; + +export default function AdminEnseignements() { + const [enseignements, setEnseignements] = useState([]); + const [modules, setModules] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filters + const [filterPromo, setFilterPromo] = useState(""); + const [filterModule, setFilterModule] = useState(""); + const [filterEnseignant, setFilterEnseignant] = useState(""); + + // Add form + const [showAdd, setShowAdd] = useState(false); + const [addPromo, setAddPromo] = useState(""); + const [addModule, setAddModule] = useState(""); + const [addProf, setAddProf] = useState(""); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + async function load() { + try { + const [eRes, mRes, pRes] = await Promise.all([ + fetch("/admin/api/enseignements"), + fetch("/admin/api/modules"), + fetch("/students/api/promotions"), + ]); + if (!eRes.ok) throw new Error("Impossible de charger les enseignements"); + setEnseignements(await eRes.json()); + 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(); + }, []); + + async function deleteEnseignement( + idProf: string, + idModule: string, + idPromo: string, + ) { + if ( + !confirm( + `Supprimer l'assignation ${idProf} → ${idModule} / ${idPromo} ?`, + ) + ) return; + try { + const res = await fetch( + `/admin/api/enseignements/${encodeURIComponent(idProf)}/${ + 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"); + } + } + + async function addEnseignement() { + if (!addProf.trim() || !addModule || !addPromo) { + setAddError("Tous les champs 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.trim(), + idModule: addModule, + idPromo: addPromo, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddProf(""); + setAddModule(""); + setAddPromo(""); + setShowAdd(false); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + + const filtered = enseignements.filter((e) => { + const matchPromo = !filterPromo || e.idPromo === filterPromo; + const matchModule = !filterModule || e.idModule === filterModule; + const matchEns = !filterEnseignant || + e.idProf.toLowerCase().includes(filterEnseignant.toLowerCase()); + return matchPromo && matchModule && matchEns; + }); + + return ( +
+

Assignations Enseignant → Module / Promo

+ + {error &&

{error}

} + +
+ + + + setFilterEnseignant((e.target as HTMLInputElement).value)} + /> + + +
+ + {showAdd && ( +
+ {addError && ( + + {addError} + + )} + + + setAddProf((e.target as HTMLInputElement).value)} + style="min-width: 10rem" + /> + + +
+ )} + + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((e) => { + const mod = moduleMap[e.idModule]; + return ( + + + + + + + ); + })} + +
PromoModuleEnseignant (User.id)Actions
+ Aucun enseignement trouvé +
+ {e.idPromo} + + {mod ? `${mod.id} – ${mod.nom}` : e.idModule} + {e.idProf} +
+ +
+
+
+ )} + +
+

+ Un même module peut être enseigné par plusieurs utilisateurs sur une + même promo. +

+

+ Clé composite = idProf (User.Id) + idModule + idPromo +

+
+
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminPermissions.tsx b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx new file mode 100644 index 0000000..57c600b --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "preact/hooks"; + +type Perm = { id: string; nom: string }; +type Role = { id: number; nom: string; permissions: string[] }; + +export default function AdminPermissions() { + const [permissions, setPermissions] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + const [pRes, rRes] = await Promise.all([ + fetch("/admin/api/permissions"), + fetch("/admin/api/roles"), + ]); + if (!pRes.ok) throw new Error("Impossible de charger les permissions"); + setPermissions(await pRes.json()); + if (rRes.ok) setRoles(await rRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + function rolesForPerm(permId: string): Role[] { + return roles.filter((r) => r.permissions.includes(permId)); + } + + const MAX_ROLE_CHIPS = 2; + + return ( +
+

Permissions

+ +
+

+ Les permissions sont définies statiquement par le serveur. +

+

+ Elles ne peuvent pas être créées ou supprimées via l'API. +

+
+ + {error &&

{error}

} + + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {permissions.map((p) => { + const associated = rolesForPerm(p.id); + const shown = associated.slice(0, MAX_ROLE_CHIPS); + const overflow = associated.length - MAX_ROLE_CHIPS; + return ( + + + + + + ); + })} + +
idPermissionnomPermissionRôles associés
+ + {p.id} + + {p.nom} +
+ {shown.map((r) => ( + {r.nom} + ))} + {overflow > 0 && ( + + +{overflow} + + )} + {associated.length === 0 && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx index 9e97fad..448e334 100644 --- a/routes/(apps)/admin/(_islands)/AdminRoles.tsx +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -1,17 +1,23 @@ import { useEffect, useState } from "preact/hooks"; -type Role = { id: number; nom: string }; -type Permission = { id: string; nom: string }; +type Role = { id: number; nom: string; permissions: string[] }; +type Perm = { id: string; nom: string }; + +const MAX_CHIPS = 3; export default function AdminRoles() { const [roles, setRoles] = useState([]); - const [permissions, setPermissions] = useState([]); + const [permissions, setPermissions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newNom, setNewNom] = useState(""); const [creating, setCreating] = useState(false); - const [editId, setEditId] = useState(null); - const [editNom, setEditNom] = useState(""); + + // Manage-perms sub-view + const [managingRole, setManagingRole] = useState(null); + const [editPerms, setEditPerms] = useState>(new Set()); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); async function load() { try { @@ -55,21 +61,6 @@ export default function AdminRoles() { } } - 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 { @@ -81,19 +72,143 @@ export default function AdminRoles() { } } + function openManage(role: Role) { + setManagingRole(role); + setEditPerms(new Set(role.permissions)); + setSaveError(null); + } + + function togglePerm(permId: string) { + setEditPerms((prev) => { + const next = new Set(prev); + if (next.has(permId)) next.delete(permId); + else next.add(permId); + return next; + }); + } + + async function savePerms() { + if (!managingRole) return; + setSaving(true); + setSaveError(null); + try { + const res = await fetch(`/admin/api/roles/${managingRole.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: managingRole.nom, + permissions: [...editPerms], + }), + }); + if (!res.ok) throw new Error("Enregistrement échoué"); + await load(); + setManagingRole(null); + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + // ---- Manage-perms view ---- + if (managingRole) { + const activeCount = editPerms.size; + return ( +
+ { + e.preventDefault(); + setManagingRole(null); + }} + > + ← Retour à la liste des rôles + +

+ Permissions du rôle – {managingRole.nom} +

+ + {saveError &&

{saveError}

} + +
+
+ idRole : {managingRole.id} + + {managingRole.nom} + + + {activeCount} permission{activeCount !== 1 ? "s" : ""} active + {activeCount !== 1 ? "s" : ""} + +
+ +
+ +
+ + Permissions disponibles + + + Activer = inclure dans le rôle + +
+ +
+ {permissions.map((p) => { + const active = editPerms.has(p.id); + return ( + + ); + })} +
+
+ ); + } + + // ---- Main list view ---- return (

Gestion des Rôles

{error &&

{error}

} -
+
setNewNom((e.target as HTMLInputElement).value)} onKeyDown={(e) => e.key === "Enter" && createRole()} + style="min-width: 14rem" />
@@ -112,8 +227,9 @@ export default function AdminRoles() { - - + + + @@ -121,105 +237,60 @@ export default function AdminRoles() { {roles.length === 0 ? ( - ) - : roles.map((r) => ( - - - - + + + - - ))} + + + + + ); + })}
IDNomidRoleNom du rôlePermissions Actions
+ Aucun rôle enregistré
{r.id} - {editId === r.id - ? ( - - setEditNom( - (e.target as HTMLInputElement).value, - )} - style="min-width: 0; width: 100%" - /> - ) - : r.nom} - -
- {editId === r.id - ? ( - <> - - - - ) - : ( - <> - - - + : roles.map((r) => { + const shown = r.permissions.slice(0, MAX_CHIPS); + const overflow = r.permissions.length - MAX_CHIPS; + return ( +
{r.id} + {r.nom} + +
+ {shown.map((p) => ( + {p} + ))} + {overflow > 0 && ( + + +{overflow} + )} -
-
+
+ + +
+
)} - - {permissions.length > 0 && ( -
-

- Permissions disponibles -

-
- - - - - - - - - {permissions.map((p) => ( - - - - - ))} - -
IDNom
{p.id}{p.nom}
-
-
- )}
); } diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index a95fb54..5563bed 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -5,11 +5,13 @@ const properties: AppProperties = { icon: "school", pages: { index: "Accueil", - modules: "Modules", users: "Utilisateurs", roles: "Rôles", + permissions: "Permissions", + modules: "Modules", + enseignements: "Enseignements", }, - adminOnly: ["modules", "users", "roles"], + adminOnly: ["users", "roles", "permissions", "modules", "enseignements"], hint: "PolyMPR module", }; diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index cb2ab47..fd5fee8 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -17,6 +17,22 @@ const CONFLICT = new Response( ); export const handler: Handlers = { + // GET /enseignements + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(JSON.stringify([]), { + headers: { "content-type": "application/json" }, + }); + } + const rows = await db.select().from(enseignements); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, + // #29 POST /enseignements async POST( request: Request, diff --git a/routes/(apps)/admin/partials/enseignements.tsx b/routes/(apps)/admin/partials/enseignements.tsx new file mode 100644 index 0000000..9b0127e --- /dev/null +++ b/routes/(apps)/admin/partials/enseignements.tsx @@ -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 AdminEnseignements from "../(_islands)/AdminEnseignements.tsx"; + +// deno-lint-ignore require-await +async function Enseignements( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Enseignements); diff --git a/routes/(apps)/admin/partials/permissions.tsx b/routes/(apps)/admin/partials/permissions.tsx new file mode 100644 index 0000000..f9359e5 --- /dev/null +++ b/routes/(apps)/admin/partials/permissions.tsx @@ -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 AdminPermissions from "../(_islands)/AdminPermissions.tsx"; + +// deno-lint-ignore require-await +async function Permissions( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Permissions); diff --git a/routes/(apps)/notes/(_islands)/AdminUEs.tsx b/routes/(apps)/notes/(_islands)/AdminUEs.tsx index d698c34..8c2ea22 100644 --- a/routes/(apps)/notes/(_islands)/AdminUEs.tsx +++ b/routes/(apps)/notes/(_islands)/AdminUEs.tsx @@ -1,19 +1,54 @@ import { useEffect, useState } from "preact/hooks"; type UE = { id: number; nom: string }; +type UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; +type Module = { id: string; nom: string }; +type Promo = { id: string; annee: string }; export default function AdminUEs() { const [ues, setUes] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [modules, setModules] = useState([]); + const [promos, setPromos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [newNom, setNewNom] = useState(""); - const [creating, setCreating] = useState(false); + + const [selectedUe, setSelectedUe] = useState(null); + + // New UE form + const [newUeNom, setNewUeNom] = useState(""); + const [creatingUe, setCreatingUe] = useState(false); + + // Add UE-module form + const [addModuleId, setAddModuleId] = useState(""); + const [addPromoId, setAddPromoId] = useState(""); + const [addCoeff, setAddCoeff] = useState("1"); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); async function load() { try { - const res = await fetch("/notes/api/ues"); - if (!res.ok) throw new Error("Impossible de charger les UEs"); - setUes(await res.json()); + const [uRes, umRes, mRes, pRes] = await Promise.all([ + fetch("/notes/api/ues"), + fetch("/notes/api/ue-modules"), + fetch("/admin/api/modules"), + fetch("/students/api/promotions"), + ]); + if (!uRes.ok) throw new Error("Impossible de charger les UEs"); + const uesData: UE[] = await uRes.json(); + setUes(uesData); + if (umRes.ok) setUeModules(await umRes.json()); + if (mRes.ok) setModules(await mRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + // Keep selection in sync + setSelectedUe((prev) => + prev ? uesData.find((u) => u.id === prev.id) ?? null : null + ); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -26,28 +61,37 @@ export default function AdminUEs() { }, []); async function createUE() { - if (!newNom.trim()) return; - setCreating(true); + if (!newUeNom.trim()) return; + setCreatingUe(true); try { const res = await fetch("/notes/api/ues", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ nom: newNom.trim() }), + body: JSON.stringify({ nom: newUeNom.trim() }), }); if (!res.ok) throw new Error("Création échouée"); - setNewNom(""); + setNewUeNom(""); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { - setCreating(false); + setCreatingUe(false); } } - async function deleteUE(id: number) { - if (!confirm("Supprimer cette UE ?")) return; + async function deleteUeModule( + idModule: string, + idUE: number, + idPromo: string, + ) { + if (!confirm("Supprimer ce module de la UE ?")) return; try { - const res = await fetch(`/notes/api/ues/${id}`, { method: "DELETE" }); + const res = await fetch( + `/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ + encodeURIComponent(idPromo) + }`, + { method: "DELETE" }, + ); if (!res.ok) throw new Error("Suppression échouée"); await load(); } catch (e) { @@ -55,68 +99,247 @@ export default function AdminUEs() { } } + async function addUeModule() { + if (!selectedUe || !addModuleId || !addPromoId) { + setAddError("Module et Promo sont requis"); + return; + } + const coeff = parseFloat(addCoeff); + if (isNaN(coeff) || coeff <= 0) { + setAddError("Coefficient invalide"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/notes/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idModule: addModuleId, + idUE: selectedUe.id, + idPromo: addPromoId, + coeff, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddModuleId(""); + setAddPromoId(""); + setAddCoeff("1"); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + + const selectedUeModules = selectedUe + ? ueModules.filter((um) => um.idUE === selectedUe.id) + : []; + return (

Gestion des UEs

+

+ UE = Unité d'Enseignement regroupant plusieurs modules +

{error &&

{error}

} -
- setNewNom((e.target as HTMLInputElement).value)} - onKeyDown={(e) => e.key === "Enter" && createUE()} - /> - -
- {loading ?

Chargement…

: ( -
- - - - - - - - - - {ues.length === 0 - ? ( - - - - ) - : ues.map((ue) => ( - - - - - +
+ {/* Left panel – UE list */} +
+
+

UEs existantes

+
+ + setNewUeNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createUE()} + style="min-width: 0; flex: 1" + /> +
+ +
+ {ues.map((ue) => ( +
{ + setSelectedUe(ue); + setAddError(null); + }} + > + {ue.nom} +
))} -
-
IDNomAction
- Aucune UE enregistrée -
{ue.id}{ue.nom} - -
+ {ues.length === 0 && ( +

+ Aucune UE +

+ )} +
+
+ + + {/* Right panel – UE detail */} +
+ {selectedUe + ? ( +
+

{selectedUe.nom}

+

+ Modules assignés (UE_Module) +

+
+ + + + + + + + + + + {selectedUeModules.length === 0 + ? ( + + + + ) + : selectedUeModules.map((um) => { + const mod = moduleMap[um.idModule]; + return ( + + + + + + + ); + })} + +
ModulePromoCoeffActions
+ Aucun module assigné +
+ {mod + ? `${mod.id} – ${mod.nom}` + : um.idModule} + + {um.idPromo} + {um.coeff} + +
+
+ +

+ Ajouter un module à cette UE +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + + setAddCoeff((e.target as HTMLInputElement).value)} + style="min-width: 5rem; max-width: 6rem" + /> + +
+
+ ) + : ( +
+

+ Sélectionnez une UE pour voir ses modules +

+
+ )} +
)} diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/students/(_islands)/AdminPromotions.tsx index 5461d9e..6143972 100644 --- a/routes/(apps)/students/(_islands)/AdminPromotions.tsx +++ b/routes/(apps)/students/(_islands)/AdminPromotions.tsx @@ -1,20 +1,42 @@ import { useEffect, useState } from "preact/hooks"; type Promotion = { id: string; annee: string | null }; +type Student = { numEtud: number; idPromo: string }; + +function parsePromo(id: string) { + const m = id.match(/^(\d+A)(FISE|FISA)(.+)$/); + if (!m) return { annee: id, filiere: "?", anneeSco: "?" }; + return { annee: m[1], filiere: m[2], anneeSco: m[3] }; +} + +const ANNEES = ["3A", "4A", "5A"]; +const FILIERES = ["FISE", "FISA"]; export default function AdminPromotions() { const [promos, setPromos] = useState([]); + const [students, setStudents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [newId, setNewId] = useState(""); - const [newAnnee, setNewAnnee] = useState(""); const [creating, setCreating] = useState(false); + // PromoBuilder state + const [selectedAnnee, setSelectedAnnee] = useState("4A"); + const [selectedFiliere, setSelectedFiliere] = useState("FISE"); + const [anneeSco, setAnneeSco] = useState(""); + + const generatedId = anneeSco.trim() + ? `${selectedAnnee}${selectedFiliere}${anneeSco.trim()}` + : ""; + async function load() { try { - const res = await fetch("/students/api/promotions"); - if (!res.ok) throw new Error("Impossible de charger les promotions"); - setPromos(await res.json()); + const [pRes, sRes] = await Promise.all([ + fetch("/students/api/promotions"), + fetch("/students/api/students"), + ]); + if (!pRes.ok) throw new Error("Impossible de charger les promotions"); + setPromos(await pRes.json()); + if (sRes.ok) setStudents(await sRes.json()); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -27,23 +49,22 @@ export default function AdminPromotions() { }, []); async function createPromo() { - if (!newId.trim()) return; + if (!generatedId) return; setCreating(true); try { const res = await fetch("/students/api/promotions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - idPromo: newId.trim(), - annee: newAnnee.trim() || null, + idPromo: generatedId, + annee: selectedAnnee, }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? "Création échouée"); } - setNewId(""); - setNewAnnee(""); + setAnneeSco(""); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); @@ -57,9 +78,7 @@ export default function AdminPromotions() { try { const res = await fetch( `/students/api/promotions/${encodeURIComponent(id)}`, - { - method: "DELETE", - }, + { method: "DELETE" }, ); if (!res.ok) throw new Error("Suppression échouée"); await load(); @@ -68,36 +87,93 @@ export default function AdminPromotions() { } } + function studentCount(idPromo: string) { + return students.filter((s) => s.idPromo === idPromo).length; + } + return (

Gestion des Promotions

{error &&

{error}

} -
- setNewId((e.target as HTMLInputElement).value)} - /> - setNewAnnee((e.target as HTMLInputElement).value)} - style="min-width: 14rem" - /> - + {/* PromoBuilder */} +
+

Créer une promotion

+

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

+ +
+
+ +
+ {ANNEES.map((a) => ( + + ))} +
+
+ +
+ +
+ {FILIERES.map((f) => ( + + ))} +
+
+ +
+ + setAnneeSco((e.target as HTMLInputElement).value)} + style="min-width: 9rem" + /> +
+
+ +
+
+ + idPromo généré : + + + {generatedId || "—"} + +
+ +
+ {/* Existing promotions table */} +

+ Promotions existantes +

+ {loading ?

Chargement…

: ( @@ -105,35 +181,51 @@ export default function AdminPromotions() { - + - + + + + {promos.length === 0 ? ( - ) - : promos.map((p) => ( - - - - - - ))} + : promos.map((p) => { + const parsed = parsePromo(p.id); + const count = studentCount(p.id); + return ( + + + + + + + + + ); + })}
IdentifiantidPromo AnnéeActionFilièreAnnée sco.Nb étudiantsActions
+ Aucune promotion enregistrée
{p.id}{p.annee ?? "—"} - -
+ {p.id} + {parsed.annee} + {parsed.filiere} + {parsed.anneeSco} + {count} étudiant{count !== 1 ? "s" : ""} + + +
diff --git a/routes/(apps)/students/(_islands)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index e69de29..f5728c4 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -0,0 +1,247 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promo = { id: string; annee: string }; +type Module = { id: string; nom: string }; + +type Props = { numEtud: number }; + +function anneeLabel(idPromo: string): string { + const m = idPromo.match(/^(\d+)A/); + if (!m) return ""; + const n = m[1]; + if (n === "3") return "3ème année"; + if (n === "4") return "4ème année"; + if (n === "5") return "5ème année"; + return `${n}ème année`; +} + +export default function EditStudents({ numEtud }: Props) { + const [student, setStudent] = useState(null); + const [promos, setPromos] = useState([]); + const [_modules, setModules] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saveMsg, setSaveMsg] = useState(null); + const [saving, setSaving] = useState(false); + + // Edit form state + const [nom, setNom] = useState(""); + const [prenom, setPrenom] = useState(""); + const [idPromo, setIdPromo] = useState(""); + + useEffect(() => { + async function load() { + try { + const [sRes, pRes, mRes] = await Promise.all([ + fetch(`/students/api/students/${numEtud}`), + fetch("/students/api/promotions"), + fetch("/admin/api/modules"), + ]); + if (!sRes.ok) throw new Error("Élève introuvable"); + const s: Student = await sRes.json(); + setStudent(s); + setNom(s.nom); + setPrenom(s.prenom); + setIdPromo(s.idPromo); + if (pRes.ok) setPromos(await pRes.json()); + if (mRes.ok) setModules(await mRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, [numEtud]); + + async function saveInfos() { + if (!student) return; + setSaving(true); + setSaveMsg(null); + try { + const res = await fetch(`/students/api/students/${numEtud}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: nom.trim(), + prenom: prenom.trim(), + idPromo, + }), + }); + if (!res.ok) throw new Error("Modification échouée"); + const updated: Student = await res.json(); + setStudent(updated); + setSaveMsg("Informations enregistrées."); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + async function deleteStudent() { + if (!confirm(`Supprimer définitivement l'élève #${numEtud} ?`)) return; + try { + const res = await fetch(`/students/api/students/${numEtud}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Suppression échouée"); + globalThis.location.href = "/students/consult"; + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + if (loading) { + return ( +
+

Chargement…

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

{error}

+
+ ); + } + + if (!student) return null; + + return ( +
+ + ← Retour à la liste + + +

+ Édition – {student.prenom} {student.nom} +

+ + {/* Info bar */} +
+ {student.numEtud} + {student.idPromo} + {anneeLabel(student.idPromo)} +
+ + {error &&

{error}

} + {saveMsg && ( +

+ {saveMsg} +

+ )} + + {/* Section 1: Informations générales */} +
+

Informations générales

+

PUT /students/{"{numEtud}"}

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

Spécialisations

+

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

+

+ Fonctionnalité non disponible (endpoint non implémenté). +

+
+ + {/* Section 3: Notes lecture seule */} +
+

Notes (lecture seule)

+

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

+
+ + Voir le récap complet des notes et moyennes de cet étudiant → + + + Récap notes + +
+
+
+ ); +} diff --git a/routes/(apps)/students/edit/[numEtud].tsx b/routes/(apps)/students/edit/[numEtud].tsx new file mode 100644 index 0000000..e88ff1b --- /dev/null +++ b/routes/(apps)/students/edit/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditStudents from "../(_islands)/EditStudents.tsx"; + +// deno-lint-ignore require-await +export default async function EditPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/static/styles/ui.css b/static/styles/ui.css index e56daa2..12132eb 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -391,3 +391,412 @@ margin-bottom: 1rem; gap: 1rem; } + +/* ------------------------------------------------------- + Chips: perm, role, promo, module +------------------------------------------------------- */ + +.perm-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.68rem; + font-family: monospace; + margin: 0.1rem; +} + +.role-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.72rem; + margin: 0.1rem; +} + +.promo-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid #b8820a; + color: #d4a017; + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +.filiere-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +.module-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.7rem; + font-family: monospace; +} + +.numEtud-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +/* ------------------------------------------------------- + Permission toggle cards (role management) +------------------------------------------------------- */ + +.perm-toggle-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem; +} + +.perm-toggle-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.55rem 0.75rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + cursor: pointer; + gap: 0.5rem; +} + +.perm-toggle-card.active { + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + background: light-dark(#f0fff4, #0d1f12); +} + +.perm-toggle-label { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.perm-toggle-id { + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + font-family: monospace; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.perm-toggle-nom { + font-size: 0.78rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* Simple toggle switch */ +.toggle-switch { + position: relative; + width: 2.4rem; + height: 1.3rem; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: light-dark(#ccc, #444); + border-radius: 1rem; + transition: background 150ms; +} + +.toggle-slider::before { + content: ""; + position: absolute; + width: 1rem; + height: 1rem; + left: 0.15rem; + top: 0.15rem; + background: white; + border-radius: 50%; + transition: transform 150ms; +} + +.toggle-switch input:checked + .toggle-slider { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(1.1rem); +} + +/* ------------------------------------------------------- + UE split layout +------------------------------------------------------- */ + +.ue-split { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.ue-panel-left { + width: 270px; + flex-shrink: 0; +} + +.ue-panel-right { + flex: 1; + min-width: 0; +} + +.panel-box { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + padding: 0.75rem; +} + +.panel-box-title { + font-size: 0.8rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.6rem; +} + +.ue-list-item { + padding: 0.45rem 0.6rem; + cursor: pointer; + border-radius: 3px; + font-size: 0.82rem; + border: 1px solid transparent; + margin-bottom: 0.2rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.ue-list-item:hover { + background: light-dark(#f0efff, #1a172d); + color: light-dark(var(--light-foreground), var(--dark-foreground)); +} + +.ue-list-item.active { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + font-weight: var(--font-weight-bold); +} + +/* ------------------------------------------------------- + Pill buttons (PromoBuilder) +------------------------------------------------------- */ + +.pill-group { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.pill-btn { + padding: 0.3rem 0.8rem; + border-radius: 20px; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + background: transparent; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.82rem; + font-family: inherit; + cursor: pointer; +} + +.pill-btn::before { + all: unset; +} + +.pill-btn.active { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + background: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + font-weight: var(--font-weight-bold); +} + +/* PromoBuilder box */ +.promo-builder { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 4px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.promo-builder-title { + font-size: 0.85rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.promo-builder-subtitle { + font-size: 0.72rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.85rem; +} + +.promo-builder-row { + display: flex; + gap: 1.5rem; + align-items: flex-start; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.promo-builder-field { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.promo-builder-field label { + font-size: 0.72rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.promo-id-preview { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.75rem; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 3px; + font-size: 0.85rem; + font-weight: var(--font-weight-bold); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + min-width: 10rem; + font-family: monospace; +} + +/* ------------------------------------------------------- + Edit student sections +------------------------------------------------------- */ + +.edit-section { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; +} + +.edit-section-title { + font-size: 0.88rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.edit-section-subtitle { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.8rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.form-field label { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.info-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + margin-bottom: 1rem; + font-size: 0.82rem; +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + text-decoration: none; + margin-bottom: 0.5rem; +} + +.back-link:hover { + text-decoration: underline; +} + +/* Info note box */ +.info-note { + padding: 0.75rem 1rem; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 4px; + background: light-dark(#f0fff4, #0a1a10); + margin-top: 1.5rem; + font-size: 0.82rem; +} + +.info-note-dim { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin-top: 0.25rem; +} -- 2.52.0 From d3de5c29e778450c4089daa7221d728c5e85d408 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 17:08:58 +0200 Subject: [PATCH 06/11] refactor: add migration, seed permissions, update permissions API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(notes): add XLSX import island and admin route feat(upload): add drag‑and‑drop upload, template download, UI tweaks --- compose.test.yml | 24 +- .../migrations/0001_seed_permissions.sql | 10 + .../0002_update_permission_names.sql | 13 + databases/migrations/meta/_journal.json | 14 ++ fresh.gen.ts | 6 + routes/(apps)/admin/(_islands)/AdminRoles.tsx | 6 +- routes/(apps)/admin/api/permissions.ts | 24 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 153 ++++++++++++ routes/(apps)/notes/(_props)/props.ts | 3 +- .../(apps)/notes/partials/(admin)/import.tsx | 29 +++ .../students/(_islands)/UploadStudents.tsx | 223 +++++++++++------- .../students/partials/(admin)/upload.tsx | 12 +- static/styles/main.css | 4 + static/styles/ui.css | 65 ++++- 14 files changed, 467 insertions(+), 119 deletions(-) create mode 100644 databases/migrations/0001_seed_permissions.sql create mode 100644 databases/migrations/0002_update_permission_names.sql create mode 100644 routes/(apps)/notes/(_islands)/ImportNotes.tsx create mode 100644 routes/(apps)/notes/partials/(admin)/import.tsx diff --git a/compose.test.yml b/compose.test.yml index 37b8e04..89a1142 100644 --- a/compose.test.yml +++ b/compose.test.yml @@ -7,9 +7,6 @@ services: POSTGRES_USER: postgres POSTGRES_DB: polympr_test volumes: - # Init script strips drizzle-kit markers and applies migrations on first start - - ./databases/docker-init.sh:/docker-entrypoint-initdb.d/01-migrate.sh:ro - - ./databases/migrations:/migrations:ro - db_data_test:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] @@ -17,6 +14,23 @@ services: timeout: 5s retries: 10 + migrate: + image: node:alpine + working_dir: /app + restart: "no" + volumes: + - .:/app + command: node_modules/.bin/drizzle-kit migrate + environment: + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + POSTGRES_USER: postgres + POSTGRES_PASS: testpass + POSTGRES_DB: polympr_test + depends_on: + db: + condition: service_healthy + app: image: denoland/deno:alpine working_dir: /app @@ -34,8 +48,8 @@ services: POSTGRES_DB: polympr_test LOCAL: "true" depends_on: - db: - condition: service_healthy + migrate: + condition: service_completed_successfully volumes: db_data_test: diff --git a/databases/migrations/0001_seed_permissions.sql b/databases/migrations/0001_seed_permissions.sql new file mode 100644 index 0000000..6ea1572 --- /dev/null +++ b/databases/migrations/0001_seed_permissions.sql @@ -0,0 +1,10 @@ +--> statement-breakpoint +INSERT INTO "permissions" ("id", "nom") VALUES + ('note_read', 'Consulter les notes des étudiants'), + ('note_write', 'Saisir et modifier les notes'), + ('student_read', 'Consulter la liste des étudiants'), + ('student_write','Gérer les étudiants (ajout, modification, suppression)'), + ('module_read', 'Consulter les modules et enseignements'), + ('module_write', 'Gérer les modules et enseignements'), + ('user_read', 'Consulter les utilisateurs et leurs rôles'), + ('user_write', 'Gérer les utilisateurs et leurs rôles'); diff --git a/databases/migrations/0002_update_permission_names.sql b/databases/migrations/0002_update_permission_names.sql new file mode 100644 index 0000000..4e1b1d0 --- /dev/null +++ b/databases/migrations/0002_update_permission_names.sql @@ -0,0 +1,13 @@ +-- Update permission names to French +-- This migration inserts or updates the permission labels used by the API. +--> statement-breakpoint +INSERT INTO "permissions" ("id", "nom") VALUES + ('note_read', 'Consulter les notes des étudiants'), + ('note_write', 'Saisir et modifier les notes'), + ('student_read', 'Consulter la liste des étudiants'), + ('student_write','Gérer les étudiants (ajout, modification, suppression)'), + ('module_read', 'Consulter les modules et enseignements'), + ('module_write', 'Gérer les modules et enseignements'), + ('user_read', 'Consulter les utilisateurs et leurs rôles'), + ('user_write', 'Gérer les utilisateurs et leurs rôles') +ON CONFLICT ("id") DO UPDATE SET "nom" = EXCLUDED."nom"; diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json index ad99452..e4f070f 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1777155028708, "tag": "0000_square_jetstream", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777155028709, + "tag": "0001_seed_permissions", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1777155028710, + "tag": "0002_update_permission_names", + "breakpoints": true } ] } diff --git a/fresh.gen.ts b/fresh.gen.ts index a4a95f9..f19d57b 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -36,6 +36,7 @@ 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_index from "./routes/(apps)/notes/index.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"; @@ -72,6 +73,7 @@ import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/ import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx"; import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx"; +import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx"; import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; @@ -129,6 +131,8 @@ const manifest = { "./routes/(apps)/notes/index.tsx": $_apps_notes_index, "./routes/(apps)/notes/partials/(admin)/courses.tsx": $_apps_notes_partials_admin_courses, + "./routes/(apps)/notes/partials/(admin)/import.tsx": + $_apps_notes_partials_admin_import, "./routes/(apps)/notes/partials/(admin)/ues.tsx": $_apps_notes_partials_admin_ues, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, @@ -187,6 +191,8 @@ const manifest = { $_apps_notes_islands_AdminConsultNotes, "./routes/(apps)/notes/(_islands)/AdminUEs.tsx": $_apps_notes_islands_AdminUEs, + "./routes/(apps)/notes/(_islands)/ImportNotes.tsx": + $_apps_notes_islands_ImportNotes, "./routes/(apps)/notes/(_islands)/NotesView.tsx": $_apps_notes_islands_NotesView, "./routes/(apps)/students/(_islands)/AdminPromotions.tsx": diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx index 448e334..b29b616 100644 --- a/routes/(apps)/admin/(_islands)/AdminRoles.tsx +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -171,21 +171,19 @@ export default function AdminRoles() { ); })} diff --git a/routes/(apps)/admin/api/permissions.ts b/routes/(apps)/admin/api/permissions.ts index 1175eb0..61bf4ed 100644 --- a/routes/(apps)/admin/api/permissions.ts +++ b/routes/(apps)/admin/api/permissions.ts @@ -1,21 +1,15 @@ -import { Handlers } from "$fresh/server.ts"; +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { permissions } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; -const PERMISSIONS = [ - { id: "student_read", nom: "Consulter les élèves" }, - { id: "student_write", nom: "Gérer les élèves" }, - { id: "note_read", nom: "Consulter les notes" }, - { id: "note_write", nom: "Gérer les notes" }, - { id: "module_read", nom: "Consulter les modules" }, - { id: "module_write", nom: "Gérer les modules" }, - { id: "user_read", nom: "Consulter les utilisateurs" }, - { id: "user_write", nom: "Gérer les utilisateurs" }, - { id: "role_write", nom: "Gérer les rôles" }, -] as const; - export const handler: Handlers = { - GET(_request, _context): Response { - return new Response(JSON.stringify(PERMISSIONS), { + async GET( + _request: Request, + _context: FreshContext, + ): Promise { + const result = await db.select().from(permissions); + return new Response(JSON.stringify(result), { headers: { "content-type": "application/json" }, }); }, diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx new file mode 100644 index 0000000..e738057 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -0,0 +1,153 @@ +// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" +import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; +import { useRef } from "preact/hooks"; +import { useSignal } from "@preact/signals"; + +export default function ImportNotes() { + const file = useSignal(null); + const dragging = useSignal(false); + const uploading = useSignal(false); + const error = useSignal(null); + const success = useSignal(null); + const inputRef = useRef(null); + + function pickFile(f: File) { + if (!f.name.match(/\.xlsx?$/i)) { + error.value = "Fichier invalide — format attendu : .xlsx"; + return; + } + file.value = f; + error.value = null; + success.value = null; + } + + function onDragOver(e: DragEvent) { + e.preventDefault(); + dragging.value = true; + } + + function onDragLeave() { + dragging.value = false; + } + + function onDrop(e: DragEvent) { + e.preventDefault(); + dragging.value = false; + const f = e.dataTransfer?.files?.[0]; + if (f) pickFile(f); + } + + function onInputChange(e: Event) { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) pickFile(f); + } + + async function doImport() { + if (!file.value) return; + uploading.value = true; + error.value = null; + success.value = null; + + try { + const arrayBuffer = await file.value.arrayBuffer(); + const workbook = XLSX.read(arrayBuffer, { type: "array" }); + let imported = 0; + let failed = 0; + + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json<{ + numEtud: number; + idModule: string; + note: number; + }>(sheet, { header: ["numEtud", "idModule", "note"], range: 1 }); + + for (const row of rows) { + const res = await fetch("/notes/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(row), + }); + if (res.ok) imported++; + else failed++; + } + } + + success.value = + `Import terminé — ${imported} ajouté${imported !== 1 ? "s" : ""}${ + failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : "" + }`; + } catch { + error.value = "Erreur lors de la lecture du fichier."; + } finally { + uploading.value = false; + } + } + + function downloadTemplate() { + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([["numEtud", "idModule", "note"]]); + XLSX.utils.book_append_sheet(wb, ws, "Notes"); + XLSX.writeFile(wb, "modele_notes.xlsx"); + } + + return ( +
+ + +
inputRef.current?.click()} + > + + {file.value + ? {file.value.name} + : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )} +
+ + {error.value &&

{error.value}

} + {success.value && ( +

+ {success.value} +

+ )} + +
+ + +
+ +

+ Format : numEtud | idModule |{" "} + note +

+
+ ); +} diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index fb7f11b..2f5be17 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -8,8 +8,9 @@ const properties: AppProperties = { notes: "Mes notes", courses: "Consulter", ues: "UEs", + import: "Import xlsx", }, - adminOnly: ["courses", "ues"], + adminOnly: ["courses", "ues", "import"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/partials/(admin)/import.tsx b/routes/(apps)/notes/partials/(admin)/import.tsx new file mode 100644 index 0000000..4a92c3d --- /dev/null +++ b/routes/(apps)/notes/partials/(admin)/import.tsx @@ -0,0 +1,29 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import ImportNotes from "../../(_islands)/ImportNotes.tsx"; + +// deno-lint-ignore require-await +async function ImportNotesPage( + _request: Request, + _context: FreshContext, +) { + return ( +
+

Importer des Notes

+

+ POST /notes/api/notes +

+ +
+ ); +} + +export const config = getPartialsConfig(); +export default makePartials(ImportNotesPage); diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index 6e21876..df6f592 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -1,111 +1,154 @@ // @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; -import { Signal, useSignal } from "@preact/signals"; +import { useRef } from "preact/hooks"; +import { useSignal } from "@preact/signals"; -/** - * Create a new handler for file change that displays - * messages in statusMessage and gets file data in fileData. - * @param statusMessage The status message signal. - * @param fileData The file data signal. - * @returns The file change handler. - */ -function getFileChangeHandler( - statusMessage: Signal, - fileData: Signal, -): (event: Event) => void { - /** - * Handle file change. - * @param event The file change event. - */ - return (event: Event) => { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - fileData.value = input.files[0]; - statusMessage.value = `File selected: ${input.files[0].name}`; - } else { - fileData.value = null; - statusMessage.value = "No file selected"; - } - }; -} +export default function UploadStudents() { + const file = useSignal(null); + const dragging = useSignal(false); + const uploading = useSignal(false); + const error = useSignal(null); + const success = useSignal(null); + const inputRef = useRef(null); -/** - * Create a new handler that sends data file to server. - * @param statusMessage The status message signal. - * @param fileData The file data signal. - * @returns The file confirmation handler. - */ -function getUploadConfirmationFunction( - statusMessage: Signal, - fileData: Signal, -): () => void { - /** - * Add students to database. - * @returns Confirm upload of students. - */ - return () => { - if (!fileData.value) { - statusMessage.value = "Please select a file before confirming upload."; + function pickFile(f: File) { + if (!f.name.match(/\.xlsx?$/i)) { + error.value = "Fichier invalide — format attendu : .xlsx"; return; } + file.value = f; + error.value = null; + success.value = null; + } - const reader = new FileReader(); + function onDragOver(e: DragEvent) { + e.preventDefault(); + dragging.value = true; + } - /** - * Send all data to the server. - * @param event The finished progress event. - */ - reader.onload = async (event: ProgressEvent) => { - const arrayBuffer = event.target!.result as ArrayBuffer; + function onDragLeave() { + dragging.value = false; + } + + function onDrop(e: DragEvent) { + e.preventDefault(); + dragging.value = false; + const f = e.dataTransfer?.files?.[0]; + if (f) pickFile(f); + } + + function onInputChange(e: Event) { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) pickFile(f); + } + + async function doImport() { + if (!file.value) return; + uploading.value = true; + error.value = null; + success.value = null; + + try { + const arrayBuffer = await file.value.arrayBuffer(); const workbook = XLSX.read(arrayBuffer, { type: "array" }); - let allOK = true; + let imported = 0; + let failed = 0; for (const sheetName of workbook.SheetNames) { const sheet = workbook.Sheets[sheetName]; - const data = XLSX.utils.sheet_to_json(sheet, { - header: ["userId", "lastName", "firstName", "mail"], - range: 1, - }); + const rows = XLSX.utils.sheet_to_json<{ + numEtud: number; + nom: string; + prenom: string; + }>(sheet, { header: ["numEtud", "nom", "prenom"], range: 1 }); - const response = await fetch("/students/api/students", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ promoName: sheetName, data }), - }); - - if (!response.ok) { - allOK = false; + for (const row of rows) { + const res = await fetch("/students/api/students", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...row, idPromo: sheetName }), + }); + if (res.ok) imported++; + else failed++; } } - statusMessage.value = allOK - ? "Failed to insert all data." - : "Data uploaded and inserted successfully!"; - }; + success.value = + `Import terminé — ${imported} ajouté${imported !== 1 ? "s" : ""}${ + failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : "" + }`; + } catch { + error.value = "Erreur lors de la lecture du fichier."; + } finally { + uploading.value = false; + } + } - /** - * Display error message if any. - */ - reader.onerror = () => { - statusMessage.value = "Error reading the file."; - }; - - reader.readAsArrayBuffer(fileData.value); - }; -} - -export default function UploadStudents() { - const statusMessage = useSignal(""); - const fileData = useSignal(null); - - const handleFileChange = getFileChangeHandler(statusMessage, fileData); - const confirmUpload = getUploadConfirmationFunction(statusMessage, fileData); + function downloadTemplate() { + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([["numEtud", "nom", "prenom"]]); + XLSX.utils.book_append_sheet(wb, ws, "4A22"); + XLSX.writeFile(wb, "modele_etudiants.xlsx"); + } return ( - <> - - -

{statusMessage.value}

- +
+ + +
inputRef.current?.click()} + > + + {file.value + ? {file.value.name} + : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )} +
+ + {error.value &&

{error.value}

} + {success.value && ( +

+ {success.value} +

+ )} + +
+ + +
+ +

+ Format : promo (nom de la feuille) |{" "} + numEtud | nom |{" "} + prénom +

+
); } diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index 2f36f6d..cdb94fd 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -9,10 +9,16 @@ import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await async function Students(_request: Request, _context: FreshContext) { return ( - <> -

Upload Students

+
+

Importer des Élèves

+

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

- +
); } diff --git a/static/styles/main.css b/static/styles/main.css index ce2282a..9fbd74e 100644 --- a/static/styles/main.css +++ b/static/styles/main.css @@ -29,6 +29,10 @@ font-family: var(--font-family-text); } +html { + font-size: 130%; /* scale up from browser default 16px → ~20.8px */ +} + html, body { margin: 0; padding: 0; diff --git a/static/styles/ui.css b/static/styles/ui.css index 12132eb..88d3080 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -6,7 +6,6 @@ .page-content { padding: 1.5rem; - max-width: 960px; } .page-title { @@ -783,6 +782,70 @@ text-decoration: underline; } +/* ------------------------------------------------------- + File drop zone (import pages) +------------------------------------------------------- */ + +.drop-zone { + border: 2px dashed + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 6px; + background: light-dark(white, #141228); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 3rem 2rem; + cursor: pointer; + transition: border-color 150ms, background 150ms; + margin-bottom: 1.25rem; + text-align: center; +} + +.drop-zone:hover, +.drop-zone.dragging { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + background: light-dark(#f0fff4, #0a1a10); +} + +.drop-zone-icon { + font-size: 2.4rem; + line-height: 1; + opacity: 0.8; +} + +.drop-zone-text { + font-size: 0.88rem; + font-weight: var(--font-weight-bold); +} + +.drop-zone-hint { + font-size: 0.75rem; + font-family: monospace; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.drop-zone-file { + font-size: 0.78rem; + font-family: monospace; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-weight: var(--font-weight-bold); +} + +.upload-actions { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.upload-format { + font-size: 0.72rem; + font-family: monospace; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + /* Info note box */ .info-note { padding: 0.75rem 1rem; -- 2.52.0 From 378cbb0c06bfccd7c4be09e2b5b299f55b2bdd1d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 17:11:46 +0200 Subject: [PATCH 07/11] style: format import success message and drop zone JSX Apply consistent string concatenation in ImportNotes and UploadStudents. Format JSX drop zone for better readability. --- .../(apps)/notes/(_islands)/ImportNotes.tsx | 21 ++++++++----------- .../students/(_islands)/UploadStudents.tsx | 21 ++++++++----------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index e738057..4114c11 100644 --- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -73,10 +73,9 @@ export default function ImportNotes() { } } - success.value = - `Import terminé — ${imported} ajouté${imported !== 1 ? "s" : ""}${ - failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : "" - }`; + success.value = `Import terminé — ${imported} ajouté${ + imported !== 1 ? "s" : "" + }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`; } catch { error.value = "Erreur lors de la lecture du fichier."; } finally { @@ -109,14 +108,12 @@ export default function ImportNotes() { onClick={() => inputRef.current?.click()} > - {file.value - ? {file.value.name} - : ( - <> - Glisser le fichier .xlsx ici - ou cliquer pour parcourir - - )} + {file.value ? {file.value.name} : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )}
{error.value &&

{error.value}

} diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index df6f592..bf751d5 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -73,10 +73,9 @@ export default function UploadStudents() { } } - success.value = - `Import terminé — ${imported} ajouté${imported !== 1 ? "s" : ""}${ - failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : "" - }`; + success.value = `Import terminé — ${imported} ajouté${ + imported !== 1 ? "s" : "" + }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`; } catch { error.value = "Erreur lors de la lecture du fichier."; } finally { @@ -109,14 +108,12 @@ export default function UploadStudents() { onClick={() => inputRef.current?.click()} > - {file.value - ? {file.value.name} - : ( - <> - Glisser le fichier .xlsx ici - ou cliquer pour parcourir - - )} + {file.value ? {file.value.name} : ( + <> + Glisser le fichier .xlsx ici + ou cliquer pour parcourir + + )} {error.value &&

{error.value}

} -- 2.52.0 From 757e364af0346bddc8fd923cd5e3be3354013546 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 17:29:31 +0200 Subject: [PATCH 08/11] chore(docker): add .dockerignore and update Dockerfile Add .dockerignore to exclude node_modules, .git, coverage, .env. Update Dockerfile to install nodejs/npm, copy package.json, run npm install, and build. Update compose.prod.yml to set working_dir, restart no, and use array command. Move drizzle-kit from devDependencies to dependencies. --- .dockerignore | 4 ++++ Dockerfile | 5 +++++ compose.prod.yml | 4 +++- package.json | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..31cfae7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.git +coverage +.env diff --git a/Dockerfile b/Dockerfile index 1a335a7..61f7fe8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,12 @@ FROM denoland/deno:alpine +RUN apk add --no-cache nodejs npm + WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev + COPY . . RUN deno cache main.ts --allow-import RUN deno task build diff --git a/compose.prod.yml b/compose.prod.yml index 6fcc5bc..6d7f11a 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -16,7 +16,9 @@ services: migrate: image: registry.docker.polytech.djalim.fr/polympr:latest - command: node_modules/.bin/drizzle-kit migrate + working_dir: /app + restart: "no" + command: ["node", "node_modules/.bin/drizzle-kit", "migrate"] env_file: .env depends_on: db: diff --git a/package.json b/package.json index bbd458d..3c2ff0c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "dependencies": { "dotenv": "^17.4.0", + "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", "pg": "^8.20.0" }, "devDependencies": { "@types/pg": "^8.20.0", - "drizzle-kit": "^0.31.10", "tsx": "^4.21.0" } } -- 2.52.0 From 2c5e4ebf112d7d26d31b1e02fc9f5decac86cd5c Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 18:22:23 +0200 Subject: [PATCH 09/11] feat(fresh.gen.ts): add routes for notes edition, recap and island recap feat(notes): add NoteRecap island component for student grade recap feat: add adjust controls to UI component Add placeholder, value binding, onInput handler, apply/reset buttons, and display of adjusted value. feat(notes): add edition and recap pages, update styles and links --- fresh.gen.ts | 8 + routes/(apps)/notes/(_islands)/NoteRecap.tsx | 385 +++++++++++++++++++ routes/(apps)/notes/edition/[numEtud].tsx | 12 + routes/(apps)/notes/recap/[numEtud].tsx | 12 + routes/_app.tsx | 6 +- static/styles/ui.css | 90 +++++ 6 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 routes/(apps)/notes/(_islands)/NoteRecap.tsx create mode 100644 routes/(apps)/notes/edition/[numEtud].tsx create mode 100644 routes/(apps)/notes/recap/[numEtud].tsx diff --git a/fresh.gen.ts b/fresh.gen.ts index f19d57b..22cab59 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -34,7 +34,9 @@ import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modul 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"; @@ -74,6 +76,7 @@ import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_ import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx"; import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx"; +import * as $_apps_notes_islands_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.tsx"; import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; @@ -128,7 +131,10 @@ const manifest = { $_apps_notes_api_ue_modules_idModule_idUE_idPromo_, "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, "./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_, + "./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": @@ -193,6 +199,8 @@ const manifest = { $_apps_notes_islands_AdminUEs, "./routes/(apps)/notes/(_islands)/ImportNotes.tsx": $_apps_notes_islands_ImportNotes, + "./routes/(apps)/notes/(_islands)/NoteRecap.tsx": + $_apps_notes_islands_NoteRecap, "./routes/(apps)/notes/(_islands)/NotesView.tsx": $_apps_notes_islands_NotesView, "./routes/(apps)/students/(_islands)/AdminPromotions.tsx": diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx new file mode 100644 index 0000000..5ee4618 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -0,0 +1,385 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { numEtud: number; nom: string; prenom: string; idPromo: string }; +type UE = { id: number; nom: string }; +type UEModule = { idModule: string; idUE: number; idPromo: string; coeff: number }; +type Module = { id: string; nom: string }; +type Note = { numEtud: number; idModule: string; note: number }; +type Ajustement = { numEtud: number; idUE: number; valeur: number }; + +type Props = { numEtud: number }; + +function fmt(n: number): string { + return `${Math.round(n * 10) / 10}/20`; +} + +function noteClass(n: number): string { + return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail"; +} + +export default function NoteRecap({ numEtud }: Props) { + const [student, setStudent] = useState(null); + const [ueList, setUeList] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [moduleMap, setModuleMap] = useState>(new Map()); + const [noteMap, setNoteMap] = useState>(new Map()); + const [ajustements, setAjustements] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingNote, setEditingNote] = useState< + { idModule: string; value: string } | null + >(null); + const [ajustInputs, setAjustInputs] = useState>({}); + + async function load() { + try { + const sRes = await fetch(`/students/api/students/${numEtud}`); + if (!sRes.ok) throw new Error("Élève introuvable"); + const s: Student = await sRes.json(); + setStudent(s); + + const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([ + fetch("/notes/api/ues"), + fetch( + `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, + ), + fetch("/admin/api/modules"), + fetch(`/notes/api/notes?numEtud=${numEtud}`), + fetch(`/notes/api/ajustements?numEtud=${numEtud}`), + ]); + + if (uesRes.ok) setUeList(await uesRes.json()); + if (umRes.ok) setUeModules(await umRes.json()); + if (mRes.ok) { + const mods: Module[] = await mRes.json(); + setModuleMap(new Map(mods.map((m) => [m.id, m.nom]))); + } + if (notesRes.ok) { + const ns: Note[] = await notesRes.json(); + setNoteMap(new Map(ns.map((n) => [n.idModule, n.note]))); + } + if (ajustRes.ok) { + const aj: Ajustement[] = await ajustRes.json(); + setAjustements(aj); + const inputs: Record = {}; + for (const a of aj) inputs[a.idUE] = String(a.valeur); + setAjustInputs(inputs); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, [numEtud]); + + function calcAvg(ueMods: UEModule[]): number | null { + let total = 0, coeff = 0; + for (const um of ueMods) { + const n = noteMap.get(um.idModule); + if (n === undefined) return null; + total += n * um.coeff; + coeff += um.coeff; + } + return coeff > 0 ? total / coeff : null; + } + + async function saveNote(idModule: string, value: string) { + const note = parseFloat(value.replace(",", ".")); + if (isNaN(note) || note < 0 || note > 20) { + setEditingNote(null); + return; + } + const res = await fetch( + `/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ note }), + }, + ); + if (res.ok) { + const updated: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, updated.note)); + } + setEditingNote(null); + } + + async function applyAjust(idUE: number) { + const val = parseFloat((ajustInputs[idUE] ?? "").replace(",", ".")); + if (isNaN(val) || val < 0 || val > 20) return; + const existing = ajustements.find((a) => a.idUE === idUE); + const res = existing + ? await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ valeur: val }), + }) + : await fetch("/notes/api/ajustements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ numEtud, idUE, valeur: val }), + }); + if (res.ok) { + const updated: Ajustement = await res.json(); + setAjustements((prev) => + existing + ? prev.map((a) => a.idUE === idUE ? updated : a) + : [...prev, updated] + ); + } + } + + async function resetAjust(idUE: number) { + const res = await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, { + method: "DELETE", + }); + if (res.ok) { + setAjustements((prev) => prev.filter((a) => a.idUE !== idUE)); + setAjustInputs((prev) => { + const c = { ...prev }; + delete c[idUE]; + return c; + }); + } + } + + if (loading) { + return ( +
+

Chargement…

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

{error}

+
+ ); + } + if (!student) return null; + + return ( +
+ + ← Retour à la liste + + +

+ Récap notes – {student.prenom} {student.nom} +

+ +
+ {student.numEtud} + {student.prenom} {student.nom} + {student.idPromo} +
+ + {error &&

{error}

} + + {ueList.length === 0 + ? ( +

+ Aucune UE configurée pour cette promotion. +

+ ) + : ueList.map((ue) => { + const ueMods = ueModules.filter((um) => um.idUE === ue.id); + const avg = calcAvg(ueMods); + const ajust = ajustements.find((a) => a.idUE === ue.id); + + return ( +
+ {/* UE header */} +
+

{ue.nom}

+ {avg !== null && ( + + Moy. calculée : {fmt(avg)} + + )} + {ajust && ( + + ⚡ Ajust. actif : {fmt(ajust.valeur)} + + )} +
+ + {/* Module rows */} + {ueMods.length === 0 + ? ( +

+ Aucun module associé à cette UE pour cette promotion. +

+ ) + : ( +
+ {ueMods.map((um) => { + const noteVal = noteMap.get(um.idModule); + const nomMod = moduleMap.get(um.idModule) ?? um.idModule; + const isEditing = editingNote?.idModule === um.idModule; + + return ( +
+ + + {um.idModule} + + {nomMod} + + + coef {um.coeff} + + {isEditing + ? ( +
+ + setEditingNote({ + idModule: um.idModule, + value: + (e.target as HTMLInputElement).value, + })} + onKeyDown={(e) => { + if (e.key === "Enter") { + saveNote( + um.idModule, + editingNote!.value, + ); + } + if (e.key === "Escape") { + setEditingNote(null); + } + }} + onBlur={() => + saveNote(um.idModule, editingNote!.value)} + /> + + /20 + +
+ ) + : ( + + setEditingNote({ + idModule: um.idModule, + value: noteVal !== undefined + ? String(noteVal) + : "", + })} + > + {noteVal !== undefined ? fmt(noteVal) : "—/20"} + + )} + +
+ ); + })} +
+ )} + + {/* Ajustement */} +
+

Ajustement de la moyenne UE

+

+ Override ponctuel – laisser vide pour utiliser la moy. + calculée +

+
+
+ + setAjustInputs((prev) => ({ + ...prev, + [ue.id]: (e.target as HTMLInputElement).value, + }))} + /> + /20 +
+ + {ajust && ( + <> + + + Affiché à l'élève : {fmt(ajust.valeur)} + {avg !== null ? ` (calculée : ${fmt(avg)})` : ""} + + + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/routes/(apps)/notes/edition/[numEtud].tsx b/routes/(apps)/notes/edition/[numEtud].tsx new file mode 100644 index 0000000..437d4c4 --- /dev/null +++ b/routes/(apps)/notes/edition/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import NoteRecap from "../(_islands)/NoteRecap.tsx"; + +// deno-lint-ignore require-await +export default async function EditionPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/routes/(apps)/notes/recap/[numEtud].tsx b/routes/(apps)/notes/recap/[numEtud].tsx new file mode 100644 index 0000000..208da0f --- /dev/null +++ b/routes/(apps)/notes/recap/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import NoteRecap from "../(_islands)/NoteRecap.tsx"; + +// deno-lint-ignore require-await +export default async function RecapPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/routes/_app.tsx b/routes/_app.tsx index 8162820..81187c3 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -26,9 +26,9 @@ export default async function App( /> - - - + + +
diff --git a/static/styles/ui.css b/static/styles/ui.css index 88d3080..f43bfc8 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -857,6 +857,96 @@ font-size: 0.82rem; } +/* ------------------------------------------------------- + Note recap chips & rows +------------------------------------------------------- */ +.note-chip { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.55rem; + border-radius: 10px; + border: 1px solid currentColor; + font-size: 0.78rem; + font-weight: var(--font-weight-bold); + font-family: monospace; + white-space: nowrap; +} + +.note-chip--ok { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.note-chip--fail { + color: light-dark(#dc2626, #f87171); +} + +.note-chip--none { + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.note-chip--promo { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + background: transparent; +} + +.note-chip--ajust { + color: #f59e0b; +} + +.note-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + flex-wrap: wrap; +} + +.note-row-label { + flex: 1; + min-width: 10rem; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.note-row-chip { + font-size: 0.68rem; + padding: 0.1rem 0.4rem; +} + +.note-row-coef { + font-size: 0.75rem; + white-space: nowrap; +} + +.ajust-section { + margin-top: 0.75rem; + padding-top: 0.65rem; + border-top: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ajust-title { + font-size: 0.78rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.ajust-hint { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.5rem; +} + +/* ------------------------------------------------------- + (end note recap) +------------------------------------------------------- */ + .info-note-dim { font-size: 0.7rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); -- 2.52.0 From f162fcaadc56be45675cb1030f5dacbce402630d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 18:56:04 +0200 Subject: [PATCH 10/11] feat: add role_write permission and update e2e tests Add role_write permission to permissions table and update migrations. Update e2e tests to use DB integration and seed permissions. Add seedPermissions helper. --- .../migrations/0001_seed_permissions.sql | 3 +- .../0002_update_permission_names.sql | 3 +- tests/e2e/permissions_test.ts | 38 ++++++++++++++----- tests/helpers/db_integration.ts | 6 +++ 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/databases/migrations/0001_seed_permissions.sql b/databases/migrations/0001_seed_permissions.sql index 6ea1572..922f6fa 100644 --- a/databases/migrations/0001_seed_permissions.sql +++ b/databases/migrations/0001_seed_permissions.sql @@ -7,4 +7,5 @@ INSERT INTO "permissions" ("id", "nom") VALUES ('module_read', 'Consulter les modules et enseignements'), ('module_write', 'Gérer les modules et enseignements'), ('user_read', 'Consulter les utilisateurs et leurs rôles'), - ('user_write', 'Gérer les utilisateurs et leurs rôles'); + ('user_write', 'Gérer les utilisateurs et leurs rôles'), + ('role_write', 'Gérer les rôles et leurs permissions'); diff --git a/databases/migrations/0002_update_permission_names.sql b/databases/migrations/0002_update_permission_names.sql index 4e1b1d0..d598c10 100644 --- a/databases/migrations/0002_update_permission_names.sql +++ b/databases/migrations/0002_update_permission_names.sql @@ -9,5 +9,6 @@ INSERT INTO "permissions" ("id", "nom") VALUES ('module_read', 'Consulter les modules et enseignements'), ('module_write', 'Gérer les modules et enseignements'), ('user_read', 'Consulter les utilisateurs et leurs rôles'), - ('user_write', 'Gérer les utilisateurs et leurs rôles') + ('user_write', 'Gérer les utilisateurs et leurs rôles'), + ('role_write', 'Gérer les rôles et leurs permissions') ON CONFLICT ("id") DO UPDATE SET "nom" = EXCLUDED."nom"; diff --git a/tests/e2e/permissions_test.ts b/tests/e2e/permissions_test.ts index 158c82a..8dff05d 100644 --- a/tests/e2e/permissions_test.ts +++ b/tests/e2e/permissions_test.ts @@ -1,24 +1,40 @@ // #115 - E2E tests for GET /permissions -// Handler statique (pas de DB), test direct du handler import { assertEquals, assertExists } from "@std/assert"; import { makeEmployeeContext, makeGetRequest } from "../helpers/handler.ts"; +import { + seedPermissions, + truncateAll, +} from "../helpers/db_integration.ts"; import { handler as permissionsHandler } from "$apps/admin/api/permissions.ts"; +const PERMISSIONS = [ + { id: "note_read", nom: "Consulter les notes des étudiants" }, + { id: "note_write", nom: "Saisir et modifier les notes" }, + { id: "student_read", nom: "Consulter la liste des étudiants" }, + { id: "student_write", nom: "Gérer les étudiants (ajout, modification, suppression)" }, + { id: "module_read", nom: "Consulter les modules et enseignements" }, + { id: "module_write", nom: "Gérer les modules et enseignements" }, + { id: "user_read", nom: "Consulter les utilisateurs et leurs rôles" }, + { id: "user_write", nom: "Gérer les utilisateurs et leurs rôles" }, + { id: "role_write", nom: "Gérer les rôles et leurs permissions" }, +]; + Deno.test({ name: "e2e permissions: GET /permissions returns all 9 permissions", - fn() { - const res = permissionsHandler.GET!( + async fn() { + await truncateAll(); + await seedPermissions(PERMISSIONS); + const res = await permissionsHandler.GET!( makeGetRequest("/permissions"), makeEmployeeContext(), ); assertEquals(res.status, 200); - return res.text().then((text) => { - const data = JSON.parse(text); - assertEquals(data.length, 9); - assertExists(data.find((p: { id: string }) => p.id === "student_read")); - assertExists(data.find((p: { id: string }) => p.id === "role_write")); - }); + const text = await res.text(); + const data = JSON.parse(text); + assertEquals(data.length, 9); + assertExists(data.find((p: { id: string }) => p.id === "student_read")); + assertExists(data.find((p: { id: string }) => p.id === "role_write")); }, sanitizeResources: false, sanitizeOps: false, @@ -27,7 +43,9 @@ Deno.test({ Deno.test({ name: "e2e permissions: GET /permissions - all entries have id and nom", async fn() { - const res = permissionsHandler.GET!( + await truncateAll(); + await seedPermissions(PERMISSIONS); + const res = await permissionsHandler.GET!( makeGetRequest("/permissions"), makeEmployeeContext(), ); diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index 4b91b25..2a571bf 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -111,3 +111,9 @@ export async function seedAjustements( ): Promise { return await testDb.insert(schema.ajustements).values(rows).returning(); } + +export async function seedPermissions( + rows: { id: string; nom: string }[], +): Promise { + return await testDb.insert(schema.permissions).values(rows).returning(); +} -- 2.52.0 From bb09c1cce5f64eac26a63d05c9a84e15b505c4f6 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 18:58:19 +0200 Subject: [PATCH 11/11] chore: formated tests --- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 22 +++++++++++++------- tests/e2e/permissions_test.ts | 10 ++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index 5ee4618..81918f5 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -1,8 +1,18 @@ import { useEffect, useState } from "preact/hooks"; -type Student = { numEtud: number; nom: string; prenom: string; idPromo: string }; +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; type UE = { id: number; nom: string }; -type UEModule = { idModule: string; idUE: number; idPromo: string; coeff: number }; +type UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; type Module = { id: string; nom: string }; type Note = { numEtud: number; idModule: string; note: number }; type Ajustement = { numEtud: number; idUE: number; valeur: number }; @@ -202,9 +212,7 @@ export default function NoteRecap({ numEtud }: Props) { return (
{/* UE header */} -
+

{ue.nom}

{avg !== null && ( @@ -254,9 +262,7 @@ export default function NoteRecap({ numEtud }: Props) { {isEditing ? ( -
+