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 0000000..65ddb68 Binary files /dev/null and b/static/templates/modele_etudiants.xlsx differ diff --git a/static/templates/modele_maquette.xlsx b/static/templates/modele_maquette.xlsx new file mode 100644 index 0000000..f326c5e Binary files /dev/null and b/static/templates/modele_maquette.xlsx differ diff --git a/static/templates/modele_notes.xlsx b/static/templates/modele_notes.xlsx new file mode 100644 index 0000000..55d9614 Binary files /dev/null and b/static/templates/modele_notes.xlsx differ 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 ---
+ 0} + onChange={toggleAll} + /> + N° étud. Nom Prénom
+ Aucun élève trouvé
+ toggleOne(s.numEtud)} + /> + {s.numEtud} {s.nom} {s.prenom}