From 9a4c6863d1ff65486ceebfc9586ff9c702ddc417 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Fri, 1 May 2026 12:47:23 +0200 Subject: [PATCH] feat: stages module, mobility frontend, theme toggle, employeeOnly access control - Add stages module with full CRUD API and admin overview island - Add mobility overview island (Liste, Kanban, Detail CRUD views) - Add contract PDF upload/download endpoints for mobilites - Add light/dark theme toggle in header - Add employeeOnly flag to hide entire modules from students (admin, students, stages) - Add read-only GET endpoints for modules/ues/ue-modules in notes module - Add [slug].tsx catch-all routes for direct URL navigation - Replace old mobility table with mobilites + stages schema (migration 0004) - Allow students to create mobilites and upload contracts - Redirect authenticated users from / to /apps catalog --- compose.prod.yml | 3 + .../0004_add_stages_and_mobilites.sql | 28 + databases/migrations/meta/_journal.json | 7 + databases/schema.ts | 36 +- defaults/interfaces.ts | 1 + defaults/makeSlug.ts | 36 + fresh.gen.ts | 57 +- routes/(_components)/Header.tsx | 8 + routes/(apps)/_middleware.ts | 8 +- .../admin/(_islands)/ImportMaquette.tsx | 48 +- routes/(apps)/admin/(_props)/props.ts | 12 +- routes/(apps)/admin/[slug].tsx | 2 + .../(apps)/admin/partials/enseignements.tsx | 1 + .../(apps)/admin/partials/import-maquette.tsx | 1 + routes/(apps)/admin/partials/modules.tsx | 1 + routes/(apps)/admin/partials/permissions.tsx | 1 + routes/(apps)/admin/partials/promotions.tsx | 1 + routes/(apps)/admin/partials/roles.tsx | 1 + routes/(apps)/admin/partials/ues.tsx | 1 + routes/(apps)/admin/partials/users.tsx | 1 + .../mobility/(_islands)/ConsultMobility.tsx | 115 --- .../(_islands)/ConsultStudents_test.tsx | 75 -- .../mobility/(_islands)/EditMobility.tsx | 248 ----- .../(apps)/mobility/(_islands)/ImportFile.tsx | 0 .../mobility/(_islands)/MobilityOverview.tsx | 931 ++++++++++++++++++ routes/(apps)/mobility/(_props)/props.ts | 12 +- routes/(apps)/mobility/[slug].tsx | 2 + routes/(apps)/mobility/api/insert_mobility.ts | 122 --- routes/(apps)/mobility/api/mobilites.ts | 116 +++ .../(apps)/mobility/api/mobilites/[idMob].ts | 149 +++ .../mobility/api/mobilites/[idMob]/contrat.ts | 156 +++ .../(admin)/consult_students_test.tsx | 21 - .../partials/(admin)/edit_mobility.tsx | 20 - routes/(apps)/mobility/partials/index.tsx | 22 +- routes/(apps)/mobility/partials/overview.tsx | 19 +- .../(apps)/notes/(_islands)/ImportNotes.tsx | 15 +- routes/(apps)/notes/(_islands)/NoteRecap.tsx | 6 +- routes/(apps)/notes/(_islands)/NotesView.tsx | 6 +- routes/(apps)/notes/[slug].tsx | 2 + routes/(apps)/notes/api/modules.ts | 12 + routes/(apps)/notes/api/ue-modules.ts | 28 + routes/(apps)/notes/api/ues.ts | 12 + .../(apps)/notes/partials/(admin)/courses.tsx | 1 + .../(apps)/notes/partials/(admin)/import.tsx | 1 + routes/(apps)/notes/partials/notes.tsx | 1 + .../stages/(_islands)/StagesOverview.tsx | 542 ++++++++++ routes/(apps)/stages/(_props)/props.ts | 15 + routes/(apps)/stages/[slug].tsx | 2 + routes/(apps)/stages/api/stages.ts | 84 ++ routes/(apps)/stages/api/stages/[idStage].ts | 122 +++ routes/(apps)/stages/index.tsx | 2 + routes/(apps)/stages/partials/index.tsx | 30 + routes/(apps)/stages/partials/overview.tsx | 19 + routes/(apps)/students/(_props)/props.ts | 1 + routes/(apps)/students/[slug].tsx | 2 + .../(apps)/students/api/students/[numEtud].ts | 8 +- .../students/partials/(admin)/consult.tsx | 1 + .../students/partials/(admin)/upload.tsx | 1 + routes/_app.tsx | 1 + routes/apps.tsx | 13 +- routes/index.tsx | 21 +- scripts/generate-templates.ts | 29 +- scripts/inspect-maquette.ts | 8 +- static/theme.js | 29 + tests/helpers/db_integration.ts | 2 +- 65 files changed, 2597 insertions(+), 681 deletions(-) create mode 100644 databases/migrations/0004_add_stages_and_mobilites.sql create mode 100644 defaults/makeSlug.ts create mode 100644 routes/(apps)/admin/[slug].tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ConsultMobility.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/EditMobility.tsx delete mode 100644 routes/(apps)/mobility/(_islands)/ImportFile.tsx create mode 100644 routes/(apps)/mobility/(_islands)/MobilityOverview.tsx create mode 100644 routes/(apps)/mobility/[slug].tsx delete mode 100644 routes/(apps)/mobility/api/insert_mobility.ts create mode 100644 routes/(apps)/mobility/api/mobilites.ts create mode 100644 routes/(apps)/mobility/api/mobilites/[idMob].ts create mode 100644 routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts delete mode 100644 routes/(apps)/mobility/partials/(admin)/consult_students_test.tsx delete mode 100644 routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx create mode 100644 routes/(apps)/notes/[slug].tsx create mode 100644 routes/(apps)/notes/api/modules.ts create mode 100644 routes/(apps)/notes/api/ue-modules.ts create mode 100644 routes/(apps)/notes/api/ues.ts create mode 100644 routes/(apps)/stages/(_islands)/StagesOverview.tsx create mode 100644 routes/(apps)/stages/(_props)/props.ts create mode 100644 routes/(apps)/stages/[slug].tsx create mode 100644 routes/(apps)/stages/api/stages.ts create mode 100644 routes/(apps)/stages/api/stages/[idStage].ts create mode 100644 routes/(apps)/stages/index.tsx create mode 100644 routes/(apps)/stages/partials/index.tsx create mode 100644 routes/(apps)/stages/partials/overview.tsx create mode 100644 routes/(apps)/students/[slug].tsx create mode 100644 static/theme.js diff --git a/compose.prod.yml b/compose.prod.yml index 6d7f11a..a20b1e8 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -30,9 +30,12 @@ services: ports: - "4430:443" env_file: .env + volumes: + - contracts:/app/uploads/contracts depends_on: migrate: condition: service_completed_successfully volumes: db_data: + contracts: diff --git a/databases/migrations/0004_add_stages_and_mobilites.sql b/databases/migrations/0004_add_stages_and_mobilites.sql new file mode 100644 index 0000000..a1f8a5d --- /dev/null +++ b/databases/migrations/0004_add_stages_and_mobilites.sql @@ -0,0 +1,28 @@ +DROP TABLE IF EXISTS "mobility"; +--> statement-breakpoint +CREATE TYPE "mobility_status" AS ENUM ('contracts_received', 'under_revision', 'done', 'validated', 'canceled'); +--> statement-breakpoint +CREATE TABLE "stages" ( + "idStage" serial PRIMARY KEY NOT NULL, + "numEtud" integer NOT NULL, + "duree" integer NOT NULL, + "nomEntreprise" text NOT NULL, + "mission" text +); +--> statement-breakpoint +CREATE TABLE "mobilites" ( + "idMob" serial PRIMARY KEY NOT NULL, + "numEtud" integer NOT NULL, + "duree" integer NOT NULL, + "contratMob" text, + "ecole" text, + "pays" text, + "status" "mobility_status" NOT NULL DEFAULT 'contracts_received', + "idStage" integer +); +--> statement-breakpoint +ALTER TABLE "stages" ADD CONSTRAINT "stages_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_idStage_stages_idStage_fk" FOREIGN KEY ("idStage") REFERENCES "public"."stages"("idStage") ON DELETE no action ON UPDATE no action; diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json index f81c27d..3cb93bd 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1777155028711, "tag": "0003_add_session2_and_malus", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1777155028712, + "tag": "0004_add_stages_and_mobilites", + "breakpoints": true } ] } diff --git a/databases/schema.ts b/databases/schema.ts index 9bf678d..eadbb3a 100644 --- a/databases/schema.ts +++ b/databases/schema.ts @@ -1,7 +1,7 @@ import { - date, doublePrecision, integer, + pgEnum, pgTable, primaryKey, serial, @@ -89,13 +89,29 @@ export const ajustements = pgTable("ajustements", { pk: primaryKey({ columns: [t.numEtud, t.idUE] }), })); -export const mobility = pgTable("mobility", { - id: serial("id").primaryKey(), - studentId: integer("studentId").references(() => students.numEtud), - startDate: date("startDate"), - endDate: date("endDate"), - weeksCount: integer("weeksCount"), - destinationCountry: text("destinationCountry"), - destinationName: text("destinationName"), - mobilityStatus: text("mobilityStatus").default("N/A"), +export const stages = pgTable("stages", { + id: serial("idStage").primaryKey(), + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + duree: integer("duree").notNull(), + nomEntreprise: text("nomEntreprise").notNull(), + mission: text("mission"), +}); + +export const mobilityStatusEnum = pgEnum("mobility_status", [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +]); + +export const mobilites = pgTable("mobilites", { + id: serial("idMob").primaryKey(), + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + duree: integer("duree").notNull(), + contratMob: text("contratMob"), + ecole: text("ecole"), + pays: text("pays"), + status: mobilityStatusEnum("status").notNull().default("contracts_received"), + idStage: integer("idStage").references(() => stages.id), }); diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index 9b65a28..951201a 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -20,6 +20,7 @@ export interface AppProperties { pages: Record; adminOnly: string[]; studentOnly?: string[]; + employeeOnly?: boolean; hint: string; } diff --git a/defaults/makeSlug.ts b/defaults/makeSlug.ts new file mode 100644 index 0000000..ee12fa4 --- /dev/null +++ b/defaults/makeSlug.ts @@ -0,0 +1,36 @@ +import { FreshContext } from "$fresh/server.ts"; +import { Route, State } from "$root/defaults/interfaces.ts"; +import { ComponentChildren } from "preact"; + +/** + * Generates a catch-all [slug] route that dynamically loads partials. + * This enables direct URL navigation to sub-pages (e.g. /admin/modules). + * @param basePath The base path of the module, should be `import.meta.dirname!`. + * @returns A route handler that loads the partial matching the slug. + */ +export default function makeSlug(basePath: string): Route { + return async function SlugRoute( + request: Request, + context: FreshContext, + ): Promise { + const slug = context.params.slug; + + // Try partials/.tsx, then partials/(admin)/.tsx + let page: Route | undefined; + try { + page = (await import(`${basePath}/partials/${slug}.tsx`)).Page; + } catch { + try { + page = (await import(`${basePath}/partials/(admin)/${slug}.tsx`)).Page; + } catch { + // No partial found for this slug + } + } + + if (!page) { + return context.renderNotFound(); + } + + return page(request, context); + }; +} diff --git a/fresh.gen.ts b/fresh.gen.ts index bd47e97..4d3229d 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,6 +4,7 @@ import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; +import * as $_apps_admin_slug_ from "./routes/(apps)/admin/[slug].tsx"; import * as $_apps_admin_api_enseignements from "./routes/(apps)/admin/api/enseignements.ts"; import * as $_apps_admin_api_enseignements_idProf_idModule_idPromo_ from "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts"; import * as $_apps_admin_api_example from "./routes/(apps)/admin/api/example.ts"; @@ -30,16 +31,22 @@ import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/rol import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx"; -import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; +import * as $_apps_mobility_slug_ from "./routes/(apps)/mobility/[slug].tsx"; +import * as $_apps_mobility_api_mobilites from "./routes/(apps)/mobility/api/mobilites.ts"; +import * as $_apps_mobility_api_mobilites_idMob_ from "./routes/(apps)/mobility/api/mobilites/[idMob].ts"; +import * as $_apps_mobility_api_mobilites_idMob_contrat from "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts"; import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx"; -import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx"; import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx"; import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx"; +import * as $_apps_notes_slug_ from "./routes/(apps)/notes/[slug].tsx"; import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustements.ts"; import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts"; +import * as $_apps_notes_api_modules from "./routes/(apps)/notes/api/modules.ts"; import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts"; import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts"; import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts"; +import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts"; +import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; @@ -47,6 +54,13 @@ import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/parti import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; +import * as $_apps_stages_slug_ from "./routes/(apps)/stages/[slug].tsx"; +import * as $_apps_stages_api_stages from "./routes/(apps)/stages/api/stages.ts"; +import * as $_apps_stages_api_stages_idStage_ from "./routes/(apps)/stages/api/stages/[idStage].ts"; +import * as $_apps_stages_index from "./routes/(apps)/stages/index.tsx"; +import * as $_apps_stages_partials_index from "./routes/(apps)/stages/partials/index.tsx"; +import * as $_apps_stages_partials_overview from "./routes/(apps)/stages/partials/overview.tsx"; +import * as $_apps_students_slug_ from "./routes/(apps)/students/[slug].tsx"; import * as $_apps_students_api_promotions from "./routes/(apps)/students/api/promotions.ts"; import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; @@ -79,13 +93,12 @@ import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_island import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx"; import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx"; import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx"; -import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; -import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx"; -import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx"; +import * as $_apps_mobility_islands_MobilityOverview from "./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx"; import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx"; import * as $_apps_notes_islands_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.tsx"; import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; +import * as $_apps_stages_islands_StagesOverview from "./routes/(apps)/stages/(_islands)/StagesOverview.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx"; import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx"; @@ -95,6 +108,7 @@ const manifest = { routes: { "./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_middleware.ts": $_apps_middleware, + "./routes/(apps)/admin/[slug].tsx": $_apps_admin_slug_, "./routes/(apps)/admin/api/enseignements.ts": $_apps_admin_api_enseignements, "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts": @@ -131,23 +145,29 @@ const manifest = { "./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, - "./routes/(apps)/mobility/api/insert_mobility.ts": - $_apps_mobility_api_insert_mobility, + "./routes/(apps)/mobility/[slug].tsx": $_apps_mobility_slug_, + "./routes/(apps)/mobility/api/mobilites.ts": $_apps_mobility_api_mobilites, + "./routes/(apps)/mobility/api/mobilites/[idMob].ts": + $_apps_mobility_api_mobilites_idMob_, + "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts": + $_apps_mobility_api_mobilites_idMob_contrat, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, - "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx": - $_apps_mobility_partials_admin_edit_mobility, "./routes/(apps)/mobility/partials/index.tsx": $_apps_mobility_partials_index, "./routes/(apps)/mobility/partials/overview.tsx": $_apps_mobility_partials_overview, + "./routes/(apps)/notes/[slug].tsx": $_apps_notes_slug_, "./routes/(apps)/notes/api/ajustements.ts": $_apps_notes_api_ajustements, "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts": $_apps_notes_api_ajustements_numEtud_idUE_, + "./routes/(apps)/notes/api/modules.ts": $_apps_notes_api_modules, "./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes, "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts": $_apps_notes_api_notes_numEtud_idModule_, "./routes/(apps)/notes/api/notes/import-xlsx.ts": $_apps_notes_api_notes_import_xlsx, + "./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules, + "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, "./routes/(apps)/notes/edition/[numEtud].tsx": $_apps_notes_edition_numEtud_, "./routes/(apps)/notes/index.tsx": $_apps_notes_index, @@ -158,6 +178,15 @@ const manifest = { "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, + "./routes/(apps)/stages/[slug].tsx": $_apps_stages_slug_, + "./routes/(apps)/stages/api/stages.ts": $_apps_stages_api_stages, + "./routes/(apps)/stages/api/stages/[idStage].ts": + $_apps_stages_api_stages_idStage_, + "./routes/(apps)/stages/index.tsx": $_apps_stages_index, + "./routes/(apps)/stages/partials/index.tsx": $_apps_stages_partials_index, + "./routes/(apps)/stages/partials/overview.tsx": + $_apps_stages_partials_overview, + "./routes/(apps)/students/[slug].tsx": $_apps_students_slug_, "./routes/(apps)/students/api/promotions.ts": $_apps_students_api_promotions, "./routes/(apps)/students/api/promotions/[idPromo].ts": @@ -210,12 +239,8 @@ const manifest = { $_apps_admin_islands_EditUser, "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx": $_apps_admin_islands_ImportMaquette, - "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx": - $_apps_mobility_islands_ConsultMobility, - "./routes/(apps)/mobility/(_islands)/EditMobility.tsx": - $_apps_mobility_islands_EditMobility, - "./routes/(apps)/mobility/(_islands)/ImportFile.tsx": - $_apps_mobility_islands_ImportFile, + "./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx": + $_apps_mobility_islands_MobilityOverview, "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx": $_apps_notes_islands_AdminConsultNotes, "./routes/(apps)/notes/(_islands)/ImportNotes.tsx": @@ -224,6 +249,8 @@ const manifest = { $_apps_notes_islands_NoteRecap, "./routes/(apps)/notes/(_islands)/NotesView.tsx": $_apps_notes_islands_NotesView, + "./routes/(apps)/stages/(_islands)/StagesOverview.tsx": + $_apps_stages_islands_StagesOverview, "./routes/(apps)/students/(_islands)/ConsultStudents.tsx": $_apps_students_islands_ConsultStudents, "./routes/(apps)/students/(_islands)/EditStudents.tsx": diff --git a/routes/(_components)/Header.tsx b/routes/(_components)/Header.tsx index 34853ad..ec5573b 100644 --- a/routes/(_components)/Header.tsx +++ b/routes/(_components)/Header.tsx @@ -11,6 +11,14 @@ export default function Header(props: HeaderProps) { ); diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index ece0de4..a671134 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -21,11 +21,17 @@ export const handler: MiddlewareHandler[] = [ `./${currentApp}/(_props)/props.ts` )).default; - context.state.availablePages = { ...properties.pages }; const isStudent = context.state.session.eduPersonPrimaryAffiliation === "student"; const isLocal = Deno.env.get("LOCAL") === "true"; + // Block students from accessing employeeOnly modules entirely + if (isStudent && properties.employeeOnly) { + return new Response(null, { status: 403 }); + } + + context.state.availablePages = { ...properties.pages }; + if (isStudent) { // Students only see studentOnly pages (+ non-restricted pages) properties.adminOnly.forEach((page) => diff --git a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx index 676e283..278081c 100644 --- a/routes/(apps)/admin/(_islands)/ImportMaquette.tsx +++ b/routes/(apps)/admin/(_islands)/ImportMaquette.tsx @@ -151,7 +151,10 @@ export default function ImportMaquette() { }); if (res.ok) { const created = await res.json(); - promos.value = [...promos.value, { id: created.id, annee: created.annee }]; + promos.value = [...promos.value, { + id: created.id, + annee: created.annee, + }]; newPromoId.value = ""; newPromoAnnee.value = ""; } else { @@ -289,7 +292,14 @@ export default function ImportMaquette() { ); const data: (string | number | null)[][] = [ - ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\nECTS", "Coeff."], + [ + "Annee\nSemestres", + "Codes APOGEE", + null, + null, + "Credits\nECTS", + "Coeff.", + ], ]; for (const ue of uesData) { @@ -303,7 +313,14 @@ export default function ImportMaquette() { data.push(["UE", null, ue.nom, null, totalCoeff]); for (const um of mods) { const mod = modMap[um.idModule]; - data.push([null, um.idModule, null, mod ? mod.nom : um.idModule, null, um.coeff]); + data.push([ + null, + um.idModule, + null, + mod ? mod.nom : um.idModule, + null, + um.coeff, + ]); } data.push([]); } @@ -312,7 +329,10 @@ export default function ImportMaquette() { const ws = XLSX.utils.aoa_to_sheet(data); XLSX.utils.book_append_sheet(wb, ws, "Maquette"); const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); - const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + const blob = new Blob([buf], { + type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -384,8 +404,9 @@ export default function ImportMaquette() { class="filter-select" placeholder="ID (ex: 3AFISE24-25)" value={newPromoId.value} - onInput={(e) => - (newPromoId.value = (e.target as HTMLInputElement).value)} + onInput={( + e, + ) => (newPromoId.value = (e.target as HTMLInputElement).value)} style="min-width: 10rem" /> - (newPromoAnnee.value = (e.target as HTMLInputElement).value)} + onInput={( + e, + ) => (newPromoAnnee.value = (e.target as HTMLInputElement).value)} style="min-width: 8rem" /> - - ); -} diff --git a/routes/(apps)/mobility/(_islands)/ImportFile.tsx b/routes/(apps)/mobility/(_islands)/ImportFile.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx new file mode 100644 index 0000000..f429469 --- /dev/null +++ b/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx @@ -0,0 +1,931 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; +type Mobilite = { + id: number; + numEtud: number; + duree: number; + contratMob: string | null; + ecole: string | null; + pays: string | null; + status: string; + idStage: number | null; +}; +type Stage = { + id: number; + numEtud: number; + duree: number; + nomEntreprise: string; + mission: string | null; +}; + +const REQUIRED_WEEKS = 12; + +const STATUS_ORDER = [ + "contracts_received", + "under_revision", + "done", + "validated", + "canceled", +] as const; + +const STATUS_LABELS: Record = { + contracts_received: "Contrats reçus", + under_revision: "En révision", + done: "Signé", + validated: "Validé", + canceled: "Annulé", +}; + +const STATUS_COLORS: Record = { + contracts_received: "#f5a623", + under_revision: "#dc2626", + done: "#22c55e", + validated: "light-dark(var(--light-accent-color), var(--dark-accent-color))", + canceled: + "light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))", +}; + +function lowestStatus(mobs: Mobilite[]): string { + let lowest = STATUS_ORDER.length - 1; + for (const m of mobs) { + const idx = STATUS_ORDER.indexOf(m.status as typeof STATUS_ORDER[number]); + if (idx >= 0 && idx < lowest) lowest = idx; + } + return STATUS_ORDER[lowest]; +} + +function validatedWeeks(mobs: Mobilite[]): number { + return mobs + .filter((m) => m.status === "validated") + .reduce((sum, m) => sum + m.duree, 0); +} + +export default function MobilityOverview() { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [mobilites, setMobilites] = useState([]); + const [stagesMap, setStagesMap] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [tab, setTab] = useState<"liste" | "kanban">("liste"); + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + + // Detail view state + const [detailStudent, setDetailStudent] = useState(null); + const [editingMob, setEditingMob] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + async function load() { + try { + const [sRes, pRes, mRes, stRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + fetch("/mobility/api/mobilites"), + fetch("/stages/api/stages"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les données"); + const [sData, pData, mData, stData] = await Promise.all([ + sRes.json(), + pRes.ok ? pRes.json() : [], + mRes.ok ? mRes.json() : [], + stRes.ok ? stRes.json() : [], + ]); + setStudents(sData); + setPromos(pData); + setMobilites(mData); + setStagesMap( + Object.fromEntries((stData as Stage[]).map((s) => [s.id, s])), + ); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + // If in detail view, render that + if (detailStudent) { + return ( + m.numEtud === detailStudent.numEtud)} + allMobilites={mobilites} + stagesMap={stagesMap} + editingMob={editingMob} + setEditingMob={setEditingMob} + showAddForm={showAddForm} + setShowAddForm={setShowAddForm} + onBack={() => { + setDetailStudent(null); + setEditingMob(null); + setShowAddForm(false); + }} + onReload={load} + /> + ); + } + + if (loading) { + return ( +
+

Chargement...

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

{error}

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

Suivi des mobilités

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

+ Aucun +

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

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

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

Aucune mobilité enregistrée.

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

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

+

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

+
+
+ {stage + ? ( +

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

+ ) + : ( +

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

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

Modifier la mobilité #{mob.id}

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

Nouvelle mobilité

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

Test consult students

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

Edit mobility

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

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

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

Mobilité internationale

+

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

+

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

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

Edit mobility

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

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

+

Chargement...

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

{error}

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

Suivi des stages

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

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

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

+ Aucun stage enregistré. +

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

+ Stage {i + 1} +

+

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

+
+
+

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

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

Modifier le stage #{stage.id}

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

Nouveau stage

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

Stages

+

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

+

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

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

PolyMPR

The ultimate HR platform

+

+ Se connecter +

); } diff --git a/scripts/generate-templates.ts b/scripts/generate-templates.ts index ab2f3bc..5245a7c 100644 --- a/scripts/generate-templates.ts +++ b/scripts/generate-templates.ts @@ -5,7 +5,12 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; { const wb = XLSX.utils.book_new(); const ws = XLSX.utils.aoa_to_sheet([ - [null, null, null, "Promotion peut etre vide mais doit prealablement Exister"], + [ + null, + null, + null, + "Promotion peut etre vide mais doit prealablement Exister", + ], ["Nom", "Prenom", "Numero-etudiant", "Promotion"], ["NOM", "PRENOM", 12345678, "3AFISE24-25"], ]); @@ -38,8 +43,26 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; { const data = [ ["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."], - ["Description des UE du diplome", null, null, null, null, null, "Nombre d'heures"], - ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\n ECTS", "Coeff.", "CM", "TD", "TP"], + [ + "Description des UE du diplome", + null, + null, + null, + null, + null, + "Nombre d'heures", + ], + [ + "Annee\nSemestres", + "Codes APOGEE", + null, + null, + "Credits\n ECTS", + "Coeff.", + "CM", + "TD", + "TP", + ], ["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"], ["SEM 5", null, null, null, 30], ["UE", "CODE_UE1", "Nom de l'UE 1", null, 6], diff --git a/scripts/inspect-maquette.ts b/scripts/inspect-maquette.ts index 0dd3dce..b96865f 100644 --- a/scripts/inspect-maquette.ts +++ b/scripts/inspect-maquette.ts @@ -9,7 +9,9 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) { for (const sheetName of wb.SheetNames) { console.log(`\n--- Sheet: ${sheetName} ---`); const sheet = wb.Sheets[sheetName]; - const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { header: 1 }); + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { + header: 1, + }); // Print first 5 cols of each row, mark rows that look like year/semester headers for (let i = 0; i < rows.length; i++) { const row = rows[i]; @@ -17,7 +19,9 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) { const col0 = row[0] != null ? String(row[0]).trim() : ""; // Show rows that are structural (year, semester, UE headers) if (col0 || (row[1] != null && String(row[1]).trim())) { - const preview = row.slice(0, 6).map(c => c != null ? String(c).substring(0, 25) : "").join(" | "); + const preview = row.slice(0, 6).map((c) => + c != null ? String(c).substring(0, 25) : "" + ).join(" | "); console.log(` [${i}] ${preview}`); } } diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..af947af --- /dev/null +++ b/static/theme.js @@ -0,0 +1,29 @@ +(function () { + var t = localStorage.getItem("theme"); + if (t) document.documentElement.style.colorScheme = t; + + document.addEventListener("click", function (e) { + var btn = e.target.closest("#theme-toggle"); + if (!btn) return; + var cs = getComputedStyle(document.documentElement).colorScheme; + var isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + var next = isDark ? "light" : "dark"; + document.documentElement.style.colorScheme = next; + localStorage.setItem("theme", next); + btn.querySelector("span").textContent = next === "dark" + ? "light_mode" + : "dark_mode"; + }); + + document.addEventListener("DOMContentLoaded", function () { + var btn = document.getElementById("theme-toggle"); + if (!btn) return; + var cs = getComputedStyle(document.documentElement).colorScheme; + var isDark = cs === "dark" || + (!cs || cs === "light dark") && + matchMedia("(prefers-color-scheme:dark)").matches; + btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode"; + }); +})(); diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index 2a571bf..be102db 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -26,7 +26,7 @@ export const testPool = createTestPool(); export const testDb = drizzle(testPool, { schema }); const ALL_TABLES = - '"mobility","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; + '"mobilites","stages","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; /** * Vide toutes les tables dans le bon ordre.