diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index d2a8d16..259baf7 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -56,6 +56,10 @@ jobs: run: | sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \ PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test + sed 's/--> statement-breakpoint/;/g' databases/migrations/0003_add_session2_and_malus.sql | \ + PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test + sed 's/--> statement-breakpoint/;/g' databases/migrations/0004_add_stages_and_mobilites.sql | \ + PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test - name: Install dependencies run: npm install --ignore-scripts && deno install diff --git a/compose.prod.yml b/compose.prod.yml index 6d7f11a..a20b1e8 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -30,9 +30,12 @@ services: ports: - "4430:443" env_file: .env + volumes: + - contracts:/app/uploads/contracts depends_on: migrate: condition: service_completed_successfully volumes: db_data: + contracts: diff --git a/databases/migrations/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/0004_add_stages_and_mobilites.sql b/databases/migrations/0004_add_stages_and_mobilites.sql new file mode 100644 index 0000000..a1f8a5d --- /dev/null +++ b/databases/migrations/0004_add_stages_and_mobilites.sql @@ -0,0 +1,28 @@ +DROP TABLE IF EXISTS "mobility"; +--> statement-breakpoint +CREATE TYPE "mobility_status" AS ENUM ('contracts_received', 'under_revision', 'done', 'validated', 'canceled'); +--> statement-breakpoint +CREATE TABLE "stages" ( + "idStage" serial PRIMARY KEY NOT NULL, + "numEtud" integer NOT NULL, + "duree" integer NOT NULL, + "nomEntreprise" text NOT NULL, + "mission" text +); +--> statement-breakpoint +CREATE TABLE "mobilites" ( + "idMob" serial PRIMARY KEY NOT NULL, + "numEtud" integer NOT NULL, + "duree" integer NOT NULL, + "contratMob" text, + "ecole" text, + "pays" text, + "status" "mobility_status" NOT NULL DEFAULT 'contracts_received', + "idStage" integer +); +--> statement-breakpoint +ALTER TABLE "stages" ADD CONSTRAINT "stages_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_idStage_stages_idStage_fk" FOREIGN KEY ("idStage") REFERENCES "public"."stages"("idStage") ON DELETE no action ON UPDATE no action; diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json index e4f070f..3cb93bd 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -22,6 +22,20 @@ "when": 1777155028710, "tag": "0002_update_permission_names", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1777155028711, + "tag": "0003_add_session2_and_malus", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1777155028712, + "tag": "0004_add_stages_and_mobilites", + "breakpoints": true } ] } diff --git a/databases/schema.ts b/databases/schema.ts index 823c7a2..eadbb3a 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -1,7 +1,7 @@ import { - date, doublePrecision, integer, + pgEnum, pgTable, primaryKey, serial, @@ -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,17 +84,34 @@ 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] }), })); -export const mobility = pgTable("mobility", { - id: serial("id").primaryKey(), - studentId: integer("studentId").references(() => students.numEtud), - startDate: date("startDate"), - endDate: date("endDate"), - weeksCount: integer("weeksCount"), - destinationCountry: text("destinationCountry"), - destinationName: text("destinationName"), - mobilityStatus: text("mobilityStatus").default("N/A"), +export const stages = pgTable("stages", { + id: serial("idStage").primaryKey(), + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + duree: integer("duree").notNull(), + nomEntreprise: text("nomEntreprise").notNull(), + mission: text("mission"), +}); + +export const mobilityStatusEnum = pgEnum("mobility_status", [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +]); + +export const mobilites = pgTable("mobilites", { + id: serial("idMob").primaryKey(), + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + duree: integer("duree").notNull(), + contratMob: text("contratMob"), + ecole: text("ecole"), + pays: text("pays"), + status: mobilityStatusEnum("status").notNull().default("contracts_received"), + idStage: integer("idStage").references(() => stages.id), }); diff --git a/defaults/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/defaults/interfaces.ts b/defaults/interfaces.ts index f385846..951201a 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -19,6 +19,8 @@ export interface AppProperties { icon: string; pages: Record; adminOnly: string[]; + studentOnly?: string[]; + employeeOnly?: boolean; hint: string; } diff --git a/defaults/makeSlug.ts b/defaults/makeSlug.ts new file mode 100644 index 0000000..d1110fe --- /dev/null +++ b/defaults/makeSlug.ts @@ -0,0 +1,62 @@ +import { FreshContext } from "$fresh/server.ts"; +import { Route, State } from "$root/defaults/interfaces.ts"; +import { ComponentChildren } from "preact"; + +/** + * Generates a catch-all [slug] route that dynamically loads partials. + * This enables direct URL navigation to sub-pages (e.g. /admin/modules). + * @param basePath The base path of the module, should be `import.meta.dirname!`. + * @returns A route handler that loads the partial matching the slug. + */ +export default function makeSlug(basePath: string): Route { + return async function SlugRoute( + request: Request, + context: FreshContext, + ): Promise { + const slug = context.params.slug; + + // Try partials/.tsx, then partials/(admin)/.tsx + let page: Route | undefined; + try { + page = (await import(`${basePath}/partials/${slug}.tsx`)).Page; + } catch { + try { + page = (await import(`${basePath}/partials/(admin)/${slug}.tsx`)).Page; + } catch { + // No partial found for this slug + } + } + + // For multi-segment slugs (e.g. "overview/12345"), try + // partials//[param].tsx and inject the param into context.params + if (!page && slug.includes("/")) { + const idx = slug.indexOf("/"); + const dir = slug.slice(0, idx); + const param = slug.slice(idx + 1); + + // Discover the dynamic segment name from the file system + try { + const entries: string[] = []; + for await (const entry of Deno.readDir(`${basePath}/partials/${dir}`)) { + if (entry.isFile) entries.push(entry.name); + } + const dynFile = entries.find((n) => + n.startsWith("[") && n.endsWith("].tsx") + ); + if (dynFile) { + const paramName = dynFile.slice(1, -5); // "[numEtud].tsx" → "numEtud" + context.params[paramName] = param; + page = (await import(`${basePath}/partials/${dir}/${dynFile}`)).Page; + } + } catch { + // directory doesn't exist or no dynamic file + } + } + + if (!page) { + return context.renderNotFound(); + } + + return page(request, context); + }; +} diff --git a/fresh.gen.ts b/fresh.gen.ts index 22cab59..d119210 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,6 +4,7 @@ import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; +import * as $_apps_admin_slug_ from "./routes/(apps)/admin/[slug].tsx"; import * as $_apps_admin_api_enseignements from "./routes/(apps)/admin/api/enseignements.ts"; import * as $_apps_admin_api_enseignements_idProf_idModule_idPromo_ from "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts"; import * as $_apps_admin_api_example from "./routes/(apps)/admin/api/example.ts"; @@ -12,36 +13,56 @@ 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_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; +import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx"; +import * as $_apps_mobility_slug_ from "./routes/(apps)/mobility/[...slug].tsx"; +import * as $_apps_mobility_api_mobilites from "./routes/(apps)/mobility/api/mobilites.ts"; +import * as $_apps_mobility_api_mobilites_idMob_ from "./routes/(apps)/mobility/api/mobilites/[idMob].ts"; +import * as $_apps_mobility_api_mobilites_idMob_contrat from "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts"; import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx"; -import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx"; import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx"; import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx"; +import * as $_apps_mobility_partials_overview_numEtud_ from "./routes/(apps)/mobility/partials/overview/[numEtud].tsx"; +import * as $_apps_notes_slug_ from "./routes/(apps)/notes/[slug].tsx"; import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustements.ts"; import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts"; +import * as $_apps_notes_api_modules from "./routes/(apps)/notes/api/modules.ts"; import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts"; import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts"; +import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts"; import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts"; -import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; -import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts"; import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; -import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx"; -import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx"; import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; +import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; +import * as $_apps_stages_slug_ from "./routes/(apps)/stages/[...slug].tsx"; +import * as $_apps_stages_api_stages from "./routes/(apps)/stages/api/stages.ts"; +import * as $_apps_stages_api_stages_idStage_ from "./routes/(apps)/stages/api/stages/[idStage].ts"; +import * as $_apps_stages_index from "./routes/(apps)/stages/index.tsx"; +import * as $_apps_stages_partials_index from "./routes/(apps)/stages/partials/index.tsx"; +import * as $_apps_stages_partials_overview from "./routes/(apps)/stages/partials/overview.tsx"; +import * as $_apps_stages_partials_overview_numEtud_ from "./routes/(apps)/stages/partials/overview/[numEtud].tsx"; +import * as $_apps_students_slug_ from "./routes/(apps)/students/[slug].tsx"; import * as $_apps_students_api_promotions from "./routes/(apps)/students/api/promotions.ts"; import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; @@ -50,7 +71,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"; @@ -68,17 +88,19 @@ 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_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_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_MobilityOverview from "./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx"; import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; -import * as $_apps_notes_islands_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_stages_islands_StagesOverview from "./routes/(apps)/stages/(_islands)/StagesOverview.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx"; import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx"; @@ -88,6 +110,7 @@ const manifest = { routes: { "./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_middleware.ts": $_apps_middleware, + "./routes/(apps)/admin/[slug].tsx": $_apps_admin_slug_, "./routes/(apps)/admin/api/enseignements.ts": $_apps_admin_api_enseignements, "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts": @@ -100,49 +123,76 @@ 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, + "./routes/(apps)/admin/modules/[idModule].tsx": + $_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)/mobility/api/insert_mobility.ts": - $_apps_mobility_api_insert_mobility, + "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, + "./routes/(apps)/mobility/[...slug].tsx": $_apps_mobility_slug_, + "./routes/(apps)/mobility/api/mobilites.ts": $_apps_mobility_api_mobilites, + "./routes/(apps)/mobility/api/mobilites/[idMob].ts": + $_apps_mobility_api_mobilites_idMob_, + "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts": + $_apps_mobility_api_mobilites_idMob_contrat, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, - "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx": - $_apps_mobility_partials_admin_edit_mobility, "./routes/(apps)/mobility/partials/index.tsx": $_apps_mobility_partials_index, "./routes/(apps)/mobility/partials/overview.tsx": $_apps_mobility_partials_overview, + "./routes/(apps)/mobility/partials/overview/[numEtud].tsx": + $_apps_mobility_partials_overview_numEtud_, + "./routes/(apps)/notes/[slug].tsx": $_apps_notes_slug_, "./routes/(apps)/notes/api/ajustements.ts": $_apps_notes_api_ajustements, "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts": $_apps_notes_api_ajustements_numEtud_idUE_, + "./routes/(apps)/notes/api/modules.ts": $_apps_notes_api_modules, "./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes, "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts": $_apps_notes_api_notes_numEtud_idModule_, + "./routes/(apps)/notes/api/notes/import-xlsx.ts": + $_apps_notes_api_notes_import_xlsx, "./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules, - "./routes/(apps)/notes/api/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, - "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, "./routes/(apps)/notes/partials/(admin)/courses.tsx": $_apps_notes_partials_admin_courses, "./routes/(apps)/notes/partials/(admin)/import.tsx": $_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_, + "./routes/(apps)/stages/[...slug].tsx": $_apps_stages_slug_, + "./routes/(apps)/stages/api/stages.ts": $_apps_stages_api_stages, + "./routes/(apps)/stages/api/stages/[idStage].ts": + $_apps_stages_api_stages_idStage_, + "./routes/(apps)/stages/index.tsx": $_apps_stages_index, + "./routes/(apps)/stages/partials/index.tsx": $_apps_stages_partials_index, + "./routes/(apps)/stages/partials/overview.tsx": + $_apps_stages_partials_overview, + "./routes/(apps)/stages/partials/overview/[numEtud].tsx": + $_apps_stages_partials_overview_numEtud_, + "./routes/(apps)/students/[slug].tsx": $_apps_students_slug_, "./routes/(apps)/students/api/promotions.ts": $_apps_students_api_promotions, "./routes/(apps)/students/api/promotions/[idPromo].ts": @@ -157,8 +207,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": @@ -183,28 +231,32 @@ 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)/mobility/(_islands)/ConsultMobility.tsx": - $_apps_mobility_islands_ConsultMobility, - "./routes/(apps)/mobility/(_islands)/EditMobility.tsx": - $_apps_mobility_islands_EditMobility, - "./routes/(apps)/mobility/(_islands)/ImportFile.tsx": - $_apps_mobility_islands_ImportFile, + "./routes/(apps)/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)/MobilityOverview.tsx": + $_apps_mobility_islands_MobilityOverview, "./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)/stages/(_islands)/StagesOverview.tsx": + $_apps_stages_islands_StagesOverview, "./routes/(apps)/students/(_islands)/ConsultStudents.tsx": $_apps_students_islands_ConsultStudents, "./routes/(apps)/students/(_islands)/EditStudents.tsx": diff --git a/openapi.yml b/openapi.yml new file mode 100644 index 0000000..29b5205 --- /dev/null +++ b/openapi.yml @@ -0,0 +1,1536 @@ +openapi: 3.1.0 +info: + title: PolyMPR API + version: 2.0.0 + description: API de gestion des étudiants, notes, mobilités, stages et administration. + +servers: + - url: / + +tags: + - name: Students + - name: Promotions + - name: Users + - name: Roles + - name: Permissions + - name: Modules + - name: Enseignements + - name: UEs + - name: UE_Modules + - name: Notes + - name: Ajustements + - name: Mobilités + - name: Stages + +paths: + # ── Students ────────────────────────────────────────────── + /students/api/students: + get: + tags: [Students] + summary: Liste des étudiants + parameters: + - $ref: "#/components/parameters/idPromoQuery" + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Student" + post: + tags: [Students] + summary: Créer un étudiant + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StudentCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + + /students/api/students/import-csv: + post: + tags: [Students] + summary: Importer des étudiants par CSV + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, idPromo] + properties: + file: + type: string + format: binary + idPromo: + type: string + responses: + "200": + description: Résultat de l'import + content: + application/json: + schema: + type: object + properties: + imported: + type: integer + errors: + type: array + items: + type: object + properties: + line: + type: integer + message: + type: string + + /students/api/students/{numEtud}: + parameters: + - $ref: "#/components/parameters/numEtud" + get: + tags: [Students] + summary: Détail d'un étudiant + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Students] + summary: Modifier un étudiant + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StudentCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Student" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Students] + summary: Supprimer un étudiant (cascade mobilités et stages) + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Promotions ──────────────────────────────────────────── + /students/api/promotions: + get: + tags: [Promotions] + summary: Liste des promotions + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Promotion" + post: + tags: [Promotions] + summary: Créer une promotion + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PromotionCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + + /students/api/promotions/{idPromo}: + parameters: + - $ref: "#/components/parameters/idPromo" + get: + tags: [Promotions] + summary: Détail d'une promotion + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Promotions] + summary: Modifier une promotion + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PromotionCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Promotion" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Promotions] + summary: Supprimer une promotion + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Users ──────────────────────────────────────────────── + /admin/api/users: + get: + tags: [Users] + summary: Liste des utilisateurs + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + post: + tags: [Users] + summary: Créer un utilisateur (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "403": + description: Accès refusé + + /admin/api/users/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + tags: [Users] + summary: Détail d'un utilisateur + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Users] + summary: Modifier un utilisateur + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Users] + summary: Supprimer un utilisateur + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Roles ──────────────────────────────────────────────── + /admin/api/roles: + get: + tags: [Roles] + summary: Liste des rôles + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Role" + post: + tags: [Roles] + summary: Créer un rôle + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + + /admin/api/roles/{idRole}: + parameters: + - name: idRole + in: path + required: true + schema: + type: integer + get: + tags: [Roles] + summary: Détail d'un rôle + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Roles] + summary: Modifier un rôle + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleCreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Roles] + summary: Supprimer un rôle + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Permissions ────────────────────────────────────────── + /admin/api/permissions: + get: + tags: [Permissions] + summary: Liste des permissions + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Permission" + + # ── Modules ─────────────────────────────────────────────── + /admin/api/modules: + get: + tags: [Modules] + summary: Liste des modules + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Module" + post: + tags: [Modules] + summary: Créer un module (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "409": + description: Un module avec cet identifiant existe déjà + + /admin/api/modules/{idModule}: + parameters: + - $ref: "#/components/parameters/idModule" + get: + tags: [Modules] + summary: Détail d'un module + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Modules] + summary: Modifier un module + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [nom] + properties: + nom: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Module" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Modules] + summary: Supprimer un module (cascade notes, ue_modules, enseignements) + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Enseignements ─────────────────────────────────────── + /admin/api/enseignements: + get: + tags: [Enseignements] + summary: Liste des enseignements + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Enseignement" + post: + tags: [Enseignements] + summary: Créer un enseignement + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EnseignementCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Enseignement" + "409": + description: Cet enseignement existe déjà + + /admin/api/enseignements/{idProf}/{idModule}/{idPromo}: + parameters: + - name: idProf + in: path + required: true + schema: + type: string + - name: idModule + in: path + required: true + schema: + type: string + - name: idPromo + in: path + required: true + schema: + type: string + get: + tags: [Enseignements] + summary: Détail d'un enseignement + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Enseignement" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Enseignements] + summary: Supprimer un enseignement + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── UEs ─────────────────────────────────────────────────── + /admin/api/ues: + get: + tags: [UEs] + summary: Liste des UEs + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UE" + post: + tags: [UEs] + summary: Créer une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UECreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + + /admin/api/ues/{idUE}: + parameters: + - $ref: "#/components/parameters/idUE" + get: + tags: [UEs] + summary: Détail d'une UE + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [UEs] + summary: Modifier une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UECreate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UE" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [UEs] + summary: Supprimer une UE + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── UE_Modules ──────────────────────────────────────────── + /admin/api/ue-modules: + get: + tags: [UE_Modules] + summary: Liste des associations UE-Module + parameters: + - $ref: "#/components/parameters/idPromoQuery" + - name: idUE + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UEModule" + post: + tags: [UE_Modules] + summary: Associer un module à une UE + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UEModuleCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + + /admin/api/ue-modules/{idModule}/{idUE}/{idPromo}: + parameters: + - name: idModule + in: path + required: true + schema: + type: string + - name: idUE + in: path + required: true + schema: + type: integer + - name: idPromo + in: path + required: true + schema: + type: string + get: + tags: [UE_Modules] + summary: Détail d'une association UE-Module + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [UE_Modules] + summary: Modifier le coefficient + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [coeff] + properties: + coeff: + type: number + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UEModule" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [UE_Modules] + summary: Supprimer l'association + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Notes ───────────────────────────────────────────────── + /notes/api/notes: + get: + tags: [Notes] + summary: Liste des notes + parameters: + - name: numEtud + in: query + schema: + type: integer + - name: idModule + in: query + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Note" + post: + tags: [Notes] + summary: Créer une note + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NoteCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "409": + description: Note déjà existante pour cet étudiant/module + + /notes/api/notes/import-xlsx: + post: + tags: [Notes] + summary: Importer des notes par fichier XLSX + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, idModule] + properties: + file: + type: string + format: binary + idModule: + type: string + responses: + "200": + description: Import réussi + content: + application/json: + schema: + type: object + properties: + imported: + type: integer + errors: + type: array + items: + type: object + properties: + line: + type: integer + student: + type: string + message: + type: string + "400": + description: Fichier invalide ou données corrompues + + /notes/api/notes/{numEtud}/{idModule}: + parameters: + - name: numEtud + in: path + required: true + schema: + type: integer + - name: idModule + in: path + required: true + schema: + type: string + get: + tags: [Notes] + summary: Détail d'une note + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Notes] + summary: Modifier une note + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [note] + properties: + note: + type: number + minimum: 0 + maximum: 20 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Note" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Notes] + summary: Supprimer une note + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Ajustements ─────────────────────────────────────────── + /notes/api/ajustements: + get: + tags: [Ajustements] + summary: Liste des ajustements + parameters: + - name: numEtud + in: query + schema: + type: integer + - name: idUE + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Ajustement" + post: + tags: [Ajustements] + summary: Créer un ajustement + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AjustementCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + + /notes/api/ajustements/{numEtud}/{idUE}: + parameters: + - name: numEtud + in: path + required: true + schema: + type: integer + - name: idUE + in: path + required: true + schema: + type: integer + get: + tags: [Ajustements] + summary: Détail d'un ajustement + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Ajustements] + summary: Modifier un ajustement + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [valeur] + properties: + valeur: + type: number + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Ajustement" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Ajustements] + summary: Supprimer un ajustement + responses: + "204": + description: Supprimé + "404": + $ref: "#/components/responses/NotFound" + + # ── Mobilités ───────────────────────────────────────────── + /mobility/api/mobilites: + get: + tags: [Mobilités] + summary: Liste des mobilités + parameters: + - name: numEtud + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Mobilite" + post: + tags: [Mobilités] + summary: Créer une mobilité + description: > + Les étudiants ne peuvent pas définir idStage ni changer le status + (reste contracts_received). Les mobilités liées à un stage sont + automatiquement validées. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MobiliteCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "400": + description: Champs requis manquants ou invalides + + /mobility/api/mobilites/{idMob}: + parameters: + - name: idMob + in: path + required: true + schema: + type: integer + get: + tags: [Mobilités] + summary: Détail d'une mobilité + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Mobilités] + summary: Modifier une mobilité (employee only) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + duree: + type: integer + minimum: 1 + ecole: + type: string + nullable: true + pays: + type: string + nullable: true + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + nullable: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Mobilités] + summary: Supprimer une mobilité (employee only, supprime aussi le contrat) + responses: + "204": + description: Supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + + /mobility/api/mobilites/{idMob}/contrat: + parameters: + - name: idMob + in: path + required: true + schema: + type: integer + get: + tags: [Mobilités] + summary: Télécharger le contrat PDF + responses: + "200": + description: Fichier PDF + content: + application/pdf: + schema: + type: string + format: binary + "404": + $ref: "#/components/responses/NotFound" + post: + tags: [Mobilités] + summary: Uploader un contrat PDF + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [contrat] + properties: + contrat: + type: string + format: binary + description: Fichier PDF du contrat + responses: + "200": + description: Mobilité mise à jour avec le nom du fichier + content: + application/json: + schema: + $ref: "#/components/schemas/Mobilite" + "400": + description: Fichier manquant ou pas un PDF + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Mobilités] + summary: Supprimer le contrat (employee only) + responses: + "204": + description: Contrat supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + + # ── Stages ──────────────────────────────────────────────── + /stages/api/stages: + get: + tags: [Stages] + summary: Liste des stages + parameters: + - name: numEtud + in: query + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Stage" + post: + tags: [Stages] + summary: Créer un stage (employee only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StageCreate" + responses: + "201": + description: Créé + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "400": + description: Champs requis manquants + "403": + description: Accès refusé + + /stages/api/stages/{idStage}: + parameters: + - name: idStage + in: path + required: true + schema: + type: integer + get: + tags: [Stages] + summary: Détail d'un stage + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [Stages] + summary: Modifier un stage (employee only, synchronise la durée sur la mobilité liée) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + duree: + type: integer + minimum: 1 + nomEntreprise: + type: string + mission: + type: string + nullable: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Stage" + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Stages] + summary: Supprimer un stage (employee only, cascade mobilités liées) + responses: + "204": + description: Supprimé + "403": + description: Accès refusé + "404": + $ref: "#/components/responses/NotFound" + +# ── Components ──────────────────────────────────────────────── +components: + parameters: + numEtud: + name: numEtud + in: path + required: true + schema: + type: integer + example: 21212006 + idPromo: + name: idPromo + in: path + required: true + schema: + type: string + example: 4AFISE25/26 + idPromoQuery: + name: idPromo + in: query + schema: + type: string + example: 4AFISE25/26 + idModule: + name: idModule + in: path + required: true + schema: + type: string + idUE: + name: idUE + in: path + required: true + schema: + type: integer + + responses: + NotFound: + description: Ressource introuvable + content: + application/json: + schema: + type: object + properties: + error: + type: string + + schemas: + # ── Student ── + Student: + type: object + properties: + numEtud: + type: integer + nom: + type: string + prenom: + type: string + idPromo: + type: string + StudentCreate: + type: object + required: [numEtud, nom, prenom, idPromo] + properties: + numEtud: + type: integer + nom: + type: string + prenom: + type: string + idPromo: + type: string + + # ── Promotion ── + Promotion: + type: object + properties: + id: + type: string + annee: + type: string + PromotionCreate: + type: object + required: [id, annee] + properties: + id: + type: string + annee: + type: string + + # ── User ── + User: + type: object + properties: + id: + type: string + nom: + type: string + prenom: + type: string + idRole: + type: integer + nullable: true + UserCreate: + type: object + required: [id, nom, prenom] + properties: + id: + type: string + nom: + type: string + prenom: + type: string + idRole: + type: integer + + # ── Role ── + Role: + type: object + properties: + id: + type: integer + nom: + type: string + RoleCreate: + type: object + required: [nom] + properties: + nom: + type: string + + # ── Permission ── + Permission: + type: object + properties: + id: + type: string + nom: + type: string + + # ── Module ── + Module: + type: object + properties: + id: + type: string + nom: + type: string + ModuleCreate: + type: object + required: [id, nom] + properties: + id: + type: string + nom: + type: string + + # ── Enseignement ── + Enseignement: + type: object + properties: + idProf: + type: string + idModule: + type: string + idPromo: + type: string + EnseignementCreate: + type: object + required: [idProf, idModule, idPromo] + properties: + idProf: + type: string + idModule: + type: string + idPromo: + type: string + + # ── UE ── + UE: + type: object + properties: + id: + type: integer + nom: + type: string + UECreate: + type: object + required: [nom] + properties: + nom: + type: string + + # ── UE_Module ── + UEModule: + type: object + properties: + idModule: + type: string + idUE: + type: integer + idPromo: + type: string + coeff: + type: number + UEModuleCreate: + type: object + required: [idModule, idUE, idPromo, coeff] + properties: + idModule: + type: string + idUE: + type: integer + idPromo: + type: string + coeff: + type: number + + # ── Note ── + Note: + type: object + properties: + numEtud: + type: integer + idModule: + type: string + note: + type: number + minimum: 0 + maximum: 20 + NoteCreate: + type: object + required: [numEtud, idModule, note] + properties: + numEtud: + type: integer + idModule: + type: string + note: + type: number + minimum: 0 + maximum: 20 + + # ── Ajustement ── + Ajustement: + type: object + properties: + numEtud: + type: integer + idUE: + type: integer + valeur: + type: number + AjustementCreate: + type: object + required: [numEtud, idUE, valeur] + properties: + numEtud: + type: integer + idUE: + type: integer + valeur: + type: number + + # ── Mobilité ── + MobilityStatus: + type: string + enum: [contracts_received, under_revision, done, validated, canceled] + Mobilite: + type: object + properties: + id: + type: integer + numEtud: + type: integer + duree: + type: integer + contratMob: + type: string + nullable: true + ecole: + type: string + nullable: true + pays: + type: string + nullable: true + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + nullable: true + MobiliteCreate: + type: object + required: [numEtud, duree] + properties: + numEtud: + type: integer + duree: + type: integer + minimum: 1 + ecole: + type: string + pays: + type: string + status: + $ref: "#/components/schemas/MobilityStatus" + idStage: + type: integer + + # ── Stage ── + Stage: + type: object + properties: + id: + type: integer + numEtud: + type: integer + duree: + type: integer + nomEntreprise: + type: string + mission: + type: string + nullable: true + StageCreate: + type: object + required: [numEtud, duree, nomEntreprise] + properties: + numEtud: + type: integer + duree: + type: integer + minimum: 1 + nomEntreprise: + type: string + mission: + type: string diff --git a/routes/(_components)/Header.tsx b/routes/(_components)/Header.tsx index 34853ad..ec5573b 100644 --- a/routes/(_components)/Header.tsx +++ b/routes/(_components)/Header.tsx @@ -11,6 +11,14 @@ export default function Header(props: HeaderProps) { ); diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index f30f19f..a671134 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -21,14 +21,29 @@ export const handler: MiddlewareHandler[] = [ `./${currentApp}/(_props)/props.ts` )).default; - context.state.availablePages = properties.pages; - if ( - context.state.session.eduPersonPrimaryAffiliation == "student" && - Deno.env.get("LOCAL") != "true" - ) { + const isStudent = + context.state.session.eduPersonPrimaryAffiliation === "student"; + const isLocal = Deno.env.get("LOCAL") === "true"; + + // Block students from accessing employeeOnly modules entirely + if (isStudent && properties.employeeOnly) { + return new Response(null, { status: 403 }); + } + + context.state.availablePages = { ...properties.pages }; + + if (isStudent) { + // Students only see studentOnly pages (+ non-restricted pages) properties.adminOnly.forEach((page) => 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] + ); } return await context.next(); diff --git a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx index 7b158d2..3b76991 100644 --- a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -115,7 +115,7 @@ export default function AdminEnseignements() { return (
-

Assignations Enseignant → Module / Promo

+

Assignations Enseignant → ECUE / Promo

{error &&

{error}

} @@ -135,7 +135,7 @@ export default function AdminEnseignements() { onChange={(e) => setFilterModule((e.target as HTMLSelectElement).value)} > - + {modules.map((m) => ( ))} @@ -169,55 +169,77 @@ export default function AdminEnseignements() {
{showAdd && ( -
- {addError && ( - - {addError} - - )} - - - setAddProf((e.target as HTMLInputElement).value)} - style="min-width: 10rem" - /> - - + )} @@ -229,7 +251,7 @@ export default function AdminEnseignements() { Promo - Module + ECUE Enseignant (User.id) Actions @@ -266,7 +288,24 @@ export default function AdminEnseignements() { e.idPromo, )} > - 🗑 + + + + +
@@ -280,7 +319,7 @@ export default function AdminEnseignements() {

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

diff --git a/routes/(apps)/admin/(_islands)/AdminModules.tsx b/routes/(apps)/admin/(_islands)/AdminModules.tsx index df0af41..2ce6ee4 100644 --- a/routes/(apps)/admin/(_islands)/AdminModules.tsx +++ b/routes/(apps)/admin/(_islands)/AdminModules.tsx @@ -1,22 +1,31 @@ import { useEffect, useState } from "preact/hooks"; type Module = { id: string; nom: string }; +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type User = { id: string; nom: string; prenom: string }; export default function AdminModules() { const [modules, setModules] = useState([]); + const [enseignements, setEnseignements] = useState([]); + const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newId, setNewId] = useState(""); const [newNom, setNewNom] = useState(""); const [creating, setCreating] = useState(false); - const [editId, setEditId] = useState(null); - const [editNom, setEditNom] = useState(""); + const [filterNom, setFilterNom] = useState(""); async function load() { try { - const res = await fetch("/admin/api/modules"); - if (!res.ok) throw new Error("Impossible de charger les modules"); - setModules(await res.json()); + const [mRes, eRes, uRes] = await Promise.all([ + fetch("/admin/api/modules"), + fetch("/admin/api/enseignements"), + fetch("/admin/api/users"), + ]); + if (!mRes.ok) throw new Error("Impossible de charger les ECUEs"); + setModules(await mRes.json()); + if (eRes.ok) setEnseignements(await eRes.json()); + if (uRes.ok) setUsers(await uRes.json()); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -51,23 +60,8 @@ export default function AdminModules() { } } - async function saveEdit(id: string) { - try { - const res = await fetch(`/admin/api/modules/${encodeURIComponent(id)}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ nom: editNom.trim() }), - }); - if (!res.ok) throw new Error("Modification échouée"); - setEditId(null); - await load(); - } catch (e) { - setError(e instanceof Error ? e.message : "Erreur"); - } - } - async function deleteModule(id: string) { - if (!confirm(`Supprimer le module ${id} ?`)) return; + if (!confirm(`Supprimer l'ECUE ${id} ?`)) return; try { const res = await fetch( `/admin/api/modules/${encodeURIComponent(id)}`, @@ -80,125 +74,181 @@ export default function AdminModules() { } } + const userMap = Object.fromEntries( + users.map((u) => [u.id, u]), + ); + + function enseignantsForModule(moduleId: string): string { + const profs = [ + ...new Set( + enseignements + .filter((e) => e.idModule === moduleId) + .map((e) => e.idProf), + ), + ]; + if (profs.length === 0) return ""; + return profs + .map((id) => { + const u = userMap[id]; + return u ? `${u.nom} ${u.prenom.charAt(0)}.` : id; + }) + .join(", "); + } + + const filtered = modules.filter((m) => + !filterNom || + `${m.id} ${m.nom}`.toLowerCase().includes(filterNom.toLowerCase()) + ); + return (

-

Gestion des Modules

+

Gestion des ECUEs

{error &&

{error}

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

Chargement…

+ ?

Chargement...

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

Nouvel ECUE

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

Créer une promotion

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

@@ -141,7 +154,7 @@ export default function AdminPromotions() { setAnneeSco((e.target as HTMLInputElement).value)} style="min-width: 9rem" @@ -218,9 +231,24 @@ export default function AdminPromotions() { diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx index b29b616..853a1e6 100644 --- a/routes/(apps)/admin/(_islands)/AdminRoles.tsx +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -131,16 +131,13 @@ export default function AdminRoles() { {saveError &&

{saveError}

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

Chargement…

+ ?

Chargement...

: (
@@ -252,7 +251,9 @@ export default function AdminRoles() { diff --git a/routes/(apps)/notes/(_islands)/AdminUEs.tsx b/routes/(apps)/admin/(_islands)/AdminUEs.tsx similarity index 56% rename from routes/(apps)/notes/(_islands)/AdminUEs.tsx rename to routes/(apps)/admin/(_islands)/AdminUEs.tsx index 8c2ea22..5bb7a57 100644 --- a/routes/(apps)/notes/(_islands)/AdminUEs.tsx +++ b/routes/(apps)/admin/(_islands)/AdminUEs.tsx @@ -19,6 +19,7 @@ export default function AdminUEs() { const [error, setError] = useState(null); const [selectedUe, setSelectedUe] = useState(null); + const [filterPromo, setFilterPromo] = useState(""); // New UE form const [newUeNom, setNewUeNom] = useState(""); @@ -31,11 +32,15 @@ export default function AdminUEs() { const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); + // Inline coeff editing + const [editingCoeff, setEditingCoeff] = useState(null); + const [editCoeffValue, setEditCoeffValue] = useState(""); + async function load() { try { const [uRes, umRes, mRes, pRes] = await Promise.all([ - fetch("/notes/api/ues"), - fetch("/notes/api/ue-modules"), + fetch("/admin/api/ues"), + fetch("/admin/api/ue-modules"), fetch("/admin/api/modules"), fetch("/students/api/promotions"), ]); @@ -64,7 +69,7 @@ export default function AdminUEs() { if (!newUeNom.trim()) return; setCreatingUe(true); try { - const res = await fetch("/notes/api/ues", { + const res = await fetch("/admin/api/ues", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ nom: newUeNom.trim() }), @@ -79,15 +84,30 @@ export default function AdminUEs() { } } + async function deleteUE(ue: UE) { + if (!confirm(`Supprimer la UE "${ue.nom}" et tous ses liens ?`)) return; + try { + const res = await fetch(`/admin/api/ues/${ue.id}`, { method: "DELETE" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Suppression échouée"); + } + if (selectedUe?.id === ue.id) setSelectedUe(null); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + async function deleteUeModule( idModule: string, idUE: number, idPromo: string, ) { - if (!confirm("Supprimer ce module de la UE ?")) return; + if (!confirm("Supprimer cet ECUE de la UE ?")) return; try { const res = await fetch( - `/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ + `/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ encodeURIComponent(idPromo) }`, { method: "DELETE" }, @@ -101,7 +121,7 @@ export default function AdminUEs() { async function addUeModule() { if (!selectedUe || !addModuleId || !addPromoId) { - setAddError("Module et Promo sont requis"); + setAddError("ECUE et Promo sont requis"); return; } const coeff = parseFloat(addCoeff); @@ -112,7 +132,7 @@ export default function AdminUEs() { setAdding(true); setAddError(null); try { - const res = await fetch("/notes/api/ue-modules", { + const res = await fetch("/admin/api/ue-modules", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -137,8 +157,41 @@ export default function AdminUEs() { } } + async function updateCoeff( + idModule: string, + idUE: number, + idPromo: string, + coeff: number, + ) { + try { + const res = await fetch( + `/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ + encodeURIComponent(idPromo) + }`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ coeff }), + }, + ); + if (!res.ok) throw new Error("Modification échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setEditingCoeff(null); + } + } + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + // Filter UEs by promo: keep UEs that have at least one ue_module for that promo + const filteredUes = filterPromo + ? ues.filter((ue) => + ueModules.some((um) => um.idUE === ue.id && um.idPromo === filterPromo) + ) + : ues; + const selectedUeModules = selectedUe ? ueModules.filter((um) => um.idUE === selectedUe.id) : []; @@ -150,7 +203,7 @@ export default function AdminUEs() { class="col-dim" style="font-size: 0.78rem; margin: -0.5rem 0 1rem" > - UE = Unité d'Enseignement regroupant plusieurs modules + UE = Unité d'Enseignement regroupant plusieurs ECUEs

{error &&

{error}

} @@ -163,6 +216,20 @@ export default function AdminUEs() {

UEs existantes

+
- {ues.map((ue) => ( + {filteredUes.map((ue) => (
{ - setSelectedUe(ue); - setAddError(null); - }} + style="display: flex; align-items: center; justify-content: space-between" > - {ue.nom} + { + setSelectedUe(ue); + setAddError(null); + }} + > + {ue.nom} + +
))} - {ues.length === 0 && ( + {filteredUes.length === 0 && (

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

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

{selectedUe.nom}

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

{shown.map((p) => ( - {p} + + {permMap[p] ?? p} + ))} {overflow > 0 && (
- + @@ -231,7 +331,7 @@ export default function AdminUEs() { ? ( ) @@ -249,7 +349,59 @@ export default function AdminUEs() { - + @@ -272,7 +441,7 @@ export default function AdminUEs() {

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

{addError && (

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

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

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

{error}

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

Chargement…

+ ?

Chargement...

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

Chargement...

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

{error}

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

+ ECUE -- {mod.id} +

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

{error}

} + {saveMsg && ( +

+ {saveMsg} +

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

Informations

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

Enseignants assignes

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

+ Aucun enseignant assigne. +

+ )} + +

+ Ajouter un enseignant +

+ {addError && ( +

+ {addError} +

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

Chargement...

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

{error}

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

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

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

{error}

} + {saveMsg && ( +

+ {saveMsg} +

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

Informations generales

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

Enseignements

+

+ ECUEs enseignes par cet utilisateur +

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

+ Aucun enseignement assigne. +

+ )} + +

+ Ajouter un enseignement +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + +
+
+
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx new file mode 100644 index 0000000..5a03b6e --- /dev/null +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -0,0 +1,557 @@ +// @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: `ECUE ${mod.code} "${mod.name}" cree`, + }); + } else if (modRes.status !== 409) { + errCount++; + details.push({ + type: "error", + message: `ECUE "${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} ECUEs + +

+ +
+ +
+ + + + + + + + + + + {year.ues.map((ue, i) => + ue.modules.length === 0 + ? ( + + + + + ) + : ue.modules.map((mod, j) => ( + + {j === 0 && ( + + )} + + + + + )) + )} + +
UEECUECodeCoeff
{ue.name} + Aucun ECUE +
+ {ue.name} + {ue.ects != null && ( + + ({ue.ects} ECTS) + + )} + {mod.name}{mod.code}{mod.coeff}
+
+
+ ); + })} +
+ )} + +
+ + + { + /* TODO: fix blob download in Fresh + + */ + } +
+ +

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

+
+ ); +} diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index 5563bed..762a5df 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -8,11 +8,24 @@ const properties: AppProperties = { users: "Utilisateurs", roles: "Rôles", permissions: "Permissions", - modules: "Modules", + modules: "ECUEs", enseignements: "Enseignements", + promotions: "Promotions", + ues: "UEs", + "import-maquette": "Import Maquette", }, - adminOnly: ["users", "roles", "permissions", "modules", "enseignements"], - hint: "PolyMPR module", + adminOnly: [ + "users", + "roles", + "permissions", + "modules", + "enseignements", + "promotions", + "ues", + "import-maquette", + ], + employeeOnly: true, + hint: "PolyMPR ECUE", }; export default properties; diff --git a/routes/(apps)/admin/[slug].tsx b/routes/(apps)/admin/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/admin/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); 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..4519db3 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" }, @@ -50,7 +44,7 @@ export const handler: Handlers = { if (existing) { return new Response( - JSON.stringify({ error: "Un module avec cet identifiant existe déjà" }), + JSON.stringify({ error: "Un ECUE avec cet identifiant existe déjà" }), { status: 409, headers: { "content-type": "application/json" } }, ); } diff --git a/routes/(apps)/admin/api/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)/admin/api/ue-modules.ts b/routes/(apps)/admin/api/ue-modules.ts new file mode 100644 index 0000000..d2672d4 --- /dev/null +++ b/routes/(apps)/admin/api/ue-modules.ts @@ -0,0 +1,72 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../databases/db.ts"; +import { ueModules } from "../../../../databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +export const handler: Handlers = { + // #37 GET /ue-modules + async GET(request) { + try { + const url = new URL(request.url); + const idPromo = url.searchParams.get("idPromo"); + const idUEParam = url.searchParams.get("idUE"); + + const idUE = idUEParam ? parseInt(idUEParam) : null; + + if (idUEParam && isNaN(idUE!)) { + return new Response("Paramètre idUE invalide", { status: 400 }); + } + + const result = await db.select().from(ueModules).where( + and( + idPromo ? eq(ueModules.idPromo, idPromo) : undefined, + idUE ? eq(ueModules.idUE, idUE) : undefined, + ), + ); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching UE-modules:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // #38 POST /ue-modules + async POST(request) { + try { + const body = await request.json(); + const { idModule, idUE, idPromo, coeff } = body; + + if (!idModule || !idUE || !idPromo || coeff === undefined) { + return new Response( + "Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis", + { status: 400 }, + ); + } + + if (typeof coeff !== "number" || coeff < 0) { + return new Response("Champ 'coeff' doit être un nombre >= 0", { + status: 400, + }); + } + + const result = await db.insert(ueModules).values({ + idModule, + idUE, + idPromo, + coeff, + }).returning(); + + return new Response(JSON.stringify(result[0]), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error creating UE-ECUE:", error); + return new Response("Failed to create UE-ECUE", { status: 500 }); + } + }, +}; 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..b71396d 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-ECUE 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)/admin/api/ues.ts b/routes/(apps)/admin/api/ues.ts new file mode 100644 index 0000000..92242da --- /dev/null +++ b/routes/(apps)/admin/api/ues.ts @@ -0,0 +1,42 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "../../../../databases/db.ts"; +import { ues } from "../../../../databases/schema.ts"; + +export const handler: Handlers = { + // #32 GET /ues + async GET() { + try { + const result = await db.select().from(ues); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching UEs:", error); + return new Response("Failed to fetch data", { status: 500 }); + } + }, + + // #33 POST /ues + async POST(request) { + try { + const body = await request.json(); + const { nom } = body; + + if (!nom || !nom.trim()) { + return new Response("Champ 'nom' manquant", { status: 400 }); + } + + const result = await db.insert(ues).values({ nom }).returning(); + + return new Response(JSON.stringify(result[0]), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error creating UE:", error); + return new Response("Failed to create UE", { status: 500 }); + } + }, +}; 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/modules/[idModule].tsx b/routes/(apps)/admin/modules/[idModule].tsx new file mode 100644 index 0000000..858bfa9 --- /dev/null +++ b/routes/(apps)/admin/modules/[idModule].tsx @@ -0,0 +1,11 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditModule from "../(_islands)/EditModule.tsx"; + +// deno-lint-ignore require-await +export default async function EditModulePage( + _request: Request, + context: FreshContext, +) { + return ; +} diff --git a/routes/(apps)/admin/partials/enseignements.tsx b/routes/(apps)/admin/partials/enseignements.tsx index 9b0127e..ea91b69 100644 --- a/routes/(apps)/admin/partials/enseignements.tsx +++ b/routes/(apps)/admin/partials/enseignements.tsx @@ -14,5 +14,6 @@ async function Enseignements( return ; } +export { Enseignements as Page }; export const config = getPartialsConfig(); export default makePartials(Enseignements); diff --git a/routes/(apps)/admin/partials/import-maquette.tsx b/routes/(apps)/admin/partials/import-maquette.tsx new file mode 100644 index 0000000..33bd0ba --- /dev/null +++ b/routes/(apps)/admin/partials/import-maquette.tsx @@ -0,0 +1,24 @@ +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 { ImportMaquettePage as Page }; +export const config = getPartialsConfig(); +export default makePartials(ImportMaquettePage); diff --git a/routes/(apps)/admin/partials/modules.tsx b/routes/(apps)/admin/partials/modules.tsx index a36640d..4b1b268 100644 --- a/routes/(apps)/admin/partials/modules.tsx +++ b/routes/(apps)/admin/partials/modules.tsx @@ -14,5 +14,6 @@ async function Modules( return ; } +export { Modules as Page }; export const config = getPartialsConfig(); export default makePartials(Modules); diff --git a/routes/(apps)/admin/partials/permissions.tsx b/routes/(apps)/admin/partials/permissions.tsx index f9359e5..9c9440e 100644 --- a/routes/(apps)/admin/partials/permissions.tsx +++ b/routes/(apps)/admin/partials/permissions.tsx @@ -14,5 +14,6 @@ async function Permissions( return ; } +export { Permissions as Page }; export const config = getPartialsConfig(); export default makePartials(Permissions); diff --git a/routes/(apps)/students/partials/(admin)/promotions.tsx b/routes/(apps)/admin/partials/promotions.tsx similarity index 81% rename from routes/(apps)/students/partials/(admin)/promotions.tsx rename to routes/(apps)/admin/partials/promotions.tsx index 003f993..815c689 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( @@ -14,5 +14,6 @@ async function Promotions( return ; } +export { Promotions as Page }; export const config = getPartialsConfig(); export default makePartials(Promotions); diff --git a/routes/(apps)/admin/partials/roles.tsx b/routes/(apps)/admin/partials/roles.tsx index b40aeb0..70eb3d7 100644 --- a/routes/(apps)/admin/partials/roles.tsx +++ b/routes/(apps)/admin/partials/roles.tsx @@ -14,5 +14,6 @@ async function Roles( return ; } +export { Roles as Page }; export const config = getPartialsConfig(); export default makePartials(Roles); diff --git a/routes/(apps)/notes/partials/(admin)/ues.tsx b/routes/(apps)/admin/partials/ues.tsx similarity index 84% rename from routes/(apps)/notes/partials/(admin)/ues.tsx rename to routes/(apps)/admin/partials/ues.tsx index 2d6b0e9..007c1d9 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( @@ -14,5 +14,6 @@ async function UEs( return ; } +export { UEs as Page }; export const config = getPartialsConfig(); export default makePartials(UEs); diff --git a/routes/(apps)/admin/partials/users.tsx b/routes/(apps)/admin/partials/users.tsx index 837d515..36383bd 100644 --- a/routes/(apps)/admin/partials/users.tsx +++ b/routes/(apps)/admin/partials/users.tsx @@ -14,5 +14,6 @@ async function Users( return ; } +export { Users as Page }; export const config = getPartialsConfig(); export default makePartials(Users); diff --git a/routes/(apps)/admin/users/[id].tsx b/routes/(apps)/admin/users/[id].tsx new file mode 100644 index 0000000..868db34 --- /dev/null +++ b/routes/(apps)/admin/users/[id].tsx @@ -0,0 +1,11 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditUser from "../(_islands)/EditUser.tsx"; + +// deno-lint-ignore require-await +export default async function EditUserPage( + _request: Request, + context: FreshContext, +) { + return ; +} diff --git a/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx b/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx deleted file mode 100644 index 7befa63..0000000 --- a/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useEffect, useState } from "preact/hooks"; - -interface Promotion { - id: number; - name: string; -} - -interface Student { - id: string; - firstName: string; - lastName: string; - promotionId: number; -} - -interface Mobility { - id: number; - studentId: string; - startDate: string | null; - endDate: string | null; - weeksCount: number | null; - destinationCountry: string | null; - destinationName: string | null; - mobilityStatus: string; -} - -export default function ConsultMobility() { - const [data, setData] = useState< - | { - promotions?: Promotion[]; - students?: Student[]; - mobilities?: Mobility[]; - } - | null - >(null); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchData = async () => { - console.log("ConsultMobility: Fetching data from API..."); - try { - const response = await fetch("/mobility/api/insert_mobility"); - console.log("ConsultMobility: API response status:", response.status); - - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - console.log("ConsultMobility: Data fetched successfully:", result); - setData(result); - } catch (err) { - console.error("ConsultMobility: Error fetching data:", err); - setError("Failed to load mobility data. Please try again later."); - } - }; - - fetchData(); - }, []); - - if (error) { - return

{error}

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

No promotions found.

; - } - - return ( -
-

Consult Mobility

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

Promotion: {promo.name}

- - - - - - - - - - - - - - - - {data.students - ?.filter((student) => student.promotionId === promo.id) - .map((student) => { - const mobility = data.mobilities?.find((mob) => - mob.studentId === student.id - ); - return ( - - - - - - - - - - - - ); - })} - -
IDFirst NameLast NameStart DateEnd DateWeeks CountDestination CountryDestination NameStatus
{student.id}{student.firstName}{student.lastName}{mobility?.startDate || "N/A"}{mobility?.endDate || "N/A"}{mobility?.weeksCount ?? "N/A"}{mobility?.destinationCountry || "N/A"}{mobility?.destinationName || "N/A"}{mobility?.mobilityStatus || "N/A"}
-
- ))} -
- ); -} diff --git a/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx b/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx deleted file mode 100644 index 3e008bc..0000000 --- a/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useState } from "preact/hooks"; - -interface Promotion { - id: number; - name: string; -} - -interface Student { - id: number; - firstName: string; - lastName: string; - mail: string; - promotionId: number; - promotionName: string; -} - -export default function ConsultStudents_test() { - const [data, setData] = useState< - { promotions: Promotion[]; students: Student[] } | null - >(null); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch("/students/api/insert_students"); - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - setData(result); - } catch (err) { - console.error("Error fetching data:", err); - setError("Failed to load data. Please try again later."); - } - }; - - fetchData(); - }, []); - - return ( -
-

Consult Students

- {error &&

{error}

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

Promotion: {promo.id}

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

{error}

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

Loading data...

; - } - - return ( -
-

Edit Mobility

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

Promotion: {promo.name}

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

Chargement...

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

{error}

+
+ ); + } + + const filtered = students.filter((s) => { + const matchPromo = !filterPromo || s.idPromo === filterPromo; + const matchNom = !filterNom || + `${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase()); + return matchPromo && matchNom; + }); + + const mobsByStudent = (numEtud: number) => + mobilites.filter((m) => m.numEtud === numEtud); + + return ( +
+

Suivi des mobilités

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

+ Aucun +

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

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

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

Aucune mobilité enregistrée.

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

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

+

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

+
+
+ {stage + ? ( +

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

+ ) + : ( +

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

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

Modifier la mobilité #{mob.id}

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

Nouvelle mobilité

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

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

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

Test consult students

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

Edit mobility

- - - ); -} - -export const config = getPartialsConfig(); -export default makePartials(Mobility); diff --git a/routes/(apps)/mobility/partials/index.tsx b/routes/(apps)/mobility/partials/index.tsx index 2971e0e..0d813f5 100644 --- a/routes/(apps)/mobility/partials/index.tsx +++ b/routes/(apps)/mobility/partials/index.tsx @@ -3,11 +3,27 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await -export async function Index(_request: Request, context: FreshContext) { - return

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

; +export async function Index( + _request: Request, + context: FreshContext, +) { + return ( +
+

Mobilité internationale

+

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

+

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

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

Edit mobility

- - - ); +async function Overview( + _request: Request, + _context: FreshContext, +) { + return ; } +export { Overview as Page }; export const config = getPartialsConfig(); -export default makePartials(Mobility); +export default makePartials(Overview); diff --git a/routes/(apps)/mobility/partials/overview/[numEtud].tsx b/routes/(apps)/mobility/partials/overview/[numEtud].tsx new file mode 100644 index 0000000..2b3da03 --- /dev/null +++ b/routes/(apps)/mobility/partials/overview/[numEtud].tsx @@ -0,0 +1,20 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import MobilityOverview from "../../(_islands)/MobilityOverview.tsx"; + +// deno-lint-ignore require-await +async function Overview( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} + +export { Overview as Page }; +export const config = getPartialsConfig(); +export default makePartials(Overview); diff --git a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx index 9ae2c94..dd4abae 100644 --- a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx +++ b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx @@ -130,7 +130,17 @@ export default function AdminConsultNotes() { href={`/notes/edition/${s.numEtud}`} f-client-nav={false} > - ✏ édit + + + {" "} + édit
diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx index 4114c11..1855520 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,407 @@ 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("/notes/api/modules").then((r) => r.json()), + fetch("/notes/api/ue-modules").then((r) => r.json()), + fetch("/notes/api/ues").then((r) => r.json()), + ]).then( + ([ + studentsData, + 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 +474,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 +505,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 = ECUE (importe) | UE = moyenne UE (ignore) | X = malus +

+
)}
@@ -128,22 +591,35 @@ 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"} + { + /* TODO: fix blob download in Fresh + + */ + }

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

); diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx index 81918f5..5a516f0 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,24 +37,31 @@ 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); @@ -53,7 +70,7 @@ export default function NoteRecap({ numEtud }: Props) { fetch( `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, ), - fetch("/admin/api/modules"), + fetch("/notes/api/modules"), fetch(`/notes/api/notes?numEtud=${numEtud}`), fetch(`/notes/api/ajustements?numEtud=${numEtud}`), ]); @@ -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,33 +311,42 @@ 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} )}
- {/* Module rows */} + {/* ECUE rows */} {ueMods.length === 0 ? (

- Aucun module associé à cette UE pour cette promotion. + Aucun ECUE 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"} )} - + + {/* Session 2 note */} + {editingNote?.idModule === um.idModule && + editingNote.field === "noteSession2" + ? ( +
+ + setEditingNote({ + ...editingNote, + value: + (e.target as HTMLInputElement).value, + })} + onKeyDown={(e) => { + if (e.key === "Enter") { + saveNote( + um.idModule, + "noteSession2", + editingNote.value, + ); + } + if (e.key === "Escape") { + setEditingNote(null); + } + }} + onBlur={() => + saveNote( + um.idModule, + "noteSession2", + editingNote.value, + )} + /> + + /20 + +
+ ) + : ( + + setEditingNote({ + idModule: um.idModule, + field: "noteSession2", + value: noteS2 != null ? String(noteS2) : "", + })} + > + S2: {noteS2 != null ? fmt(noteS2) : "—"} + + )} + + {/* Effective note indicator */} + {noteS2 != null && ( + + → {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 && ( <> @@ -370,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..35cc897 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([]); @@ -49,7 +64,7 @@ export default function NotesView({ numEtud, prenom }: Props) { fetch(`/notes/api/notes?numEtud=${numEtud}`), fetch("/notes/api/ues"), fetch("/notes/api/ue-modules"), - fetch("/admin/api/modules"), + fetch("/notes/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}) + {mod ? mod.nom : "ECUE 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 2f5be17..38f1625 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -7,10 +7,10 @@ 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/[slug].tsx b/routes/(apps)/notes/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/notes/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/notes/api/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/modules.ts b/routes/(apps)/notes/api/modules.ts new file mode 100644 index 0000000..3333369 --- /dev/null +++ b/routes/(apps)/notes/api/modules.ts @@ -0,0 +1,12 @@ +import { Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { modules } from "$root/databases/schema.ts"; + +export const handler: Handlers = { + async GET() { + const rows = await db.select().from(modules); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/notes/api/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/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts index 1a825a6..08e3a11 100644 --- a/routes/(apps)/notes/api/ue-modules.ts +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -1,72 +1,28 @@ import { Handlers } from "$fresh/server.ts"; -import { db } from "../../../../databases/db.ts"; -import { ueModules } from "../../../../databases/schema.ts"; +import { db } from "$root/databases/db.ts"; +import { ueModules } from "$root/databases/schema.ts"; import { and, eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { - // #37 GET /ue-modules async GET(request) { - try { - const url = new URL(request.url); - const idPromo = url.searchParams.get("idPromo"); - const idUEParam = url.searchParams.get("idUE"); + const url = new URL(request.url); + const idPromo = url.searchParams.get("idPromo"); + const idUEParam = url.searchParams.get("idUE"); + const idUE = idUEParam ? parseInt(idUEParam) : null; - const idUE = idUEParam ? parseInt(idUEParam) : null; - - if (idUEParam && isNaN(idUE!)) { - return new Response("Paramètre idUE invalide", { status: 400 }); - } - - const result = await db.select().from(ueModules).where( - and( - idPromo ? eq(ueModules.idPromo, idPromo) : undefined, - idUE ? eq(ueModules.idUE, idUE) : undefined, - ), - ); - - return new Response(JSON.stringify(result), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error fetching UE-modules:", error); - return new Response("Failed to fetch data", { status: 500 }); + if (idUEParam && isNaN(idUE!)) { + return new Response("Paramètre idUE invalide", { status: 400 }); } - }, - // #38 POST /ue-modules - async POST(request) { - try { - const body = await request.json(); - const { idModule, idUE, idPromo, coeff } = body; + const rows = await db.select().from(ueModules).where( + and( + idPromo ? eq(ueModules.idPromo, idPromo) : undefined, + idUE ? eq(ueModules.idUE, idUE) : undefined, + ), + ); - if (!idModule || !idUE || !idPromo || coeff === undefined) { - return new Response( - "Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis", - { status: 400 }, - ); - } - - if (typeof coeff !== "number" || coeff < 0) { - return new Response("Champ 'coeff' doit être un nombre >= 0", { - status: 400, - }); - } - - const result = await db.insert(ueModules).values({ - idModule, - idUE, - idPromo, - coeff, - }).returning(); - - return new Response(JSON.stringify(result[0]), { - status: 201, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error creating UE-module:", error); - return new Response("Failed to create UE-module", { status: 500 }); - } + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); }, }; diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts index 92242da..09230a9 100644 --- a/routes/(apps)/notes/api/ues.ts +++ b/routes/(apps)/notes/api/ues.ts @@ -1,42 +1,12 @@ import { Handlers } from "$fresh/server.ts"; -import { db } from "../../../../databases/db.ts"; -import { ues } from "../../../../databases/schema.ts"; +import { db } from "$root/databases/db.ts"; +import { ues } from "$root/databases/schema.ts"; export const handler: Handlers = { - // #32 GET /ues async GET() { - try { - const result = await db.select().from(ues); - - return new Response(JSON.stringify(result), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error fetching UEs:", error); - return new Response("Failed to fetch data", { status: 500 }); - } - }, - - // #33 POST /ues - async POST(request) { - try { - const body = await request.json(); - const { nom } = body; - - if (!nom || !nom.trim()) { - return new Response("Champ 'nom' manquant", { status: 400 }); - } - - const result = await db.insert(ues).values({ nom }).returning(); - - return new Response(JSON.stringify(result[0]), { - status: 201, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error creating UE:", error); - return new Response("Failed to create UE", { status: 500 }); - } + const rows = await db.select().from(ues); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); }, }; diff --git a/routes/(apps)/notes/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx index 0ec8ebe..6f9f8ba 100644 --- a/routes/(apps)/notes/partials/(admin)/courses.tsx +++ b/routes/(apps)/notes/partials/(admin)/courses.tsx @@ -11,5 +11,6 @@ async function Courses(_request: Request, _context: FreshContext) { return ; } +export { Courses as Page }; export const config = getPartialsConfig(); export default makePartials(Courses); diff --git a/routes/(apps)/notes/partials/(admin)/import.tsx b/routes/(apps)/notes/partials/(admin)/import.tsx index 4a92c3d..3f56e2d 100644 --- a/routes/(apps)/notes/partials/(admin)/import.tsx +++ b/routes/(apps)/notes/partials/(admin)/import.tsx @@ -14,16 +14,11 @@ async function ImportNotesPage( return (

Importer des Notes

-

- POST /notes/api/notes -

); } +export { ImportNotesPage as Page }; export const config = getPartialsConfig(); export default makePartials(ImportNotesPage); diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index 188a05e..de9e686 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -6,32 +6,54 @@ 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 { Notes as Page }; export const config = getPartialsConfig(); export default makePartials(Notes); diff --git a/routes/(apps)/stages/(_islands)/StagesOverview.tsx b/routes/(apps)/stages/(_islands)/StagesOverview.tsx new file mode 100644 index 0000000..de0af6f --- /dev/null +++ b/routes/(apps)/stages/(_islands)/StagesOverview.tsx @@ -0,0 +1,558 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; +type Stage = { + id: number; + numEtud: number; + duree: number; + nomEntreprise: string; + mission: string | null; +}; + +const REQUIRED_WEEKS = 40; + +export default function StagesOverview( + { initialNumEtud }: { initialNumEtud?: number } = {}, +) { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [stagesList, setStagesList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + + // Detail view state + const [detailStudent, setDetailStudent] = useState(null); + const [editingStage, setEditingStage] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + async function load() { + try { + const [sRes, pRes, stRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + fetch("/stages/api/stages"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les données"); + const [sData, pData, stData] = await Promise.all([ + sRes.json(), + pRes.ok ? pRes.json() : [], + stRes.ok ? stRes.json() : [], + ]); + setStudents(sData); + setPromos(pData); + setStagesList(stData); + if (initialNumEtud) { + const found = (sData as Student[]).find((s) => + s.numEtud === initialNumEtud + ); + if (found) setDetailStudent(found); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + function openStudent(s: Student) { + setDetailStudent(s); + history.pushState(null, "", `/stages/overview/${s.numEtud}`); + } + + function closeStudent() { + setDetailStudent(null); + setEditingStage(null); + setShowAddForm(false); + history.pushState(null, "", "/stages/overview"); + } + + if (detailStudent) { + return ( + s.numEtud === detailStudent.numEtud)} + allStages={stagesList} + editingStage={editingStage} + setEditingStage={setEditingStage} + showAddForm={showAddForm} + setShowAddForm={setShowAddForm} + onBack={closeStudent} + onReload={load} + /> + ); + } + + if (loading) { + return ( +
+

Chargement...

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

{error}

+
+ ); + } + + const filtered = students.filter((s) => { + const matchPromo = !filterPromo || s.idPromo === filterPromo; + const matchNom = !filterNom || + `${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase()); + return matchPromo && matchNom; + }); + + const stagesByStudent = (numEtud: number) => + stagesList.filter((s) => s.numEtud === numEtud); + + return ( +
+

Suivi des stages

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

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

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

+ Aucun stage enregistré. +

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

+ Stage {i + 1} +

+

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

+
+
+

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

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

Modifier le stage #{stage.id}

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

Nouveau stage

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

Stages

+

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

+

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

+
+ ); +} + +export const config = getPartialsConfig(); +export default makePartials(Index); diff --git a/routes/(apps)/stages/partials/overview.tsx b/routes/(apps)/stages/partials/overview.tsx new file mode 100644 index 0000000..d0d496c --- /dev/null +++ b/routes/(apps)/stages/partials/overview.tsx @@ -0,0 +1,19 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import StagesOverview from "../(_islands)/StagesOverview.tsx"; + +// deno-lint-ignore require-await +async function Overview( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export { Overview as Page }; +export const config = getPartialsConfig(); +export default makePartials(Overview); diff --git a/routes/(apps)/stages/partials/overview/[numEtud].tsx b/routes/(apps)/stages/partials/overview/[numEtud].tsx new file mode 100644 index 0000000..3a06562 --- /dev/null +++ b/routes/(apps)/stages/partials/overview/[numEtud].tsx @@ -0,0 +1,20 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import StagesOverview from "../../(_islands)/StagesOverview.tsx"; + +// deno-lint-ignore require-await +async function Overview( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} + +export { Overview as Page }; +export const config = getPartialsConfig(); +export default makePartials(Overview); diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index 031bbe9..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

@@ -67,6 +154,7 @@ export default function ConsultStudents() { class="btn btn-primary" href="/students/upload" f-partial="/students/partials/upload" + style="margin-left: auto" > Importer xlsx @@ -92,6 +180,44 @@ export default function ConsultStudents() { />
+ {/* Bulk actions bar */} + {selectedInView.length > 0 && ( +
+ + {selectedInView.length} sélectionné(s) + +
+ + + +
+
+ )} + {loading ?

Chargement…

: ( @@ -99,6 +225,13 @@ export default function ConsultStudents() { + @@ -110,13 +243,23 @@ export default function ConsultStudents() { {filtered.length === 0 ? ( - ) : filtered.map((s) => ( - + + @@ -128,14 +271,34 @@ export default function ConsultStudents() { href={`/students/edit/${s.numEtud}`} f-client-nav={false} > - ✏ + + + diff --git a/routes/(apps)/students/(_islands)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index f5728c4..b72abf4 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -8,6 +8,8 @@ type Student = { }; type Promo = { id: string; annee: string }; type Module = { id: string; nom: string }; +type Mobilite = { id: number; duree: number; status: string }; +type Stage = { id: number; duree: number }; type Props = { numEtud: number }; @@ -25,6 +27,8 @@ export default function EditStudents({ numEtud }: Props) { const [student, setStudent] = useState(null); const [promos, setPromos] = useState([]); const [_modules, setModules] = useState([]); + const [mobWeeks, setMobWeeks] = useState(0); + const [stageWeeks, setStageWeeks] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [saveMsg, setSaveMsg] = useState(null); @@ -38,10 +42,12 @@ export default function EditStudents({ numEtud }: Props) { useEffect(() => { async function load() { try { - const [sRes, pRes, mRes] = await Promise.all([ + const [sRes, pRes, mRes, mobRes, stRes] = await Promise.all([ fetch(`/students/api/students/${numEtud}`), fetch("/students/api/promotions"), - fetch("/admin/api/modules"), + fetch("/notes/api/modules"), + fetch(`/mobility/api/mobilites?numEtud=${numEtud}`), + fetch(`/stages/api/stages?numEtud=${numEtud}`), ]); if (!sRes.ok) throw new Error("Élève introuvable"); const s: Student = await sRes.json(); @@ -51,6 +57,19 @@ export default function EditStudents({ numEtud }: Props) { setIdPromo(s.idPromo); if (pRes.ok) setPromos(await pRes.json()); if (mRes.ok) setModules(await mRes.json()); + if (mobRes.ok) { + const mobs: Mobilite[] = await mobRes.json(); + setMobWeeks( + mobs.filter((m) => m.status === "validated").reduce( + (s, m) => s + m.duree, + 0, + ), + ); + } + if (stRes.ok) { + const stages: Stage[] = await stRes.json(); + setStageWeeks(stages.reduce((s, st) => s + st.duree, 0)); + } } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -147,8 +166,6 @@ export default function EditStudents({ numEtud }: Props) { {/* Section 1: Informations générales */}

Informations générales

-

PUT /students/{"{numEtud}"}

-
@@ -209,36 +226,69 @@ export default function EditStudents({ numEtud }: Props) {
- {/* Section 2: Spécialisations */} + {/* Section 2: Notes */}
-

Spécialisations

-

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

-

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

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

Notes (lecture seule)

-

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

+

Notes

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

Mobilités

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

Stages

+
+ + = 40 ? "#22c55e" : "#dc2626", + }} + > + {stageWeeks}/40 semaines + + + + Consulter
diff --git a/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..9a503f6 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -6,10 +6,10 @@ const properties: AppProperties = { pages: { index: "Accueil", consult: "Élèves", - promotions: "Promotions", upload: "Import xlsx", }, - adminOnly: ["consult", "promotions", "upload"], + adminOnly: ["consult", "upload"], + employeeOnly: true, hint: "Create students promotion and see informations", }; diff --git a/routes/(apps)/students/[slug].tsx b/routes/(apps)/students/[slug].tsx new file mode 100644 index 0000000..9b29f17 --- /dev/null +++ b/routes/(apps)/students/[slug].tsx @@ -0,0 +1,2 @@ +import makeSlug from "$root/defaults/makeSlug.ts"; +export default makeSlug(import.meta.dirname!); diff --git a/routes/(apps)/students/api/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..6d2c0e6 100644 --- a/routes/(apps)/students/api/students/[numEtud].ts +++ b/routes/(apps)/students/api/students/[numEtud].ts @@ -1,15 +1,22 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; -import { students } from "$root/databases/schema.ts"; +import { + ajustements, + mobilites, + notes, + stages, + 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 +25,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 +35,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 +48,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 +81,32 @@ export const handler: Handlers = { }, // #12 DELETE /students/{numEtud} + // Cascade: deletes notes, ajustements, mobilites, stages 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(mobilites).where(eq(mobilites.numEtud, numEtud)); + await tx.delete(stages).where(eq(stages.numEtud, numEtud)); + await tx.delete(students).where(eq(students.numEtud, numEtud)); + }); return new Response(null, { status: 204 }); }, diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index 4c81c71..2adaaa4 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -11,5 +11,6 @@ async function Students(_request: Request, _context: FreshContext) { return ; } +export { Students as Page }; export const config = getPartialsConfig(); export default makePartials(Students); diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index cdb94fd..ca1b847 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -11,16 +11,11 @@ async function Students(_request: Request, _context: FreshContext) { return (

Importer des Élèves

-

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

); } +export { Students as Page }; export const config = getPartialsConfig(); export default makePartials(Students); diff --git a/routes/_app.tsx b/routes/_app.tsx index 81187c3..77ba7c3 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -29,6 +29,7 @@ export default async function App( +
diff --git a/routes/apps.tsx b/routes/apps.tsx index d64cabb..067798d 100644 --- a/routes/apps.tsx +++ b/routes/apps.tsx @@ -44,9 +44,20 @@ export default async function Apps( _request: Request, context: FreshContext>, ) { + let visibleApps = context.data; + + if ( + context.state.isAuthenticated && + context.state.session.eduPersonPrimaryAffiliation === "student" + ) { + visibleApps = Object.fromEntries( + Object.entries(context.data).filter(([_, app]) => !app.employeeOnly), + ); + } + return ( <> - + ); } diff --git a/routes/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/index.tsx b/routes/index.tsx index f92dc1b..b16caea 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,13 +1,28 @@ -import { FreshContext } from "$fresh/server.ts"; +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; -// deno-lint-ignore require-await -export default async function Home(_request: Request, _context: FreshContext) { +export const handler: Handlers = { + GET(_request: Request, context: FreshContext) { + if (context.state.isAuthenticated) { + return new Response(null, { + status: 302, + headers: { Location: "/apps" }, + }); + } + return context.render(); + }, +}; + +export default function Home() { return ( <>

PolyMPR

The ultimate HR platform

+

+ Se connecter +

); } diff --git a/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..5245a7c --- /dev/null +++ b/scripts/generate-templates.ts @@ -0,0 +1,83 @@ +// @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..b96865f --- /dev/null +++ b/scripts/inspect-maquette.ts @@ -0,0 +1,29 @@ +// @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 f43bfc8..6583f3c 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -40,6 +40,12 @@ font-size: 0.8rem; font-family: inherit; min-width: 8rem; + box-sizing: border-box; +} + +.form-field .filter-select { + width: 100%; + min-width: 0; } .filter-input:focus, @@ -368,7 +374,9 @@ color: light-dark(var(--light-foreground), var(--dark-foreground)); font-size: 0.82rem; font-family: inherit; - min-width: 12rem; + min-width: 0; + width: 100%; + box-sizing: border-box; } .form-input:focus { @@ -391,6 +399,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 ------------------------------------------------------- */ @@ -470,6 +526,18 @@ Permission toggle cards (role management) ------------------------------------------------------- */ +.perm-header-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.85rem; + margin-bottom: 1.25rem; + background: light-dark(#f5f4ff, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; +} + .perm-toggle-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -739,8 +807,8 @@ .form-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); - gap: 0.5rem; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 0.75rem 1rem; margin-bottom: 0.75rem; } @@ -840,6 +908,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; @@ -947,9 +1023,189 @@ (end note recap) ------------------------------------------------------- */ +/* ------------------------------------------------------- + Modal overlay +------------------------------------------------------- */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-box { + background: light-dark(white, #1a172d); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 6px; + padding: 1.25rem; + min-width: 22rem; + max-width: 90vw; +} + +.modal-title { + font-size: 0.95rem; + font-weight: var(--font-weight-bold); + margin: 0 0 1rem; +} + +.modal-form { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + .info-note-dim { font-size: 0.7rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); 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/static/theme.js b/static/theme.js new file mode 100644 index 0000000..041e9a8 --- /dev/null +++ b/static/theme.js @@ -0,0 +1,29 @@ +(function () { + const t = localStorage.getItem("theme"); + if (t) document.documentElement.style.colorScheme = t; + + document.addEventListener("click", function (e) { + const btn = e.target.closest("#theme-toggle"); + if (!btn) return; + const cs = getComputedStyle(document.documentElement).colorScheme; + const isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + const next = isDark ? "light" : "dark"; + document.documentElement.style.colorScheme = next; + localStorage.setItem("theme", next); + btn.querySelector("span").textContent = next === "dark" + ? "light_mode" + : "dark_mode"; + }); + + document.addEventListener("DOMContentLoaded", function () { + const btn = document.getElementById("theme-toggle"); + if (!btn) return; + const cs = getComputedStyle(document.documentElement).colorScheme; + const isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode"; + }); +})(); diff --git a/tests/e2e/modules_test.ts b/tests/e2e/modules_test.ts index 7b33ca0..3077062 100644 --- a/tests/e2e/modules_test.ts +++ b/tests/e2e/modules_test.ts @@ -34,7 +34,7 @@ Deno.test({ }); Deno.test({ - name: "e2e modules: GET /modules returns empty for non-employee", + name: "e2e modules: GET /modules returns all for non-employee", async fn() { await truncateAll(); await seedModules([{ id: "MATH101", nom: "Mathématiques" }]); @@ -44,7 +44,7 @@ Deno.test({ ); assertEquals(res.status, 200); const body = await res.json(); - assertEquals(body.length, 0); + assertEquals(body.length, 1); }, sanitizeResources: false, sanitizeOps: false, 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 --- diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index 2a571bf..be102db 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -26,7 +26,7 @@ export const testPool = createTestPool(); export const testDb = drizzle(testPool, { schema }); const ALL_TABLES = - '"mobility","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; + '"mobilites","stages","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; /** * Vide toutes les tables dans le bon ordre.
+ 0} + onChange={toggleAll} + /> + N° étud. Nom Prénom
+ Aucun élève trouvé
+ toggleOne(s.numEtud)} + /> + {s.numEtud} {s.nom} {s.prenom}