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

Gestion des Modules

{error &&

{error}

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

Chargement…

+ ?

Chargement...

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

Nouveau module

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

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

Chargement…

+ ?

Chargement...

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

{error}

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

Chargement…

+ ?

Chargement...

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

Chargement...

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

{error}

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

+ Module -- {mod.id} +

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

{error}

} + {saveMsg && ( +

+ {saveMsg} +

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

Informations

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

Enseignants assignes

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

+ Aucun enseignant assigne. +

+ )} + +

+ Ajouter un enseignant +

+ {addError && ( +

+ {addError} +

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

Chargement...

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

{error}

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

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

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

{error}

} + {saveMsg && ( +

+ {saveMsg} +

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

Informations generales

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

Enseignements

+

+ Modules enseignes par cet utilisateur +

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

+ Aucun enseignement assigne. +

+ )} + +

+ Ajouter un enseignement +

+ {addError && ( +

+ {addError} +

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

Importer des Notes

-

- POST /notes/api/notes -

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

Créer une promotion

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

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

Informations générales

-

PUT /students/{"{numEtud}"}

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

Spécialisations

-

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

Notes (lecture seule)

-

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

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

Importer des Élèves

-

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

); diff --git a/static/styles/ui.css b/static/styles/ui.css index f43bfc8..a4efd9a 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -470,6 +470,18 @@ Permission toggle cards (role management) ------------------------------------------------------- */ +.perm-header-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.85rem; + margin-bottom: 1.25rem; + background: light-dark(#f5f4ff, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; +} + .perm-toggle-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -740,7 +752,7 @@ .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); - gap: 0.5rem; + gap: 0.75rem 1rem; margin-bottom: 0.75rem; } @@ -947,6 +959,49 @@ (end note recap) ------------------------------------------------------- */ +/* ------------------------------------------------------- + Modal overlay +------------------------------------------------------- */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-box { + background: light-dark(white, #1a172d); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 6px; + padding: 1.25rem; + min-width: 22rem; + max-width: 90vw; +} + +.modal-title { + font-size: 0.95rem; + font-weight: var(--font-weight-bold); + margin: 0 0 1rem; +} + +.modal-form { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + .info-note-dim { font-size: 0.7rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); -- 2.52.0 From df3957741d72696183e06d87bbb0641e0b493521 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Thu, 30 Apr 2026 13:49:47 +0200 Subject: [PATCH 2/6] feat : fix a lot of stuff --- .../0003_add_session2_and_malus.sql | 3 + databases/migrations/meta/_journal.json | 7 + databases/schema.ts | 2 + defaults/ImportResultPopup.tsx | 102 +++ fresh.gen.ts | 47 +- routes/(apps)/_middleware.ts | 10 +- .../(_islands)/AdminPromotions.tsx | 21 +- .../{notes => admin}/(_islands)/AdminUEs.tsx | 98 ++- .../admin/(_islands)/ImportMaquette.tsx | 531 ++++++++++++++++ routes/(apps)/admin/(_props)/props.ts | 5 +- routes/(apps)/admin/api/enseignements.ts | 24 +- .../[idProf]/[idModule]/[idPromo].ts | 19 +- routes/(apps)/admin/api/modules.ts | 8 +- routes/(apps)/admin/api/modules/[idModule].ts | 43 +- routes/(apps)/admin/api/roles/[idRole].ts | 37 +- .../(apps)/{notes => admin}/api/ue-modules.ts | 0 .../ue-modules/[idModule]/[idUE]/[idPromo].ts | 38 +- routes/(apps)/{notes => admin}/api/ues.ts | 0 .../(apps)/{notes => admin}/api/ues/[idUE].ts | 17 +- routes/(apps)/admin/api/users/[id].ts | 34 +- .../(apps)/admin/partials/import-maquette.tsx | 23 + .../(admin) => admin/partials}/promotions.tsx | 2 +- .../(admin) => admin/partials}/ues.tsx | 2 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 587 ++++++++++++++++-- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 348 ++++++++--- routes/(apps)/notes/(_islands)/NotesView.tsx | 100 +-- routes/(apps)/notes/(_props)/props.ts | 5 +- routes/(apps)/notes/api/ajustements.ts | 19 +- .../notes/api/ajustements/[numEtud]/[idUE].ts | 42 +- routes/(apps)/notes/api/notes.ts | 29 +- .../notes/api/notes/[numEtud]/[idModule].ts | 34 +- routes/(apps)/notes/api/notes/import-xlsx.ts | 24 +- routes/(apps)/notes/partials/notes.tsx | 45 +- .../students/(_islands)/ConsultStudents.tsx | 146 ++++- .../students/(_islands)/UploadStudents.tsx | 72 ++- routes/(apps)/students/(_props)/props.ts | 3 +- .../students/api/promotions/[idPromo].ts | 126 +++- routes/(apps)/students/api/students.ts | 16 +- .../(apps)/students/api/students/[numEtud].ts | 66 +- routes/dev-login.ts | 74 ++- routes/login.tsx | 2 + scripts/generate-templates.ts | 60 ++ scripts/inspect-maquette.ts | 25 + static/styles/ui.css | 193 ++++++ static/templates/modele_etudiants.xlsx | Bin 0 -> 16207 bytes static/templates/modele_maquette.xlsx | Bin 0 -> 17926 bytes static/templates/modele_notes.xlsx | Bin 0 -> 16337 bytes tests/e2e/robustness_test.ts | 4 +- tests/e2e/ue_modules_test.ts | 4 +- tests/e2e/ues_test.ts | 4 +- 50 files changed, 2664 insertions(+), 437 deletions(-) create mode 100644 databases/migrations/0003_add_session2_and_malus.sql create mode 100644 defaults/ImportResultPopup.tsx rename routes/(apps)/{students => admin}/(_islands)/AdminPromotions.tsx (92%) rename routes/(apps)/{notes => admin}/(_islands)/AdminUEs.tsx (82%) create mode 100644 routes/(apps)/admin/(_islands)/ImportMaquette.tsx rename routes/(apps)/{notes => admin}/api/ue-modules.ts (100%) rename routes/(apps)/{notes => admin}/api/ue-modules/[idModule]/[idUE]/[idPromo].ts (81%) rename routes/(apps)/{notes => admin}/api/ues.ts (100%) rename routes/(apps)/{notes => admin}/api/ues/[idUE].ts (86%) create mode 100644 routes/(apps)/admin/partials/import-maquette.tsx rename routes/(apps)/{students/partials/(admin) => admin/partials}/promotions.tsx (86%) rename routes/(apps)/{notes/partials/(admin) => admin/partials}/ues.tsx (88%) create mode 100644 scripts/generate-templates.ts create mode 100644 scripts/inspect-maquette.ts create mode 100644 static/templates/modele_etudiants.xlsx create mode 100644 static/templates/modele_maquette.xlsx create mode 100644 static/templates/modele_notes.xlsx diff --git a/databases/migrations/0003_add_session2_and_malus.sql b/databases/migrations/0003_add_session2_and_malus.sql new file mode 100644 index 0000000..d3a950b --- /dev/null +++ b/databases/migrations/0003_add_session2_and_malus.sql @@ -0,0 +1,3 @@ +ALTER TABLE "notes" ADD COLUMN "noteSession2" double precision; +--> statement-breakpoint +ALTER TABLE "ajustements" ADD COLUMN "malus" integer NOT NULL DEFAULT 0; diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json index e4f070f..f81c27d 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1777155028710, "tag": "0002_update_permission_names", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1777155028711, + "tag": "0003_add_session2_and_malus", + "breakpoints": true } ] } diff --git a/databases/schema.ts b/databases/schema.ts index 823c7a2..9bf678d 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -75,6 +75,7 @@ export const notes = pgTable("notes", { numEtud: integer("numEtud").notNull().references(() => students.numEtud), idModule: text("idModule").notNull().references(() => modules.id), note: doublePrecision("note").notNull(), + noteSession2: doublePrecision("noteSession2"), }, (t) => ({ pk: primaryKey({ columns: [t.numEtud, t.idModule] }), })); @@ -83,6 +84,7 @@ export const ajustements = pgTable("ajustements", { numEtud: integer("numEtud").notNull().references(() => students.numEtud), idUE: integer("idUE").notNull().references(() => ues.id), valeur: doublePrecision("valeur").notNull(), + malus: integer("malus").notNull().default(0), }, (t) => ({ pk: primaryKey({ columns: [t.numEtud, t.idUE] }), })); diff --git a/defaults/ImportResultPopup.tsx b/defaults/ImportResultPopup.tsx new file mode 100644 index 0000000..075db00 --- /dev/null +++ b/defaults/ImportResultPopup.tsx @@ -0,0 +1,102 @@ +import { useState } from "preact/hooks"; + +export type ImportResult = { + added: number; + modified: number; + ignored: number; + errors: number; + details: ImportDetail[]; +}; + +export type ImportDetail = { + type: "change" | "error"; + message: string; +}; + +type Props = { + result: ImportResult; + onClose: () => void; +}; + +export default function ImportResultPopup({ result, onClose }: Props) { + const [showDetails, setShowDetails] = useState(false); + const hasErrors = result.errors > 0; + const changes = result.details.filter((d) => d.type === "change"); + const errors = result.details.filter((d) => d.type === "error"); + + return ( +
+
e.stopPropagation()}> +
+

Resultats de l'import

+ + {hasErrors ? "Erreur" : "Succes"} + +
+ +
+
+ Ajoutes + + {result.added} note{result.added !== 1 ? "s" : ""} + +
+
+ Modifies + + {result.modified} note{result.modified !== 1 ? "s" : ""} + +
+
+ Ignores + + {result.ignored} note{result.ignored !== 1 ? "s" : ""} + +
+
+ Erreurs + + {result.errors} note{result.errors !== 1 ? "s" : ""} + +
+
+ +
+ {result.details.length > 0 && ( + + )} + +
+ + {showDetails && result.details.length > 0 && ( +
+ {changes.length > 0 && + changes.map((d, i) => ( +

{d.message}

+ ))} + {errors.length > 0 && + errors.map((d, i) => ( +

{d.message}

+ ))} +
+ )} +
+
+ ); +} diff --git a/fresh.gen.ts b/fresh.gen.ts index ffa7923..bd47e97 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -12,15 +12,22 @@ import * as $_apps_admin_api_modules_idModule_ from "./routes/(apps)/admin/api/m 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_ue_modules from "./routes/(apps)/admin/api/ue-modules.ts"; +import * as $_apps_admin_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; +import * as $_apps_admin_api_ues from "./routes/(apps)/admin/api/ues.ts"; +import * as $_apps_admin_api_ues_idUE_ from "./routes/(apps)/admin/api/ues/[idUE].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_modules_idModule_ from "./routes/(apps)/admin/modules/[idModule].tsx"; import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx"; +import * as $_apps_admin_partials_import_maquette from "./routes/(apps)/admin/partials/import-maquette.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_promotions from "./routes/(apps)/admin/partials/promotions.tsx"; import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx"; +import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; @@ -33,15 +40,10 @@ import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/not import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts"; import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts"; import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts"; -import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts"; -import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; -import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; -import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts"; import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx"; -import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx"; import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; @@ -53,7 +55,6 @@ import * as $_apps_students_api_students_import_csv from "./routes/(apps)/studen 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"; 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"; @@ -71,19 +72,20 @@ 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_AdminPromotions from "./routes/(apps)/admin/(_islands)/AdminPromotions.tsx"; import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx"; +import * as $_apps_admin_islands_AdminUEs from "./routes/(apps)/admin/(_islands)/AdminUEs.tsx"; import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx"; import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx"; import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx"; +import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.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_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"; 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"; @@ -105,6 +107,11 @@ const manifest = { "./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/ue-modules.ts": $_apps_admin_api_ue_modules, + "./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts": + $_apps_admin_api_ue_modules_idModule_idUE_idPromo_, + "./routes/(apps)/admin/api/ues.ts": $_apps_admin_api_ues, + "./routes/(apps)/admin/api/ues/[idUE].ts": $_apps_admin_api_ues_idUE_, "./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, @@ -112,11 +119,16 @@ const manifest = { $_apps_admin_modules_idModule_, "./routes/(apps)/admin/partials/enseignements.tsx": $_apps_admin_partials_enseignements, + "./routes/(apps)/admin/partials/import-maquette.tsx": + $_apps_admin_partials_import_maquette, "./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/promotions.tsx": + $_apps_admin_partials_promotions, "./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles, + "./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, "./routes/(apps)/mobility/api/insert_mobility.ts": @@ -136,11 +148,6 @@ const manifest = { $_apps_notes_api_notes_numEtud_idModule_, "./routes/(apps)/notes/api/notes/import-xlsx.ts": $_apps_notes_api_notes_import_xlsx, - "./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules, - "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts": - $_apps_notes_api_ue_modules_idModule_idUE_idPromo_, - "./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, @@ -148,8 +155,6 @@ const manifest = { $_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, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, @@ -167,8 +172,6 @@ const manifest = { "./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": @@ -193,14 +196,20 @@ const manifest = { $_apps_admin_islands_AdminModules, "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx": $_apps_admin_islands_AdminPermissions, + "./routes/(apps)/admin/(_islands)/AdminPromotions.tsx": + $_apps_admin_islands_AdminPromotions, "./routes/(apps)/admin/(_islands)/AdminRoles.tsx": $_apps_admin_islands_AdminRoles, + "./routes/(apps)/admin/(_islands)/AdminUEs.tsx": + $_apps_admin_islands_AdminUEs, "./routes/(apps)/admin/(_islands)/AdminUsers.tsx": $_apps_admin_islands_AdminUsers, "./routes/(apps)/admin/(_islands)/EditModule.tsx": $_apps_admin_islands_EditModule, "./routes/(apps)/admin/(_islands)/EditUser.tsx": $_apps_admin_islands_EditUser, + "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx": + $_apps_admin_islands_ImportMaquette, "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx": $_apps_mobility_islands_ConsultMobility, "./routes/(apps)/mobility/(_islands)/EditMobility.tsx": @@ -209,16 +218,12 @@ const manifest = { $_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)/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": - $_apps_students_islands_AdminPromotions, "./routes/(apps)/students/(_islands)/ConsultStudents.tsx": $_apps_students_islands_ConsultStudents, "./routes/(apps)/students/(_islands)/EditStudents.tsx": diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index e60886b..ece0de4 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -21,16 +21,20 @@ export const handler: MiddlewareHandler[] = [ `./${currentApp}/(_props)/props.ts` )).default; - context.state.availablePages = properties.pages; + context.state.availablePages = { ...properties.pages }; const isStudent = - context.state.session.eduPersonPrimaryAffiliation == "student" && - Deno.env.get("LOCAL") != "true"; + context.state.session.eduPersonPrimaryAffiliation === "student"; + const isLocal = Deno.env.get("LOCAL") === "true"; if (isStudent) { + // Students only see studentOnly pages (+ non-restricted pages) properties.adminOnly.forEach((page) => delete context.state.availablePages[page] ); + } else if (isLocal) { + // In local mode, employees see all pages (admin + student) } else { + // In prod, employees don't see studentOnly pages properties.studentOnly?.forEach((page) => delete context.state.availablePages[page] ); diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/admin/(_islands)/AdminPromotions.tsx similarity index 92% rename from routes/(apps)/students/(_islands)/AdminPromotions.tsx rename to routes/(apps)/admin/(_islands)/AdminPromotions.tsx index 7f32f91..68c71c6 100644 --- a/routes/(apps)/students/(_islands)/AdminPromotions.tsx +++ b/routes/(apps)/admin/(_islands)/AdminPromotions.tsx @@ -74,13 +74,26 @@ export default function AdminPromotions() { } async function deletePromo(id: string) { - if (!confirm(`Supprimer la promotion ${id} ?`)) return; + if (studentCount(id) > 0) { + setError( + `Impossible de supprimer ${id} : des étudiants y sont encore assignés. Réassignez-les d'abord.`, + ); + return; + } + if ( + !confirm(`Supprimer la promotion ${id} et toutes ses données liées ?`) + ) { + return; + } try { const res = await fetch( `/students/api/promotions/${encodeURIComponent(id)}`, { method: "DELETE" }, ); - if (!res.ok) throw new Error("Suppression échouée"); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Suppression échouée"); + } await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); @@ -218,6 +231,10 @@ export default function AdminPromotions() {
))} - {ues.length === 0 && ( + {filteredUes.length === 0 && (

- Aucune UE + {filterPromo ? "Aucune UE pour cette promo" : "Aucune UE"}

)}
diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx new file mode 100644 index 0000000..676e283 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -0,0 +1,531 @@ +// @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 { useEffect, useRef } from "preact/hooks"; +import { useSignal } from "@preact/signals"; +import ImportResultPopup, { + type ImportDetail, + type ImportResult, +} from "$root/defaults/ImportResultPopup.tsx"; + +type ParsedUE = { + code: string | null; + name: string; + ects: number | null; + modules: ParsedModule[]; +}; + +type ParsedModule = { + code: string; + name: string; + coeff: number; +}; + +type ParsedYear = { + label: string; + ues: ParsedUE[]; +}; + +type Promo = { id: string; annee: string | null }; + +function parseMaquette(workbook: XLSX.WorkBook): ParsedYear[] { + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); + + const years: ParsedYear[] = []; + let currentYear: ParsedYear | null = null; + let currentUE: ParsedUE | null = null; + let moduleIndex = 0; + + for (const row of rows) { + if (!row || row.length === 0) continue; + + const col0 = row[0] != null ? String(row[0]).trim() : ""; + + // Detect year row: INFO3A, INFO4A, INFO5A, INFOAPP3A, INFOAPP4A, etc. + if (/^(INFO|INFOAPP)\s*\d+A$/i.test(col0)) { + currentYear = { label: col0, ues: [] }; + years.push(currentYear); + currentUE = null; + continue; + } + + // Detect UE row: col[0] === "UE" or starts with "UE" (e.g., "UE51") + if (col0 === "UE" || (col0.startsWith("UE") && /^UE\d/.test(col0))) { + const ueCode = row[1] != null ? String(row[1]).trim() : null; + const ueName = row[2] != null ? String(row[2]).trim() : "UE sans nom"; + const ects = typeof row[4] === "number" ? row[4] : null; + + currentUE = { code: ueCode, name: ueName, ects, modules: [] }; + if (currentYear) { + currentYear.ues.push(currentUE); + } else { + // No year detected yet — create a default one + currentYear = { label: "Maquette", ues: [currentUE] }; + years.push(currentYear); + } + moduleIndex = 0; + continue; + } + + // Detect semester header rows — just skip, don't reset UE + if (/^SEM\s*\d/i.test(col0)) { + currentUE = null; + continue; + } + + // Detect module row: inside a UE, col[3] has a name, col[5] is a number (coeff) + if (currentUE && row[3] != null && typeof row[5] === "number") { + const modName = String(row[3]).trim(); + if (!modName) continue; + + let modCode = row[1] != null ? String(row[1]).trim() : ""; + if (!modCode) { + const uePrefix = (currentUE.code || "MOD").replace(/[^A-Z0-9]/gi, ""); + modCode = `${uePrefix}_${String(moduleIndex).padStart(2, "0")}`; + } + + currentUE.modules.push({ code: modCode, name: modName, coeff: row[5] }); + moduleIndex++; + } + } + + return years; +} + +export default function ImportMaquette() { + const file = useSignal(null); + const dragging = useSignal(false); + const uploading = useSignal(false); + const error = useSignal(null); + const importResult = useSignal(null); + const preview = useSignal(null); + const promos = useSignal([]); + // Map: year label -> selected promo id + const yearPromos = useSignal>({}); + // Inline promo creation + const newPromoId = useSignal(""); + const newPromoAnnee = useSignal(""); + const creatingPromo = useSignal(false); + const inputRef = useRef(null); + + useEffect(() => { + fetch("/students/api/promotions") + .then((r) => (r.ok ? r.json() : [])) + .then((data) => (promos.value = data)); + }, []); + + function pickFile(f: File) { + if (!f.name.match(/\.xlsx?$/i)) { + error.value = "Fichier invalide — format attendu : .xlsx"; + return; + } + file.value = f; + error.value = null; + importResult.value = null; + preview.value = null; + yearPromos.value = {}; + + f.arrayBuffer().then((buf) => { + try { + const wb = XLSX.read(buf, { type: "array" }); + preview.value = parseMaquette(wb); + } catch { + error.value = "Impossible de lire le fichier."; + } + }); + } + + async function createPromo() { + if (!newPromoId.value.trim() || !newPromoAnnee.value.trim()) return; + creatingPromo.value = true; + try { + const res = await fetch("/students/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idPromo: newPromoId.value.trim(), + annee: newPromoAnnee.value.trim(), + }), + }); + if (res.ok) { + const created = await res.json(); + promos.value = [...promos.value, { id: created.id, annee: created.annee }]; + newPromoId.value = ""; + newPromoAnnee.value = ""; + } else { + error.value = "Erreur lors de la creation de la promotion."; + } + } finally { + creatingPromo.value = false; + } + } + + function setYearPromo(yearLabel: string, promoId: string) { + yearPromos.value = { ...yearPromos.value, [yearLabel]: promoId }; + } + + // Check that at least one year has a promo assigned + function canImport(): boolean { + if (!preview.value || uploading.value) return false; + return preview.value.some((y) => yearPromos.value[y.label]); + } + + async function doImport() { + if (!preview.value) return; + uploading.value = true; + error.value = null; + importResult.value = null; + + let added = 0; + let ignored = 0; + let errCount = 0; + const details: ImportDetail[] = []; + + try { + for (const year of preview.value) { + const promoId = yearPromos.value[year.label]; + if (!promoId) { + ignored += year.ues.reduce((s, ue) => s + ue.modules.length + 1, 0); + details.push({ + type: "error", + message: `${year.label} : ignoree (pas de promo selectionnee)`, + }); + continue; + } + + for (const ue of year.ues) { + const ueRes = await fetch("/admin/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: ue.name }), + }); + if (!ueRes.ok) { + errCount++; + details.push({ + type: "error", + message: `UE "${ue.name}" : creation echouee`, + }); + continue; + } + const createdUE = await ueRes.json(); + added++; + details.push({ + type: "change", + message: `UE "${ue.name}" creee (id: ${createdUE.id})`, + }); + + for (const mod of ue.modules) { + const modRes = await fetch("/admin/api/modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: mod.code, nom: mod.name }), + }); + if (modRes.ok) { + added++; + details.push({ + type: "change", + message: `Module ${mod.code} "${mod.name}" cree`, + }); + } else if (modRes.status !== 409) { + errCount++; + details.push({ + type: "error", + message: `Module "${mod.code}" : creation echouee`, + }); + continue; + } + + const linkRes = await fetch("/admin/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idModule: mod.code, + idUE: createdUE.id, + idPromo: promoId, + coeff: mod.coeff, + }), + }); + if (linkRes.ok) { + added++; + } else { + errCount++; + details.push({ + type: "error", + message: `Lien ${mod.code} -> UE ${ue.name} : echoue`, + }); + } + } + } + } + + importResult.value = { + added, + modified: 0, + ignored, + errors: errCount, + details, + }; + } catch { + error.value = "Erreur lors de l'import."; + } finally { + uploading.value = false; + } + } + + function downloadTemplate() { + globalThis.open("/templates/modele_maquette.xlsx", "_blank"); + } + + function downloadExport() { + Promise.all([ + fetch("/admin/api/ues").then((r) => r.json()), + fetch("/admin/api/ue-modules").then((r) => r.json()), + fetch("/admin/api/modules").then((r) => r.json()), + ]).then(([uesData, ueModulesData, modulesData]) => { + const modMap = Object.fromEntries( + modulesData.map((m: { id: string; nom: string }) => [m.id, m]), + ); + + const data: (string | number | null)[][] = [ + ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\nECTS", "Coeff."], + ]; + + for (const ue of uesData) { + const mods = ueModulesData.filter( + (um: { idUE: number }) => um.idUE === ue.id, + ); + const totalCoeff = mods.reduce( + (s: number, um: { coeff: number }) => s + um.coeff, + 0, + ); + data.push(["UE", null, ue.nom, null, totalCoeff]); + for (const um of mods) { + const mod = modMap[um.idModule]; + data.push([null, um.idModule, null, mod ? mod.nom : um.idModule, null, um.coeff]); + } + data.push([]); + } + + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet(data); + XLSX.utils.book_append_sheet(wb, ws, "Maquette"); + const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); + const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "export_maquette.xlsx"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); + }); + } + + return ( +
+ { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) pickFile(f); + }} + /> + +
{ + e.preventDefault(); + dragging.value = true; + }} + onDragLeave={() => (dragging.value = false)} + onDrop={(e) => { + e.preventDefault(); + dragging.value = false; + const f = e.dataTransfer?.files?.[0]; + if (f) pickFile(f); + }} + onClick={() => inputRef.current?.click()} + > + + {file.value ? {file.value.name} : ( + <> + + Glisser le fichier maquette .xlsx ici + + ou cliquer pour parcourir + + )} +
+ + {error.value &&

{error.value}

} + + {importResult.value && ( + (importResult.value = null)} + /> + )} + + {/* Create promo inline */} +
+ +
+ + (newPromoId.value = (e.target as HTMLInputElement).value)} + style="min-width: 10rem" + /> + + (newPromoAnnee.value = (e.target as HTMLInputElement).value)} + style="min-width: 8rem" + /> + +
+
+ + {/* Preview grouped by year */} + {preview.value && preview.value.length > 0 && ( +
+ {preview.value.map((year) => { + const totalMods = year.ues.reduce( + (s, ue) => s + ue.modules.length, + 0, + ); + return ( +
+
+

+ {year.label} + + {" "}— {year.ues.length} UE, {totalMods} modules + +

+ +
+ +
+ + + + + + + + + + + {year.ues.map((ue, i) => + ue.modules.length === 0 + ? ( + + + + + ) + : ue.modules.map((mod, j) => ( + + {j === 0 && ( + + )} + + + + + )) + )} + +
UEModuleCodeCoeff
{ue.name} + Aucun module +
+ {ue.name} + {ue.ects != null && ( + + {" "}({ue.ects} ECTS) + + )} + {mod.name}{mod.code}{mod.coeff}
+
+
+ ); + })} +
+ )} + +
+ + + +
+ +

+ Format : fichier maquette FISE / FISA avec lignes UE + {" "}et modules (colonnes code, nom, coefficient) +

+
+ ); +} diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index 5563bed..a681b46 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -10,8 +10,11 @@ const properties: AppProperties = { permissions: "Permissions", modules: "Modules", enseignements: "Enseignements", + promotions: "Promotions", + ues: "UEs", + "import-maquette": "Import Maquette", }, - adminOnly: ["users", "roles", "permissions", "modules", "enseignements"], + adminOnly: ["users", "roles", "permissions", "modules", "enseignements", "promotions", "ues", "import-maquette"], hint: "PolyMPR module", }; diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index fd5fee8..bae6a2c 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -4,17 +4,19 @@ import { enseignements } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { and, eq } from "npm:drizzle-orm@0.45.2"; -const _NOT_FOUND = new Response( - JSON.stringify({ error: "Ressource introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const _NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); -const CONFLICT = new Response( - JSON.stringify({ error: "Cet enseignement existe déjà." }), - { status: 409, headers: { "content-type": "application/json" } }, -); +const CONFLICT = () => + new Response( + JSON.stringify({ error: "Cet enseignement existe déjà." }), + { status: 409, headers: { "content-type": "application/json" } }, + ); export const handler: Handlers = { // GET /enseignements @@ -39,7 +41,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } let body: { idProf: string; idModule: string; idPromo: string }; @@ -67,7 +69,7 @@ export const handler: Handlers = { .then((rows) => rows[0] ?? null); if (existing) { - return CONFLICT; + return CONFLICT(); } const [created] = await db diff --git a/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts b/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts index 30dbd8a..27cc6e2 100644 --- a/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts +++ b/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts @@ -4,12 +4,13 @@ import { enseignements } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { and, eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( - JSON.stringify({ error: "Ressource introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); export const handler: Handlers = { // #30 GET /enseignements/{idProf}/{idModule}/{idPromo} @@ -18,7 +19,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idProf = context.params.idProf; @@ -37,7 +38,7 @@ export const handler: Handlers = { ) .then((rows) => rows[0] ?? null); - if (!enseignement) return NOT_FOUND; + if (!enseignement) return NOT_FOUND(); return new Response(JSON.stringify(enseignement), { headers: { "content-type": "application/json" }, @@ -50,7 +51,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idProf = context.params.idProf; @@ -68,7 +69,7 @@ export const handler: Handlers = { ) .returning(); - if (!deleted) return NOT_FOUND; + if (!deleted) return NOT_FOUND(); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts index bdb37b9..63ebfe1 100644 --- a/routes/(apps)/admin/api/modules.ts +++ b/routes/(apps)/admin/api/modules.ts @@ -8,14 +8,8 @@ export const handler: Handlers = { // #23 GET /modules async GET( _request: Request, - context: FreshContext, + _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(modules); return new Response(JSON.stringify(rows), { headers: { "content-type": "application/json" }, diff --git a/routes/(apps)/admin/api/modules/[idModule].ts b/routes/(apps)/admin/api/modules/[idModule].ts index d3d9467..8c3f91f 100644 --- a/routes/(apps)/admin/api/modules/[idModule].ts +++ b/routes/(apps)/admin/api/modules/[idModule].ts @@ -1,13 +1,19 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { modules } from "$root/databases/schema.ts"; +import { + enseignements, + modules, + notes, + ueModules, +} from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( - JSON.stringify({ error: "Ressource introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); export const handler: Handlers = { // #25 GET /modules/{idModule} @@ -21,7 +27,7 @@ export const handler: Handlers = { .where(eq(modules.id, context.params.idModule)) .then((rows) => rows[0] ?? null); - if (!module) return NOT_FOUND; + if (!module) return NOT_FOUND(); return new Response(JSON.stringify(module), { headers: { "content-type": "application/json" }, @@ -50,7 +56,7 @@ export const handler: Handlers = { .where(eq(modules.id, context.params.idModule)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -58,16 +64,29 @@ export const handler: Handlers = { }, // #27 DELETE /modules/{idModule} + // Cascade: deletes notes, ue_modules, enseignements for this module. async DELETE( _request: Request, context: FreshContext, ): Promise { - const [deleted] = await db - .delete(modules) - .where(eq(modules.id, context.params.idModule)) - .returning(); + const idModule = context.params.idModule; - if (!deleted) return NOT_FOUND; + const mod = await db + .select() + .from(modules) + .where(eq(modules.id, idModule)) + .then((r) => r[0] ?? null); + + if (!mod) return NOT_FOUND(); + + await db.transaction(async (tx) => { + await tx.delete(notes).where(eq(notes.idModule, idModule)); + await tx.delete(ueModules).where(eq(ueModules.idModule, idModule)); + await tx.delete(enseignements).where( + eq(enseignements.idModule, idModule), + ); + await tx.delete(modules).where(eq(modules.id, idModule)); + }); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/admin/api/roles/[idRole].ts b/routes/(apps)/admin/api/roles/[idRole].ts index d29d047..7b15c8c 100644 --- a/routes/(apps)/admin/api/roles/[idRole].ts +++ b/routes/(apps)/admin/api/roles/[idRole].ts @@ -1,13 +1,14 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { rolePermissions, roles } from "$root/databases/schema.ts"; +import { rolePermissions, roles, users } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( - JSON.stringify({ error: "Ressource introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); async function getRoleWithPermissions( id: number, @@ -41,7 +42,7 @@ export const handler: Handlers = { const id = Number(context.params.idRole); const role = await getRoleWithPermissions(id); - if (!role) return NOT_FOUND; + if (!role) return NOT_FOUND(); return new Response(JSON.stringify(role), { headers: { "content-type": "application/json" }, @@ -62,7 +63,7 @@ export const handler: Handlers = { .where(eq(roles.id, id)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); // Reset permissions await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id)); @@ -80,21 +81,29 @@ export const handler: Handlers = { }, // #69 DELETE /roles/{idRole} + // Cascade: deletes role_permissions, detaches users (idRole set to null). async DELETE( _request: Request, context: FreshContext, ): Promise { const id = Number(context.params.idRole); - // Cascade delete role_permissions first - await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id)); - - const [deleted] = await db - .delete(roles) + const role = await db + .select() + .from(roles) .where(eq(roles.id, id)) - .returning(); + .then((r) => r[0] ?? null); - if (!deleted) return NOT_FOUND; + if (!role) return NOT_FOUND(); + + await db.transaction(async (tx) => { + await tx.delete(rolePermissions).where(eq(rolePermissions.idRole, id)); + await tx + .update(users) + .set({ idRole: null }) + .where(eq(users.idRole, id)); + await tx.delete(roles).where(eq(roles.id, id)); + }); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/admin/api/ue-modules.ts similarity index 100% rename from routes/(apps)/notes/api/ue-modules.ts rename to routes/(apps)/admin/api/ue-modules.ts diff --git a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts similarity index 81% rename from routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts rename to routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts index f447f12..7470e7f 100644 --- a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts +++ b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -4,17 +4,19 @@ import { ueModules } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { and, eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( - JSON.stringify({ error: "Association UE-Module introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Association UE-Module introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); -const BAD_REQUEST = new Response( - JSON.stringify({ error: "Paramètres invalides" }), - { status: 400, headers: { "content-type": "application/json" } }, -); +const BAD_REQUEST = () => + new Response( + JSON.stringify({ error: "Paramètres invalides" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); export const handler: Handlers = { // #39 GET /ue-modules/{idModule}/{idUE}/{idPromo} @@ -23,7 +25,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idModule = context.params.idModule; @@ -31,7 +33,7 @@ export const handler: Handlers = { const idPromo = context.params.idPromo; if (isNaN(idUE)) { - return BAD_REQUEST; + return BAD_REQUEST(); } const ueModuleAssociation = await db @@ -44,7 +46,7 @@ export const handler: Handlers = { ) .then((rows) => rows[0] ?? null); - if (!ueModuleAssociation) return NOT_FOUND; + if (!ueModuleAssociation) return NOT_FOUND(); return new Response(JSON.stringify(ueModuleAssociation), { headers: { "content-type": "application/json" }, @@ -57,7 +59,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idModule = context.params.idModule; @@ -65,7 +67,7 @@ export const handler: Handlers = { const idPromo = context.params.idPromo; if (isNaN(idUE)) { - return BAD_REQUEST; + return BAD_REQUEST(); } const body: { coeff: number } = await request.json(); @@ -89,7 +91,7 @@ export const handler: Handlers = { ) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response( JSON.stringify({ @@ -110,7 +112,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const idModule = context.params.idModule; @@ -118,7 +120,7 @@ export const handler: Handlers = { const idPromo = context.params.idPromo; if (isNaN(idUE)) { - return BAD_REQUEST; + return BAD_REQUEST(); } const [deleted] = await db @@ -132,7 +134,7 @@ export const handler: Handlers = { ) .returning(); - if (!deleted) return NOT_FOUND; + if (!deleted) return NOT_FOUND(); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/admin/api/ues.ts similarity index 100% rename from routes/(apps)/notes/api/ues.ts rename to routes/(apps)/admin/api/ues.ts diff --git a/routes/(apps)/notes/api/ues/[idUE].ts b/routes/(apps)/admin/api/ues/[idUE].ts similarity index 86% rename from routes/(apps)/notes/api/ues/[idUE].ts rename to routes/(apps)/admin/api/ues/[idUE].ts index c8f586f..92f6e1a 100644 --- a/routes/(apps)/notes/api/ues/[idUE].ts +++ b/routes/(apps)/admin/api/ues/[idUE].ts @@ -1,6 +1,10 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "../../../../../databases/db.ts"; -import { ues } from "../../../../../databases/schema.ts"; +import { + ajustements, + ueModules, + ues, +} from "../../../../../databases/schema.ts"; import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { @@ -87,6 +91,7 @@ export const handler: Handlers = { }, // #36 DELETE /ues/:idUE + // Cascade: deletes ajustements, ue_modules for this UE. async DELETE(_request, context) { try { const idUE = parseInt(context.params.idUE); @@ -101,9 +106,9 @@ export const handler: Handlers = { ); } - const result = await db.delete(ues).where(eq(ues.id, idUE)).returning(); + const existing = await db.select().from(ues).where(eq(ues.id, idUE)); - if (result.length === 0) { + if (existing.length === 0) { return new Response( JSON.stringify({ error: "Ressource introuvable" }), { @@ -113,6 +118,12 @@ export const handler: Handlers = { ); } + await db.transaction(async (tx) => { + await tx.delete(ajustements).where(eq(ajustements.idUE, idUE)); + await tx.delete(ueModules).where(eq(ueModules.idUE, idUE)); + await tx.delete(ues).where(eq(ues.id, idUE)); + }); + return new Response(null, { status: 204 }); } catch (error) { console.error("Error deleting UE:", error); diff --git a/routes/(apps)/admin/api/users/[id].ts b/routes/(apps)/admin/api/users/[id].ts index 236156c..ae064d0 100644 --- a/routes/(apps)/admin/api/users/[id].ts +++ b/routes/(apps)/admin/api/users/[id].ts @@ -1,13 +1,14 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { users } from "$root/databases/schema.ts"; +import { enseignements, users } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( - JSON.stringify({ error: "Ressource introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); export const handler: Handlers = { // #62 GET /users/{id} @@ -21,7 +22,7 @@ export const handler: Handlers = { .where(eq(users.id, context.params.id)) .then((rows) => rows[0] ?? null); - if (!user) return NOT_FOUND; + if (!user) return NOT_FOUND(); return new Response(JSON.stringify(user), { headers: { "content-type": "application/json" }, @@ -42,7 +43,7 @@ export const handler: Handlers = { .where(eq(users.id, context.params.id)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -50,16 +51,25 @@ export const handler: Handlers = { }, // #64 DELETE /users/{id} + // Cascade: deletes enseignements for this user. async DELETE( _request: Request, context: FreshContext, ): Promise { - const [deleted] = await db - .delete(users) - .where(eq(users.id, context.params.id)) - .returning(); + const id = context.params.id; - if (!deleted) return NOT_FOUND; + const user = await db + .select() + .from(users) + .where(eq(users.id, id)) + .then((r) => r[0] ?? null); + + if (!user) return NOT_FOUND(); + + await db.transaction(async (tx) => { + await tx.delete(enseignements).where(eq(enseignements.idProf, id)); + await tx.delete(users).where(eq(users.id, id)); + }); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/admin/partials/import-maquette.tsx b/routes/(apps)/admin/partials/import-maquette.tsx new file mode 100644 index 0000000..74f1985 --- /dev/null +++ b/routes/(apps)/admin/partials/import-maquette.tsx @@ -0,0 +1,23 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import ImportMaquette from "../(_islands)/ImportMaquette.tsx"; + +// deno-lint-ignore require-await +async function ImportMaquettePage( + _request: Request, + _context: FreshContext, +) { + return ( +
+

Importer une Maquette (UE & Modules)

+ +
+ ); +} + +export const config = getPartialsConfig(); +export default makePartials(ImportMaquettePage); diff --git a/routes/(apps)/students/partials/(admin)/promotions.tsx b/routes/(apps)/admin/partials/promotions.tsx similarity index 86% rename from routes/(apps)/students/partials/(admin)/promotions.tsx rename to routes/(apps)/admin/partials/promotions.tsx index 003f993..bf6b622 100644 --- a/routes/(apps)/students/partials/(admin)/promotions.tsx +++ b/routes/(apps)/admin/partials/promotions.tsx @@ -4,7 +4,7 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; -import AdminPromotions from "../../(_islands)/AdminPromotions.tsx"; +import AdminPromotions from "../(_islands)/AdminPromotions.tsx"; // deno-lint-ignore require-await async function Promotions( diff --git a/routes/(apps)/notes/partials/(admin)/ues.tsx b/routes/(apps)/admin/partials/ues.tsx similarity index 88% rename from routes/(apps)/notes/partials/(admin)/ues.tsx rename to routes/(apps)/admin/partials/ues.tsx index 2d6b0e9..4f69270 100644 --- a/routes/(apps)/notes/partials/(admin)/ues.tsx +++ b/routes/(apps)/admin/partials/ues.tsx @@ -4,7 +4,7 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; -import AdminUEs from "../../(_islands)/AdminUEs.tsx"; +import AdminUEs from "../(_islands)/AdminUEs.tsx"; // deno-lint-ignore require-await async function UEs( diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index 4114c11..2490029 100644 --- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -1,15 +1,61 @@ // @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 { useEffect, useRef } from "preact/hooks"; import { useSignal } from "@preact/signals"; +import ImportResultPopup, { + type ImportDetail, + type ImportResult, +} from "$root/defaults/ImportResultPopup.tsx"; + +type Student = { numEtud: number; nom: string; prenom: string }; +type ColumnInfo = { + index: number; + code: string; + name: string; + coeff: number | null; + type: "module" | "malus" | "ue" | "semester" | "unknown"; +}; + +function parseHeader(header: string): { code: string; name: string } { + const parts = header.split(" - "); + if (parts.length >= 2) { + return { code: parts[0].trim(), name: parts.slice(1).join(" - ").trim() }; + } + return { code: header.trim(), name: header.trim() }; +} + +function detectColumnType( + header: string, + _coeff: number | null, +): ColumnInfo["type"] { + const h = header.trim(); + if (/^MALUS/i.test(h)) return "malus"; + if (/^S\d+$/i.test(h)) return "semester"; + // UE codes typically contain "U" followed by digits (e.g., JIN5U05, JTR5U01) + const { code } = parseHeader(h); + if (/U\d/i.test(code) && !/^MALUS/i.test(code)) return "ue"; + return "module"; +} 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 importResult = useSignal(null); const inputRef = useRef(null); + const students = useSignal([]); + const columns = useSignal([]); + const sheetNames = useSignal([]); + const selectedSheet = useSignal(""); + const session = useSignal<"1" | "2">("1"); + const workbookRef = useRef(null); + + useEffect(() => { + fetch("/students/api/students") + .then((r) => (r.ok ? r.json() : [])) + .then((data) => (students.value = data)); + }, []); function pickFile(f: File) { if (!f.name.match(/\.xlsx?$/i)) { @@ -18,76 +64,404 @@ export default function ImportNotes() { } file.value = f; error.value = null; - success.value = null; + importResult.value = null; + columns.value = []; + + f.arrayBuffer().then((buf) => { + try { + const wb = XLSX.read(buf, { type: "array" }); + workbookRef.current = wb; + sheetNames.value = wb.SheetNames; + if (wb.SheetNames.length > 0) { + selectedSheet.value = wb.SheetNames[0]; + parseSheet(wb, wb.SheetNames[0]); + } + } catch { + error.value = "Impossible de lire le fichier."; + } + }); } - function onDragOver(e: DragEvent) { - e.preventDefault(); - dragging.value = true; + function parseSheet(wb: XLSX.WorkBook, sheetName: string) { + const sheet = wb.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); + if (rows.length < 2) { + columns.value = []; + return; + } + + const headerRow = rows[0]; + const coeffRow = rows[1]; + + const cols: ColumnInfo[] = []; + // First 2 columns are nom/prenom, skip them + for (let i = 2; i < headerRow.length; i++) { + const h = headerRow[i]; + if (h == null || String(h).trim() === "") continue; + const header = String(h).trim(); + const coeff = typeof coeffRow[i] === "number" ? coeffRow[i] : null; + const { code, name } = parseHeader(header); + const type = detectColumnType(header, coeff as number | null); + cols.push({ index: i, code, name, coeff: coeff as number | null, type }); + } + columns.value = cols; } - function onDragLeave() { - dragging.value = false; + function onSheetChange(name: string) { + selectedSheet.value = name; + if (workbookRef.current) { + parseSheet(workbookRef.current, name); + } } - 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); + function findStudent( + nom: string, + prenom: string, + ): Student | undefined { + const normNom = nom.toUpperCase().trim(); + const normPrenom = prenom.toUpperCase().trim(); + return students.value.find( + (s) => + s.nom.toUpperCase().trim() === normNom && + s.prenom.toUpperCase().trim() === normPrenom, + ); } async function doImport() { - if (!file.value) return; + if (!workbookRef.current || !selectedSheet.value) return; uploading.value = true; error.value = null; - success.value = null; + importResult.value = null; try { - const arrayBuffer = await file.value.arrayBuffer(); - const workbook = XLSX.read(arrayBuffer, { type: "array" }); - let imported = 0; - let failed = 0; + const sheet = workbookRef.current.Sheets[selectedSheet.value]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); - 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 }); + const moduleCols = columns.value.filter((c) => c.type === "module"); - for (const row of rows) { - const res = await fetch("/notes/api/notes", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(row), + let added = 0; + let modified = 0; + let ignored = 0; + let errors = 0; + const details: ImportDetail[] = []; + + // Process data rows (skip header + coeff rows) + for (let r = 2; r < rows.length; r++) { + const row = rows[r]; + if (!row || row.length < 3) continue; + + const nom = row[0] != null ? String(row[0]).trim() : ""; + const prenom = row[1] != null ? String(row[1]).trim() : ""; + if (!nom || !prenom) continue; + + const student = findStudent(nom, prenom); + if (!student) { + ignored++; + details.push({ + type: "error", + message: `${nom} ${prenom} : Etudiant non trouve`, }); - if (res.ok) imported++; - else failed++; + continue; + } + + // Import module notes + for (const col of moduleCols) { + const val = row[col.index]; + if (val == null || typeof val !== "number") { + if (val != null && typeof val !== "number") { + errors++; + details.push({ + type: "error", + message: + `${student.numEtud} : ${col.code} : Note "${val}" invalide`, + }); + } + continue; + } + if (val < 0 || val > 20) { + errors++; + details.push({ + type: "error", + message: + `${student.numEtud} : ${col.code} : Note ${val} hors limites`, + }); + continue; + } + + const noteField = session.value === "2" ? "noteSession2" : "note"; + + // Try PUT first (update), then POST (create) + const putRes = await fetch( + `/notes/api/notes/${student.numEtud}/${ + encodeURIComponent(col.code) + }`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ [noteField]: val }), + }, + ); + + if (putRes.ok) { + const prev = await putRes.json(); + const oldVal = session.value === "2" + ? prev.noteSession2 + : prev.note; + modified++; + details.push({ + type: "change", + message: `${student.numEtud} : ${col.code} : ${ + oldVal ?? "null" + } -> ${val}`, + }); + } else if (putRes.status === 404) { + // Note doesn't exist yet, create it + const body: Record = { + numEtud: student.numEtud, + idModule: col.code, + note: session.value === "1" ? val : 0, + }; + if (session.value === "2") body.noteSession2 = val; + + const postRes = await fetch("/notes/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (postRes.ok) { + added++; + details.push({ + type: "change", + message: `${student.numEtud} : ${col.code} : null -> ${val}`, + }); + } else { + errors++; + details.push({ + type: "error", + message: + `${student.numEtud} : ${col.code} : Matiere non trouvee`, + }); + } + } else { + errors++; + details.push({ + type: "error", + message: `${student.numEtud} : ${col.code} : Erreur serveur`, + }); + } } } - success.value = `Import terminé — ${imported} ajouté${ - imported !== 1 ? "s" : "" - }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`; + importResult.value = { added, modified, ignored, errors, details }; } catch { - error.value = "Erreur lors de la lecture du fichier."; + error.value = "Erreur lors de l'import."; } 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"); + globalThis.open("/templates/modele_notes.xlsx", "_blank"); + } + + function downloadExport() { + // Export notes from the API in the same format + Promise.all([ + fetch("/students/api/students").then((r) => r.json()), + fetch("/notes/api/notes").then((r) => r.json()), + fetch("/admin/api/modules").then((r) => r.json()), + fetch("/admin/api/ue-modules").then((r) => r.json()), + fetch("/admin/api/ues").then((r) => r.json()), + ]).then( + ([ + studentsData, + notesData, + modulesData, + ueModulesData, + uesData, + ]) => { + // Build module map + const modMap = new Map( + modulesData.map((m: { id: string; nom: string }) => [m.id, m.nom]), + ); + + // Get unique module IDs from notes + const moduleIds = [ + ...new Set( + notesData.map((n: { idModule: string }) => n.idModule), + ), + ] as string[]; + + // Group ue-modules by UE + const ueMap = new Map( + uesData.map((u: { id: number; nom: string }) => [u.id, u.nom]), + ); + const umByUE = new Map(); + for (const um of ueModulesData) { + if (!umByUE.has(um.idUE)) umByUE.set(um.idUE, []); + umByUE.get(um.idUE)!.push(um); + } + + // Build column order: group modules by UE, add UE avg columns + const orderedCols: { + id: string; + header: string; + coeff: number | null; + type: "module" | "ue"; + ueId?: number; + }[] = []; + + const usedModules = new Set(); + for (const [ueId, ums] of umByUE) { + for (const um of ums) { + if (!moduleIds.includes(um.idModule)) continue; + orderedCols.push({ + id: um.idModule, + header: `${um.idModule} - ${ + modMap.get(um.idModule) || um.idModule + }`, + coeff: um.coeff, + type: "module", + ueId, + }); + usedModules.add(um.idModule); + } + const ueName = ueMap.get(ueId) || `UE ${ueId}`; + orderedCols.push({ + id: `ue_${ueId}`, + header: ueName, + coeff: ums.reduce( + (s: number, um: { coeff: number }) => s + um.coeff, + 0, + ), + type: "ue", + ueId, + }); + } + // Add modules not linked to any UE + for (const mId of moduleIds) { + if (usedModules.has(mId)) continue; + orderedCols.push({ + id: mId, + header: `${mId} - ${modMap.get(mId) || mId}`, + coeff: null, + type: "module", + }); + } + + // Build note lookup: numEtud -> idModule -> note + const noteLookup = new Map< + number, + Map + >(); + for (const n of notesData) { + if (!noteLookup.has(n.numEtud)) noteLookup.set(n.numEtud, new Map()); + noteLookup.get(n.numEtud)!.set(n.idModule, { + note: n.note, + noteSession2: n.noteSession2, + }); + } + + // Get students who have notes + const studentsWithNotes = studentsData.filter( + (s: Student) => noteLookup.has(s.numEtud), + ); + + // Build header rows + const headerRow: (string | null)[] = [null, null]; + const coeffRow: (number | null)[] = [null, null]; + for (const col of orderedCols) { + headerRow.push(col.header); + coeffRow.push(col.coeff); + } + + // Build session 1 data rows + const s1Rows: (string | number | null)[][] = []; + for (const s of studentsWithNotes) { + const row: (string | number | null)[] = [s.nom, s.prenom]; + const sNotes = noteLookup.get(s.numEtud) || new Map(); + for (const col of orderedCols) { + if (col.type === "module") { + const n = sNotes.get(col.id); + row.push(n ? n.note : null); + } else { + // UE average - calculate + const ueMods = orderedCols.filter( + (c) => c.type === "module" && c.ueId === col.ueId, + ); + let total = 0, coeffSum = 0; + for (const um of ueMods) { + const n = sNotes.get(um.id); + if (n && um.coeff) { + total += n.note * um.coeff; + coeffSum += um.coeff; + } + } + row.push( + coeffSum > 0 + ? Math.round((total / coeffSum) * 100) / 100 + : null, + ); + } + } + s1Rows.push(row); + } + + // Build session 2 data rows + const s2Rows: (string | number | null)[][] = []; + for (const s of studentsWithNotes) { + const row: (string | number | null)[] = [s.nom, s.prenom]; + const sNotes = noteLookup.get(s.numEtud) || new Map(); + for (const col of orderedCols) { + if (col.type === "module") { + const n = sNotes.get(col.id); + // Use session 2 note if available, else session 1 + row.push(n ? (n.noteSession2 ?? n.note) : null); + } else { + const ueMods = orderedCols.filter( + (c) => c.type === "module" && c.ueId === col.ueId, + ); + let total = 0, coeffSum = 0; + for (const um of ueMods) { + const n = sNotes.get(um.id); + if (n && um.coeff) { + const noteVal = n.noteSession2 ?? n.note; + total += noteVal * um.coeff; + coeffSum += um.coeff; + } + } + row.push( + coeffSum > 0 + ? Math.round((total / coeffSum) * 100) / 100 + : null, + ); + } + } + s2Rows.push(row); + } + + const wb = XLSX.utils.book_new(); + const ws1 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s1Rows]); + XLSX.utils.book_append_sheet(wb, ws1, "Session 1"); + const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]); + XLSX.utils.book_append_sheet(wb, ws2, "Session 2"); + const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); + const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "export_notes.xlsx"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); + }, + ); } return ( @@ -97,14 +471,25 @@ export default function ImportNotes() { type="file" accept=".xlsx,.xls" style="display:none" - onChange={onInputChange} + onChange={(e) => { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) pickFile(f); + }} />
{ + e.preventDefault(); + dragging.value = true; + }} + onDragLeave={() => (dragging.value = false)} + onDrop={(e) => { + e.preventDefault(); + dragging.value = false; + const f = e.dataTransfer?.files?.[0]; + if (f) pickFile(f); + }} onClick={() => inputRef.current?.click()} > @@ -117,10 +502,85 @@ export default function ImportNotes() {
{error.value &&

{error.value}

} - {success.value && ( -

- {success.value} -

+ + {importResult.value && ( + (importResult.value = null)} + /> + )} + + {/* Sheet + session selector */} + {sheetNames.value.length > 0 && ( +
+
+ + +
+
+ + +
+
+ )} + + {/* Column preview */} + {columns.value.length > 0 && ( +
+

+ Colonnes detectees : +

+
+ {columns.value.map((col) => ( + + {col.type === "module" + ? "M" + : col.type === "ue" + ? "UE" + : col.type === "malus" + ? "X" + : "?"} {col.code} + + ))} +
+

+ M = module (importe) | UE = moyenne UE (ignore) | X = malus +

+
)}
@@ -128,22 +588,31 @@ export default function ImportNotes() { type="button" class="btn btn-primary" onClick={doImport} - disabled={!file.value || uploading.value} + disabled={!file.value || uploading.value || + columns.value.filter((c) => c.type === "module").length === 0} > - {uploading.value ? "…" : "⊕ Importer"} + {uploading.value ? "..." : "+ Importer"} +

- Format : numEtud | idModule |{" "} - note + Format : Nom | Prenom |{" "} + CODE - Module (colonnes notes){" "} + — les colonnes UE et MALUS sont auto-detectees

); diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index f72bc89..af24da8 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -14,8 +14,18 @@ type UEModule = { 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 Note = { + numEtud: number; + idModule: string; + note: number; + noteSession2: number | null; +}; +type Ajustement = { + numEtud: number; + idUE: number; + valeur: number; + malus: number; +}; type Props = { numEtud: number }; @@ -27,31 +37,38 @@ function noteClass(n: number): string { return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail"; } +/** Returns the effective note (session 2 if exists, otherwise session 1). */ +function effectiveNote(n: Note): number { + return n.noteSession2 ?? n.note; +} + 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 [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 + { idModule: string; field: "note" | "noteSession2"; value: string } | null >(null); - const [ajustInputs, setAjustInputs] = useState>({}); + const [ajustInputs, setAjustInputs] = useState< + Record + >({}); async function load() { try { const sRes = await fetch(`/students/api/students/${numEtud}`); - if (!sRes.ok) throw new Error("Élève introuvable"); + if (!sRes.ok) throw new Error("Eleve introuvable"); const s: Student = await sRes.json(); setStudent(s); const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([ - fetch("/notes/api/ues"), + fetch("/admin/api/ues"), fetch( - `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, + `/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, ), fetch("/admin/api/modules"), fetch(`/notes/api/notes?numEtud=${numEtud}`), @@ -66,13 +83,18 @@ export default function NoteRecap({ numEtud }: Props) { } if (notesRes.ok) { const ns: Note[] = await notesRes.json(); - setNoteMap(new Map(ns.map((n) => [n.idModule, n.note]))); + setNoteMap(new Map(ns.map((n) => [n.idModule, n]))); } 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); + const inputs: Record = {}; + for (const a of aj) { + inputs[a.idUE] = { + valeur: String(a.valeur), + malus: String(a.malus), + }; + } setAjustInputs(inputs); } } catch (e) { @@ -87,57 +109,108 @@ export default function NoteRecap({ numEtud }: Props) { }, [numEtud]); function calcAvg(ueMods: UEModule[]): number | null { - let total = 0, coeff = 0; + 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; + const val = effectiveNote(n); + total += val * um.coeff; coeff += um.coeff; } return coeff > 0 ? total / coeff : null; } - async function saveNote(idModule: string, value: string) { + async function saveNote( + idModule: string, + field: "note" | "noteSession2", + value: string, + ) { + if (value.trim() === "" && field === "noteSession2") { + // Clear session 2 note + const res = await fetch( + `/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ noteSession2: null }), + }, + ); + if (res.ok) { + const updated: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, updated)); + } + setEditingNote(null); + return; + } + 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", + + const existing = noteMap.get(idModule); + + if (existing) { + // Update + const res = await fetch( + `/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ [field]: note }), + }, + ); + if (res.ok) { + const updated: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, updated)); + } + } else { + // Create + const body: Record = { + numEtud, + idModule, + note: field === "note" ? note : 0, + }; + if (field === "noteSession2") body.noteSession2 = note; + const res = await fetch("/notes/api/notes", { + method: "POST", 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)); + body: JSON.stringify(body), + }); + if (res.ok) { + const created: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, created)); + } } setEditingNote(null); } async function applyAjust(idUE: number) { - const val = parseFloat((ajustInputs[idUE] ?? "").replace(",", ".")); + const inputs = ajustInputs[idUE]; + const val = parseFloat((inputs?.valeur ?? "").replace(",", ".")); + const malus = parseInt(inputs?.malus ?? "0"); if (isNaN(val) || val < 0 || val > 20) return; + if (isNaN(malus) || malus < 0) 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 }), + body: JSON.stringify({ valeur: val, malus }), }) : await fetch("/notes/api/ajustements", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ numEtud, idUE, valeur: val }), + body: JSON.stringify({ numEtud, idUE, valeur: val, malus }), }); if (res.ok) { const updated: Ajustement = await res.json(); setAjustements((prev) => existing - ? prev.map((a) => a.idUE === idUE ? updated : a) + ? prev.map((a) => (a.idUE === idUE ? updated : a)) : [...prev, updated] ); } @@ -160,7 +233,7 @@ export default function NoteRecap({ numEtud }: Props) { if (loading) { return (
-

Chargement…

+

Chargement...

); } @@ -180,19 +253,21 @@ export default function NoteRecap({ numEtud }: Props) { href="/notes/courses" f-partial="/notes/partials/courses" > - ← Retour à la liste + ← Retour a la liste

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

{student.numEtud} - {student.prenom} {student.nom} + + {student.prenom} {student.nom} + {student.idPromo}
@@ -201,7 +276,7 @@ export default function NoteRecap({ numEtud }: Props) { {ueList.length === 0 ? (

- Aucune UE configurée pour cette promotion. + Aucune UE configuree pour cette promotion.

) : ueList.map((ue) => { @@ -209,14 +284,26 @@ export default function NoteRecap({ numEtud }: Props) { const avg = calcAvg(ueMods); const ajust = ajustements.find((a) => a.idUE === ue.id); + // Final displayed average: if ajust.valeur exists it replaces avg, then subtract malus + let finalAvg = avg; + if (ajust) { + finalAvg = ajust.valeur; + if (ajust.malus > 0) { + finalAvg = (finalAvg ?? 0) - ajust.malus; + } + } + return (
{/* UE header */}

{ue.nom}

{avg !== null && ( - - Moy. calculée : {fmt(avg)} + + Moy. calculee : {fmt(avg)} )} {ajust && ( @@ -224,7 +311,15 @@ export default function NoteRecap({ numEtud }: Props) { class="note-chip note-chip--ajust" style="font-size: 0.78rem" > - ⚡ Ajust. actif : {fmt(ajust.valeur)} + Ajust. actif : {fmt(ajust.valeur)} + + )} + {ajust && ajust.malus > 0 && ( + + Malus : -{ajust.malus} )}
@@ -236,21 +331,22 @@ export default function NoteRecap({ numEtud }: Props) { class="col-dim" style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem" > - Aucun module associé à cette UE pour cette promotion. + Aucun module associe a cette UE pour cette promotion.

) : (
{ueMods.map((um) => { - const noteVal = noteMap.get(um.idModule); + const noteObj = noteMap.get(um.idModule); + const noteVal = noteObj?.note; + const noteS2 = noteObj?.noteSession2; + const effective = noteObj + ? effectiveNote(noteObj) + : undefined; const nomMod = moduleMap.get(um.idModule) ?? um.idModule; - const isEditing = editingNote?.idModule === um.idModule; return ( -
+
{um.idModule} @@ -260,17 +356,20 @@ export default function NoteRecap({ numEtud }: Props) { coef {um.coeff} - {isEditing + + {/* Session 1 note */} + {editingNote?.idModule === um.idModule && + editingNote.field === "note" ? (
setEditingNote({ - idModule: um.idModule, + ...editingNote, value: (e.target as HTMLInputElement).value, })} @@ -278,7 +377,8 @@ export default function NoteRecap({ numEtud }: Props) { if (e.key === "Enter") { saveNote( um.idModule, - editingNote!.value, + "note", + editingNote.value, ); } if (e.key === "Escape") { @@ -286,7 +386,11 @@ export default function NoteRecap({ numEtud }: Props) { } }} onBlur={() => - saveNote(um.idModule, editingNote!.value)} + saveNote( + um.idModule, + "note", + editingNote.value, + )} /> setEditingNote({ idModule: um.idModule, + field: "note", value: noteVal !== undefined ? String(noteVal) : "", })} > + S1:{" "} {noteVal !== undefined ? fmt(noteVal) : "—/20"} )} -
+ ) + : ( + + setEditingNote({ + idModule: um.idModule, + field: "noteSession2", + value: noteS2 != null ? String(noteS2) : "", + })} + > + S2: {noteS2 != null ? fmt(noteS2) : "—"} + + )} + + {/* Effective note indicator */} + {noteS2 != null && ( + - - {" "} - note - + → {fmt(effective!)} + + )}
); })}
)} - {/* Ajustement */} + {/* Ajustement + Malus */}

Ajustement de la moyenne UE

- Override ponctuel – laisser vide pour utiliser la moy. - calculée + La valeur remplace la moyenne calculee. Le malus est + soustrait.

+ + Val: + setAjustInputs((prev) => ({ ...prev, - [ue.id]: (e.target as HTMLInputElement).value, + [ue.id]: { + valeur: (e.target as HTMLInputElement).value, + malus: prev[ue.id]?.malus ?? "0", + }, }))} /> /20
+
+ + Malus: + + + setAjustInputs((prev) => ({ + ...prev, + [ue.id]: { + valeur: prev[ue.id]?.valeur ?? "", + malus: (e.target as HTMLInputElement).value, + }, + }))} + /> +
{ajust && ( <> @@ -380,14 +561,19 @@ export default function NoteRecap({ numEtud }: Props) { class="btn btn-sm btn-secondary" onClick={() => resetAjust(ue.id)} > - ✕ Réinitialiser + Reinitialiser - Affiché à l'élève : {fmt(ajust.valeur)} - {avg !== null ? ` (calculée : ${fmt(avg)})` : ""} + Affiche : {fmt(ajust.valeur)} + {ajust.malus > 0 + ? ` - ${ajust.malus} = ${ + fmt(ajust.valeur - ajust.malus) + }` + : ""} + {avg !== null ? ` (calculee : ${fmt(avg)})` : ""} )} diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx index fd77b87..6dcbf7e 100644 --- a/routes/(apps)/notes/(_islands)/NotesView.tsx +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from "preact/hooks"; -type Note = { numEtud: number; idModule: string; note: number }; +type Note = { + numEtud: number; + idModule: string; + note: number; + noteSession2: number | null; +}; type UE = { id: number; nom: string }; type UEModule = { idModule: string; @@ -9,7 +14,12 @@ type UEModule = { coeff: number; }; type Module = { id: string; nom: string }; -type Ajustement = { numEtud: number; idUE: number; valeur: number }; +type Ajustement = { + numEtud: number; + idUE: number; + valeur: number; + malus: number; +}; type Props = { numEtud: number | null; @@ -26,6 +36,11 @@ function avgClass(avg: number | null): string { return avg >= 10 ? "avg-good" : "avg-warn"; } +/** Returns the effective note (session 2 if exists, otherwise session 1). */ +function effectiveNote(n: Note): number { + return n.noteSession2 ?? n.note; +} + export default function NotesView({ numEtud, prenom }: Props) { const [notes, setNotes] = useState([]); const [ues, setUes] = useState([]); @@ -47,8 +62,8 @@ export default function NotesView({ numEtud, prenom }: Props) { 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/ues"), + fetch("/admin/api/ue-modules"), fetch("/admin/api/modules"), fetch(`/notes/api/ajustements?numEtud=${numEtud}`), ]); @@ -72,7 +87,6 @@ export default function NotesView({ numEtud, prenom }: Props) { 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( @@ -99,7 +113,7 @@ export default function NotesView({ numEtud, prenom }: Props) {

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

); @@ -108,7 +122,7 @@ export default function NotesView({ numEtud, prenom }: Props) { if (loading) { return (
-

Chargement…

+

Chargement...

); } @@ -121,20 +135,18 @@ export default function NotesView({ numEtud, prenom }: Props) { ); } - // 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]), + notes.map((n) => [n.idModule, n]), ); const ajMap = Object.fromEntries( - ajustements.map((a) => [a.idUE, a.valeur]), + ajustements.map((a) => [a.idUE, a]), ); return ( @@ -155,7 +167,7 @@ export default function NotesView({ numEtud, prenom }: Props) { )} {ueIds.length === 0 && ( -

Aucune note disponible pour cette période.

+

Aucune note disponible pour cette periode.

)} {ueIds.map((ueId) => { @@ -166,51 +178,65 @@ export default function NotesView({ numEtud, prenom }: Props) { let weightedSum = 0; let coveredCoeff = 0; ueModsForUE.forEach((um) => { - const note = noteMap[um.idModule]; - if (note !== undefined) { - weightedSum += note * um.coeff; + const noteObj = noteMap[um.idModule]; + if (noteObj) { + const val = effectiveNote(noteObj); + weightedSum += val * 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; + const ajust = ajMap[ueId] ?? null; + + // If ajust.valeur exists, it replaces the calculated average + // Then malus is subtracted + let finalAvg: number | null = avg; + if (ajust) { + finalAvg = ajust.valeur; + if (ajust.malus > 0) { + finalAvg = (finalAvg ?? 0) - ajust.malus; + } + } return (

UE : {ue.nom}

- {finalAvg !== null && ( -

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

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

Notes non disponibles

- )} + {finalAvg !== null + ? ( +

+ Moyenne : {finalAvg.toFixed(2)}/20 + {ajust && ajust.malus > 0 && ( + (malus : -{ajust.malus}) + )} +

+ ) + :

Notes non disponibles

}
{ueModsForUE.map((um) => { const mod = moduleMap[um.idModule]; - const note = noteMap[um.idModule] ?? null; + const noteObj = noteMap[um.idModule] ?? null; + const effective = noteObj ? effectiveNote(noteObj) : null; + const hasS2 = noteObj?.noteSession2 != null; + return (
{mod ? mod.id : um.idModule} —{" "} {mod ? mod.nom : "Module inconnu"} (coef {um.coeff}) - - {note !== null ? `${note}/20` : "—"} + + {effective !== null ? `${effective}/20` : "—"} + {hasS2 && ( + + (S1: {noteObj!.note}) + + )}
); diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index 2e4dc98..38f1625 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -7,10 +7,9 @@ const properties: AppProperties = { index: "Accueil", notes: "Mes notes", courses: "Consulter", - ues: "UEs", - import: "Import xlsx", + import: "Import Notes", }, - adminOnly: ["courses", "ues", "import"], + adminOnly: ["courses", "import"], studentOnly: ["notes"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/api/ajustements.ts b/routes/(apps)/notes/api/ajustements.ts index 6239fb2..b40e61e 100644 --- a/routes/(apps)/notes/api/ajustements.ts +++ b/routes/(apps)/notes/api/ajustements.ts @@ -52,8 +52,12 @@ export const handler: Handlers = { } try { - const body: { numEtud: number; idUE: number; valeur: number } = - await request.json(); + const body: { + numEtud: number; + idUE: number; + valeur: number; + malus?: number; + } = await request.json(); if (!body.numEtud || !body.idUE || body.valeur === undefined) { return new Response( @@ -62,12 +66,23 @@ export const handler: Handlers = { ); } + if ( + body.malus !== undefined && + (!Number.isInteger(body.malus) || body.malus < 0) + ) { + return new Response( + JSON.stringify({ error: "malus doit être un entier >= 0" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + const [created] = await db .insert(ajustements) .values({ numEtud: body.numEtud, idUE: body.idUE, valeur: body.valeur, + malus: body.malus ?? 0, }) .returning(); diff --git a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts index a165f44..b527cdc 100644 --- a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts +++ b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts @@ -4,12 +4,13 @@ import { ajustements } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { and, eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( - JSON.stringify({ error: "Ajustement introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ajustement introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); export const handler: Handlers = { // #50 GET /ajustements/{numEtud}/{idUE} @@ -18,7 +19,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); @@ -34,7 +35,7 @@ export const handler: Handlers = { .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .then((rows) => rows[0] ?? null); - if (!ajustement) return NOT_FOUND; + if (!ajustement) return NOT_FOUND(); return new Response(JSON.stringify(ajustement), { headers: { "content-type": "application/json" }, @@ -47,7 +48,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); @@ -57,7 +58,7 @@ export const handler: Handlers = { return new Response("Paramètres invalides", { status: 400 }); } - const body: { valeur: number } = await request.json(); + const body: { valeur: number; malus?: number } = await request.json(); if (body.valeur === undefined) { return new Response(JSON.stringify({ error: "Champ requis: valeur" }), { @@ -66,13 +67,28 @@ export const handler: Handlers = { }); } + if ( + body.malus !== undefined && + (!Number.isInteger(body.malus) || body.malus < 0) + ) { + return new Response( + JSON.stringify({ error: "malus doit être un entier >= 0" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const set: { valeur: number; malus?: number } = { valeur: body.valeur }; + if (body.malus !== undefined) { + set.malus = body.malus; + } + const [updated] = await db .update(ajustements) - .set({ valeur: body.valeur }) + .set(set) .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -85,7 +101,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); @@ -100,7 +116,7 @@ export const handler: Handlers = { .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .returning(); - if (!deleted) return NOT_FOUND; + if (!deleted) return NOT_FOUND(); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts index b7fd580..498d007 100644 --- a/routes/(apps)/notes/api/notes.ts +++ b/routes/(apps)/notes/api/notes.ts @@ -41,7 +41,7 @@ export const handler: Handlers = { async POST(request) { try { const body = await request.json(); - const { note, numEtud, idModule } = body; + const { note, numEtud, idModule, noteSession2 } = body; if (note === undefined || !numEtud || !idModule) { return new Response("Champs 'note', 'numEtud' et 'idModule' requis", { @@ -55,7 +55,32 @@ export const handler: Handlers = { }); } - const result = await db.insert(notes).values({ note, numEtud, idModule }) + if ( + noteSession2 !== undefined && noteSession2 !== null && + (typeof noteSession2 !== "number" || noteSession2 < 0 || + noteSession2 > 20) + ) { + return new Response( + "Champ 'noteSession2' doit être un nombre entre 0 et 20", + { status: 400 }, + ); + } + + const values: { + note: number; + numEtud: number; + idModule: string; + noteSession2?: number | null; + } = { + note, + numEtud, + idModule, + }; + if (noteSession2 !== undefined) { + values.noteSession2 = noteSession2; + } + + const result = await db.insert(notes).values(values) .returning(); return new Response(JSON.stringify(result[0]), { diff --git a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts index 8618366..544e56a 100644 --- a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts +++ b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts @@ -64,13 +64,39 @@ export const handler: Handlers = { } const body = await request.json(); - const { note } = body; + const { note, noteSession2 } = body; - if (note === undefined) { - return new Response("Champ 'note' manquant", { status: 400 }); + if (note === undefined && noteSession2 === undefined) { + return new Response("Au moins 'note' ou 'noteSession2' requis", { + status: 400, + }); } - const result = await db.update(notes).set({ note }).where( + if ( + note !== undefined && + (typeof note !== "number" || note < 0 || note > 20) + ) { + return new Response("Champ 'note' doit être un nombre entre 0 et 20", { + status: 400, + }); + } + + if ( + noteSession2 !== undefined && noteSession2 !== null && + (typeof noteSession2 !== "number" || noteSession2 < 0 || + noteSession2 > 20) + ) { + return new Response( + "Champ 'noteSession2' doit être un nombre entre 0 et 20", + { status: 400 }, + ); + } + + const set: { note?: number; noteSession2?: number | null } = {}; + if (note !== undefined) set.note = note; + if (noteSession2 !== undefined) set.noteSession2 = noteSession2; + + const result = await db.update(notes).set(set).where( and( eq(notes.numEtud, numEtud), eq(notes.idModule, idModule), diff --git a/routes/(apps)/notes/api/notes/import-xlsx.ts b/routes/(apps)/notes/api/notes/import-xlsx.ts index b31079b..7b01333 100644 --- a/routes/(apps)/notes/api/notes/import-xlsx.ts +++ b/routes/(apps)/notes/api/notes/import-xlsx.ts @@ -26,20 +26,38 @@ export const handler: Handlers = { const rows = XLSX.utils.sheet_to_json(sheet) as { numEtud: number; note: number; + noteSession2?: number; }[]; for (const row of rows) { - const { numEtud, note } = row; + const { numEtud, note, noteSession2 } = row; if (!numEtud || note === undefined) { continue; } + const values: { + numEtud: number; + idModule: string; + note: number; + noteSession2?: number | null; + } = { + numEtud, + idModule, + note, + }; + const set: { note: number; noteSession2?: number | null } = { note }; + + if (noteSession2 !== undefined) { + values.noteSession2 = noteSession2; + set.noteSession2 = noteSession2; + } + await db.insert(notes) - .values({ numEtud, idModule, note }) + .values(values) .onConflictDoUpdate({ target: [notes.numEtud, notes.idModule], - set: { note }, + set, }); } diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index 188a05e..ec2e5d8 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -6,31 +6,52 @@ import { getPartialsConfig, makePartials, } from "$root/defaults/makePartials.tsx"; -import { State } from "$root/defaults/interfaces.ts"; +import { CasContent, State } from "$root/defaults/interfaces.ts"; import NotesView from "../(_islands)/NotesView.tsx"; async function Notes( _request: Request, context: FreshContext, ) { - const session = - (context.state as unknown as { session: { sn: string; givenName: string } }) - .session; - const { sn, givenName } = session; + const session = (context.state as unknown as { session: CasContent }).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; + if (session.eduPersonPrimaryAffiliation === "student") { + // Students: uid is "21212006" in AMU CAS — strip non-digit prefix + const etudId = parseInt(session.uid.replace(/^\D+/, ""), 10); + if (!isNaN(etudId)) { + const student = await db + .select() + .from(students) + .where(eq(students.numEtud, etudId)) + .then((rows) => rows[0] ?? null); + numEtud = student?.numEtud ?? null; + } + } else { + // Employees: look up by nom/prenom + const student = await db + .select() + .from(students) + .where( + and( + eq(students.nom, session.sn), + eq(students.prenom, session.givenName), + ), + ) + .then((rows) => rows[0] ?? null); + numEtud = student?.numEtud ?? null; + } } catch { // DB lookup failed — island will show fallback message } - return ; + return ( + + ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index c55ae51..86132e9 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -15,6 +15,9 @@ export default function ConsultStudents() { const [error, setError] = useState(null); const [filterPromo, setFilterPromo] = useState(""); const [filterNom, setFilterNom] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [bulkPromo, setBulkPromo] = useState(""); + const [bulkBusy, setBulkBusy] = useState(false); async function load() { try { @@ -44,6 +47,11 @@ export default function ConsultStudents() { }); if (!res.ok) throw new Error("Suppression échouée"); await load(); + setSelected((prev) => { + const next = new Set(prev); + next.delete(numEtud); + return next; + }); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } @@ -56,6 +64,85 @@ export default function ConsultStudents() { return matchPromo && matchNom; }); + const filteredIds = new Set(filtered.map((s) => s.numEtud)); + const selectedInView = [...selected].filter((id) => filteredIds.has(id)); + const allFilteredSelected = filtered.length > 0 && + filtered.every((s) => selected.has(s.numEtud)); + + function toggleOne(numEtud: number) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(numEtud)) next.delete(numEtud); + else next.add(numEtud); + return next; + }); + } + + function toggleAll() { + if (allFilteredSelected) { + setSelected((prev) => { + const next = new Set(prev); + for (const s of filtered) next.delete(s.numEtud); + return next; + }); + } else { + setSelected((prev) => { + const next = new Set(prev); + for (const s of filtered) next.add(s.numEtud); + return next; + }); + } + } + + async function bulkDelete() { + const count = selectedInView.length; + if (count === 0) return; + if ( + !confirm(`Supprimer définitivement ${count} élève(s) sélectionné(s) ?`) + ) return; + setBulkBusy(true); + try { + const results = await Promise.all( + selectedInView.map((id) => + fetch(`/students/api/students/${id}`, { method: "DELETE" }) + ), + ); + const failed = results.filter((r) => !r.ok).length; + if (failed > 0) setError(`${failed} suppression(s) échouée(s)`); + setSelected(new Set()); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setBulkBusy(false); + } + } + + async function bulkChangePromo() { + if (!bulkPromo || selectedInView.length === 0) return; + setBulkBusy(true); + try { + const results = await Promise.all( + selectedInView.map((id) => + fetch(`/students/api/students/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idPromo: bulkPromo }), + }) + ), + ); + const failed = results.filter((r) => !r.ok).length; + if (failed > 0) setError(`${failed} modification(s) échouée(s)`); + setSelected(new Set()); + setBulkPromo(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setBulkBusy(false); + } + } + return (

Gestion des Élèves

@@ -93,6 +180,44 @@ export default function ConsultStudents() { />
+ {/* Bulk actions bar */} + {selectedInView.length > 0 && ( +
+ + {selectedInView.length} sélectionné(s) + +
+ + + +
+
+ )} + {loading ?

Chargement…

: ( @@ -100,6 +225,13 @@ export default function ConsultStudents() { + @@ -111,13 +243,23 @@ export default function ConsultStudents() { {filtered.length === 0 ? ( - ) : filtered.map((s) => ( - + + diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index bf751d5..2a20255 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -2,13 +2,17 @@ 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"; +import ImportResultPopup, { + type ImportDetail, + type ImportResult, +} from "$root/defaults/ImportResultPopup.tsx"; 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 importResult = useSignal(null); const inputRef = useRef(null); function pickFile(f: File) { @@ -18,7 +22,7 @@ export default function UploadStudents() { } file.value = f; error.value = null; - success.value = null; + importResult.value = null; } function onDragOver(e: DragEvent) { @@ -46,36 +50,58 @@ export default function UploadStudents() { if (!file.value) return; uploading.value = true; error.value = null; - success.value = null; + importResult.value = null; try { const arrayBuffer = await file.value.arrayBuffer(); const workbook = XLSX.read(arrayBuffer, { type: "array" }); - let imported = 0; - let failed = 0; + let added = 0; + let errors = 0; + const details: ImportDetail[] = []; for (const sheetName of workbook.SheetNames) { const sheet = workbook.Sheets[sheetName]; const rows = XLSX.utils.sheet_to_json<{ - numEtud: number; nom: string; prenom: string; - }>(sheet, { header: ["numEtud", "nom", "prenom"], range: 1 }); + numEtud: number; + idPromo: string; + }>(sheet, { + header: ["nom", "prenom", "numEtud", "idPromo"], + range: 2, + }); 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 }), + body: JSON.stringify(row), }); - if (res.ok) imported++; - else failed++; + if (res.ok) { + added++; + details.push({ + type: "change", + message: + `${row.numEtud} : ${row.nom} ${row.prenom} -> ${row.idPromo}`, + }); + } else { + errors++; + const body = await res.json().catch(() => ({})); + details.push({ + type: "error", + message: `${row.numEtud} : ${body.error ?? "Erreur creation"}`, + }); + } } } - success.value = `Import terminé — ${imported} ajouté${ - imported !== 1 ? "s" : "" - }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`; + importResult.value = { + added, + modified: 0, + ignored: 0, + errors, + details, + }; } catch { error.value = "Erreur lors de la lecture du fichier."; } finally { @@ -84,10 +110,7 @@ export default function UploadStudents() { } 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"); + globalThis.open("/templates/modele_etudiants.xlsx", "_blank"); } return ( @@ -117,10 +140,12 @@ export default function UploadStudents() { {error.value &&

{error.value}

} - {success.value && ( -

- {success.value} -

+ + {importResult.value && ( + (importResult.value = null)} + /> )}
@@ -142,9 +167,8 @@ export default function UploadStudents() {

- Format : promo (nom de la feuille) |{" "} - numEtud | nom |{" "} - prénom + Format : Nom | Prenom |{" "} + Numero-etudiant | Promotion

); diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index 5483732..d6b498c 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -6,10 +6,9 @@ const properties: AppProperties = { pages: { index: "Accueil", consult: "Élèves", - promotions: "Promotions", upload: "Import xlsx", }, - adminOnly: ["consult", "promotions", "upload"], + adminOnly: ["consult", "upload"], hint: "Create students promotion and see informations", }; diff --git a/routes/(apps)/students/api/promotions/[idPromo].ts b/routes/(apps)/students/api/promotions/[idPromo].ts index a206d3a..53f1d95 100644 --- a/routes/(apps)/students/api/promotions/[idPromo].ts +++ b/routes/(apps)/students/api/promotions/[idPromo].ts @@ -1,15 +1,25 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { promotions } from "$root/databases/schema.ts"; +import { + ajustements, + enseignements, + modules, + notes, + promotions, + students, + ueModules, + ues, +} from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( - JSON.stringify({ error: "Ressource introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); export const handler: Handlers = { // #15 GET /promotions/{idPromo} @@ -18,7 +28,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const promo = await db @@ -27,7 +37,7 @@ export const handler: Handlers = { .where(eq(promotions.id, context.params.idPromo)) .then((rows) => rows[0] ?? null); - if (!promo) return NOT_FOUND; + if (!promo) return NOT_FOUND(); return new Response(JSON.stringify(promo), { headers: { "content-type": "application/json" }, @@ -40,7 +50,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const body: { annee: string } = await request.json(); @@ -51,7 +61,7 @@ export const handler: Handlers = { .where(eq(promotions.id, context.params.idPromo)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -59,20 +69,104 @@ export const handler: Handlers = { }, // #17 DELETE /promotions/{idPromo} + // Blocked if students are still assigned (409). + // Cascade: deletes linked ue_modules, enseignements, and orphaned + // modules (+ their notes) & UEs (+ their ajustements). async DELETE( _request: Request, context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } - const [deleted] = await db - .delete(promotions) - .where(eq(promotions.id, context.params.idPromo)) - .returning(); + const idPromo = context.params.idPromo; - if (!deleted) return NOT_FOUND; + const promo = await db + .select() + .from(promotions) + .where(eq(promotions.id, idPromo)) + .then((r) => r[0] ?? null); + + if (!promo) return NOT_FOUND(); + + // Block deletion if students are still assigned + const assignedStudents = await db + .select() + .from(students) + .where(eq(students.idPromo, idPromo)) + .then((r) => r.length); + + if (assignedStudents > 0) { + return new Response( + JSON.stringify({ + error: + `Impossible de supprimer : ${assignedStudents} étudiant(s) encore assigné(s) à cette promotion`, + }), + { status: 409, headers: { "content-type": "application/json" } }, + ); + } + + await db.transaction(async (tx) => { + // Collect linked module IDs and UE IDs before deleting junction rows + const linkedUeModules = await tx + .select({ idModule: ueModules.idModule, idUE: ueModules.idUE }) + .from(ueModules) + .where(eq(ueModules.idPromo, idPromo)); + + const linkedEns = await tx + .select({ idModule: enseignements.idModule }) + .from(enseignements) + .where(eq(enseignements.idPromo, idPromo)); + + const moduleIds = [ + ...new Set([ + ...linkedUeModules.map((um) => um.idModule), + ...linkedEns.map((e) => e.idModule), + ]), + ]; + const ueIds = [...new Set(linkedUeModules.map((um) => um.idUE))]; + + // Delete junction rows that directly reference this promo + await tx.delete(ueModules).where(eq(ueModules.idPromo, idPromo)); + await tx.delete(enseignements).where(eq(enseignements.idPromo, idPromo)); + + // Delete orphaned modules (not used by another promo) and their notes + for (const modId of moduleIds) { + const stillInUeModules = await tx + .select() + .from(ueModules) + .where(eq(ueModules.idModule, modId)) + .then((r) => r.length > 0); + const stillInEns = await tx + .select() + .from(enseignements) + .where(eq(enseignements.idModule, modId)) + .then((r) => r.length > 0); + + if (!stillInUeModules && !stillInEns) { + await tx.delete(notes).where(eq(notes.idModule, modId)); + await tx.delete(modules).where(eq(modules.id, modId)); + } + } + + // Delete orphaned UEs (not used by another promo) and their ajustements + for (const ueId of ueIds) { + const stillUsed = await tx + .select() + .from(ueModules) + .where(eq(ueModules.idUE, ueId)) + .then((r) => r.length > 0); + + if (!stillUsed) { + await tx.delete(ajustements).where(eq(ajustements.idUE, ueId)); + await tx.delete(ues).where(eq(ues.id, ueId)); + } + } + + // Delete the promotion + await tx.delete(promotions).where(eq(promotions.id, idPromo)); + }); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/students/api/students.ts b/routes/(apps)/students/api/students.ts index 65ed62d..e2e5d38 100644 --- a/routes/(apps)/students/api/students.ts +++ b/routes/(apps)/students/api/students.ts @@ -44,13 +44,25 @@ export const handler: Handlers = { idPromo: string; } = await request.json(); - if (!body.nom || !body.prenom || !body.idPromo) { + if (!body.nom || !body.prenom) { return new Response(null, { status: 400 }); } + const values: { + numEtud?: number; + nom: string; + prenom: string; + idPromo?: string; + } = { + nom: body.nom, + prenom: body.prenom, + }; + if (body.numEtud) values.numEtud = body.numEtud; + if (body.idPromo) values.idPromo = body.idPromo; + const [created] = await db .insert(students) - .values({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo }) + .values(values) .returning(); return new Response(JSON.stringify(created), { diff --git a/routes/(apps)/students/api/students/[numEtud].ts b/routes/(apps)/students/api/students/[numEtud].ts index 3d92371..ce0f2d3 100644 --- a/routes/(apps)/students/api/students/[numEtud].ts +++ b/routes/(apps)/students/api/students/[numEtud].ts @@ -1,15 +1,21 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { students } from "$root/databases/schema.ts"; +import { + ajustements, + mobility, + notes, + students, +} from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( - JSON.stringify({ error: "Ressource introuvable" }), - { status: 404, headers: { "content-type": "application/json" } }, -); +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); -const FORBIDDEN = new Response(null, { status: 403 }); +const FORBIDDEN = () => new Response(null, { status: 403 }); export const handler: Handlers = { // #10 GET /students/{numEtud} @@ -18,7 +24,7 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); @@ -28,7 +34,7 @@ export const handler: Handlers = { .where(eq(students.numEtud, numEtud)) .then((rows) => rows[0] ?? null); - if (!student) return NOT_FOUND; + if (!student) return NOT_FOUND(); return new Response(JSON.stringify(student), { headers: { "content-type": "application/json" }, @@ -41,20 +47,32 @@ export const handler: Handlers = { context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); - const body: { nom: string; prenom: string; idPromo: string } = await request - .json(); + const body: { nom?: string; prenom?: string; idPromo?: string } = + await request.json(); + + const set: { nom?: string; prenom?: string; idPromo?: string } = {}; + if (body.nom !== undefined) set.nom = body.nom; + if (body.prenom !== undefined) set.prenom = body.prenom; + if (body.idPromo !== undefined) set.idPromo = body.idPromo; + + if (Object.keys(set).length === 0) { + return new Response( + JSON.stringify({ error: "Au moins un champ requis" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } const [updated] = await db .update(students) - .set({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo }) + .set(set) .where(eq(students.numEtud, numEtud)) .returning(); - if (!updated) return NOT_FOUND; + if (!updated) return NOT_FOUND(); return new Response(JSON.stringify(updated), { headers: { "content-type": "application/json" }, @@ -62,21 +80,31 @@ export const handler: Handlers = { }, // #12 DELETE /students/{numEtud} + // Cascade: deletes notes, ajustements, mobility for this student. async DELETE( _request: Request, context: FreshContext, ): Promise { if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { - return FORBIDDEN; + return FORBIDDEN(); } const numEtud = Number(context.params.numEtud); - const [deleted] = await db - .delete(students) - .where(eq(students.numEtud, numEtud)) - .returning(); - if (!deleted) return NOT_FOUND; + const student = await db + .select() + .from(students) + .where(eq(students.numEtud, numEtud)) + .then((r) => r[0] ?? null); + + if (!student) return NOT_FOUND(); + + await db.transaction(async (tx) => { + await tx.delete(notes).where(eq(notes.numEtud, numEtud)); + await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud)); + await tx.delete(mobility).where(eq(mobility.studentId, numEtud)); + await tx.delete(students).where(eq(students.numEtud, numEtud)); + }); return new Response(null, { status: 204 }); }, diff --git a/routes/dev-login.ts b/routes/dev-login.ts index b50898e..22058a7 100644 --- a/routes/dev-login.ts +++ b/routes/dev-login.ts @@ -4,41 +4,73 @@ 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", -}; +function makeFakeUser( + role: "employee" | "student", + numEtud?: string, +): CasContent { + if (role === "student" && numEtud) { + return { + amuCampus: "local", + amuComposante: "local", + amuDateValidation: "", + coGroup: "", + eduPersonPrimaryAffiliation: "student", + eduPersonPrincipalName: `${numEtud}@local`, + mail: `${numEtud}@local`, + displayName: `Etudiant ${numEtud}`, + givenName: "", + memberOf: [], + sn: "", + supannCivilite: "", + supannEntiteAffectation: "", + supannEtuAnneeInscription: "", + supannEtuEtape: "", + uid: `e${numEtud}`, + }; + } + return { + 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) { + async GET(request: Request, _context: FreshContext) { if (Deno.env.get("LOCAL") !== "true") { return new Response("Not available outside LOCAL mode.", { status: 403 }); } + const url = new URL(request.url); + const role = url.searchParams.get("role") === "student" + ? "student" + : "employee"; + const numEtud = url.searchParams.get("numEtud") ?? undefined; + const user = makeFakeUser(role, numEtud); + const now = Math.floor(Date.now() / 1000); const payload: LoginJWT = { iss: "PolyMPR", iat: now, exp: now + 0xe10, aud: "PolyMPR", - user: FAKE_ADMIN, + user, }; - const token = await createJwt(payload, getKey(FAKE_ADMIN.uid)); + const token = await createJwt(payload, getKey(user.uid)); const headers = new Headers(); setCookie(headers, { name: "sessionToken", value: token }); headers.set("Location", "/apps"); diff --git a/routes/login.tsx b/routes/login.tsx index 3b1da1e..dd35867 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -45,6 +45,8 @@ function createUserJWT(casResponse: CasResponse): Promise { } }); + console.log(fullUserInfos); + const now = Math.floor(Date.now() / 1000); const payload: LoginJWT = { iss: "PolyMPR", diff --git a/scripts/generate-templates.ts b/scripts/generate-templates.ts new file mode 100644 index 0000000..ab2f3bc --- /dev/null +++ b/scripts/generate-templates.ts @@ -0,0 +1,60 @@ +// @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"; + +// --- Template 1: Students --- +{ + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([ + [null, null, null, "Promotion peut etre vide mais doit prealablement Exister"], + ["Nom", "Prenom", "Numero-etudiant", "Promotion"], + ["NOM", "PRENOM", 12345678, "3AFISE24-25"], + ]); + XLSX.utils.book_append_sheet(wb, ws, "Eleves"); + XLSX.writeFile(wb, "static/templates/modele_etudiants.xlsx"); + console.log("Created static/templates/modele_etudiants.xlsx"); +} + +// --- Template 2: Notes --- +{ + const headers = [ + null, + null, + "MOD01 - Module 1", + "MOD02 - Module 2", + "MOD03 - Module 3", + ]; + const coeffs = [null, null, 2, 3, 2]; + const row1 = ["NOM", "PRENOM", 12, 15.5, 14]; + const row2 = ["DUPONT", "JEAN", 8, 10, 16.5]; + + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet([headers, coeffs, row1, row2]); + XLSX.utils.book_append_sheet(wb, ws, "Session 1"); + XLSX.writeFile(wb, "static/templates/modele_notes.xlsx"); + console.log("Created static/templates/modele_notes.xlsx"); +} + +// --- Template 3: Maquette --- +{ + const data = [ + ["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."], + ["Description des UE du diplome", null, null, null, null, null, "Nombre d'heures"], + ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\n ECTS", "Coeff.", "CM", "TD", "TP"], + ["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"], + ["SEM 5", null, null, null, 30], + ["UE", "CODE_UE1", "Nom de l'UE 1", null, 6], + [null, "MOD01", null, "Module 1", null, 2, 10, 10, 10], + [null, "MOD02", null, "Module 2", null, 2, 10, 10, 10], + [null, "MOD03", null, "Module 3", null, 2, 10, 10, 10], + [], + ["UE", "CODE_UE2", "Nom de l'UE 2", null, 4], + [null, "MOD04", null, "Module 4", null, 2, 10, 10, 10], + [null, "MOD05", null, "Module 5", null, 2, 10, 10, 10], + ]; + + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.aoa_to_sheet(data); + XLSX.utils.book_append_sheet(wb, ws, "Maquette"); + XLSX.writeFile(wb, "static/templates/modele_maquette.xlsx"); + console.log("Created static/templates/modele_maquette.xlsx"); +} diff --git a/scripts/inspect-maquette.ts b/scripts/inspect-maquette.ts new file mode 100644 index 0000000..0dd3dce --- /dev/null +++ b/scripts/inspect-maquette.ts @@ -0,0 +1,25 @@ +// @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"; + +for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) { + console.log(`\n=== ${file} ===`); + const wb = XLSX.read(Deno.readFileSync(`Excels/${file}`), { type: "array" }); + console.log(`Sheets: ${wb.SheetNames.join(", ")}`); + + for (const sheetName of wb.SheetNames) { + console.log(`\n--- Sheet: ${sheetName} ---`); + const sheet = wb.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { header: 1 }); + // Print first 5 cols of each row, mark rows that look like year/semester headers + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row || row.length === 0) continue; + const col0 = row[0] != null ? String(row[0]).trim() : ""; + // Show rows that are structural (year, semester, UE headers) + if (col0 || (row[1] != null && String(row[1]).trim())) { + const preview = row.slice(0, 6).map(c => c != null ? String(c).substring(0, 25) : "").join(" | "); + console.log(` [${i}] ${preview}`); + } + } + } +} diff --git a/static/styles/ui.css b/static/styles/ui.css index a4efd9a..9d2218e 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -391,6 +391,54 @@ gap: 1rem; } +/* ------------------------------------------------------- + Bulk actions bar +------------------------------------------------------- */ + +.bulk-bar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + border-radius: 6px; + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + font-size: 0.82rem; + flex-wrap: wrap; +} + +.bulk-count { + font-weight: var(--font-weight-bold); + white-space: nowrap; +} + +.bulk-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex-wrap: wrap; +} + +.bulk-bar .filter-select { + color: light-dark( + var(--light-foreground-color), + var(--dark-foreground-color) + ); + font-size: 0.78rem; +} + +.row-selected { + background: light-dark( + color-mix(in srgb, var(--light-accent-color) 8%, transparent), + color-mix(in srgb, var(--dark-accent-color) 12%, transparent) + ); +} + /* ------------------------------------------------------- Chips: perm, role, promo, module ------------------------------------------------------- */ @@ -852,6 +900,14 @@ margin-bottom: 0.75rem; } +.create-promo-inline { + margin-bottom: 1rem; + padding: 0.75rem; + border: 1px dashed + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 6px; +} + .upload-format { font-size: 0.72rem; font-family: monospace; @@ -1008,3 +1064,140 @@ font-family: monospace; margin-top: 0.25rem; } + +/* ------------------------------------------------------- + Import result popup +------------------------------------------------------- */ + +.import-popup-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.import-popup { + background: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + border: 1px solid + light-dark(var(--light-border-color), var(--dark-border-color)); + border-radius: 10px; + padding: 1.5rem 2rem; + min-width: 28rem; + max-width: 40rem; + max-height: 80vh; + overflow-y: auto; +} + +.import-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.25rem; +} + +.import-popup-title { + font-size: 1.1rem; + font-weight: var(--font-weight-bold); + margin: 0; +} + +.import-popup-badge { + font-size: 0.78rem; + font-weight: 600; + padding: 0.25rem 0.75rem; + border-radius: 4px; + border: 1px solid; +} + +.badge-error { + color: #f5a623; + border-color: #f5a623; +} + +.badge-success { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.import-popup-stats { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1.25rem; +} + +.import-stat-row { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.import-stat-label { + min-width: 6rem; + font-size: 0.85rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.import-stat-value { + font-size: 0.85rem; + font-family: monospace; + padding: 0.2rem 0.6rem; + border-radius: 4px; + border: 1px solid; + min-width: 8rem; +} + +.stat-added { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.stat-modified { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.stat-ignored { + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + border-color: light-dark(var(--light-border-color), var(--dark-border-color)); +} + +.stat-errors { + color: #f5a623; + border-color: #f5a623; +} + +.import-popup-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.import-popup-details { + border-top: 1px solid + light-dark(var(--light-border-color), var(--dark-border-color)); + padding-top: 0.75rem; + font-family: monospace; + font-size: 0.75rem; + max-height: 12rem; + overflow-y: auto; +} + +.import-detail-change { + margin: 0.15rem 0; + color: light-dark( + var(--light-foreground-color), + var(--dark-foreground-color) + ); +} + +.import-detail-error { + margin: 0.15rem 0; + color: #f5a623; +} diff --git a/static/templates/modele_etudiants.xlsx b/static/templates/modele_etudiants.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..65ddb687fa311ad737d2224daf70456e79a59f31 GIT binary patch literal 16207 zcmeHOTZklA8J?IZvJzrYK_WuYpa^lMr)PR*XU6FXbIopcXSbd1Y%U`4bk*tZnyFi= zs(Yprg=8ZV3@U0AeGq&QqQpxu5%Umz_rX_v^JQ~EqwI?af)9TGIdwbL)jgMOlxSw@ zt*TT1|DXT-xAUKK{;FTP{f-Y$#eYA#?aQzJ>OE6a^t%U*M^^bUpIbrs(De^mu6r)q6H~!h$Zu_=7IzA&4@+!^2vML?W7k zvba=8Hl=bgGjo$Qs|2=B2iX@E8} zos#A-8zy{J7^v11ieFoeh;Li411AiqLAU${IS*Gb;2LbjIH=Gi7zCS^ul1P)+ch1- zJ#6wL*t5lg5HD(_S@A~}TuzXza0G4!Z%6z^coBkeFem!-JFTSC%1(9p{8mC=09fHL z-1heN=5{5aCjgkPW2BOoEY0f9Qe`8inE{fw!?ZVN7iOy}e1`t~5YNjS8ygGD5igRc z0Ft-WA^4@)#p?Q;0xy6h{K61?bz^;Vb5Vg8KoWj&2!7}M@?uqm7eHOhbPk4~NufJC z5%mP5?OGQK!IuEOG#i1ZP_Tx?x~Pk`>x83h&1U9FEWX19Ek zQ3q^=v&~n-~8(jp3?sE@(@C*H!>`UDw)h{Xzj;qP{W`ZuXo`ap!(7 z3%AEuzo_p`<|wM&?s$5GjbmLyg|K2b>p-?h(^ez8kph zPfv;!bwu2Cw?r_W1whbNxML1V|j5kC35<|QyRQs+q zuvJJuG!KesUS_VFh1_xvHoMIBieMY2)6K$O3=UwTOsl&{Hin&a*Ht})o}mZkj8ykE zlQPLPZX6#79yB`&OR0g-o1S05*8SX7?6)zn+Kh`RgOIL7YTIB13+?GAO8@6ku zbxdMK&T}CbtVhAU14Hx{>p67}uNb!ecO&$>5xVq0AE8J2^5o-k8j2C6DIt-$w#dBg zk%@QPw5$fL$u9>Y_YN=t89QhsJh839lZBYq#VvUR27qPWPT_YJ| z7RM%5yatZdLQ;+IC#<4q)N&wQW^kz@r8y(biqKtbLPO)D;Yk}goir1vL1@P_AdVvYf1MS&nvCD=ja~EtE8!d9_j-D_z{$9+VbPF37A7HbwQ&msWeM zFA673N4#8YRTXSrjBpsmjXggIw^-1XIux%WRap-8B>A6PsFJZwW&j1jsIJduB)TO-v_Y*XO*Buq3SS7-E%Z>X9U^h>)3SZx0)0K7bympv@T3t zM3X2C2)Mlcsn3xIwK|leWc^KvWXaSiec(TxxJYNlpmMnc*>5cWghe4OHF1&n0zIB1 zvjd?QX_i!ty+WD%mY1Wh&d9<^Cg99mDi02k!=Agyl5u-85XFTi!Y4jiFKhxJO zF3gxF)TXR$`Lb{MVA&FCD0ZnLRXbIwc z(V{^`jNl1X&Xx)C-0{c>hUgg~J3$s3YDENaJ{ARTTG|0#23a|3a;{iXmUAm2I|1{> zA}d_^oK$3Tp*U7iXAoI?1M_Rft+1zpPgMYu{`A6@V znxfyG@;EpUhun0SeD;H2L8C#()hDlY2t1!NLsS*0r@^9HVx^D3dA6bk!`17)>jl@E zIFt)upH9!-vBN;q-JS!gq(IrE$qUs|EG(8ZS&6af8rTl_ze2C`Y;NyC&4(m}}@WDnwkT?dK>2?OLs)UdXS zSqQ1Vvx5hk+~}S}SSvXwZV^L}aK}Jn-WpV}Y!v+p!ezohzb)4{P)w6mglt+{wRoG% z7Fk`FuA%GU|CSquu8kYRWF6OG7H-QiMsx$RI*+oehuaG6qjpBYn14wKq-(kbL(#3I zCm)D@mYSq}%Vf2qcGMa3JZo^JXSX;Gkg4t`(yizf{bUBC3?*SIs(iRY$T*rx2)BBs zg*2;R7@%n!rGgPte8^fB7YS+73xs0G$*h^Ytd-LHU2d2?dpUusn|)VKX|+;%FQKYJ z_i0eb1)=?`Kl$l>w@*#c@1rsZ71kM@Jsu_GCKis6Pn<0p<*T?19EUqpPp0|2jboO| z%GNxIO9Vn3nH@r3$-Nerq|u91x5@h$b_)eUa31%Na!iVEI^ArcIt&Jevq-e`*ANtK zmx|IfkM}~2hd#oIiCr)h;RKrDnjscx9OtmG4Jx_OwvVvD=l*KBPi}!9I!JnV%sx`&LXc>L1h5`Z6%%Pz3m#X^rkq6rjkJg_=X76qYU=4{;c)c3OFCV) zknHTf7E_s+s~uTBf^^Q??6|;SM#qdGYZ=S=6rysz?3;R5mdnXB;>Z{-OFJnoV}L2w z)R~V7t+aMd(^liLip(N2jp1GHZ4{>;zPz-NND#nidb&nAPJrhQKp?q9e{Jw7|2>aK{uIDtG~}+;pu**KA7@!E%&Evx~$O z4V$hl>gWV576h;xajU!Br@AE&qDf{K#f9`x7UL}|0~Sr~UfAS~x#Fug|yVXZ6{+Wqtlj zLIIK%kfGL`Av+KX_ZrnCCMZd^t;~C-B z684zN#GH0w^JC0e-G_e}{E8?IZFbGzp{P1LnJnYUFS0=_&ihoXD<(&pE6FuhOx%b% zX-e(Ht2jtfrFTx;5A5Il&&#L^r{4zwIj&8|jCfLpj;~FJgL22<#(0*HUXSAl7BM-FRGw4$;B$i5hGSmF zR%rjS@v+~{!QklkK^R>k-)JyrpP4g3QA~zIlWKu!sH2ia+J&;8_$qxJOCwCSl%TFP zXe;4?#%ej+xOxHm1t^iLGfaKR#KM(6cHpo`4$LPofx-S;EZ|bXeTMQSak@ZY@RLqr;6?=`0+JEDP+4r6A-*5D+O9L7wg?lzh+~*z z%CX>q63mz#8PA6J6GKK%Iojn^*}0(hhi`rH?ng0y(C_Z7eUXRX+AN}bD?}viT23b` zI*yA^8HM6RV>s?eLo@V*q}TH;@wJShmwEJcK)EtgDPuWIAKI)KI#w-q`oT>WLA=R8 z+Gd#jekKcQ=<;O(eH0*DiN5vfpJIG&6Z;WZXFWzH8+`uOR3T`bd^T4;G}0v>(ne0x(jt-2Xlf+{z&Ik5=`E)40=yP|K{67r?-(<7FwKvUam@&q zp8*2%2QxN9$?~>Ct6hN5cPPd5`(ybc`f3=RmLU{jTzqIX1N<%d(aVSqi)teeo=VkuxB3cjtVG>ceZ z$5D)+^f&zu@!rT0ui~pnpEi$ZZj(uRIiLBQs<)iiC`l+3h=P9?`b%BA$`wB4&WihEEi?|I-gVEnFBbCVfjsBWBPbdFrXiDU7=nr)A zMw};*KTJ#s{7VxAQlreH=8phU)TcioP!mVXet4XxlHb!zsr=(pf=X6fdF1@wN{amA zrxO<9&%yJ^`JI9k`JNeN-yn~eU+txc-m>jH literal 0 HcmV?d00001 diff --git a/static/templates/modele_maquette.xlsx b/static/templates/modele_maquette.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f326c5e2752f48b637cc079bd2e03e6d08d7e22c GIT binary patch literal 17926 zcmeHP-EU;cRritw!JsIt2ogdJF@#@#yjH~Yr1E469h~3z17`! ze82YI+ig1mvDpAx5rH5q2p*7lKm>#!D}XN^ksK8Gh`%EQR^z5PmUZM|Y}&-5+Z8|_yf_q(gx6(fq7XEDe3_s!`zY-ojp@r8pP&9ouk7pb6eqwJ|K zj(N;17PDLmHz`Tws4B+RJW-5&{ZntA{?kvdtkCDjrLpU(yuOvQT|rCQ_byw;3ayZx z!Fyas8lcN;uVQ%2g$X|t25R(0$2a#9;vENe;KdOYm`>Ou=iv$lT!Zaer#1SDE~1tb z8dK)Lc5Tn{&-(lv_Uy1IM#X+*UHp*+S2H9ho`YM!>x#bwFF~+QHw4dcG{~6Nx{aON z?To$vaN=3GPN&oA)G~SkfSD#*DrG6sY;?D4hbhehkbIn_-CEyVZ>aDE`t=!^cMcB^ zH+K?QBvAn*9~(39+v{76gAD~<07>}G8TiKGL94Z;zzZM=zcmBjy}h&5P~ip8*s;CS z8EERzZa1Nxfed};ZYlUSz_-^E@Ei)(kXaY882Vm3&(>V_)DOD=AV7-Io)IGm519!o zZZUTd+Kd>mUCs(MvdD~PiinpHnV}sZ;=U9xctlR`qj&%Mqj$e)eDv&Cx)^H0C`)*qC^Bedm=KKPrjzW?{XUk(Tl z$^k$8!?(Ztt+zk?qu=@Gzj~_#{2*k5B6#0+d1O4`XT~GnMcag8RLS`uyc$M-%xndN zjX}GFbSEAwfDbO1QwDd)RpmVi5j@HO?@XR5vi8Q|B(_U9-y6FM;IZ#JhkjV9fP2KN zpyB?+8|D4iWEk70Wi;LO*8g#Y{y0Kc{?A9~dA@w}aXAmg zh|-jh$Xr`w-tOGQduTgOkLKj}BawSY7=WxU3JFgvtMF_h7L4&oo(u*G+$dxM#|ZuS zWjh}C#*7wFYGN8WiW1VJ$OwF_*{qA=Qrg56xqmEYo0~Kbk&z=}@v(2o;yM+l(~XRb zkZL66v?-%H5m=U4{dPhWZMzP$8VR*Dx0(=51Vx2g)?`w#n3qH4S<_IMX%m@^fqV#B z-onxru|_h+9F9e-bPk+$3u!fdzTqxPS}hwgW)_!Sq!bslSrdJin$S%9sCm*xuBOdQ zYf#$dLI|^%Z$C>aIVF%PR&f11p|1~LKBWl<3;gFkgv9k*$q&xYVL_&WO^RPFz&WwRG2_G?ZKXgj%vFr#7 z7Q&(F1*>bMZBeDh)+Evt*R%wjEN4zIW=XW9)tS`eB)BluOa}2*5D#rDm0dLM>P;b! zzp17a&G`RWQ)oEA3PRnY9x$=E8#04DWxwKw@z_Tc3dXkC4KePE*a2(73N5Okd~BqM z?~qUFTl$=&A>0}h!T6CqGC~`}NjwfYKS5X$)^`c9S~B!Vl1@TMSX4pWD3D?g_>?<+ zih)}cgDb`urY@pM5(WgEZhz`~;z5HEOT`v zXiH68B)&jT$H-zss3Omj8mU((gWt|h!s?nLEHeNX##$M%W?HvCVf?d0)8WF5X+Z7!j}Ti8Y(Gf-8L;YAifXVR3Xt+?fV@W*N@F5YQng8PN?Jl@C3GQOI!NwQ`7`-;lWk|>p(;)^$_>_o$gu4UO=RikHh7e&!9orj2y5gT2_y(oY1 z6dm2hs{h21yHinsH{l1tNhqvq;9D2TqZ7#iS6Us-VF=Rk9`T`JTj%NgBh!R*A-O}6 zrpN-C(8$m-#CqAJK}Ag93001k8S=*E$Qg#XFkiSOimORcGNXg7N0LxS)wT@Z`Saz!x=J6y?P@4V$hM6E=*S2 z4u1R}&%gDB=T}zf^GUfK9EnYC+DyLwMzEmKqwVVD&pJdw$eATh6{x$x;6oK=4 zhZ-#3JPG|EdeXf|eem0FR^NZ;tN1cx*?26!Km6l2zxkpZ+ zNJ9n9z37Y5>1$$Z)$|=dG(;aJlVsW@mk=#%6(oz*Lu_0)7qVF3NyC%{(m}}@BnR<@ zRR@X(nE>QPRM6bRD1=ns>*9q5H+m)&?pHh!6!d1cm-+>=m zI80OQ2wBwJbNG|uM8F1=fgq= zWBes0P+!v{1WFzyJ^4cPi_|3TXC`Z$4-;lA2%L)t6L-L|flTLqlD-vO!Kcs|r74+E z$;pQYgp94JjPPJ$J4mxingN>Dd9D~S#K&ylaFLK^T%Z(74rcwVa=(&4A9Kr|xH}ot zi9Pk@khWjRpCwd7^nD%_v>^1m-~QS4fB%t{75e<73__*njIJM#GIBQ-j*w4WFB<8m zxGZdkdvu=62>B4(EVbRETAlPIA|dw7&d_1mvyq-jql&z9v-%XajT1uf9{-GzObT!& zJsjY87%U8%ktmrqkG$B9Ck{8P2_{T|;2cE~dv9Z3k zh8OzEkgH&vai@49xo;@k+gu)Tpr&I_)Og&PE5{ub^MQ{e6_ENf3`?>vj`<|yQ8{m% z{=(>i{6ZYXzyK5Ra7xL!6bRLcI_7{)%gaT}Z}o8S2&ydb7E}*&s-$EZJ|g4bcu8>l&f92H{&2Ni?7?5u7$RGH%iWWoa!}k0`!d zUVJoDtcbjwiHx(i1!a=R8sD}CAw|}Jxkz}fF1Z$0+B%4o zT!ZQYs#J1WJS%y5Ii(>)rT{M`mnXkvhGCCzhK5_6F!V#@9>rJ6wUO5LVgfjb=-e%( zB1ZI@mgrQASRj(0JJ<5WUw(b%%Wr;SWraSUl6hkB?DO^0!;}gQ!^<7azhts{?u3|E zXbGa5o{3vCF^Q4K@2fhY+U;s+n`3#xTh4sXt=W^*%hIwgFiW&e7Ge%oY2Gr7y|k@j zGF+H>p-!t!rWUWoyFI2KT!`KcaIl&mI9TsQSBsY? z%$acPoZ}q}|0A)rZbX4&$6Z_O0iXi;V#{}WV|yrV1S_a4nGi`#QPA>Tar9fzVo?OU z5s#+NLpo{;LNqTr#(8*psfzYil?IEV;Uq@NNMNL9t(p0vfYiv~lyHP{V8OJ4pS(g_ zC@?0Wy}>H>w!L|KwQkmj^>uT7W5=xjs^|bo3&`l!-K2XC zsC#IQg5w|`vc+LrcAIF@5SnpV5Ygs1P1bVuRq#(=_vj^)4zpq*HYWN?TM1fBkF&-v zXl`gyID%4>jTgZcj?9ooGIR$NiS4kOkP%?%P&;HMY(5jYq3aJ6Ivt@EaK)QQf1EdgFA4k6{oT+yjJuZ zKl5uZH(+q|`7sz>rr)G7*B_e;VR7lvS#6cPSyZZfIl$iF(((b;^Dx}fRljDBeE20y z(2Iq8cd^KgbKNHlgCEkXcz23zBdqL3_7^dP!Ttv<;%qmYVwN-0twW?UN@}gbK#EQF{-xDn&h?)YVYsbZF zpG8p1l$9Cud4!B5Y1i4=+1goMh=EM5R(<98zSuQltF}iMSxs8!S8ovh#XmlM{U?5S zWraR;gYXwxSVqS^wO7e#qhEZst`IbqAJLWjv8I$`l{68hN<5G9M0{>zbKom!rn#4F z_Zl4v?DA4D`nh5xDxucwR}cczm{6r!4B{nt1G@M)7jNoWYi_j4&yjVl38!BJ0t+wJ zT!x!A9go)N0YcX&%jr)S%0=|mF?Ll3Q3P^vKX?uJZFyI74g)!5S=fTb99-l|&SwRg z@+J)2KvNM8q2qQmm6u}#bS_a5Nn%iWImXBymSRwOIR4{){1UIwdGpC_6dAj>Cz%%i5({~f)e2T z`ut}TM|_CO{Gp8nQySf5qF&W=pDS_GyhfScCyq-_x&(hYv=^`0-<%+n+X?!Y$RW0y zi?{5bMFc_Y5{CKVgR6D&VzrAC4U0Hu>1vuxT#b8bV%l_LDUPsSkzqfJW=nG0MZ96) zX`b@TtV;|$U0X;+sER=yS;TXg9L)dO4WtrD>?En)iV*&Uop{< z^|gW=`5zj}%7Kok&-Ze~_U+l(pN_5P@;UaO?-a08XVlU43^YftzF3-%p4|JvA0nVz N`1d6k#iT`V{|$?G$*%wa literal 0 HcmV?d00001 diff --git a/static/templates/modele_notes.xlsx b/static/templates/modele_notes.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..55d96146bc9f7adaee89a5a6e21a9fa7b9372121 GIT binary patch literal 16337 zcmeHO-ESn>Rritwp+zCA00|)>m0BVQMRvLEw#PQ^4tBe}vpeG%%kG`s1VOl6b-TO9 z^|e*iZ95^-W`od*2qePtkq0Cm5J5t~N(o{mmPh^s-~ox3{fHo-ec=Vf1HW@_eckHn zw#Q?E0_#yvRoy!0+;h)8U-#Zq$M-(*?2j#Fe}DAMufFq_-?y|xzn?_og=>Bxl5T9C z2hrIe2+me6Ja>g!c0Y?dV=ANA3H)}Wxw6_Yq;CbbLtcfT6q^1iVe>|2l0Z|c+ zWYTVsNVq9B`O+lWG|YNt)^3xT1VWMpBm8Lc{|k9ozfB(EfhIk+XfL;g$gQUv>Kjr)v7gS^w`VQc%& zK~A3m++-H6*XwnA&72+s2+P7q6)$C)t;4P6enztdq;6+vcUL!7TRMD+{`w5h+xz?b z8`~)_iYNoA+tv*H*6LvlIacm`7Nn=|l-ceXcMIy?i7UB^G0 zfu;#PJWQ$QAj81DTMNDg@U7Jpynuo>g97KlzAVBibzLCHQ4}}FS z?h0=ZIf6P6J5rS1s3t3(X`;T2*ovGG9`~hCz#jmEHyTdUEwE4L427Q^e#A)vSxIj za~w^sM~)Q*aWG7b&pF1vaB4-;cLtgw6-aj-5A$+aH)c$=CYz&2#(v<|jru@Nbx0V5 z;ABOab8cdN>1twpK}-_i)sgHAWILsaxi6A&9h0Y*kyVE4#R+D5B;CO1*)opna2`e& zZ;AFj0e@9H?MLpVr$a`Gb5=+5z6gRcjfET`g?lm4uxbd1DCFOYR~Z( zxoy{h-&Q?*?6%vDJp{2jO@2S-+&e}9vJcTnc)YD5^MzPA#w}SgI?&)okzgDn z3X)fxWPCanw1Lv(W#lMMNsnS946tXj%FVU3sZ-?siCS%L&^knUj#wl|fvuX?sX1G2 ztp+j{wp3H3HJ7B>MdaTvwPfr!Uy4*69&RjyAxDgM_sTBK~Z zGk_Xray>5Ax1b{bYg|A_^r@A@p|q0nWS3e`?)YFvrv=~mc3pX5#)LYW-OlENkoK-dFfuBn3VQjU$^WdI* zC}i=sHI%X%|34cFg#+v$tlKmL7B+VyVUVS?8$pze19+iu>{y2p;y(KhXbW~|@fyO% zK?+|ZIi;(zIYoob8VkYX#2FcpgK(0JBPoyJmRS3)Al6Jnk0R-&gjl0W?8c#DdmyLM z?UN7OBp=)`#!z+kCaD`>xT^l?.sMwFwJ?M?G!dFWI&@IRfnC}YN;D!BydZzlho zMV6MHxF~!^&thbGAoQZhl3JNnXu)rLJMHR*ELf49d_SSE^CU{|PP4_^6m}|MP1dSyuBb7yRTD+iFk6T4#PC5^`7A0J zJjO&HVAp@*s?(`B#1q>=cpR~I4FdZzy>(+1aFy248ipWU|3nTA$G*tcAGsuy38@p3 zEJbE$LL)`X5!dS$4LV{9PpB$d=E!STBj*_0GhB9#%o}QT1aLl2=Ef7ePm9I;(8u zIQZp%fA+1;;Fu4&=`i``2f<9^l#Z*fU+WNukrXyp73inITrIIPh2y+g zQG*>=$59Z*kNP;2i=m(X#5?qo*sy|$52`eQs>x6ns-@W2Y#6E%qZ`=R4*2wIlOXxI z_uu*T4}RlK^Zj?ehKr%v#$$E;@EdQa8_!eM58nJWbrZ+x`kilnJ;h;bNZIJ@l2K^f zP(x=o{-QGaCLgU@fg3~y&tbYqrc-hS(Zx|gx>-HM!G(LNni)?irfN_IO4^`$;0tyg z$RFekkQGrwXBSZjslI=R2MTURi44<_C~;sBZIe$u(+UeQl!FxpUZrqarX z2ZW5Hshsd&;7>Fzic;(`y**P6K^|*I(DXk z3Tf>|aj&3SJoklDs5l{i=kNaUuRpQ0M8BUHa#yiv7^%f@lI zPxWL*B!@U=Y3}q`TA0E)9_qeILw)e@DCdFS@9Xc6PkJ*TT_QH^G^=c-%^NEl zI>}!8qLnvmthI91S{iEyFCRaA(9dxccRI*Ry}buIK$oaZJ6n3|fog58>dnw69>`{i zW5t@P;Zw@ujBpw%?OqfG5!9&sNL~{T>NFvMy_hPfNUMzaHC<)RG7@MM1%wrY|K-=0 zzVarFoqnHE245~>ySbfbRBp}8FWd+LqGIV@R@|^+AsVSVO0$~2H1C*^V~tg*o=RaZ z?AemI>(a6>5$+vJL(VIT`QorEE_UZnjf zuVqcQ?W`cYW zWX`WCRFie=IJn_5ZOSY&%)HRNBJr0VKmQ;1QIt-X+&nm!{36H0nYen*nW&Zht#1k8bHJ2isAIt~^FVoH#!l|iW?1A=qq7QmvzYISU;F#7|H|{9M--vo^JNtyBY(PB zVud?Ir07}&l^qKQ)>qrT=7_R%W7hR3U9+v6WH^aj{$9tnOqqV4U^Z8pCKlxMsn3pW zVe`Z*KKUsl@S75(=OpDXc*>x*rC#RH7cp{{bX@1>=PT#y%m#9~rupj8eck{R%Z^Xq zBw4g&qkjzYC;#yH^&kJir6u~&#~{DZ#f}I*Irsu;ZTyQ*Hx*3d`Y~OTy`}hACP|=j zSqbqXE>iKigDrrcNHU$>bm!jak!M%mccI&ck&1*y({8{AW=_GRw}|33cmw)|WiH;- zw^zJ)SzaLTT4AeS0fLAwS3H5D>7Gy9EdZe}Ve08m7xG2)bx*piVgo!mf9kaY`~&sb z&72O@qGjO-7Wd#GTXH|cWa@`7@WP6SP)~>t-Kf1DBcu`&jU|Oa?e!QVXIP6t?e!Sa ztH-!lrZ@FC=W}uBO+C)zuzoC$Lpg6S{HkvJ3viM-0zmsb&iGYbHw$n=yDGJ^nb6yM zDPSiIwyjNR8L>u>Qy)PYaPcMNXHr9ai0?8Z2Sr>I-c+h?R+FD6>aAim+5$f{Tr$#Y z^si!j`HRWhV}z1Brhf$=d~#6!VDf2LU}9HvSezq0*(5I(J0DIh>N(F>vt;7Qde2m> zvfj8>kI-IDv7bh=HOcLwzMl(LFiJ%lJwzw*dt0CCHh&W zQl70Ua&?ELK>nHKoQ3Rz^eS?7v!Fo!#)`IeP(`dR_zJ`yZq6?Ms@Txcffnfh*{aP)kMDi|4-n9E`1d6!#m~3t>AwN5j07eC literal 0 HcmV?d00001 diff --git a/tests/e2e/robustness_test.ts b/tests/e2e/robustness_test.ts index fb5552b..ced5ac4 100644 --- a/tests/e2e/robustness_test.ts +++ b/tests/e2e/robustness_test.ts @@ -21,8 +21,8 @@ import { import { handler as modulesHandler } from "$apps/admin/api/modules.ts"; import { handler as moduleHandler } from "$apps/admin/api/modules/[idModule].ts"; import { handler as notesHandler } from "$apps/notes/api/notes.ts"; -import { handler as uesHandler } from "$apps/notes/api/ues.ts"; -import { handler as ueModulesHandler } from "$apps/notes/api/ue-modules.ts"; +import { handler as uesHandler } from "$apps/admin/api/ues.ts"; +import { handler as ueModulesHandler } from "$apps/admin/api/ue-modules.ts"; import { handler as ajustementsHandler } from "$apps/notes/api/ajustements.ts"; import { handler as enseignementsHandler } from "$apps/admin/api/enseignements.ts"; import { handler as usersHandler } from "$apps/admin/api/users.ts"; diff --git a/tests/e2e/ue_modules_test.ts b/tests/e2e/ue_modules_test.ts index 3a921f8..30dba17 100644 --- a/tests/e2e/ue_modules_test.ts +++ b/tests/e2e/ue_modules_test.ts @@ -14,8 +14,8 @@ import { seedUes, truncateAll, } from "../helpers/db_integration.ts"; -import { handler as ueModulesHandler } from "$apps/notes/api/ue-modules.ts"; -import { handler as ueModuleHandler } from "$apps/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; +import { handler as ueModulesHandler } from "$apps/admin/api/ue-modules.ts"; +import { handler as ueModuleHandler } from "$apps/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; import { ueModules as ueModulesTable } from "$root/databases/schema.ts"; import { testDb } from "../helpers/db_integration.ts"; diff --git a/tests/e2e/ues_test.ts b/tests/e2e/ues_test.ts index 1797f8d..d5d726d 100644 --- a/tests/e2e/ues_test.ts +++ b/tests/e2e/ues_test.ts @@ -7,8 +7,8 @@ import { makeJsonRequest, } from "../helpers/handler.ts"; import { seedUes, truncateAll } from "../helpers/db_integration.ts"; -import { handler as uesHandler } from "$apps/notes/api/ues.ts"; -import { handler as ueHandler } from "$apps/notes/api/ues/[idUE].ts"; +import { handler as uesHandler } from "$apps/admin/api/ues.ts"; +import { handler as ueHandler } from "$apps/admin/api/ues/[idUE].ts"; // --- GET /ues --- -- 2.52.0 From 9a4c6863d1ff65486ceebfc9586ff9c702ddc417 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 12:47:23 +0200 Subject: [PATCH 3/6] feat: stages module, mobility frontend, theme toggle, employeeOnly access control - Add stages module with full CRUD API and admin overview island - Add mobility overview island (Liste, Kanban, Detail CRUD views) - Add contract PDF upload/download endpoints for mobilites - Add light/dark theme toggle in header - Add employeeOnly flag to hide entire modules from students (admin, students, stages) - Add read-only GET endpoints for modules/ues/ue-modules in notes module - Add [slug].tsx catch-all routes for direct URL navigation - Replace old mobility table with mobilites + stages schema (migration 0004) - Allow students to create mobilites and upload contracts - Redirect authenticated users from / to /apps catalog --- compose.prod.yml | 3 + .../0004_add_stages_and_mobilites.sql | 28 + databases/migrations/meta/_journal.json | 7 + databases/schema.ts | 36 +- defaults/interfaces.ts | 1 + defaults/makeSlug.ts | 36 + fresh.gen.ts | 57 +- routes/(_components)/Header.tsx | 8 + routes/(apps)/_middleware.ts | 8 +- .../admin/(_islands)/ImportMaquette.tsx | 48 +- routes/(apps)/admin/(_props)/props.ts | 12 +- routes/(apps)/admin/[slug].tsx | 2 + .../(apps)/admin/partials/enseignements.tsx | 1 + .../(apps)/admin/partials/import-maquette.tsx | 1 + routes/(apps)/admin/partials/modules.tsx | 1 + routes/(apps)/admin/partials/permissions.tsx | 1 + routes/(apps)/admin/partials/promotions.tsx | 1 + routes/(apps)/admin/partials/roles.tsx | 1 + routes/(apps)/admin/partials/ues.tsx | 1 + routes/(apps)/admin/partials/users.tsx | 1 + .../mobility/(_islands)/ConsultMobility.tsx | 115 --- .../(_islands)/ConsultStudents_test.tsx | 75 -- .../mobility/(_islands)/EditMobility.tsx | 248 ----- .../(apps)/mobility/(_islands)/ImportFile.tsx | 0 .../mobility/(_islands)/MobilityOverview.tsx | 931 ++++++++++++++++++ routes/(apps)/mobility/(_props)/props.ts | 12 +- routes/(apps)/mobility/[slug].tsx | 2 + routes/(apps)/mobility/api/insert_mobility.ts | 122 --- routes/(apps)/mobility/api/mobilites.ts | 116 +++ .../(apps)/mobility/api/mobilites/[idMob].ts | 149 +++ .../mobility/api/mobilites/[idMob]/contrat.ts | 156 +++ .../(admin)/consult_students_test.tsx | 21 - .../partials/(admin)/edit_mobility.tsx | 20 - routes/(apps)/mobility/partials/index.tsx | 22 +- routes/(apps)/mobility/partials/overview.tsx | 19 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 15 +- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 6 +- routes/(apps)/notes/(_islands)/NotesView.tsx | 6 +- routes/(apps)/notes/[slug].tsx | 2 + routes/(apps)/notes/api/modules.ts | 12 + routes/(apps)/notes/api/ue-modules.ts | 28 + routes/(apps)/notes/api/ues.ts | 12 + .../(apps)/notes/partials/(admin)/courses.tsx | 1 + .../(apps)/notes/partials/(admin)/import.tsx | 1 + routes/(apps)/notes/partials/notes.tsx | 1 + .../stages/(_islands)/StagesOverview.tsx | 542 ++++++++++ routes/(apps)/stages/(_props)/props.ts | 15 + routes/(apps)/stages/[slug].tsx | 2 + routes/(apps)/stages/api/stages.ts | 84 ++ routes/(apps)/stages/api/stages/[idStage].ts | 122 +++ routes/(apps)/stages/index.tsx | 2 + routes/(apps)/stages/partials/index.tsx | 30 + routes/(apps)/stages/partials/overview.tsx | 19 + routes/(apps)/students/(_props)/props.ts | 1 + routes/(apps)/students/[slug].tsx | 2 + .../(apps)/students/api/students/[numEtud].ts | 8 +- .../students/partials/(admin)/consult.tsx | 1 + .../students/partials/(admin)/upload.tsx | 1 + routes/_app.tsx | 1 + routes/apps.tsx | 13 +- routes/index.tsx | 21 +- scripts/generate-templates.ts | 29 +- scripts/inspect-maquette.ts | 8 +- static/theme.js | 29 + tests/helpers/db_integration.ts | 2 +- 65 files changed, 2597 insertions(+), 681 deletions(-) create mode 100644 databases/migrations/0004_add_stages_and_mobilites.sql create mode 100644 defaults/makeSlug.ts create mode 100644 routes/(apps)/admin/[slug].tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ConsultMobility.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/EditMobility.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ImportFile.tsx create mode 100644 routes/(apps)/mobility/(_islands)/MobilityOverview.tsx create mode 100644 routes/(apps)/mobility/[slug].tsx delete mode 100644 routes/(apps)/mobility/api/insert_mobility.ts create mode 100644 routes/(apps)/mobility/api/mobilites.ts create mode 100644 routes/(apps)/mobility/api/mobilites/[idMob].ts create mode 100644 routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts delete mode 100644 routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx delete mode 100644 routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx create mode 100644 routes/(apps)/notes/[slug].tsx create mode 100644 routes/(apps)/notes/api/modules.ts create mode 100644 routes/(apps)/notes/api/ue-modules.ts create mode 100644 routes/(apps)/notes/api/ues.ts create mode 100644 routes/(apps)/stages/(_islands)/StagesOverview.tsx create mode 100644 routes/(apps)/stages/(_props)/props.ts create mode 100644 routes/(apps)/stages/[slug].tsx create mode 100644 routes/(apps)/stages/api/stages.ts create mode 100644 routes/(apps)/stages/api/stages/[idStage].ts create mode 100644 routes/(apps)/stages/index.tsx create mode 100644 routes/(apps)/stages/partials/index.tsx create mode 100644 routes/(apps)/stages/partials/overview.tsx create mode 100644 routes/(apps)/students/[slug].tsx create mode 100644 static/theme.js diff --git a/compose.prod.yml b/compose.prod.yml index 6d7f11a..a20b1e8 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -30,9 +30,12 @@ services: ports: - "4430:443" env_file: .env + volumes: + - contracts:/app/uploads/contracts depends_on: migrate: condition: service_completed_successfully volumes: db_data: + contracts: diff --git a/databases/migrations/0004_add_stages_and_mobilites.sql b/databases/migrations/0004_add_stages_and_mobilites.sql new file mode 100644 index 0000000..a1f8a5d --- /dev/null +++ b/databases/migrations/0004_add_stages_and_mobilites.sql @@ -0,0 +1,28 @@ +DROP TABLE IF EXISTS "mobility"; +--> statement-breakpoint +CREATE TYPE "mobility_status" AS ENUM ('contracts_received', 'under_revision', 'done', 'validated', 'canceled'); +--> statement-breakpoint +CREATE TABLE "stages" ( + "idStage" serial PRIMARY KEY NOT NULL, + "numEtud" integer NOT NULL, + "duree" integer NOT NULL, + "nomEntreprise" text NOT NULL, + "mission" text +); +--> statement-breakpoint +CREATE TABLE "mobilites" ( + "idMob" serial PRIMARY KEY NOT NULL, + "numEtud" integer NOT NULL, + "duree" integer NOT NULL, + "contratMob" text, + "ecole" text, + "pays" text, + "status" "mobility_status" NOT NULL DEFAULT 'contracts_received', + "idStage" integer +); +--> statement-breakpoint +ALTER TABLE "stages" ADD CONSTRAINT "stages_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_idStage_stages_idStage_fk" FOREIGN KEY ("idStage") REFERENCES "public"."stages"("idStage") ON DELETE no action ON UPDATE no action; diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json index f81c27d..3cb93bd 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1777155028711, "tag": "0003_add_session2_and_malus", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1777155028712, + "tag": "0004_add_stages_and_mobilites", + "breakpoints": true } ] } diff --git a/databases/schema.ts b/databases/schema.ts index 9bf678d..eadbb3a 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -1,7 +1,7 @@ import { - date, doublePrecision, integer, + pgEnum, pgTable, primaryKey, serial, @@ -89,13 +89,29 @@ export const ajustements = pgTable("ajustements", { pk: primaryKey({ columns: [t.numEtud, t.idUE] }), })); -export const mobility = pgTable("mobility", { - id: serial("id").primaryKey(), - studentId: integer("studentId").references(() => students.numEtud), - startDate: date("startDate"), - endDate: date("endDate"), - weeksCount: integer("weeksCount"), - destinationCountry: text("destinationCountry"), - destinationName: text("destinationName"), - mobilityStatus: text("mobilityStatus").default("N/A"), +export const stages = pgTable("stages", { + id: serial("idStage").primaryKey(), + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + duree: integer("duree").notNull(), + nomEntreprise: text("nomEntreprise").notNull(), + mission: text("mission"), +}); + +export const mobilityStatusEnum = pgEnum("mobility_status", [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +]); + +export const mobilites = pgTable("mobilites", { + id: serial("idMob").primaryKey(), + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + duree: integer("duree").notNull(), + contratMob: text("contratMob"), + ecole: text("ecole"), + pays: text("pays"), + status: mobilityStatusEnum("status").notNull().default("contracts_received"), + idStage: integer("idStage").references(() => stages.id), }); diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index 9b65a28..951201a 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -20,6 +20,7 @@ export interface AppProperties { pages: Record; adminOnly: string[]; studentOnly?: string[]; + employeeOnly?: boolean; hint: string; } diff --git a/defaults/makeSlug.ts b/defaults/makeSlug.ts new file mode 100644 index 0000000..ee12fa4 --- /dev/null +++ b/defaults/makeSlug.ts @@ -0,0 +1,36 @@ +import { FreshContext } from "$fresh/server.ts"; +import { Route, State } from "$root/defaults/interfaces.ts"; +import { ComponentChildren } from "preact"; + +/** + * Generates a catch-all [slug] route that dynamically loads partials. + * This enables direct URL navigation to sub-pages (e.g. /admin/modules). + * @param basePath The base path of the module, should be `import.meta.dirname!`. + * @returns A route handler that loads the partial matching the slug. + */ +export default function makeSlug(basePath: string): Route { + return async function SlugRoute( + request: Request, + context: FreshContext, + ): Promise { + const slug = context.params.slug; + + // Try partials/.tsx, then partials/(admin)/.tsx + let page: Route | undefined; + try { + page = (await import(`${basePath}/partials/${slug}.tsx`)).Page; + } catch { + try { + page = (await import(`${basePath}/partials/(admin)/${slug}.tsx`)).Page; + } catch { + // No partial found for this slug + } + } + + if (!page) { + return context.renderNotFound(); + } + + return page(request, context); + }; +} diff --git a/fresh.gen.ts b/fresh.gen.ts index bd47e97..4d3229d 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,6 +4,7 @@ import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; +import * as $_apps_admin_slug_ from "./routes/(apps)/admin/[slug].tsx"; 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"; @@ -30,16 +31,22 @@ import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/rol import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx"; -import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; +import * as $_apps_mobility_slug_ from "./routes/(apps)/mobility/[slug].tsx"; +import * as $_apps_mobility_api_mobilites from "./routes/(apps)/mobility/api/mobilites.ts"; +import * as $_apps_mobility_api_mobilites_idMob_ from "./routes/(apps)/mobility/api/mobilites/[idMob].ts"; +import * as $_apps_mobility_api_mobilites_idMob_contrat from "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.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_slug_ from "./routes/(apps)/notes/[slug].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_modules from "./routes/(apps)/notes/api/modules.ts"; import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts"; import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts"; import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts"; +import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts"; +import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.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_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; @@ -47,6 +54,13 @@ import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/parti import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; +import * as $_apps_stages_slug_ from "./routes/(apps)/stages/[slug].tsx"; +import * as $_apps_stages_api_stages from "./routes/(apps)/stages/api/stages.ts"; +import * as $_apps_stages_api_stages_idStage_ from "./routes/(apps)/stages/api/stages/[idStage].ts"; +import * as $_apps_stages_index from "./routes/(apps)/stages/index.tsx"; +import * as $_apps_stages_partials_index from "./routes/(apps)/stages/partials/index.tsx"; +import * as $_apps_stages_partials_overview from "./routes/(apps)/stages/partials/overview.tsx"; +import * as $_apps_students_slug_ from "./routes/(apps)/students/[slug].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"; @@ -79,13 +93,12 @@ import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_island import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx"; import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx"; import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.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_mobility_islands_MobilityOverview from "./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx"; import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.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_stages_islands_StagesOverview from "./routes/(apps)/stages/(_islands)/StagesOverview.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"; @@ -95,6 +108,7 @@ const manifest = { routes: { "./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_middleware.ts": $_apps_middleware, + "./routes/(apps)/admin/[slug].tsx": $_apps_admin_slug_, "./routes/(apps)/admin/api/enseignements.ts": $_apps_admin_api_enseignements, "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts": @@ -131,23 +145,29 @@ const manifest = { "./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, - "./routes/(apps)/mobility/api/insert_mobility.ts": - $_apps_mobility_api_insert_mobility, + "./routes/(apps)/mobility/[slug].tsx": $_apps_mobility_slug_, + "./routes/(apps)/mobility/api/mobilites.ts": $_apps_mobility_api_mobilites, + "./routes/(apps)/mobility/api/mobilites/[idMob].ts": + $_apps_mobility_api_mobilites_idMob_, + "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts": + $_apps_mobility_api_mobilites_idMob_contrat, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, - "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx": - $_apps_mobility_partials_admin_edit_mobility, "./routes/(apps)/mobility/partials/index.tsx": $_apps_mobility_partials_index, "./routes/(apps)/mobility/partials/overview.tsx": $_apps_mobility_partials_overview, + "./routes/(apps)/notes/[slug].tsx": $_apps_notes_slug_, "./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/modules.ts": $_apps_notes_api_modules, "./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes, "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts": $_apps_notes_api_notes_numEtud_idModule_, "./routes/(apps)/notes/api/notes/import-xlsx.ts": $_apps_notes_api_notes_import_xlsx, + "./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules, + "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, "./routes/(apps)/notes/edition/[numEtud].tsx": $_apps_notes_edition_numEtud_, "./routes/(apps)/notes/index.tsx": $_apps_notes_index, @@ -158,6 +178,15 @@ const manifest = { "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, + "./routes/(apps)/stages/[slug].tsx": $_apps_stages_slug_, + "./routes/(apps)/stages/api/stages.ts": $_apps_stages_api_stages, + "./routes/(apps)/stages/api/stages/[idStage].ts": + $_apps_stages_api_stages_idStage_, + "./routes/(apps)/stages/index.tsx": $_apps_stages_index, + "./routes/(apps)/stages/partials/index.tsx": $_apps_stages_partials_index, + "./routes/(apps)/stages/partials/overview.tsx": + $_apps_stages_partials_overview, + "./routes/(apps)/students/[slug].tsx": $_apps_students_slug_, "./routes/(apps)/students/api/promotions.ts": $_apps_students_api_promotions, "./routes/(apps)/students/api/promotions/[idPromo].ts": @@ -210,12 +239,8 @@ const manifest = { $_apps_admin_islands_EditUser, "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx": $_apps_admin_islands_ImportMaquette, - "./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)/mobility/(_islands)/MobilityOverview.tsx": + $_apps_mobility_islands_MobilityOverview, "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx": $_apps_notes_islands_AdminConsultNotes, "./routes/(apps)/notes/(_islands)/ImportNotes.tsx": @@ -224,6 +249,8 @@ const manifest = { $_apps_notes_islands_NoteRecap, "./routes/(apps)/notes/(_islands)/NotesView.tsx": $_apps_notes_islands_NotesView, + "./routes/(apps)/stages/(_islands)/StagesOverview.tsx": + $_apps_stages_islands_StagesOverview, "./routes/(apps)/students/(_islands)/ConsultStudents.tsx": $_apps_students_islands_ConsultStudents, "./routes/(apps)/students/(_islands)/EditStudents.tsx": diff --git a/routes/(_components)/Header.tsx b/routes/(_components)/Header.tsx index 34853ad..ec5573b 100644 --- a/routes/(_components)/Header.tsx +++ b/routes/(_components)/Header.tsx @@ -11,6 +11,14 @@ export default function Header(props: HeaderProps) { ); diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index ece0de4..a671134 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -21,11 +21,17 @@ export const handler: MiddlewareHandler[] = [ `./${currentApp}/(_props)/props.ts` )).default; - context.state.availablePages = { ...properties.pages }; const isStudent = context.state.session.eduPersonPrimaryAffiliation === "student"; const isLocal = Deno.env.get("LOCAL") === "true"; + // Block students from accessing employeeOnly modules entirely + if (isStudent && properties.employeeOnly) { + return new Response(null, { status: 403 }); + } + + context.state.availablePages = { ...properties.pages }; + if (isStudent) { // Students only see studentOnly pages (+ non-restricted pages) properties.adminOnly.forEach((page) => diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx index 676e283..278081c 100644 --- a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -151,7 +151,10 @@ export default function ImportMaquette() { }); if (res.ok) { const created = await res.json(); - promos.value = [...promos.value, { id: created.id, annee: created.annee }]; + promos.value = [...promos.value, { + id: created.id, + annee: created.annee, + }]; newPromoId.value = ""; newPromoAnnee.value = ""; } else { @@ -289,7 +292,14 @@ export default function ImportMaquette() { ); const data: (string | number | null)[][] = [ - ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\nECTS", "Coeff."], + [ + "Annee\nSemestres", + "Codes APOGEE", + null, + null, + "Credits\nECTS", + "Coeff.", + ], ]; for (const ue of uesData) { @@ -303,7 +313,14 @@ export default function ImportMaquette() { data.push(["UE", null, ue.nom, null, totalCoeff]); for (const um of mods) { const mod = modMap[um.idModule]; - data.push([null, um.idModule, null, mod ? mod.nom : um.idModule, null, um.coeff]); + data.push([ + null, + um.idModule, + null, + mod ? mod.nom : um.idModule, + null, + um.coeff, + ]); } data.push([]); } @@ -312,7 +329,10 @@ export default function ImportMaquette() { const ws = XLSX.utils.aoa_to_sheet(data); XLSX.utils.book_append_sheet(wb, ws, "Maquette"); const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); - const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + const blob = new Blob([buf], { + type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -384,8 +404,9 @@ export default function ImportMaquette() { class="filter-select" placeholder="ID (ex: 3AFISE24-25)" value={newPromoId.value} - onInput={(e) => - (newPromoId.value = (e.target as HTMLInputElement).value)} + onInput={( + e, + ) => (newPromoId.value = (e.target as HTMLInputElement).value)} style="min-width: 10rem" /> - (newPromoAnnee.value = (e.target as HTMLInputElement).value)} + onInput={( + e, + ) => (newPromoAnnee.value = (e.target as HTMLInputElement).value)} style="min-width: 8rem" />
+ 0} + onChange={toggleAll} + /> + N° étud. Nom Prénom
+ Aucun élève trouvé
+ toggleOne(s.numEtud)} + /> + {s.numEtud} {s.nom} {s.prenom}
- - - - - - - - - - - - - - - {data.students - ?.filter((student) => student.promotionId === promo.id) - .map((student) => { - const mobility = data.mobilities?.find((mob) => - mob.studentId === student.id - ); - return ( - - - - - - - - - - - - ); - })} - -
IDFirst NameLast NameStart DateEnd DateWeeks CountDestination CountryDestination NameStatus
{student.id}{student.firstName}{student.lastName}{mobility?.startDate || "N/A"}{mobility?.endDate || "N/A"}{mobility?.weeksCount ?? "N/A"}{mobility?.destinationCountry || "N/A"}{mobility?.destinationName || "N/A"}{mobility?.mobilityStatus || "N/A"}
-
- ))} - - ); -} diff --git a/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx b/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx deleted file mode 100644 index 3e008bc..0000000 --- a/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useState } from "preact/hooks"; - -interface Promotion { - id: number; - name: string; -} - -interface Student { - id: number; - firstName: string; - lastName: string; - mail: string; - promotionId: number; - promotionName: string; -} - -export default function ConsultStudents_test() { - const [data, setData] = useState< - { promotions: Promotion[]; students: Student[] } | null - >(null); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch("/students/api/insert_students"); - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - setData(result); - } catch (err) { - console.error("Error fetching data:", err); - setError("Failed to load data. Please try again later."); - } - }; - - fetchData(); - }, []); - - return ( -
-

Consult Students

- {error &&

{error}

} - {data?.promotions.map((promo) => ( -
-

Promotion: {promo.id}

- - - - - - - - - - - {data.students - .filter((student) => student.promotionId === promo.id) - .map((student) => ( - - - - - - - ))} - -
IDFirst NameLast NameEmail
{student.id}{student.firstName}{student.lastName}{student.mail}
-
- ))} -
- ); -} diff --git a/routes/(apps)/mobility/(_islands)/EditMobility.tsx b/routes/(apps)/mobility/(_islands)/EditMobility.tsx deleted file mode 100644 index 8991926..0000000 --- a/routes/(apps)/mobility/(_islands)/EditMobility.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { useEffect, useState } from "preact/hooks"; - -interface Student { - id: string; - firstName: string; - lastName: string; - promotionId: number; -} - -interface Promotion { - id: number; - name: string; -} - -interface Mobility { - id: number | null; - studentId: string; - startDate: string | null; - endDate: string | null; - weeksCount: number | null; - destinationCountry: string | null; - destinationName: string | null; - mobilityStatus: string; -} - -export default function EditMobility() { - const [data, setData] = useState< - | { - promotions?: Promotion[]; - students?: Student[]; - mobilities?: Mobility[]; - } - | null - >(null); - const [error, setError] = useState(null); - const [isSaving, setIsSaving] = useState(false); - - useEffect(() => { - const fetchData = async () => { - console.log("EditMobility: Fetching data from API..."); - try { - const response = await fetch("/mobility/api/insert_mobility"); - console.log("EditMobility: API response status:", response.status); - - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - console.log("EditMobility: Data fetched successfully:", result); - setData(result); - } catch (err) { - console.error("EditMobility: Error fetching data:", err); - setError("Failed to load mobility data. Please try again later."); - } - }; - - fetchData(); - }, []); - - const handleChange = ( - studentId: string, - field: keyof Mobility, - value: string | number | null, - ) => { - if (!data) return; - - setData((prevData) => { - if (!prevData) return null; - - const updatedMobilities = prevData.mobilities?.map((mobility) => { - if (mobility.studentId === studentId) { - const updatedMobility = { ...mobility, [field]: value }; - - if (field === "startDate" || field === "endDate") { - const startDate = new Date(updatedMobility.startDate || ""); - const endDate = new Date(updatedMobility.endDate || ""); - if (startDate && endDate && startDate <= endDate) { - const weeks = Math.ceil( - (endDate.getTime() - startDate.getTime()) / - (7 * 24 * 60 * 60 * 1000), - ); - updatedMobility.weeksCount = weeks; - } else { - updatedMobility.weeksCount = null; - } - } - - return updatedMobility; - } - return mobility; - }) || []; - - return { ...prevData, mobilities: updatedMobilities }; - }); - }; - - const handleSave = async () => { - setIsSaving(true); - - try { - const response = await fetch("/mobility/api/insert_mobility", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: data?.mobilities }), - }); - - console.log("EditMobility: Save response status:", response.status); - - if (response.ok) { - alert("Data saved successfully!"); - globalThis.location.reload(); - } else { - throw new Error(`Failed to save data: ${response.statusText}`); - } - } catch (error) { - console.error("EditMobility: Error saving data:", error); - alert("An error occurred while saving data."); - } finally { - setIsSaving(false); - } - }; - - if (error) { - return

{error}

; - } - - if (!data?.promotions) { - return

Loading data...

; - } - - return ( -
-

Edit Mobility

- {data.promotions.map((promo) => ( -
-

Promotion: {promo.name}

- - - - - - - - - - - - - - - - {data.students - ?.filter((student) => student.promotionId === promo.id) - .map((student) => { - const mobility = data.mobilities?.find((mob) => - mob.studentId === student.id - ) || { - id: null, - studentId: student.id, - startDate: null, - endDate: null, - weeksCount: null, - destinationCountry: null, - destinationName: null, - mobilityStatus: "N/A", - }; - - return ( - - - - - - - - - - - - ); - })} - -
IDFirst NameLast NameStart DateEnd DateWeeks CountDestination CountryDestination NameStatus
{student.id}{student.firstName}{student.lastName} - - handleChange( - student.id, - "startDate", - e.target.value, - )} - /> - - - handleChange(student.id, "endDate", e.target.value)} - /> - {mobility.weeksCount ?? "N/A"} - - handleChange( - student.id, - "destinationCountry", - e.target.value, - )} - /> - - - handleChange( - student.id, - "destinationName", - e.target.value, - )} - /> - - -
-
- ))} - -
- ); -} diff --git a/routes/(apps)/mobility/(_islands)/ImportFile.tsx b/routes/(apps)/mobility/(_islands)/ImportFile.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx new file mode 100644 index 0000000..f429469 --- /dev/null +++ b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx @@ -0,0 +1,931 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; +type Mobilite = { + id: number; + numEtud: number; + duree: number; + contratMob: string | null; + ecole: string | null; + pays: string | null; + status: string; + idStage: number | null; +}; +type Stage = { + id: number; + numEtud: number; + duree: number; + nomEntreprise: string; + mission: string | null; +}; + +const REQUIRED_WEEKS = 12; + +const STATUS_ORDER = [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +] as const; + +const STATUS_LABELS: Record = { + contracts_received: "Contrats reçus", + under_revision: "En révision", + done: "Signé", + validated: "Validé", + canceled: "Annulé", +}; + +const STATUS_COLORS: Record = { + contracts_received: "#f5a623", + under_revision: "#dc2626", + done: "#22c55e", + validated: "light-dark(var(--light-accent-color), var(--dark-accent-color))", + canceled: + "light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))", +}; + +function lowestStatus(mobs: Mobilite[]): string { + let lowest = STATUS_ORDER.length - 1; + for (const m of mobs) { + const idx = STATUS_ORDER.indexOf(m.status as typeof STATUS_ORDER[number]); + if (idx >= 0 && idx < lowest) lowest = idx; + } + return STATUS_ORDER[lowest]; +} + +function validatedWeeks(mobs: Mobilite[]): number { + return mobs + .filter((m) => m.status === "validated") + .reduce((sum, m) => sum + m.duree, 0); +} + +export default function MobilityOverview() { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [mobilites, setMobilites] = useState([]); + const [stagesMap, setStagesMap] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [tab, setTab] = useState<"liste" | "kanban">("liste"); + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + + // Detail view state + const [detailStudent, setDetailStudent] = useState(null); + const [editingMob, setEditingMob] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + async function load() { + try { + const [sRes, pRes, mRes, stRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + fetch("/mobility/api/mobilites"), + fetch("/stages/api/stages"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les données"); + const [sData, pData, mData, stData] = await Promise.all([ + sRes.json(), + pRes.ok ? pRes.json() : [], + mRes.ok ? mRes.json() : [], + stRes.ok ? stRes.json() : [], + ]); + setStudents(sData); + setPromos(pData); + setMobilites(mData); + setStagesMap( + Object.fromEntries((stData as Stage[]).map((s) => [s.id, s])), + ); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + // If in detail view, render that + if (detailStudent) { + return ( + m.numEtud === detailStudent.numEtud)} + allMobilites={mobilites} + stagesMap={stagesMap} + editingMob={editingMob} + setEditingMob={setEditingMob} + showAddForm={showAddForm} + setShowAddForm={setShowAddForm} + onBack={() => { + setDetailStudent(null); + setEditingMob(null); + setShowAddForm(false); + }} + onReload={load} + /> + ); + } + + if (loading) { + return ( +
+

Chargement...

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

{error}

+
+ ); + } + + 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; + }); + + const mobsByStudent = (numEtud: number) => + mobilites.filter((m) => m.numEtud === numEtud); + + return ( +
+

Suivi des mobilités

+ +
+ + +
+ +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + {tab === "liste" + ? ( + setDetailStudent(s)} + /> + ) + : ( + setDetailStudent(s)} + /> + )} +
+ ); +} + +// ─── Liste View ───────────────────────────────────────────── + +function ListView( + { students, mobsByStudent, onConsult }: { + students: Student[]; + mobsByStudent: (n: number) => Mobilite[]; + onConsult: (s: Student) => void; + }, +) { + return ( +
+ + + + + + + + + + + + {students.length === 0 + ? ( + + + + ) + : students.map((s) => { + const mobs = mobsByStudent(s.numEtud); + const weeks = validatedWeeks(mobs); + const ok = weeks >= REQUIRED_WEEKS; + return ( + + + + + + + + ); + })} + +
N° étud.NomPrénomSemainesActions
Aucun élève trouvé
{s.numEtud}{s.nom}{s.prenom} + + {weeks}/{REQUIRED_WEEKS} + + + +
+
+ ); +} + +// ─── Kanban View ──────────────────────────────────────────── + +function KanbanView( + { students, mobsByStudent, onConsult }: { + students: Student[]; + mobsByStudent: (n: number) => Mobilite[]; + onConsult: (s: Student) => void; + }, +) { + // Students who have at least one mobility + const studentsWithMobs = students.filter( + (s) => mobsByStudent(s.numEtud).length > 0, + ); + + // Group students by their lowest status + const columns: Record = {}; + for (const status of STATUS_ORDER) columns[status] = []; + + for (const s of studentsWithMobs) { + const mobs = mobsByStudent(s.numEtud); + // Filter out canceled for lowest-status calc (canceled is separate) + const activeMobs = mobs.filter((m) => m.status !== "canceled"); + if (activeMobs.length === 0) { + // All canceled + columns["canceled"].push(s); + } else { + const lowest = lowestStatus(activeMobs); + columns[lowest].push(s); + } + } + + return ( +
+ {STATUS_ORDER.map((status) => ( +
+
+ + + {STATUS_LABELS[status]} + + + {columns[status].length} + +
+
+ {columns[status].length === 0 + ? ( +

+ Aucun +

+ ) + : columns[status].map((s) => { + const mobs = mobsByStudent(s.numEtud); + const weeks = validatedWeeks(mobs); + return ( +
onConsult(s)} + > +
+ {s.nom} {s.prenom} +
+
+ {s.numEtud} + = REQUIRED_WEEKS + ? "#22c55e" + : "#dc2626", + fontWeight: "var(--font-weight-bold)", + fontFamily: "monospace", + }} + > + {weeks}/{REQUIRED_WEEKS} sem. + +
+
+ ); + })} +
+
+ ))} +
+ ); +} + +// ─── Detail View ──────────────────────────────────────────── + +function DetailView( + { + student, + mobilites, + allMobilites, + stagesMap, + editingMob, + setEditingMob, + showAddForm, + setShowAddForm, + onBack, + onReload, + }: { + student: Student; + mobilites: Mobilite[]; + allMobilites: Mobilite[]; + stagesMap: Record; + editingMob: Mobilite | null; + setEditingMob: (m: Mobilite | null) => void; + showAddForm: boolean; + setShowAddForm: (v: boolean) => void; + onBack: () => void; + onReload: () => Promise; + }, +) { + const weeks = validatedWeeks(mobilites); + const ecoles = [...new Set(allMobilites.map((m) => m.ecole).filter(Boolean))]; + const paysList = [ + ...new Set(allMobilites.map((m) => m.pays).filter(Boolean)), + ]; + + async function deleteMob(id: number) { + if (!confirm("Supprimer cette mobilité ?")) return; + await fetch(`/mobility/api/mobilites/${id}`, { method: "DELETE" }); + await onReload(); + } + + return ( +
+ +

+ Consulter : {student.prenom} {student.nom} + = REQUIRED_WEEKS ? "#22c55e" : "#dc2626", + fontFamily: "monospace", + }} + > + {weeks}/{REQUIRED_WEEKS} semaines validées + +

+ + {mobilites.length === 0 && ( +

Aucune mobilité enregistrée.

+ )} + + {mobilites.map((mob, i) => { + const stage = mob.idStage ? stagesMap[mob.idStage] : null; + const isEditing = editingMob?.id === mob.id; + + if (isEditing) { + return ( + setEditingMob(null)} + onSave={async () => { + setEditingMob(null); + await onReload(); + }} + /> + ); + } + + return ( +
+
+

+ Mobilité {i + 1} + {stage ? " : Stage" : " : Étude"} +

+

+ + {STATUS_LABELS[mob.status] ?? mob.status} + + Durée : {mob.duree} semaine(s) +

+
+
+ {stage + ? ( +

+ Entreprise : {stage.nomEntreprise} + {stage.mission && — {stage.mission}} +

+ ) + : ( +

+ {mob.ecole && ( + <> + École : {mob.ecole} + + )} + {mob.ecole && mob.pays && ,} + {mob.pays && ( + <> + Pays : {mob.pays} + + )} +

+ )} +
+ {mob.contratMob && ( + + Télécharger contrat + + )} + {!mob.idStage && ( + + )} + + +
+
+
+ ); + })} + + {showAddForm + ? ( + setShowAddForm(false)} + onSave={async () => { + setShowAddForm(false); + await onReload(); + }} + /> + ) + : ( + + )} +
+ ); +} + +// ─── Inline forms ─────────────────────────────────────────── + +function MobEditForm( + { mob, ecoles, paysList, onCancel, onSave }: { + mob: Mobilite; + ecoles: string[]; + paysList: string[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState(String(mob.duree)); + const [ecole, setEcole] = useState(mob.ecole ?? ""); + const [pays, setPays] = useState(mob.pays ?? ""); + const [status, setStatus] = useState(mob.status); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + try { + const res = await fetch(`/mobility/api/mobilites/${mob.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + duree: parseInt(duree), + ecole: ecole || null, + pays: pays || null, + status, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la modification"); + } finally { + setBusy(false); + } + } + + return ( +
+

Modifier la mobilité #{mob.id}

+
+
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+ {!mob.idStage && ( + <> +
+ + setEcole((e.target as HTMLInputElement).value)} + /> + + {ecoles.map((e) => +
+
+ + setPays((e.target as HTMLInputElement).value)} + /> + + {paysList.map((p) => +
+
+ + +
+ + )} +
+
+ + +
+
+ ); +} + +function MobAddForm( + { numEtud, ecoles, paysList, onCancel, onSave }: { + numEtud: number; + ecoles: string[]; + paysList: string[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState("4"); + const [ecole, setEcole] = useState(""); + const [pays, setPays] = useState(""); + const [status, setStatus] = useState("contracts_received"); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + try { + const res = await fetch("/mobility/api/mobilites", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + numEtud, + duree: parseInt(duree), + ecole: ecole || null, + pays: pays || null, + status, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la création"); + } finally { + setBusy(false); + } + } + + return ( +
+

Nouvelle mobilité

+
+
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+
+ + setEcole((e.target as HTMLInputElement).value)} + /> + + {ecoles.map((e) => +
+
+ + setPays((e.target as HTMLInputElement).value)} + /> + + {paysList.map((p) => +
+
+ + +
+
+
+ + +
+
+ ); +} + +function UploadContratBtn( + { mobId, hasContrat, onDone }: { + mobId: number; + hasContrat: boolean; + onDone: () => Promise; + }, +) { + const [busy, setBusy] = useState(false); + + function upload() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/pdf"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + setBusy(true); + try { + const fd = new FormData(); + fd.append("contrat", file); + const res = await fetch(`/mobility/api/mobilites/${mobId}/contrat`, { + method: "POST", + body: fd, + }); + if (!res.ok) throw new Error("Erreur upload"); + await onDone(); + } catch { + alert("Erreur lors de l'upload du contrat"); + } finally { + setBusy(false); + } + }; + input.click(); + } + + return ( + + ); +} diff --git a/routes/(apps)/mobility/(_props)/props.ts b/routes/(apps)/mobility/(_props)/props.ts index 75a91b4..3efac01 100644 --- a/routes/(apps)/mobility/(_props)/props.ts +++ b/routes/(apps)/mobility/(_props)/props.ts @@ -3,14 +3,14 @@ import { AppProperties } from "$root/defaults/interfaces.ts"; const properties: AppProperties = { name: "PolyMobility", icon: "flight_takeoff", - hint: "Student mobility management", + hint: "Suivi des mobilités internationales", pages: { - index: "Homepage", - overview: "Mobility overview", - edit_mobility: "Mobility management", - consult_students_test: "Test consult students", + index: "Accueil", + overview: "Suivi des mobilités", + "my-mobility": "Ma mobilité", }, - adminOnly: ["edit_mobility", "consult_students_test"], + adminOnly: ["overview"], + studentOnly: ["my-mobility"], }; export default properties; diff --git a/routes/(apps)/mobility/[slug].tsx b/routes/(apps)/mobility/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/mobility/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/mobility/api/insert_mobility.ts b/routes/(apps)/mobility/api/insert_mobility.ts deleted file mode 100644 index a6e9aa9..0000000 --- a/routes/(apps)/mobility/api/insert_mobility.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Handlers } from "$fresh/server.ts"; -import { db } from "$root/databases/db.ts"; -import { mobility, promotions, students } from "$root/databases/schema.ts"; -import { eq } from "npm:drizzle-orm@0.45.2"; - -export const handler: Handlers = { - async GET() { - try { - const studentRows = await db - .select({ - id: students.userId, - firstName: students.firstName, - lastName: students.lastName, - promotionId: students.promotionId, - endyear: promotions.endyear, - current: promotions.current, - }) - .from(students) - .leftJoin(promotions, eq(students.promotionId, promotions.id)); - - const mobilityRows = await db.select().from(mobility); - - const promotionRows = await db - .select({ - id: promotions.id, - endyear: promotions.endyear, - current: promotions.current, - }) - .from(promotions); - - return new Response( - JSON.stringify({ - mobilities: mobilityRows, - students: studentRows, - promotions: promotionRows, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } catch (error) { - console.error("Error fetching mobility data:", error); - return new Response("Failed to fetch data", { status: 500 }); - } - }, - - async POST(request) { - try { - const body = await request.json(); - const { data } = body; - - if (!Array.isArray(data)) { - throw new Error("Invalid request body"); - } - - for (const entry of data) { - const { - id, - studentId, - startDate, - endDate, - weeksCount, - destinationCountry, - destinationName, - mobilityStatus = "N/A", - } = entry; - - const studentExists = await db - .select({ userId: students.userId }) - .from(students) - .where(eq(students.userId, studentId)) - .limit(1) - .then((rows) => rows.length > 0); - - if (!studentExists) { - console.warn(`Skipping mobility for unknown studentId: ${studentId}`); - continue; - } - - let calculatedWeeksCount = weeksCount; - if (startDate && endDate) { - const start = new Date(startDate); - const end = new Date(endDate); - calculatedWeeksCount = start <= end - ? Math.ceil( - (end.getTime() - start.getTime()) / (7 * 24 * 60 * 60 * 1000), - ) - : null; - } - - await db - .insert(mobility) - .values({ - id, - studentId, - startDate, - endDate, - weeksCount: calculatedWeeksCount, - destinationCountry, - destinationName, - mobilityStatus, - }) - .onConflictDoUpdate({ - target: mobility.id, - set: { - startDate, - endDate, - weeksCount: calculatedWeeksCount, - destinationCountry, - destinationName, - mobilityStatus, - }, - }); - } - - return new Response("Data inserted/updated successfully", { - status: 200, - }); - } catch (error) { - console.error("Error inserting mobility data:", error); - return new Response("Failed to insert/update data", { status: 500 }); - } - }, -}; diff --git a/routes/(apps)/mobility/api/mobilites.ts b/routes/(apps)/mobility/api/mobilites.ts new file mode 100644 index 0000000..8485a07 --- /dev/null +++ b/routes/(apps)/mobility/api/mobilites.ts @@ -0,0 +1,116 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { mobilites } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const VALID_STATUSES = [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +] as const; + +export const handler: Handlers = { + // GET /mobilites — list all, optional ?numEtud filter + async GET(request) { + try { + const url = new URL(request.url); + const numEtudParam = url.searchParams.get("numEtud"); + + let query = db.select().from(mobilites).$dynamic(); + + if (numEtudParam) { + const numEtud = parseInt(numEtudParam); + if (isNaN(numEtud)) { + return new Response("Paramètre numEtud invalide", { status: 400 }); + } + query = query.where(eq(mobilites.numEtud, numEtud)); + } + + const result = await query; + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching mobilites:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // POST /mobilites — create mobility + async POST( + request: Request, + context: FreshContext, + ): Promise { + const isEmployee = + context.state.session.eduPersonPrimaryAffiliation === "employee"; + + try { + const body = await request.json(); + const { numEtud, duree, ecole, pays, status, idStage } = body; + + // Students can only create mobilites for themselves + if (!isEmployee && numEtud !== undefined) { + // Students cannot set idStage or status + if (idStage || (status && status !== "contracts_received")) { + return new Response(null, { status: 403 }); + } + } + + if (!numEtud || duree === undefined) { + return new Response( + JSON.stringify({ error: "Champs requis: numEtud, duree" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if (!Number.isInteger(duree) || duree < 1) { + return new Response( + JSON.stringify({ error: "duree doit être un entier >= 1" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if ( + status !== undefined && + !VALID_STATUSES.includes(status) + ) { + return new Response( + JSON.stringify({ + error: `status invalide, valeurs: ${VALID_STATUSES.join(", ")}`, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + // Stage-linked mobilities are always validated + const effectiveStatus = idStage + ? "validated" + : (status ?? "contracts_received"); + + const [created] = await db + .insert(mobilites) + .values({ + numEtud, + duree, + ecole: ecole ?? null, + pays: pays ?? null, + status: effectiveStatus, + idStage: idStage ?? null, + }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + console.error("Error creating mobilite:", error); + return new Response("Failed to create mobilite", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/mobility/api/mobilites/[idMob].ts b/routes/(apps)/mobility/api/mobilites/[idMob].ts new file mode 100644 index 0000000..e774c3c --- /dev/null +++ b/routes/(apps)/mobility/api/mobilites/[idMob].ts @@ -0,0 +1,149 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { mobilites } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const VALID_STATUSES = [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +] as const; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Mobilité introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +export const handler: Handlers = { + // GET /mobilites/:idMob + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + const row = await db + .select() + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (!row) return NOT_FOUND(); + + return new Response(JSON.stringify(row), { + headers: { "content-type": "application/json" }, + }); + }, + + // PUT /mobilites/:idMob (employee only) + async PUT( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + const body = await request.json(); + const { duree, ecole, pays, status, idStage } = body; + + if ( + status !== undefined && + !VALID_STATUSES.includes(status) + ) { + return new Response( + JSON.stringify({ + error: `status invalide, valeurs: ${VALID_STATUSES.join(", ")}`, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) { + return new Response( + JSON.stringify({ error: "duree doit être un entier >= 1" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const set: Record = {}; + if (duree !== undefined) set.duree = duree; + if (ecole !== undefined) set.ecole = ecole; + if (pays !== undefined) set.pays = pays; + if (status !== undefined) set.status = status; + if (idStage !== undefined) { + set.idStage = idStage; + if (idStage) set.status = "validated"; + } + + if (Object.keys(set).length === 0) { + return new Response( + JSON.stringify({ error: "Au moins un champ à modifier requis" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [updated] = await db + .update(mobilites) + .set(set) + .where(eq(mobilites.id, idMob)) + .returning(); + + if (!updated) return NOT_FOUND(); + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // DELETE /mobilites/:idMob (employee only) + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + // Delete contract file if exists + const row = await db + .select({ contratMob: mobilites.contratMob }) + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (row?.contratMob) { + try { + await Deno.remove(`uploads/contracts/${row.contratMob}`); + } catch { /* file may not exist */ } + } + + const [deleted] = await db + .delete(mobilites) + .where(eq(mobilites.id, idMob)) + .returning(); + + if (!deleted) return NOT_FOUND(); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts b/routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts new file mode 100644 index 0000000..391c2ef --- /dev/null +++ b/routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts @@ -0,0 +1,156 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { mobilites } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const CONTRACTS_DIR = "uploads/contracts"; + +export const handler: Handlers = { + // GET /mobilites/:idMob/contrat — download contract PDF + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + const row = await db + .select({ contratMob: mobilites.contratMob }) + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (!row) { + return new Response( + JSON.stringify({ error: "Mobilité introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + + if (!row.contratMob) { + return new Response( + JSON.stringify({ error: "Aucun contrat pour cette mobilité" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + + try { + const file = await Deno.readFile(`${CONTRACTS_DIR}/${row.contratMob}`); + return new Response(file, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `inline; filename="${row.contratMob}"`, + }, + }); + } catch { + return new Response( + JSON.stringify({ error: "Fichier contrat introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + }, + + // POST /mobilites/:idMob/contrat — upload contract PDF + async POST( + request: Request, + context: FreshContext, + ): Promise { + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + // Check mobility exists + const row = await db + .select({ id: mobilites.id }) + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (!row) { + return new Response( + JSON.stringify({ error: "Mobilité introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + + const formData = await request.formData(); + const file = formData.get("contrat"); + + if (!file || !(file instanceof File)) { + return new Response( + JSON.stringify({ error: "Fichier 'contrat' requis (PDF)" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if (file.type !== "application/pdf") { + return new Response( + JSON.stringify({ error: "Le fichier doit être un PDF" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const filename = `mob_${idMob}.pdf`; + await Deno.mkdir(CONTRACTS_DIR, { recursive: true }); + await Deno.writeFile( + `${CONTRACTS_DIR}/${filename}`, + new Uint8Array(await file.arrayBuffer()), + ); + + const [updated] = await db + .update(mobilites) + .set({ contratMob: filename }) + .where(eq(mobilites.id, idMob)) + .returning(); + + return new Response(JSON.stringify(updated), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + + // DELETE /mobilites/:idMob/contrat — remove contract (employee only) + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } + + const idMob = Number(context.params.idMob); + if (isNaN(idMob)) { + return new Response("Paramètre idMob invalide", { status: 400 }); + } + + const row = await db + .select({ contratMob: mobilites.contratMob }) + .from(mobilites) + .where(eq(mobilites.id, idMob)) + .then((rows) => rows[0] ?? null); + + if (!row) { + return new Response( + JSON.stringify({ error: "Mobilité introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + } + + if (row.contratMob) { + try { + await Deno.remove(`${CONTRACTS_DIR}/${row.contratMob}`); + } catch { /* file may not exist */ } + } + + await db + .update(mobilites) + .set({ contratMob: null }) + .where(eq(mobilites.id, idMob)); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx b/routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx deleted file mode 100644 index 8727e5d..0000000 --- a/routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import ConsultStudents_test from "$root/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx"; -import { - getPartialsConfig, - makePartials, -} from "$root/defaults/makePartials.tsx"; -import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; -//import EditStudents from "../(_islands)/EditStudents.tsx"; - -// deno-lint-ignore require-await -async function Mobility(_request: Request, _context: FreshContext) { - return ( - <> -

Test consult students

- - - ); -} - -export const config = getPartialsConfig(); -export default makePartials(Mobility); diff --git a/routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx b/routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx deleted file mode 100644 index 88e75bf..0000000 --- a/routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import EditMobility from "$root/routes/(apps)/mobility/(_islands)/EditMobility.tsx"; -import { - getPartialsConfig, - makePartials, -} from "$root/defaults/makePartials.tsx"; -import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; - -// deno-lint-ignore require-await -async function Mobility(_request: Request, _context: FreshContext) { - return ( - <> -

Edit mobility

- - - ); -} - -export const config = getPartialsConfig(); -export default makePartials(Mobility); diff --git a/routes/(apps)/mobility/partials/index.tsx b/routes/(apps)/mobility/partials/index.tsx index 2971e0e..0d813f5 100644 --- a/routes/(apps)/mobility/partials/index.tsx +++ b/routes/(apps)/mobility/partials/index.tsx @@ -3,11 +3,27 @@ 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, +) { + return ( +
+

Mobilité internationale

+

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

+

Suivi des mobilités : 12 semaines validées requises par élève.

+
+ ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/mobility/partials/overview.tsx b/routes/(apps)/mobility/partials/overview.tsx index c8775a6..21da0a4 100644 --- a/routes/(apps)/mobility/partials/overview.tsx +++ b/routes/(apps)/mobility/partials/overview.tsx @@ -1,20 +1,19 @@ -import ConsultMobility from "$root/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; 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 MobilityOverview from "../(_islands)/MobilityOverview.tsx"; // deno-lint-ignore require-await -async function Mobility(_request: Request, _context: FreshContext) { - return ( - <> -

Edit mobility

- - - ); +async function Overview( + _request: Request, + _context: FreshContext, +) { + return ; } +export { Overview as Page }; export const config = getPartialsConfig(); -export default makePartials(Mobility); +export default makePartials(Overview); diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index 2490029..a582797 100644 --- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -273,9 +273,9 @@ export default function ImportNotes() { Promise.all([ fetch("/students/api/students").then((r) => r.json()), fetch("/notes/api/notes").then((r) => r.json()), - fetch("/admin/api/modules").then((r) => r.json()), - fetch("/admin/api/ue-modules").then((r) => r.json()), - fetch("/admin/api/ues").then((r) => r.json()), + fetch("/notes/api/modules").then((r) => r.json()), + fetch("/notes/api/ue-modules").then((r) => r.json()), + fetch("/notes/api/ues").then((r) => r.json()), ]).then( ([ studentsData, @@ -450,7 +450,10 @@ export default function ImportNotes() { const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]); XLSX.utils.book_append_sheet(wb, ws2, "Session 2"); const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); - const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + const blob = new Blob([buf], { + type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -600,6 +603,8 @@ export default function ImportNotes() { > Telecharger Modele + { + /* TODO: fix blob download in Fresh + */ + }

diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index af24da8..de9ec39 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -66,11 +66,11 @@ export default function NoteRecap({ numEtud }: Props) { setStudent(s); const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([ - fetch("/admin/api/ues"), + fetch("/notes/api/ues"), fetch( - `/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, + `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, ), - fetch("/admin/api/modules"), + fetch("/notes/api/modules"), fetch(`/notes/api/notes?numEtud=${numEtud}`), fetch(`/notes/api/ajustements?numEtud=${numEtud}`), ]); diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx index 6dcbf7e..326d6e7 100644 --- a/routes/(apps)/notes/(_islands)/NotesView.tsx +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -62,9 +62,9 @@ export default function NotesView({ numEtud, prenom }: Props) { try { const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([ fetch(`/notes/api/notes?numEtud=${numEtud}`), - fetch("/admin/api/ues"), - fetch("/admin/api/ue-modules"), - fetch("/admin/api/modules"), + fetch("/notes/api/ues"), + fetch("/notes/api/ue-modules"), + fetch("/notes/api/modules"), fetch(`/notes/api/ajustements?numEtud=${numEtud}`), ]); diff --git a/routes/(apps)/notes/[slug].tsx b/routes/(apps)/notes/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/notes/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/notes/api/modules.ts b/routes/(apps)/notes/api/modules.ts new file mode 100644 index 0000000..3333369 --- /dev/null +++ b/routes/(apps)/notes/api/modules.ts @@ -0,0 +1,12 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { modules } from "$root/databases/schema.ts"; + +export const handler: Handlers = { + async GET() { + const rows = await db.select().from(modules); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts new file mode 100644 index 0000000..08e3a11 --- /dev/null +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -0,0 +1,28 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { ueModules } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + async GET(request) { + const url = new URL(request.url); + const idPromo = url.searchParams.get("idPromo"); + const idUEParam = url.searchParams.get("idUE"); + const idUE = idUEParam ? parseInt(idUEParam) : null; + + if (idUEParam && isNaN(idUE!)) { + return new Response("Paramètre idUE invalide", { status: 400 }); + } + + const rows = await db.select().from(ueModules).where( + and( + idPromo ? eq(ueModules.idPromo, idPromo) : undefined, + idUE ? eq(ueModules.idUE, idUE) : undefined, + ), + ); + + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts new file mode 100644 index 0000000..09230a9 --- /dev/null +++ b/routes/(apps)/notes/api/ues.ts @@ -0,0 +1,12 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { ues } from "$root/databases/schema.ts"; + +export const handler: Handlers = { + async GET() { + const rows = await db.select().from(ues); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/notes/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx index 0ec8ebe..6f9f8ba 100644 --- a/routes/(apps)/notes/partials/(admin)/courses.tsx +++ b/routes/(apps)/notes/partials/(admin)/courses.tsx @@ -11,5 +11,6 @@ async function Courses(_request: Request, _context: FreshContext) { return ; } +export { Courses as Page }; export const config = getPartialsConfig(); export default makePartials(Courses); diff --git a/routes/(apps)/notes/partials/(admin)/import.tsx b/routes/(apps)/notes/partials/(admin)/import.tsx index 111edf0..3f56e2d 100644 --- a/routes/(apps)/notes/partials/(admin)/import.tsx +++ b/routes/(apps)/notes/partials/(admin)/import.tsx @@ -19,5 +19,6 @@ async function ImportNotesPage( ); } +export { ImportNotesPage as Page }; export const config = getPartialsConfig(); export default makePartials(ImportNotesPage); diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index ec2e5d8..de9e686 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -54,5 +54,6 @@ async function Notes( ); } +export { Notes as Page }; export const config = getPartialsConfig(); export default makePartials(Notes); diff --git a/routes/(apps)/stages/(_islands)/StagesOverview.tsx b/routes/(apps)/stages/(_islands)/StagesOverview.tsx new file mode 100644 index 0000000..60b0a7e --- /dev/null +++ b/routes/(apps)/stages/(_islands)/StagesOverview.tsx @@ -0,0 +1,542 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; +type Stage = { + id: number; + numEtud: number; + duree: number; + nomEntreprise: string; + mission: string | null; +}; + +const REQUIRED_WEEKS = 40; + +export default function StagesOverview() { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [stagesList, setStagesList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + + // Detail view state + const [detailStudent, setDetailStudent] = useState(null); + const [editingStage, setEditingStage] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + async function load() { + try { + const [sRes, pRes, stRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + fetch("/stages/api/stages"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les données"); + const [sData, pData, stData] = await Promise.all([ + sRes.json(), + pRes.ok ? pRes.json() : [], + stRes.ok ? stRes.json() : [], + ]); + setStudents(sData); + setPromos(pData); + setStagesList(stData); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + if (detailStudent) { + return ( + s.numEtud === detailStudent.numEtud)} + allStages={stagesList} + editingStage={editingStage} + setEditingStage={setEditingStage} + showAddForm={showAddForm} + setShowAddForm={setShowAddForm} + onBack={() => { + setDetailStudent(null); + setEditingStage(null); + setShowAddForm(false); + }} + onReload={load} + /> + ); + } + + if (loading) { + return ( +

+

Chargement...

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

{error}

+
+ ); + } + + 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; + }); + + const stagesByStudent = (numEtud: number) => + stagesList.filter((s) => s.numEtud === numEtud); + + return ( +
+

Suivi des stages

+ +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + setDetailStudent(s)} + /> +
+ ); +} + +// ─── Liste View ───────────────────────────────────────────── + +function ListView( + { students, stagesByStudent, onConsult }: { + students: Student[]; + stagesByStudent: (n: number) => Stage[]; + onConsult: (s: Student) => void; + }, +) { + return ( +
+ + + + + + + + + + + + {students.length === 0 + ? ( + + + + ) + : students.map((s) => { + const stages = stagesByStudent(s.numEtud); + const weeks = stages.reduce((sum, st) => sum + st.duree, 0); + const ok = weeks >= REQUIRED_WEEKS; + return ( + + + + + + + + ); + })} + +
N° étud.NomPrénomSemainesActions
Aucun élève trouvé
{s.numEtud}{s.nom}{s.prenom} + + {weeks}/{REQUIRED_WEEKS} + + + +
+
+ ); +} + +// ─── Detail View ──────────────────────────────────────────── + +function DetailView( + { + student, + stages, + allStages, + editingStage, + setEditingStage, + showAddForm, + setShowAddForm, + onBack, + onReload, + }: { + student: Student; + stages: Stage[]; + allStages: Stage[]; + editingStage: Stage | null; + setEditingStage: (s: Stage | null) => void; + showAddForm: boolean; + setShowAddForm: (v: boolean) => void; + onBack: () => void; + onReload: () => Promise; + }, +) { + const weeks = stages.reduce((sum, s) => sum + s.duree, 0); + const entreprises = [ + ...new Set(allStages.map((s) => s.nomEntreprise).filter(Boolean)), + ]; + + async function deleteStage(id: number) { + if (!confirm("Supprimer ce stage ?")) return; + await fetch(`/stages/api/stages/${id}`, { method: "DELETE" }); + await onReload(); + } + + return ( +
+ +

+ Consulter : {student.prenom} {student.nom} + = REQUIRED_WEEKS ? "#22c55e" : "#dc2626", + fontFamily: "monospace", + }} + > + {weeks}/{REQUIRED_WEEKS} semaines + +

+ + {stages.length === 0 && ( +

+ Aucun stage enregistré. +

+ )} + + {stages.map((stage, i) => { + const isEditing = editingStage?.id === stage.id; + + if (isEditing) { + return ( + setEditingStage(null)} + onSave={async () => { + setEditingStage(null); + await onReload(); + }} + /> + ); + } + + return ( +
+
+

+ Stage {i + 1} +

+

+ Durée : {stage.duree} semaine(s) +

+
+
+

+ Entreprise : {stage.nomEntreprise} + {stage.mission && — {stage.mission}} +

+
+ + +
+
+
+ ); + })} + + {showAddForm + ? ( + setShowAddForm(false)} + onSave={async () => { + setShowAddForm(false); + await onReload(); + }} + /> + ) + : ( + + )} +
+ ); +} + +// ─── Inline forms ─────────────────────────────────────────── + +function StageEditForm( + { stage, entreprises, onCancel, onSave }: { + stage: Stage; + entreprises: string[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState(String(stage.duree)); + const [nomEntreprise, setNomEntreprise] = useState(stage.nomEntreprise); + const [mission, setMission] = useState(stage.mission ?? ""); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + try { + const res = await fetch(`/stages/api/stages/${stage.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + duree: parseInt(duree), + nomEntreprise, + mission: mission || null, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la modification"); + } finally { + setBusy(false); + } + } + + return ( +
+

Modifier le stage #{stage.id}

+
+
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+
+ + + setNomEntreprise((e.target as HTMLInputElement).value)} + /> + + {entreprises.map((e) => +
+
+ + setMission((e.target as HTMLInputElement).value)} + /> +
+
+
+ + +
+
+ ); +} + +function StageAddForm( + { numEtud, entreprises, onCancel, onSave }: { + numEtud: number; + entreprises: string[]; + onCancel: () => void; + onSave: () => Promise; + }, +) { + const [duree, setDuree] = useState("4"); + const [nomEntreprise, setNomEntreprise] = useState(""); + const [mission, setMission] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit() { + setBusy(true); + try { + const res = await fetch("/stages/api/stages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + numEtud, + duree: parseInt(duree), + nomEntreprise, + mission: mission || null, + }), + }); + if (!res.ok) throw new Error("Erreur"); + await onSave(); + } catch { + alert("Erreur lors de la création"); + } finally { + setBusy(false); + } + } + + return ( +
+

Nouveau stage

+
+
+ + setDuree((e.target as HTMLInputElement).value)} + /> +
+
+ + + setNomEntreprise((e.target as HTMLInputElement).value)} + /> + + {entreprises.map((e) => +
+
+ + setMission((e.target as HTMLInputElement).value)} + /> +
+
+
+ + +
+
+ ); +} diff --git a/routes/(apps)/stages/(_props)/props.ts b/routes/(apps)/stages/(_props)/props.ts new file mode 100644 index 0000000..feffd2b --- /dev/null +++ b/routes/(apps)/stages/(_props)/props.ts @@ -0,0 +1,15 @@ +import { AppProperties } from "$root/defaults/interfaces.ts"; + +const properties: AppProperties = { + name: "Stages", + icon: "work", + pages: { + index: "Accueil", + overview: "Suivi des stages", + }, + adminOnly: ["overview"], + employeeOnly: true, + hint: "Suivi des stages et semaines", +}; + +export default properties; diff --git a/routes/(apps)/stages/[slug].tsx b/routes/(apps)/stages/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/stages/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/stages/api/stages.ts b/routes/(apps)/stages/api/stages.ts new file mode 100644 index 0000000..602381c --- /dev/null +++ b/routes/(apps)/stages/api/stages.ts @@ -0,0 +1,84 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { stages } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // GET /stages — list all, optional ?numEtud filter + async GET(request) { + try { + const url = new URL(request.url); + const numEtudParam = url.searchParams.get("numEtud"); + + let query = db.select().from(stages).$dynamic(); + + if (numEtudParam) { + const numEtud = parseInt(numEtudParam); + if (isNaN(numEtud)) { + return new Response("Paramètre numEtud invalide", { status: 400 }); + } + query = query.where(eq(stages.numEtud, numEtud)); + } + + const result = await query; + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching stages:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // POST /stages — create stage (employee only) + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(null, { status: 403 }); + } + + try { + const body = await request.json(); + const { numEtud, duree, nomEntreprise, mission } = body; + + if (!numEtud || duree === undefined || !nomEntreprise) { + return new Response( + JSON.stringify({ + error: "Champs requis: numEtud, duree, nomEntreprise", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + if (!Number.isInteger(duree) || duree < 1) { + return new Response( + JSON.stringify({ error: "duree doit être un entier >= 1" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [created] = await db + .insert(stages) + .values({ + numEtud, + duree, + nomEntreprise, + mission: mission ?? null, + }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + console.error("Error creating stage:", error); + return new Response("Failed to create stage", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/stages/api/stages/[idStage].ts b/routes/(apps)/stages/api/stages/[idStage].ts new file mode 100644 index 0000000..2fea148 --- /dev/null +++ b/routes/(apps)/stages/api/stages/[idStage].ts @@ -0,0 +1,122 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { mobilites, stages } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = () => + new Response( + JSON.stringify({ error: "Stage introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, + ); + +const FORBIDDEN = () => new Response(null, { status: 403 }); + +export const handler: Handlers = { + // GET /stages/:idStage + async GET( + _request: Request, + context: FreshContext, + ): Promise { + const idStage = Number(context.params.idStage); + if (isNaN(idStage)) { + return new Response("Paramètre idStage invalide", { status: 400 }); + } + + const row = await db + .select() + .from(stages) + .where(eq(stages.id, idStage)) + .then((rows) => rows[0] ?? null); + + if (!row) return NOT_FOUND(); + + return new Response(JSON.stringify(row), { + headers: { "content-type": "application/json" }, + }); + }, + + // PUT /stages/:idStage (employee only) + async PUT( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idStage = Number(context.params.idStage); + if (isNaN(idStage)) { + return new Response("Paramètre idStage invalide", { status: 400 }); + } + + const body = await request.json(); + const { duree, nomEntreprise, mission } = body; + + if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) { + return new Response( + JSON.stringify({ error: "duree doit être un entier >= 1" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const set: Record = {}; + if (duree !== undefined) set.duree = duree; + if (nomEntreprise !== undefined) set.nomEntreprise = nomEntreprise; + if (mission !== undefined) set.mission = mission; + + if (Object.keys(set).length === 0) { + return new Response( + JSON.stringify({ error: "Au moins un champ à modifier requis" }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + + const [updated] = await db + .update(stages) + .set(set) + .where(eq(stages.id, idStage)) + .returning(); + + if (!updated) return NOT_FOUND(); + + // If duration changed and this stage is linked as a mobility, update the mobility too + if (duree !== undefined) { + await db + .update(mobilites) + .set({ duree }) + .where(eq(mobilites.idStage, idStage)); + } + + return new Response(JSON.stringify(updated), { + headers: { "content-type": "application/json" }, + }); + }, + + // DELETE /stages/:idStage (employee only) + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN(); + } + + const idStage = Number(context.params.idStage); + if (isNaN(idStage)) { + return new Response("Paramètre idStage invalide", { status: 400 }); + } + + // Remove linked mobilites first (FK constraint) + await db.delete(mobilites).where(eq(mobilites.idStage, idStage)); + + const [deleted] = await db + .delete(stages) + .where(eq(stages.id, idStage)) + .returning(); + + if (!deleted) return NOT_FOUND(); + + return new Response(null, { status: 204 }); + }, +}; diff --git a/routes/(apps)/stages/index.tsx b/routes/(apps)/stages/index.tsx new file mode 100644 index 0000000..1d82f7f --- /dev/null +++ b/routes/(apps)/stages/index.tsx @@ -0,0 +1,2 @@ +import makeIndex from "$root/defaults/makeIndex.ts"; +export default makeIndex(import.meta.dirname!); diff --git a/routes/(apps)/stages/partials/index.tsx b/routes/(apps)/stages/partials/index.tsx new file mode 100644 index 0000000..cfbf369 --- /dev/null +++ b/routes/(apps)/stages/partials/index.tsx @@ -0,0 +1,30 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; + +// deno-lint-ignore require-await +export async function Index( + _request: Request, + context: FreshContext, +) { + return ( +
+

Stages

+

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

+

Suivi des stages : 40 semaines requises par élève.

+
+ ); +} + +export const config = getPartialsConfig(); +export default makePartials(Index); diff --git a/routes/(apps)/stages/partials/overview.tsx b/routes/(apps)/stages/partials/overview.tsx new file mode 100644 index 0000000..d0d496c --- /dev/null +++ b/routes/(apps)/stages/partials/overview.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import StagesOverview from "../(_islands)/StagesOverview.tsx"; + +// deno-lint-ignore require-await +async function Overview( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Overview as Page }; +export const config = getPartialsConfig(); +export default makePartials(Overview); diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index d6b498c..9a503f6 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -9,6 +9,7 @@ const properties: AppProperties = { upload: "Import xlsx", }, adminOnly: ["consult", "upload"], + employeeOnly: true, hint: "Create students promotion and see informations", }; diff --git a/routes/(apps)/students/[slug].tsx b/routes/(apps)/students/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/students/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/students/api/students/[numEtud].ts b/routes/(apps)/students/api/students/[numEtud].ts index ce0f2d3..6d2c0e6 100644 --- a/routes/(apps)/students/api/students/[numEtud].ts +++ b/routes/(apps)/students/api/students/[numEtud].ts @@ -2,8 +2,9 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; import { ajustements, - mobility, + mobilites, notes, + stages, students, } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; @@ -80,7 +81,7 @@ export const handler: Handlers = { }, // #12 DELETE /students/{numEtud} - // Cascade: deletes notes, ajustements, mobility for this student. + // Cascade: deletes notes, ajustements, mobilites, stages for this student. async DELETE( _request: Request, context: FreshContext, @@ -102,7 +103,8 @@ export const handler: Handlers = { await db.transaction(async (tx) => { await tx.delete(notes).where(eq(notes.numEtud, numEtud)); await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud)); - await tx.delete(mobility).where(eq(mobility.studentId, numEtud)); + await tx.delete(mobilites).where(eq(mobilites.numEtud, numEtud)); + await tx.delete(stages).where(eq(stages.numEtud, numEtud)); await tx.delete(students).where(eq(students.numEtud, numEtud)); }); diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index 4c81c71..2adaaa4 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -11,5 +11,6 @@ async function Students(_request: Request, _context: FreshContext) { return ; } +export { Students as Page }; export const config = getPartialsConfig(); export default makePartials(Students); diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index 578d830..ca1b847 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -16,5 +16,6 @@ async function Students(_request: Request, _context: FreshContext) { ); } +export { Students as Page }; export const config = getPartialsConfig(); export default makePartials(Students); diff --git a/routes/_app.tsx b/routes/_app.tsx index 81187c3..77ba7c3 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -29,6 +29,7 @@ export default async function App( +
diff --git a/routes/apps.tsx b/routes/apps.tsx index d64cabb..067798d 100644 --- a/routes/apps.tsx +++ b/routes/apps.tsx @@ -44,9 +44,20 @@ export default async function Apps( _request: Request, context: FreshContext>, ) { + let visibleApps = context.data; + + if ( + context.state.isAuthenticated && + context.state.session.eduPersonPrimaryAffiliation === "student" + ) { + visibleApps = Object.fromEntries( + Object.entries(context.data).filter(([_, app]) => !app.employeeOnly), + ); + } + return ( <> - + ); } diff --git a/routes/index.tsx b/routes/index.tsx index f92dc1b..b16caea 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,13 +1,28 @@ -import { FreshContext } from "$fresh/server.ts"; +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; -// deno-lint-ignore require-await -export default async function Home(_request: Request, _context: FreshContext) { +export const handler: Handlers = { + GET(_request: Request, context: FreshContext) { + if (context.state.isAuthenticated) { + return new Response(null, { + status: 302, + headers: { Location: "/apps" }, + }); + } + return context.render(); + }, +}; + +export default function Home() { return ( <>

PolyMPR

The ultimate HR platform

+

+ Se connecter +

); } diff --git a/scripts/generate-templates.ts b/scripts/generate-templates.ts index ab2f3bc..5245a7c 100644 --- a/scripts/generate-templates.ts +++ b/scripts/generate-templates.ts @@ -5,7 +5,12 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; { const wb = XLSX.utils.book_new(); const ws = XLSX.utils.aoa_to_sheet([ - [null, null, null, "Promotion peut etre vide mais doit prealablement Exister"], + [ + null, + null, + null, + "Promotion peut etre vide mais doit prealablement Exister", + ], ["Nom", "Prenom", "Numero-etudiant", "Promotion"], ["NOM", "PRENOM", 12345678, "3AFISE24-25"], ]); @@ -38,8 +43,26 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; { const data = [ ["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."], - ["Description des UE du diplome", null, null, null, null, null, "Nombre d'heures"], - ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\n ECTS", "Coeff.", "CM", "TD", "TP"], + [ + "Description des UE du diplome", + null, + null, + null, + null, + null, + "Nombre d'heures", + ], + [ + "Annee\nSemestres", + "Codes APOGEE", + null, + null, + "Credits\n ECTS", + "Coeff.", + "CM", + "TD", + "TP", + ], ["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"], ["SEM 5", null, null, null, 30], ["UE", "CODE_UE1", "Nom de l'UE 1", null, 6], diff --git a/scripts/inspect-maquette.ts b/scripts/inspect-maquette.ts index 0dd3dce..b96865f 100644 --- a/scripts/inspect-maquette.ts +++ b/scripts/inspect-maquette.ts @@ -9,7 +9,9 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) { for (const sheetName of wb.SheetNames) { console.log(`\n--- Sheet: ${sheetName} ---`); const sheet = wb.Sheets[sheetName]; - const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { header: 1 }); + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); // Print first 5 cols of each row, mark rows that look like year/semester headers for (let i = 0; i < rows.length; i++) { const row = rows[i]; @@ -17,7 +19,9 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) { const col0 = row[0] != null ? String(row[0]).trim() : ""; // Show rows that are structural (year, semester, UE headers) if (col0 || (row[1] != null && String(row[1]).trim())) { - const preview = row.slice(0, 6).map(c => c != null ? String(c).substring(0, 25) : "").join(" | "); + const preview = row.slice(0, 6).map((c) => + c != null ? String(c).substring(0, 25) : "" + ).join(" | "); console.log(` [${i}] ${preview}`); } } diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..af947af --- /dev/null +++ b/static/theme.js @@ -0,0 +1,29 @@ +(function () { + var t = localStorage.getItem("theme"); + if (t) document.documentElement.style.colorScheme = t; + + document.addEventListener("click", function (e) { + var btn = e.target.closest("#theme-toggle"); + if (!btn) return; + var cs = getComputedStyle(document.documentElement).colorScheme; + var isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + var next = isDark ? "light" : "dark"; + document.documentElement.style.colorScheme = next; + localStorage.setItem("theme", next); + btn.querySelector("span").textContent = next === "dark" + ? "light_mode" + : "dark_mode"; + }); + + document.addEventListener("DOMContentLoaded", function () { + var btn = document.getElementById("theme-toggle"); + if (!btn) return; + var cs = getComputedStyle(document.documentElement).colorScheme; + var isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode"; + }); +})(); diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index 2a571bf..be102db 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -26,7 +26,7 @@ export const testPool = createTestPool(); export const testDb = drizzle(testPool, { schema }); const ALL_TABLES = - '"mobility","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; + '"mobilites","stages","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; /** * Vide toutes les tables dans le bon ordre. -- 2.52.0 From b6586f7715bca67c5a36e7d4e5d979d349039e95 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 14:14:33 +0200 Subject: [PATCH 4/6] feat: made stuff --- .gitea/workflows/test.yml | 4 + defaults/makeSlug.ts | 26 + openapi.yml | 1536 +++++++++++++++++ .../admin/(_islands)/AdminEnseignements.tsx | 12 +- .../(apps)/admin/(_islands)/AdminModules.tsx | 18 +- routes/(apps)/admin/(_islands)/AdminUEs.tsx | 18 +- routes/(apps)/admin/(_islands)/EditModule.tsx | 12 +- .../admin/(_islands)/ImportMaquette.tsx | 2 +- .../mobility/(_islands)/MobilityOverview.tsx | 162 +- .../mobility/{[slug].tsx => [...slug].tsx} | 0 .../mobility/partials/overview/[numEtud].tsx | 20 + .../(apps)/notes/(_islands)/ImportNotes.tsx | 2 +- .../stages/(_islands)/StagesOverview.tsx | 30 +- .../stages/{[slug].tsx => [...slug].tsx} | 0 .../stages/partials/overview/[numEtud].tsx | 20 + .../students/(_islands)/EditStudents.tsx | 92 +- static/styles/ui.css | 12 +- static/theme.js | 16 +- tests/e2e/modules_test.ts | 4 +- 19 files changed, 1870 insertions(+), 116 deletions(-) create mode 100644 openapi.yml rename routes/(apps)/mobility/{[slug].tsx => [...slug].tsx} (100%) create mode 100644 routes/(apps)/mobility/partials/overview/[numEtud].tsx rename routes/(apps)/stages/{[slug].tsx => [...slug].tsx} (100%) create mode 100644 routes/(apps)/stages/partials/overview/[numEtud].tsx diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index d2a8d16..259baf7 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -56,6 +56,10 @@ jobs: run: | sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \ PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test + sed 's/--> statement-breakpoint/;/g' databases/migrations/0003_add_session2_and_malus.sql | \ + PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test + sed 's/--> statement-breakpoint/;/g' databases/migrations/0004_add_stages_and_mobilites.sql | \ + PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test - name: Install dependencies run: npm install --ignore-scripts && deno install diff --git a/defaults/makeSlug.ts b/defaults/makeSlug.ts index ee12fa4..d1110fe 100644 --- a/defaults/makeSlug.ts +++ b/defaults/makeSlug.ts @@ -27,6 +27,32 @@ export default function makeSlug(basePath: string): Route { } } + // For multi-segment slugs (e.g. "overview/12345"), try + // partials//[param].tsx and inject the param into context.params + if (!page && slug.includes("/")) { + const idx = slug.indexOf("/"); + const dir = slug.slice(0, idx); + const param = slug.slice(idx + 1); + + // Discover the dynamic segment name from the file system + try { + const entries: string[] = []; + for await (const entry of Deno.readDir(`${basePath}/partials/${dir}`)) { + if (entry.isFile) entries.push(entry.name); + } + const dynFile = entries.find((n) => + n.startsWith("[") && n.endsWith("].tsx") + ); + if (dynFile) { + const paramName = dynFile.slice(1, -5); // "[numEtud].tsx" → "numEtud" + context.params[paramName] = param; + page = (await import(`${basePath}/partials/${dir}/${dynFile}`)).Page; + } + } catch { + // directory doesn't exist or no dynamic file + } + } + if (!page) { return context.renderNotFound(); } diff --git a/openapi.yml b/openapi.yml new file mode 100644 index 0000000..29b5205 --- /dev/null +++ b/openapi.yml @@ -0,0 +1,1536 @@ +openapi: 3.1.0 +info: + title: PolyMPR API + version: 2.0.0 + description: API de gestion des étudiants, notes, mobilités, stages et administration. + +servers: + - url: / + +tags: + - name: Students + - name: Promotions + - name: Users + - name: Roles + - name: Permissions + - name: Modules + - name: Enseignements + - name: UEs + - name: UE_Modules + - name: Notes + - name: Ajustements + - name: Mobilités + - name: Stages + +paths: + # ── Students ────────────────────────────────────────────── + /students/api/students: + get: + tags: [Students] + summary: Liste des étudiants + parameters: + - $ref: "#/components/parameters/idPromoQuery" + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Student" + post: + tags: [Students] + summary: Créer un étudiant + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StudentCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + + /students/api/students/import-csv: + post: + tags: [Students] + summary: Importer des étudiants par CSV + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, idPromo] + properties: + file: + type: string + format: binary + idPromo: + type: string + responses: + "200": + description: Résultat de l'import + content: + application/json: + schema: + type: object + properties: + imported: + type: integer + errors: + type: array + items: + type: object + properties: + line: + type: integer + message: + type: string + + /students/api/students/{numEtud}: + parameters: + - $ref: "#/components/parameters/numEtud" + get: + tags: [Students] + summary: Détail d'un étudiant + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Students] + summary: Modifier un étudiant + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StudentCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Students] + summary: Supprimer un étudiant (cascade mobilités et stages) + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Promotions ──────────────────────────────────────────── + /students/api/promotions: + get: + tags: [Promotions] + summary: Liste des promotions + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Promotion" + post: + tags: [Promotions] + summary: Créer une promotion + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PromotionCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + + /students/api/promotions/{idPromo}: + parameters: + - $ref: "#/components/parameters/idPromo" + get: + tags: [Promotions] + summary: Détail d'une promotion + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Promotions] + summary: Modifier une promotion + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PromotionCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Promotions] + summary: Supprimer une promotion + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Users ──────────────────────────────────────────────── + /admin/api/users: + get: + tags: [Users] + summary: Liste des utilisateurs + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + post: + tags: [Users] + summary: Créer un utilisateur (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "403": + description: Accès refusé + + /admin/api/users/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + tags: [Users] + summary: Détail d'un utilisateur + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Users] + summary: Modifier un utilisateur + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Users] + summary: Supprimer un utilisateur + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Roles ──────────────────────────────────────────────── + /admin/api/roles: + get: + tags: [Roles] + summary: Liste des rôles + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Role" + post: + tags: [Roles] + summary: Créer un rôle + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + + /admin/api/roles/{idRole}: + parameters: + - name: idRole + in: path + required: true + schema: + type: integer + get: + tags: [Roles] + summary: Détail d'un rôle + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Roles] + summary: Modifier un rôle + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Roles] + summary: Supprimer un rôle + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Permissions ────────────────────────────────────────── + /admin/api/permissions: + get: + tags: [Permissions] + summary: Liste des permissions + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Permission" + + # ── Modules ─────────────────────────────────────────────── + /admin/api/modules: + get: + tags: [Modules] + summary: Liste des modules + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Module" + post: + tags: [Modules] + summary: Créer un module (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "409": + description: Un module avec cet identifiant existe déjà + + /admin/api/modules/{idModule}: + parameters: + - $ref: "#/components/parameters/idModule" + get: + tags: [Modules] + summary: Détail d'un module + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Modules] + summary: Modifier un module + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nom] + properties: + nom: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Modules] + summary: Supprimer un module (cascade notes, ue_modules, enseignements) + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Enseignements ─────────────────────────────────────── + /admin/api/enseignements: + get: + tags: [Enseignements] + summary: Liste des enseignements + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Enseignement" + post: + tags: [Enseignements] + summary: Créer un enseignement + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EnseignementCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Enseignement" + "409": + description: Cet enseignement existe déjà + + /admin/api/enseignements/{idProf}/{idModule}/{idPromo}: + parameters: + - name: idProf + in: path + required: true + schema: + type: string + - name: idModule + in: path + required: true + schema: + type: string + - name: idPromo + in: path + required: true + schema: + type: string + get: + tags: [Enseignements] + summary: Détail d'un enseignement + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Enseignement" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Enseignements] + summary: Supprimer un enseignement + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── UEs ─────────────────────────────────────────────────── + /admin/api/ues: + get: + tags: [UEs] + summary: Liste des UEs + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UE" + post: + tags: [UEs] + summary: Créer une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UECreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + + /admin/api/ues/{idUE}: + parameters: + - $ref: "#/components/parameters/idUE" + get: + tags: [UEs] + summary: Détail d'une UE + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [UEs] + summary: Modifier une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UECreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [UEs] + summary: Supprimer une UE + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── UE_Modules ──────────────────────────────────────────── + /admin/api/ue-modules: + get: + tags: [UE_Modules] + summary: Liste des associations UE-Module + parameters: + - $ref: "#/components/parameters/idPromoQuery" + - name: idUE + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UEModule" + post: + tags: [UE_Modules] + summary: Associer un module à une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UEModuleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + + /admin/api/ue-modules/{idModule}/{idUE}/{idPromo}: + parameters: + - name: idModule + in: path + required: true + schema: + type: string + - name: idUE + in: path + required: true + schema: + type: integer + - name: idPromo + in: path + required: true + schema: + type: string + get: + tags: [UE_Modules] + summary: Détail d'une association UE-Module + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [UE_Modules] + summary: Modifier le coefficient + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [coeff] + properties: + coeff: + type: number + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [UE_Modules] + summary: Supprimer l'association + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Notes ───────────────────────────────────────────────── + /notes/api/notes: + get: + tags: [Notes] + summary: Liste des notes + parameters: + - name: numEtud + in: query + schema: + type: integer + - name: idModule + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Note" + post: + tags: [Notes] + summary: Créer une note + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NoteCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "409": + description: Note déjà existante pour cet étudiant/module + + /notes/api/notes/import-xlsx: + post: + tags: [Notes] + summary: Importer des notes par fichier XLSX + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, idModule] + properties: + file: + type: string + format: binary + idModule: + type: string + responses: + "200": + description: Import réussi + content: + application/json: + schema: + type: object + properties: + imported: + type: integer + errors: + type: array + items: + type: object + properties: + line: + type: integer + student: + type: string + message: + type: string + "400": + description: Fichier invalide ou données corrompues + + /notes/api/notes/{numEtud}/{idModule}: + parameters: + - name: numEtud + in: path + required: true + schema: + type: integer + - name: idModule + in: path + required: true + schema: + type: string + get: + tags: [Notes] + summary: Détail d'une note + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Notes] + summary: Modifier une note + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: + type: number + minimum: 0 + maximum: 20 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Notes] + summary: Supprimer une note + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Ajustements ─────────────────────────────────────────── + /notes/api/ajustements: + get: + tags: [Ajustements] + summary: Liste des ajustements + parameters: + - name: numEtud + in: query + schema: + type: integer + - name: idUE + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Ajustement" + post: + tags: [Ajustements] + summary: Créer un ajustement + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AjustementCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + + /notes/api/ajustements/{numEtud}/{idUE}: + parameters: + - name: numEtud + in: path + required: true + schema: + type: integer + - name: idUE + in: path + required: true + schema: + type: integer + get: + tags: [Ajustements] + summary: Détail d'un ajustement + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Ajustements] + summary: Modifier un ajustement + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [valeur] + properties: + valeur: + type: number + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Ajustements] + summary: Supprimer un ajustement + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Mobilités ───────────────────────────────────────────── + /mobility/api/mobilites: + get: + tags: [Mobilités] + summary: Liste des mobilités + parameters: + - name: numEtud + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Mobilite" + post: + tags: [Mobilités] + summary: Créer une mobilité + description: > + Les étudiants ne peuvent pas définir idStage ni changer le status + (reste contracts_received). Les mobilités liées à un stage sont + automatiquement validées. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MobiliteCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "400": + description: Champs requis manquants ou invalides + + /mobility/api/mobilites/{idMob}: + parameters: + - name: idMob + in: path + required: true + schema: + type: integer + get: + tags: [Mobilités] + summary: Détail d'une mobilité + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Mobilités] + summary: Modifier une mobilité (employee only) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + duree: + type: integer + minimum: 1 + ecole: + type: string + nullable: true + pays: + type: string + nullable: true + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + nullable: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Mobilités] + summary: Supprimer une mobilité (employee only, supprime aussi le contrat) + responses: + "204": + description: Supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + + /mobility/api/mobilites/{idMob}/contrat: + parameters: + - name: idMob + in: path + required: true + schema: + type: integer + get: + tags: [Mobilités] + summary: Télécharger le contrat PDF + responses: + "200": + description: Fichier PDF + content: + application/pdf: + schema: + type: string + format: binary + "404": + $ref: "#/components/responses/NotFound" + post: + tags: [Mobilités] + summary: Uploader un contrat PDF + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [contrat] + properties: + contrat: + type: string + format: binary + description: Fichier PDF du contrat + responses: + "200": + description: Mobilité mise à jour avec le nom du fichier + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "400": + description: Fichier manquant ou pas un PDF + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Mobilités] + summary: Supprimer le contrat (employee only) + responses: + "204": + description: Contrat supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + + # ── Stages ──────────────────────────────────────────────── + /stages/api/stages: + get: + tags: [Stages] + summary: Liste des stages + parameters: + - name: numEtud + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Stage" + post: + tags: [Stages] + summary: Créer un stage (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StageCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "400": + description: Champs requis manquants + "403": + description: Accès refusé + + /stages/api/stages/{idStage}: + parameters: + - name: idStage + in: path + required: true + schema: + type: integer + get: + tags: [Stages] + summary: Détail d'un stage + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Stages] + summary: Modifier un stage (employee only, synchronise la durée sur la mobilité liée) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + duree: + type: integer + minimum: 1 + nomEntreprise: + type: string + mission: + type: string + nullable: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Stages] + summary: Supprimer un stage (employee only, cascade mobilités liées) + responses: + "204": + description: Supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + +# ── Components ──────────────────────────────────────────────── +components: + parameters: + numEtud: + name: numEtud + in: path + required: true + schema: + type: integer + example: 21212006 + idPromo: + name: idPromo + in: path + required: true + schema: + type: string + example: 4AFISE25/26 + idPromoQuery: + name: idPromo + in: query + schema: + type: string + example: 4AFISE25/26 + idModule: + name: idModule + in: path + required: true + schema: + type: string + idUE: + name: idUE + in: path + required: true + schema: + type: integer + + responses: + NotFound: + description: Ressource introuvable + content: + application/json: + schema: + type: object + properties: + error: + type: string + + schemas: + # ── Student ── + Student: + type: object + properties: + numEtud: + type: integer + nom: + type: string + prenom: + type: string + idPromo: + type: string + StudentCreate: + type: object + required: [numEtud, nom, prenom, idPromo] + properties: + numEtud: + type: integer + nom: + type: string + prenom: + type: string + idPromo: + type: string + + # ── Promotion ── + Promotion: + type: object + properties: + id: + type: string + annee: + type: string + PromotionCreate: + type: object + required: [id, annee] + properties: + id: + type: string + annee: + type: string + + # ── User ── + User: + type: object + properties: + id: + type: string + nom: + type: string + prenom: + type: string + idRole: + type: integer + nullable: true + UserCreate: + type: object + required: [id, nom, prenom] + properties: + id: + type: string + nom: + type: string + prenom: + type: string + idRole: + type: integer + + # ── Role ── + Role: + type: object + properties: + id: + type: integer + nom: + type: string + RoleCreate: + type: object + required: [nom] + properties: + nom: + type: string + + # ── Permission ── + Permission: + type: object + properties: + id: + type: string + nom: + type: string + + # ── Module ── + Module: + type: object + properties: + id: + type: string + nom: + type: string + ModuleCreate: + type: object + required: [id, nom] + properties: + id: + type: string + nom: + type: string + + # ── Enseignement ── + Enseignement: + type: object + properties: + idProf: + type: string + idModule: + type: string + idPromo: + type: string + EnseignementCreate: + type: object + required: [idProf, idModule, idPromo] + properties: + idProf: + type: string + idModule: + type: string + idPromo: + type: string + + # ── UE ── + UE: + type: object + properties: + id: + type: integer + nom: + type: string + UECreate: + type: object + required: [nom] + properties: + nom: + type: string + + # ── UE_Module ── + UEModule: + type: object + properties: + idModule: + type: string + idUE: + type: integer + idPromo: + type: string + coeff: + type: number + UEModuleCreate: + type: object + required: [idModule, idUE, idPromo, coeff] + properties: + idModule: + type: string + idUE: + type: integer + idPromo: + type: string + coeff: + type: number + + # ── Note ── + Note: + type: object + properties: + numEtud: + type: integer + idModule: + type: string + note: + type: number + minimum: 0 + maximum: 20 + NoteCreate: + type: object + required: [numEtud, idModule, note] + properties: + numEtud: + type: integer + idModule: + type: string + note: + type: number + minimum: 0 + maximum: 20 + + # ── Ajustement ── + Ajustement: + type: object + properties: + numEtud: + type: integer + idUE: + type: integer + valeur: + type: number + AjustementCreate: + type: object + required: [numEtud, idUE, valeur] + properties: + numEtud: + type: integer + idUE: + type: integer + valeur: + type: number + + # ── Mobilité ── + MobilityStatus: + type: string + enum: [contracts_received, under_revision, done, validated, canceled] + Mobilite: + type: object + properties: + id: + type: integer + numEtud: + type: integer + duree: + type: integer + contratMob: + type: string + nullable: true + ecole: + type: string + nullable: true + pays: + type: string + nullable: true + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + nullable: true + MobiliteCreate: + type: object + required: [numEtud, duree] + properties: + numEtud: + type: integer + duree: + type: integer + minimum: 1 + ecole: + type: string + pays: + type: string + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + + # ── Stage ── + Stage: + type: object + properties: + id: + type: integer + numEtud: + type: integer + duree: + type: integer + nomEntreprise: + type: string + mission: + type: string + nullable: true + StageCreate: + type: object + required: [numEtud, duree, nomEntreprise] + properties: + numEtud: + type: integer + duree: + type: integer + minimum: 1 + nomEntreprise: + type: string + mission: + type: string diff --git a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx index 2a0c2af..3b76991 100644 --- a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -115,7 +115,7 @@ export default function AdminEnseignements() { return (
-

Assignations Enseignant → Module / Promo

+

Assignations Enseignant → ECUE / Promo

{error &&

{error}

} @@ -135,7 +135,7 @@ export default function AdminEnseignements() { onChange={(e) => setFilterModule((e.target as HTMLSelectElement).value)} > - + {modules.map((m) => ( ))} @@ -194,7 +194,7 @@ export default function AdminEnseignements() {
- + setNewNom((e.target as HTMLInputElement).value)} /> diff --git a/routes/(apps)/admin/(_islands)/AdminUEs.tsx b/routes/(apps)/admin/(_islands)/AdminUEs.tsx index c8612c2..5bb7a57 100644 --- a/routes/(apps)/admin/(_islands)/AdminUEs.tsx +++ b/routes/(apps)/admin/(_islands)/AdminUEs.tsx @@ -104,7 +104,7 @@ export default function AdminUEs() { idUE: number, idPromo: string, ) { - if (!confirm("Supprimer ce module de la UE ?")) return; + if (!confirm("Supprimer cet ECUE de la UE ?")) return; try { const res = await fetch( `/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ @@ -121,7 +121,7 @@ export default function AdminUEs() { async function addUeModule() { if (!selectedUe || !addModuleId || !addPromoId) { - setAddError("Module et Promo sont requis"); + setAddError("ECUE et Promo sont requis"); return; } const coeff = parseFloat(addCoeff); @@ -203,7 +203,7 @@ export default function AdminUEs() { class="col-dim" style="font-size: 0.78rem; margin: -0.5rem 0 1rem" > - UE = Unité d'Enseignement regroupant plusieurs modules + UE = Unité d'Enseignement regroupant plusieurs ECUEs

{error &&

{error}

} @@ -314,13 +314,13 @@ export default function AdminUEs() {

{selectedUe.nom}

- Modules assignés (UE_Module) + ECUEs assignés (UE_Module)

- + @@ -331,7 +331,7 @@ export default function AdminUEs() { ? ( ) @@ -441,7 +441,7 @@ export default function AdminUEs() {

- Ajouter un module à cette UE + Ajouter un ECUE à cette UE

{addError && (

@@ -458,7 +458,7 @@ export default function AdminUEs() { )} style="min-width: 12rem" > - + {modules.map((m) => (

- Sélectionnez une UE pour voir ses modules + Sélectionnez une UE pour voir ses ECUEs

)} diff --git a/routes/(apps)/admin/(_islands)/EditModule.tsx b/routes/(apps)/admin/(_islands)/EditModule.tsx index a9770ba..1bd554e 100644 --- a/routes/(apps)/admin/(_islands)/EditModule.tsx +++ b/routes/(apps)/admin/(_islands)/EditModule.tsx @@ -33,7 +33,7 @@ export default function EditModule({ moduleId }: Props) { fetch("/admin/api/users"), fetch("/students/api/promotions"), ]); - if (!mRes.ok) throw new Error("Module introuvable"); + if (!mRes.ok) throw new Error("ECUE introuvable"); const m: Module = await mRes.json(); setMod(m); setNom(m.nom); @@ -70,7 +70,7 @@ export default function EditModule({ moduleId }: Props) { if (!res.ok) throw new Error("Modification échouée"); const updated: Module = await res.json(); setMod(updated); - setSaveMsg("Module enregistré."); + setSaveMsg("ECUE enregistré."); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -79,7 +79,7 @@ export default function EditModule({ moduleId }: Props) { } async function deleteModule() { - if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return; + if (!confirm(`Supprimer définitivement l'ECUE ${moduleId} ?`)) return; try { const res = await fetch( `/admin/api/modules/${encodeURIComponent(moduleId)}`, @@ -173,7 +173,7 @@ export default function EditModule({ moduleId }: Props) { class="page-title" style="border-bottom: none; margin-bottom: 0.5rem" > - Module -- {mod.id} + ECUE -- {mod.id}
@@ -202,7 +202,7 @@ export default function EditModule({ moduleId }: Props) { />
- + - Supprimer le module + Supprimer l'ECUE
diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx index 278081c..9e9dc33 100644 --- a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -281,7 +281,7 @@ export default function ImportMaquette() { globalThis.open("/templates/modele_maquette.xlsx", "_blank"); } - function downloadExport() { + function _downloadExport() { Promise.all([ fetch("/admin/api/ues").then((r) => r.json()), fetch("/admin/api/ue-modules").then((r) => r.json()), diff --git a/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx index f429469..a167414 100644 --- a/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx +++ b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx @@ -67,7 +67,9 @@ function validatedWeeks(mobs: Mobilite[]): number { .reduce((sum, m) => sum + m.duree, 0); } -export default function MobilityOverview() { +export default function MobilityOverview( + { initialNumEtud }: { initialNumEtud?: number } = {}, +) { const [students, setStudents] = useState([]); const [promos, setPromos] = useState([]); const [mobilites, setMobilites] = useState([]); @@ -105,6 +107,12 @@ export default function MobilityOverview() { setStagesMap( Object.fromEntries((stData as Stage[]).map((s) => [s.id, s])), ); + if (initialNumEtud) { + const s = (sData as Student[]).find((s) => + s.numEtud === initialNumEtud + ); + if (s) setDetailStudent(s); + } } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -116,6 +124,18 @@ export default function MobilityOverview() { load(); }, []); + function openStudent(s: Student) { + setDetailStudent(s); + history.pushState(null, "", `/mobility/overview/${s.numEtud}`); + } + + function closeStudent() { + setDetailStudent(null); + setEditingMob(null); + setShowAddForm(false); + history.pushState(null, "", "/mobility/overview"); + } + // If in detail view, render that if (detailStudent) { return ( @@ -128,11 +148,7 @@ export default function MobilityOverview() { setEditingMob={setEditingMob} showAddForm={showAddForm} setShowAddForm={setShowAddForm} - onBack={() => { - setDetailStudent(null); - setEditingMob(null); - setShowAddForm(false); - }} + onBack={closeStudent} onReload={load} /> ); @@ -207,14 +223,14 @@ export default function MobilityOverview() { setDetailStudent(s)} + onConsult={(s) => openStudent(s)} /> ) : ( setDetailStudent(s)} + onConsult={(s) => openStudent(s)} /> )} @@ -637,6 +653,9 @@ function DetailView( numEtud={student.numEtud} ecoles={ecoles} paysList={paysList} + availableStages={Object.values(stagesMap) + .filter((s) => s.numEtud === student.numEtud) + .filter((s) => !mobilites.some((m) => m.idStage === s.id))} onCancel={() => setShowAddForm(false)} onSave={async () => { setShowAddForm(false); @@ -774,10 +793,11 @@ function MobEditForm( } function MobAddForm( - { numEtud, ecoles, paysList, onCancel, onSave }: { + { numEtud, ecoles, paysList, availableStages, onCancel, onSave }: { numEtud: number; ecoles: string[]; paysList: string[]; + availableStages: Stage[]; onCancel: () => void; onSave: () => Promise; }, @@ -786,8 +806,19 @@ function MobAddForm( const [ecole, setEcole] = useState(""); const [pays, setPays] = useState(""); const [status, setStatus] = useState("contracts_received"); + const [selectedStageId, setSelectedStageId] = useState(""); const [busy, setBusy] = useState(false); + const isStageLinked = selectedStageId !== ""; + + function onStageChange(value: string) { + setSelectedStageId(value); + if (value) { + const stage = availableStages.find((s) => s.id === Number(value)); + if (stage) setDuree(String(stage.duree)); + } + } + async function submit() { setBusy(true); try { @@ -797,9 +828,10 @@ function MobAddForm( body: JSON.stringify({ numEtud, duree: parseInt(duree), - ecole: ecole || null, - pays: pays || null, - status, + ecole: isStageLinked ? null : (ecole || null), + pays: isStageLinked ? null : (pays || null), + status: isStageLinked ? "validated" : status, + idStage: isStageLinked ? Number(selectedStageId) : null, }), }); if (!res.ok) throw new Error("Erreur"); @@ -815,6 +847,24 @@ function MobAddForm(

Nouvelle mobilité

+ {availableStages.length > 0 && ( +
+ + +
+ )}
setDuree((e.target as HTMLInputElement).value)} />
-
- - setEcole((e.target as HTMLInputElement).value)} - /> - - {ecoles.map((e) => -
-
- - setPays((e.target as HTMLInputElement).value)} - /> - - {paysList.map((p) => -
-
- - -
+ {!isStageLinked && ( + <> +
+ + setEcole((e.target as HTMLInputElement).value)} + /> + + {ecoles.map((e) => +
+
+ + setPays((e.target as HTMLInputElement).value)} + /> + + {paysList.map((p) => +
+
+ + +
+ + )}
+ {isStageLinked && ( +

+ Mobilité liée à un stage — status automatiquement « Validé » +

+ )}
); diff --git a/routes/(apps)/stages/[slug].tsx b/routes/(apps)/stages/[...slug].tsx similarity index 100% rename from routes/(apps)/stages/[slug].tsx rename to routes/(apps)/stages/[...slug].tsx diff --git a/routes/(apps)/stages/partials/overview/[numEtud].tsx b/routes/(apps)/stages/partials/overview/[numEtud].tsx new file mode 100644 index 0000000..3a06562 --- /dev/null +++ b/routes/(apps)/stages/partials/overview/[numEtud].tsx @@ -0,0 +1,20 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import StagesOverview from "../../(_islands)/StagesOverview.tsx"; + +// deno-lint-ignore require-await +async function Overview( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} + +export { Overview as Page }; +export const config = getPartialsConfig(); +export default makePartials(Overview); diff --git a/routes/(apps)/students/(_islands)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index a7fc770..b72abf4 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -8,6 +8,8 @@ type Student = { }; type Promo = { id: string; annee: string }; type Module = { id: string; nom: string }; +type Mobilite = { id: number; duree: number; status: string }; +type Stage = { id: number; duree: number }; type Props = { numEtud: number }; @@ -25,6 +27,8 @@ export default function EditStudents({ numEtud }: Props) { const [student, setStudent] = useState(null); const [promos, setPromos] = useState([]); const [_modules, setModules] = useState([]); + const [mobWeeks, setMobWeeks] = useState(0); + const [stageWeeks, setStageWeeks] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [saveMsg, setSaveMsg] = useState(null); @@ -38,10 +42,12 @@ export default function EditStudents({ numEtud }: Props) { useEffect(() => { async function load() { try { - const [sRes, pRes, mRes] = await Promise.all([ + const [sRes, pRes, mRes, mobRes, stRes] = await Promise.all([ fetch(`/students/api/students/${numEtud}`), fetch("/students/api/promotions"), - fetch("/admin/api/modules"), + fetch("/notes/api/modules"), + fetch(`/mobility/api/mobilites?numEtud=${numEtud}`), + fetch(`/stages/api/stages?numEtud=${numEtud}`), ]); if (!sRes.ok) throw new Error("Élève introuvable"); const s: Student = await sRes.json(); @@ -51,6 +57,19 @@ export default function EditStudents({ numEtud }: Props) { setIdPromo(s.idPromo); if (pRes.ok) setPromos(await pRes.json()); if (mRes.ok) setModules(await mRes.json()); + if (mobRes.ok) { + const mobs: Mobilite[] = await mobRes.json(); + setMobWeeks( + mobs.filter((m) => m.status === "validated").reduce( + (s, m) => s + m.duree, + 0, + ), + ); + } + if (stRes.ok) { + const stages: Stage[] = await stRes.json(); + setStageWeeks(stages.reduce((s, st) => s + st.duree, 0)); + } } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -207,30 +226,69 @@ export default function EditStudents({ numEtud }: Props) {
- {/* Section 2: Spécialisations */} + {/* Section 2: Notes */}
-

Spécialisations

-

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

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

Notes (lecture seule)

+

Notes

- Voir le récap complet des notes et moyennes de cet étudiant → + Récap complet des notes et moyennes - Récap notes + Voir les notes + +
+
+ + {/* Section 3: Mobilités */} +
+

Mobilités

+
+ + = 12 ? "#22c55e" : "#dc2626", + }} + > + {mobWeeks}/12 semaines validées + + + + Consulter + +
+
+ + {/* Section 4: Stages */} +
+

Stages

+
+ + = 40 ? "#22c55e" : "#dc2626", + }} + > + {stageWeeks}/40 semaines + + + + Consulter
diff --git a/static/styles/ui.css b/static/styles/ui.css index 9d2218e..6583f3c 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -40,6 +40,12 @@ font-size: 0.8rem; font-family: inherit; min-width: 8rem; + box-sizing: border-box; +} + +.form-field .filter-select { + width: 100%; + min-width: 0; } .filter-input:focus, @@ -368,7 +374,9 @@ color: light-dark(var(--light-foreground), var(--dark-foreground)); font-size: 0.82rem; font-family: inherit; - min-width: 12rem; + min-width: 0; + width: 100%; + box-sizing: border-box; } .form-input:focus { @@ -799,7 +807,7 @@ .form-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 0.75rem 1rem; margin-bottom: 0.75rem; } diff --git a/static/theme.js b/static/theme.js index af947af..041e9a8 100644 --- a/static/theme.js +++ b/static/theme.js @@ -1,15 +1,15 @@ (function () { - var t = localStorage.getItem("theme"); + const t = localStorage.getItem("theme"); if (t) document.documentElement.style.colorScheme = t; document.addEventListener("click", function (e) { - var btn = e.target.closest("#theme-toggle"); + const btn = e.target.closest("#theme-toggle"); if (!btn) return; - var cs = getComputedStyle(document.documentElement).colorScheme; - var isDark = cs === "dark" || + const cs = getComputedStyle(document.documentElement).colorScheme; + const isDark = cs === "dark" || (!cs || cs === "light dark") && matchMedia("(prefers-color-scheme:dark)").matches; - var next = isDark ? "light" : "dark"; + const next = isDark ? "light" : "dark"; document.documentElement.style.colorScheme = next; localStorage.setItem("theme", next); btn.querySelector("span").textContent = next === "dark" @@ -18,10 +18,10 @@ }); document.addEventListener("DOMContentLoaded", function () { - var btn = document.getElementById("theme-toggle"); + const btn = document.getElementById("theme-toggle"); if (!btn) return; - var cs = getComputedStyle(document.documentElement).colorScheme; - var isDark = cs === "dark" || + const cs = getComputedStyle(document.documentElement).colorScheme; + const isDark = cs === "dark" || (!cs || cs === "light dark") && matchMedia("(prefers-color-scheme:dark)").matches; btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode"; diff --git a/tests/e2e/modules_test.ts b/tests/e2e/modules_test.ts index 7b33ca0..3077062 100644 --- a/tests/e2e/modules_test.ts +++ b/tests/e2e/modules_test.ts @@ -34,7 +34,7 @@ Deno.test({ }); Deno.test({ - name: "e2e modules: GET /modules returns empty for non-employee", + name: "e2e modules: GET /modules returns all for non-employee", async fn() { await truncateAll(); await seedModules([{ id: "MATH101", nom: "Mathématiques" }]); @@ -44,7 +44,7 @@ Deno.test({ ); assertEquals(res.status, 200); const body = await res.json(); - assertEquals(body.length, 0); + assertEquals(body.length, 1); }, sanitizeResources: false, sanitizeOps: false, -- 2.52.0 From ae4d4d3020f8fb9dfa49a000297ed29883a1a0f8 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 14:26:00 +0200 Subject: [PATCH 5/6] refactor: rename Module to ECUE, update routes, UI, and API messages refactor: rename Module to ECUE in API, UI, and error messages --- fresh.gen.ts | 14 +++++++---- routes/(apps)/admin/(_islands)/EditUser.tsx | 8 +++---- .../admin/(_islands)/ImportMaquette.tsx | 23 +++++++++---------- routes/(apps)/admin/(_props)/props.ts | 4 ++-- routes/(apps)/admin/api/modules.ts | 2 +- routes/(apps)/admin/api/ue-modules.ts | 4 ++-- .../ue-modules/[idModule]/[idUE]/[idPromo].ts | 2 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 4 ++-- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 4 ++-- routes/(apps)/notes/(_islands)/NotesView.tsx | 2 +- 10 files changed, 36 insertions(+), 31 deletions(-) diff --git a/fresh.gen.ts b/fresh.gen.ts index 4d3229d..d119210 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -31,13 +31,14 @@ import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/rol import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx"; -import * as $_apps_mobility_slug_ from "./routes/(apps)/mobility/[slug].tsx"; +import * as $_apps_mobility_slug_ from "./routes/(apps)/mobility/[...slug].tsx"; import * as $_apps_mobility_api_mobilites from "./routes/(apps)/mobility/api/mobilites.ts"; import * as $_apps_mobility_api_mobilites_idMob_ from "./routes/(apps)/mobility/api/mobilites/[idMob].ts"; import * as $_apps_mobility_api_mobilites_idMob_contrat from "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts"; import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.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_mobility_partials_overview_numEtud_ from "./routes/(apps)/mobility/partials/overview/[numEtud].tsx"; import * as $_apps_notes_slug_ from "./routes/(apps)/notes/[slug].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"; @@ -54,12 +55,13 @@ import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/parti import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; -import * as $_apps_stages_slug_ from "./routes/(apps)/stages/[slug].tsx"; +import * as $_apps_stages_slug_ from "./routes/(apps)/stages/[...slug].tsx"; import * as $_apps_stages_api_stages from "./routes/(apps)/stages/api/stages.ts"; import * as $_apps_stages_api_stages_idStage_ from "./routes/(apps)/stages/api/stages/[idStage].ts"; import * as $_apps_stages_index from "./routes/(apps)/stages/index.tsx"; import * as $_apps_stages_partials_index from "./routes/(apps)/stages/partials/index.tsx"; import * as $_apps_stages_partials_overview from "./routes/(apps)/stages/partials/overview.tsx"; +import * as $_apps_stages_partials_overview_numEtud_ from "./routes/(apps)/stages/partials/overview/[numEtud].tsx"; import * as $_apps_students_slug_ from "./routes/(apps)/students/[slug].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"; @@ -145,7 +147,7 @@ const manifest = { "./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, - "./routes/(apps)/mobility/[slug].tsx": $_apps_mobility_slug_, + "./routes/(apps)/mobility/[...slug].tsx": $_apps_mobility_slug_, "./routes/(apps)/mobility/api/mobilites.ts": $_apps_mobility_api_mobilites, "./routes/(apps)/mobility/api/mobilites/[idMob].ts": $_apps_mobility_api_mobilites_idMob_, @@ -156,6 +158,8 @@ const manifest = { $_apps_mobility_partials_index, "./routes/(apps)/mobility/partials/overview.tsx": $_apps_mobility_partials_overview, + "./routes/(apps)/mobility/partials/overview/[numEtud].tsx": + $_apps_mobility_partials_overview_numEtud_, "./routes/(apps)/notes/[slug].tsx": $_apps_notes_slug_, "./routes/(apps)/notes/api/ajustements.ts": $_apps_notes_api_ajustements, "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts": @@ -178,7 +182,7 @@ const manifest = { "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, - "./routes/(apps)/stages/[slug].tsx": $_apps_stages_slug_, + "./routes/(apps)/stages/[...slug].tsx": $_apps_stages_slug_, "./routes/(apps)/stages/api/stages.ts": $_apps_stages_api_stages, "./routes/(apps)/stages/api/stages/[idStage].ts": $_apps_stages_api_stages_idStage_, @@ -186,6 +190,8 @@ const manifest = { "./routes/(apps)/stages/partials/index.tsx": $_apps_stages_partials_index, "./routes/(apps)/stages/partials/overview.tsx": $_apps_stages_partials_overview, + "./routes/(apps)/stages/partials/overview/[numEtud].tsx": + $_apps_stages_partials_overview_numEtud_, "./routes/(apps)/students/[slug].tsx": $_apps_students_slug_, "./routes/(apps)/students/api/promotions.ts": $_apps_students_api_promotions, diff --git a/routes/(apps)/admin/(_islands)/EditUser.tsx b/routes/(apps)/admin/(_islands)/EditUser.tsx index c9e45ca..2254461 100644 --- a/routes/(apps)/admin/(_islands)/EditUser.tsx +++ b/routes/(apps)/admin/(_islands)/EditUser.tsx @@ -106,7 +106,7 @@ export default function EditUser({ userId }: Props) { async function addEnseignement() { if (!addModule || !addPromo) { - setAddError("Module et Promo sont requis"); + setAddError("ECUE et Promo sont requis"); return; } setAdding(true); @@ -276,7 +276,7 @@ export default function EditUser({ userId }: Props) { class="col-dim" style="font-size: 0.75rem; margin: 0 0 0.75rem" > - Modules enseignes par cet utilisateur + ECUEs enseignes par cet utilisateur

{enseignements.length > 0 @@ -285,7 +285,7 @@ export default function EditUser({ userId }: Props) {
ModuleECUE Promo Coeff Actions
- Aucun module assigné + Aucun ECUE assigné
- + @@ -360,7 +360,7 @@ export default function EditUser({ userId }: Props) { setAddModule((e.target as HTMLSelectElement).value)} style="min-width: 12rem" > - + {modules.map((m) => ( - + @@ -485,7 +484,7 @@ export default function ImportMaquette() { ) @@ -550,7 +549,7 @@ export default function ImportMaquette() {

Format : fichier maquette FISE / FISA avec lignes UE - et modules (colonnes code, nom, coefficient) + et ECUEs (colonnes code, nom, coefficient)

); diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index add375c..762a5df 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -8,7 +8,7 @@ const properties: AppProperties = { users: "Utilisateurs", roles: "Rôles", permissions: "Permissions", - modules: "Modules", + modules: "ECUEs", enseignements: "Enseignements", promotions: "Promotions", ues: "UEs", @@ -25,7 +25,7 @@ const properties: AppProperties = { "import-maquette", ], employeeOnly: true, - hint: "PolyMPR module", + hint: "PolyMPR ECUE", }; export default properties; diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts index 63ebfe1..4519db3 100644 --- a/routes/(apps)/admin/api/modules.ts +++ b/routes/(apps)/admin/api/modules.ts @@ -44,7 +44,7 @@ export const handler: Handlers = { if (existing) { return new Response( - JSON.stringify({ error: "Un module avec cet identifiant existe déjà" }), + JSON.stringify({ error: "Un ECUE avec cet identifiant existe déjà" }), { status: 409, headers: { "content-type": "application/json" } }, ); } diff --git a/routes/(apps)/admin/api/ue-modules.ts b/routes/(apps)/admin/api/ue-modules.ts index 1a825a6..d2672d4 100644 --- a/routes/(apps)/admin/api/ue-modules.ts +++ b/routes/(apps)/admin/api/ue-modules.ts @@ -65,8 +65,8 @@ export const handler: Handlers = { headers: { "Content-Type": "application/json" }, }); } catch (error) { - console.error("Error creating UE-module:", error); - return new Response("Failed to create UE-module", { status: 500 }); + console.error("Error creating UE-ECUE:", error); + return new Response("Failed to create UE-ECUE", { status: 500 }); } }, }; diff --git a/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts index 7470e7f..b71396d 100644 --- a/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts +++ b/routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -6,7 +6,7 @@ import { and, eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = () => new Response( - JSON.stringify({ error: "Association UE-Module introuvable" }), + JSON.stringify({ error: "Association UE-ECUE introuvable" }), { status: 404, headers: { "content-type": "application/json" } }, ); diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index 50168c8..1855520 100644 --- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx +++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx @@ -581,7 +581,7 @@ export default function ImportNotes() { ))}

- M = module (importe) | UE = moyenne UE (ignore) | X = malus + M = ECUE (importe) | UE = moyenne UE (ignore) | X = malus

)} @@ -618,7 +618,7 @@ export default function ImportNotes() {

Format : Nom | Prenom |{" "} - CODE - Module (colonnes notes){" "} + CODE - ECUE (colonnes notes){" "} — les colonnes UE et MALUS sont auto-detectees

diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index de9ec39..5a516f0 100644 --- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -324,14 +324,14 @@ export default function NoteRecap({ numEtud }: Props) { )} - {/* Module rows */} + {/* ECUE rows */} {ueMods.length === 0 ? (

- Aucun module associe a cette UE pour cette promotion. + Aucun ECUE associe a cette UE pour cette promotion.

) : ( diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx index 326d6e7..35cc897 100644 --- a/routes/(apps)/notes/(_islands)/NotesView.tsx +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -225,7 +225,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
{mod ? mod.id : um.idModule} —{" "} - {mod ? mod.nom : "Module inconnu"} (coef {um.coeff}) + {mod ? mod.nom : "ECUE inconnu"} (coef {um.coeff}) {effective !== null ? `${effective}/20` : "—"} -- 2.52.0 From 77e0b966a58cba1f577d4c63ec38e71ea1af3593 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 14:35:58 +0200 Subject: [PATCH 6/6] style: fix formatting of ImportMaquette error handling block --- routes/(apps)/admin/(_islands)/ImportMaquette.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx index 42c2b58..5a03b6e 100644 --- a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -230,13 +230,14 @@ export default function ImportMaquette() { details.push({ type: "change", message: `ECUE ${mod.code} "${mod.name}" cree`, - }); - } else if (modRes.status !== 409) { - errCount++; - details.push({ + }); + } else if (modRes.status !== 409) { + errCount++; + details.push({ type: "error", message: `ECUE "${mod.code}" : creation echouee`, - }); continue; + }); + continue; } const linkRes = await fetch("/admin/api/ue-modules", { -- 2.52.0
ModuleECUE Promo Actions
UEModuleECUE Code Coeff
{ue.name} - Aucun module + Aucun ECUE