Compare commits

...

94 Commits

Author SHA1 Message Date
djalim 49bcc3083a fix: faculty users are now recognized as employees
Check Deno code / Check Deno code (push) Has been cancelled
Tests / Unit tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
2026-05-05 15:29:02 +02:00
djalim 0f87bc18c3 refactor(fresh.config): change server port to 80 and remove cert/key
Check Deno code / Check Deno code (push) Has been cancelled
Tests / Unit tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Check Deno code / Check Deno code (pull_request) Has been cancelled
Tests / Unit tests (pull_request) Has been cancelled
Tests / Integration tests (pull_request) Has been cancelled
2026-05-05 12:51:38 +00:00
djalim e719e5129f feat: added logs
Check Deno code / Check Deno code (pull_request) Has been cancelled
Tests / Unit tests (pull_request) Has been cancelled
Tests / Integration tests (pull_request) Has been cancelled
Check Deno code / Check Deno code (push) Has been cancelled
Tests / Unit tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
2026-05-05 14:50:17 +02:00
djalim 951c9c1fea test : changed test format + added playwright support
Check Deno code / Check Deno code (pull_request) Has been cancelled
Tests / Unit tests (pull_request) Has been cancelled
Tests / Integration tests (pull_request) Has been cancelled
Check Deno code / Check Deno code (push) Has been cancelled
Tests / Unit tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
2026-05-03 21:52:02 +02:00
djalim ed2fe69f54 refactor(props): comment out my-mobility page until student page is fixed
Check Deno code / Check Deno code (pull_request) Successful in 7s
Tests / Unit tests (pull_request) Successful in 13s
Tests / Integration tests (pull_request) Successful in 1m47s
Build and push image / Check Deno code (push) Successful in 5s
Check Deno code / Check Deno code (push) Successful in 7s
Tests / Unit tests (push) Successful in 12s
Tests / Integration tests (push) Successful in 1m49s
Build and push image / Build Docker image (push) Successful in 3m13s
2026-05-01 19:09:31 +02:00
djalim f409d9e5e8 refactor: add employeeOnly flag to mobility props and drop debug log 2026-05-01 19:07:37 +02:00
djalim c0aeb33193 chore(Footer): update copyright year to 2026 2026-05-01 18:46:08 +02:00
djalim 5c804bd7fb chore: add .env.template and remove test workflow
docs: remove CLAUDE.md

chore(compose): delete compose.yml file
2026-05-01 18:43:46 +02:00
djalim 77e0b966a5 style: fix formatting of ImportMaquette error handling block
Check Deno code / Check Deno code (push) Successful in 7s
Tests / Unit tests (push) Successful in 13s
Tests / Integration tests (push) Failing after 13m46s
Check Deno code / Check Deno code (pull_request) Successful in 14s
Tests / Unit tests (pull_request) Successful in 13s
Tests / Integration tests (pull_request) Successful in 3m54s
2026-05-01 14:35:58 +02:00
djalim ae4d4d3020 refactor: rename Module to ECUE, update routes, UI, and API messages
Check Deno code / Check Deno code (pull_request) Failing after 27s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m16s
refactor: rename Module to ECUE in API, UI, and error messages
2026-05-01 14:26:00 +02:00
djalim b6586f7715 feat: made stuff 2026-05-01 14:14:33 +02:00
djalim 9a4c6863d1 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
2026-05-01 12:47:23 +02:00
djalim df3957741d feat : fix a lot of stuff
Check Deno code / Check Deno code (pull_request) Failing after 8s
Tests / Unit tests (pull_request) Successful in 13s
Tests / Integration tests (pull_request) Failing after 1m0s
2026-04-30 13:49:47 +02:00
djalim 04be659d6b feat(app): add studentOnly pages and new routes
Add routes for modules, users, notes import, recap, and islands edit.
Update middleware to filter pages based on user role.

feat(admin): add modal for assigning teaching, replace delete icon with SVG

refactor(server): rename port variable to uppercase and add env support
feat(admin): add enseignants, users, filtering and role colors

refactor(AdminRoles): improve role UI and add permission mapping

feat(admin-users): add role colors, role filter, and modal for creating users

feat(admin): add EditModule component for module editing

feat(admin): add EditUser page for editing users and managing enseignements

feat(promo-select): display id and name in options for promo dropdown

feat: add edit module/user routes, inline coeff editing, UI tweaks

refactor: UI – icons, modal overlay, grid, subtitles, import margin
2026-04-29 09:12:55 +02:00
Clément Oudelet f71128a7f3 PMPR-44 : fix missing newline
Check Deno code / Check Deno code (pull_request) Successful in 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m14s
Check Deno code / Check Deno code (push) Successful in 5s
Tests / Unit tests (push) Successful in 12s
Tests / Integration tests (push) Successful in 1m13s
2026-04-27 17:19:57 +00:00
Clément Oudelet 720a380be8 PMPR-44 : fix formatting 2026-04-27 17:19:57 +00:00
Clément Oudelet 6c602cb10a PMPR-44 : POST /notes/import-xlsx - importer des notes via Excel 2026-04-27 17:19:57 +00:00
djalim bb09c1cce5 chore: formated tests
Check Deno code / Check Deno code (pull_request) Successful in 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m18s
Check Deno code / Check Deno code (push) Successful in 5s
Tests / Unit tests (push) Successful in 11s
Tests / Integration tests (push) Successful in 1m13s
2026-04-27 18:58:19 +02:00
djalim f162fcaadc feat: add role_write permission and update e2e tests
Check Deno code / Check Deno code (pull_request) Failing after 5s
Tests / Unit tests (pull_request) Successful in 13s
Tests / Integration tests (pull_request) Successful in 1m12s
Add role_write permission to permissions table and update migrations.
Update e2e tests to use DB integration and seed permissions.
Add seedPermissions helper.
2026-04-27 18:56:04 +02:00
djalim 2c5e4ebf11 feat(fresh.gen.ts): add routes for notes edition, recap and island recap
Check Deno code / Check Deno code (pull_request) Failing after 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Failing after 1m17s
feat(notes): add NoteRecap island component for student grade recap

feat: add adjust controls to UI component

Add placeholder, value binding, onInput handler, apply/reset buttons,
and display of adjusted value.

feat(notes): add edition and recap pages, update styles and links
2026-04-27 18:22:23 +02:00
djalim 757e364af0 chore(docker): add .dockerignore and update Dockerfile
Add .dockerignore to exclude node_modules, .git, coverage, .env.
Update Dockerfile to install nodejs/npm, copy package.json, run npm install, and build.
Update compose.prod.yml to set working_dir, restart no, and use array command.
Move drizzle-kit from devDependencies to dependencies.
2026-04-27 17:29:31 +02:00
djalim 378cbb0c06 style: format import success message and drop zone JSX
Check Deno code / Check Deno code (pull_request) Successful in 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Failing after 1m7s
Apply consistent string concatenation in ImportNotes and UploadStudents.
Format JSX drop zone for better readability.
2026-04-27 17:11:46 +02:00
djalim d3de5c29e7 refactor: add migration, seed permissions, update permissions API
feat(notes): add XLSX import island and admin route

feat(upload): add drag‑and‑drop upload, template download, UI tweaks
2026-04-27 17:08:58 +02:00
djalim 733259e317 feat : fixed some page not being as described in the figma 2026-04-27 11:21:32 +02:00
djalim 56019ad372 fix: fixed test ci 2026-04-27 00:04:28 +02:00
djalim fcc9547a30 feat(dev): add compose files and dev-login bypass route
- compose.prod.yml: production stack with registry image, healthcheck,
  migration service
- compose.test.yml: local test stack with source mount and LOCAL=true
- routes/dev-login.ts: fake admin JWT login, only active when LOCAL=true
- routes/_middleware.ts: expose /dev-login as public route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:01:59 +02:00
djalim 5ba8b8cb68 feat(ui): implement full UI layer for all modules
Add interactive island components and server partials for notes,
students, and admin modules, following the Figma prototype design.

- static/styles/ui.css: shared component library (buttons, tables,
  chips, cards, filters, tabs, form inputs)
- notes: NotesView (student grade view with UE cards, promo tabs,
  weighted averages), AdminConsultNotes, AdminUEs islands + partials
- students: ConsultStudents (list/filter/delete), AdminPromotions
  (CRUD) islands + partials
- admin: AdminModules, AdminUsers, AdminRoles islands + partials
- All partials use State type with unknown cast for session access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:54:10 +02:00
djalim 34b7ac0231 docs: update CLAUDE.md to reflect completed API layer
Mark all implemented endpoints as , document the 3-level test
architecture, and clarify that UI pages are the next priority.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:29:10 +02:00
djalim 714486f43c chore: formated tests
Check Deno code / Check Deno code (pull_request) Successful in 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m9s
Check Deno code / Check Deno code (push) Successful in 6s
Tests / Unit tests (push) Successful in 12s
Tests / Integration tests (push) Successful in 1m13s
2026-04-26 19:07:15 +02:00
djalim b0930b8da2 fix: correct handler bugs exposed by test suite
Check Deno code / Check Deno code (pull_request) Failing after 6s
Tests / Unit tests (pull_request) Successful in 13s
Tests / Integration tests (pull_request) Successful in 1m17s
- ajustements [numEtud]/[idUE]: fix .where() missing and() — PUT/DELETE
  were applying only numEtud condition, modifying all rows for a student
- modules/users/enseignements POST: add try/catch, return 500 on invalid JSON
- modules/[idModule] PUT: add try/catch + type check on nom (string required)
- modules POST: add .trim() check to reject whitespace-only id/nom
- users POST: add .trim() check to reject whitespace-only id/nom/prenom
- ues POST: add .trim() check to reject whitespace-only nom
- notes POST: add type check (typeof number) and bounds check (0 ≤ note ≤ 20)
- ue-modules POST: add coeff >= 0 validation

Update robustness tests to reflect fixed behavior (remove [BUG] labels,
replace assertRejects with status code assertions).
2026-04-26 19:01:53 +02:00
djalim 2f4d8db1bf test: add full test coverage for notes, ues, ue-modules, ajustements, enseignements, users
Check Deno code / Check Deno code (pull_request) Failing after 6s
Tests / Unit tests (pull_request) Successful in 13s
Tests / Integration tests (pull_request) Failing after 1m14s
- Unit tests (mock DB + API) for all missing endpoints
- Integration tests (Drizzle direct) for all missing entities
- E2E tests (handler + real DB) for all missing endpoints
- Robustness tests: invalid inputs, SQL injection, type errors, business rule violations
- Seed helpers: seedNotes, seedUeModules, seedEnseignements, seedAjustements
- Add test:coverage and test:coverage:html tasks to deno.json

Tests expose known handler bugs (marked [BUG] in test names):
- ajustements PUT/DELETE: .where() without and() modifies all rows for student
- Missing try/catch in modules, users, enseignements handlers
- Whitespace accepted as valid string values
- No type or business rule validation (note bounds, coeff >= 0)
2026-04-26 18:25:00 +02:00
djalim a3b55d0a1b fix: remove unused body variable in permissions e2e test
Check Deno code / Check Deno code (pull_request) Successful in 6s
Tests / Unit tests (pull_request) Successful in 11s
Tests / Integration tests (pull_request) Successful in 1m19s
Check Deno code / Check Deno code (push) Successful in 6s
Tests / Unit tests (push) Successful in 11s
Tests / Integration tests (push) Successful in 1m7s
2026-04-26 13:34:43 +00:00
djalim 86080b8042 test(permissions): add unit and e2e tests for GET /permissions (#115)
Handler is static (no DB), tests verify the 9 known permissions are returned
with correct id/nom shapes.
2026-04-26 13:34:43 +00:00
djalim e3a7e20993 fix: remove unused assertExists import
Check Deno code / Check Deno code (pull_request) Successful in 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m1s
Check Deno code / Check Deno code (push) Successful in 5s
Tests / Unit tests (push) Successful in 11s
Tests / Integration tests (push) Successful in 1m10s
2026-04-26 13:34:27 +00:00
djalim c5d02a2890 style: fix deno fmt and lint 2026-04-26 13:34:27 +00:00
djalim c86d20ca81 test(modules): add unit, integration and e2e tests for /modules (#113)
- unit: fixture shapes, mock API (GET/POST/PUT/DELETE + 409), mock DB CRUD
- integration: list, create, get, duplicate rejection, update, delete
- e2e: handler calls with mock context + real DB, covers 400/403/404/409
2026-04-26 13:34:27 +00:00
djalim f038e4020b style: fix deno fmt and lint
Check Deno code / Check Deno code (pull_request) Successful in 6s
Tests / Unit tests (pull_request) Successful in 11s
Tests / Integration tests (pull_request) Successful in 59s
Check Deno code / Check Deno code (push) Successful in 6s
Tests / Unit tests (push) Successful in 11s
Tests / Integration tests (push) Successful in 1m2s
2026-04-26 13:34:09 +00:00
djalim e75098083a test(roles): add unit, integration and e2e tests for /roles (#112)
- unit: fixture shapes, mock API (GET/POST/PUT/DELETE), mock DB CRUD
- integration: list, create, assign permissions, update, reset perms, delete
- e2e: handler calls with mock context + real DB, covers 400/404 cases
2026-04-26 13:34:09 +00:00
djalim e3eefd945c chore: remove .github workflows (act only uses .gitea)
Check Deno code / Check Deno code (pull_request) Successful in 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m3s
Check Deno code / Check Deno code (push) Successful in 5s
Tests / Unit tests (push) Successful in 12s
Tests / Integration tests (push) Successful in 1m3s
2026-04-26 13:08:03 +00:00
djalim d25c353018 fix: remove unused assertExists import 2026-04-26 13:08:03 +00:00
djalim b3eb1b60a5 style: fix deno fmt and lint 2026-04-26 13:08:03 +00:00
djalim 222c3237f0 test(promotions): add unit, integration and e2e tests for /promotions (#110)
- unit: fixture shapes, mock API (GET/POST/PUT/DELETE), mock DB CRUD
- integration: real DB list, create, get, update, delete, not-found cases
- e2e: handler calls with mock context + real DB, covers 400/403/404 cases
2026-04-26 13:08:03 +00:00
djalim e2f5bf7b95 ci: remove Run tests step from lint workflow
Check Deno code / Check Deno code (push) Successful in 6s
Tests / Unit tests (push) Successful in 12s
Tests / Integration tests (push) Successful in 57s
Check Deno code / Check Deno code (pull_request) Successful in 6s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m3s
2026-04-26 14:30:40 +02:00
djalim cd5c524ff0 style: fix deno fmt on students tests and drizzle.config
Check Deno code / Check Deno code (pull_request) Failing after 8s
Tests / Unit tests (pull_request) Successful in 11s
Tests / Integration tests (pull_request) Successful in 58s
2026-04-26 14:18:09 +02:00
djalim e5c6c389ea test(students): add unit, integration and e2e tests for /students (#109)
Check Deno code / Check Deno code (pull_request) Failing after 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 58s
- unit: fixture shapes, mock API (GET/POST/PUT/DELETE), mock DB operations
- integration: real DB CRUD via testDb (list, filter, create, get, update, delete)
- e2e: handler calls directly with mock FreshContext + real DB
  covers auth (employee vs non-employee), 400/403/404 cases
- adds test:e2e deno task and CI step
- adds tests/helpers/handler.ts with makeEmployeeContext, makeContextWithAffiliation,
  makeGetRequest, makeJsonRequest utilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 14:00:38 +02:00
djalim daa7f4951f fix(ci): fix postgres TCP setup and truncateAll superuser error
Check Deno code / Check Deno code (push) Failing after 5s
Tests / Unit tests (push) Successful in 11s
Tests / Integration tests (push) Successful in 55s
- Use apt-get install + configure listen_addresses + md5 auth in pg_hba
  so psql can connect via 127.0.0.1 (not just Unix socket)
- Use pg_ctlcluster restart after config changes + wait for pg_isready
- Replace session_replication_role (requires superuser) with a single
  TRUNCATE ... CASCADE which handles FK deps without elevated privileges
- All 3 integration tests now pass in CI (act + Gitea Actions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:30:33 +00:00
djalim a95818e3bf fix(ci): use connection URL with ssl:false in drizzle config 2026-04-26 11:30:33 +00:00
djalim 26eedcc4f2 debug(ci): add connection diagnostics before migrate 2026-04-26 11:30:33 +00:00
djalim ce4782580d fix(ci): remove unsupported --verbose from drizzle-kit migrate 2026-04-26 11:30:33 +00:00
djalim 91248370da fix(ci): add GRANT on public schema and verbose migrate output 2026-04-26 11:30:33 +00:00
djalim 6b8b5e6aa3 fix(ci): start postgres with pg_ctlcluster instead of systemctl 2026-04-26 11:30:33 +00:00
djalim d1c3b93755 fix(ci): install postgres via apt-get instead of docker 2026-04-26 11:30:33 +00:00
djalim f42df29f06 fix(ci): use docker run instead of services for postgres 2026-04-26 11:30:33 +00:00
djalim c8b808f509 fix(ci): use bash /dev/tcp for postgres readiness check 2026-04-26 11:30:33 +00:00
djalim fdfdd74894 fix(ci): replace pg_isready with nc for postgres readiness check 2026-04-26 11:30:33 +00:00
djalim 60dde4675c fix(ci): use deno install for unit tests, add postgres readiness check 2026-04-26 11:30:33 +00:00
djalim fef9457795 fix(ci): install npm deps before running unit tests 2026-04-26 11:30:33 +00:00
djalim 6db04045f4 fix(lint): add version to drizzle-orm imports and prefix unused NOT_FOUND 2026-04-26 11:30:33 +00:00
djalim cdd9c0bf06 chore(test): set up integration test framework with postgres
- Generate Drizzle migrations (databases/migrations/)
- Add databases/schema.kit.ts for drizzle-kit (Node-compatible imports)
- Update drizzle.config.ts to use schema.kit.ts
- Add deno tasks: test:unit, test:integration, migrate
- Add tests/helpers/db_integration.ts: testDb, truncateAll, seed helpers
- Add .gitea/workflows/test.yml: CI with postgres service container
- Update lint.yml: run test:unit only (no DB needed)
- Update deploy.yml: add check-code job, gate deploy on it
2026-04-26 11:30:33 +00:00
djalim 980efcfbc3 ci: add Deno code check job and enable lint on develop
Check Deno code / Check Deno code (pull_request) Failing after 9s
Check Deno code / Check Deno code (push) Failing after 6s
2026-04-23 14:29:08 +02:00
anys 66183c2ad8 feat(api): implement UE-Module coefficient update and deletion endpoint
- PUT /ue-modules/{idModule}/{idUE}/{idPromo}: update coeff for
  UE-Module-Promo association
- DELETE /ue-modules/{idModule}/{idUE}/{idPromo}: remove UE-Module-Promo
  association
- requires employee role
2026-04-23 14:01:40 +02:00
anys 9976b9e2b4 feat(api): implement UE-Module association get endpoint
- GET /ue-modules/{idModule}/{idUE}/{idPromo}: recover the detail of an
  ue-module association by its composite key
- requires employee role
2026-04-23 11:57:30 +00:00
Clément Oudelet 457b008ba3 PMPR-46/47 : PUT et DELETE /notes/{numEtud}/{idModule} 2026-04-23 11:56:20 +00:00
anys 22750ba07e feat(api): implement ajustement delete endpoint
- DELETE /ajustements/{numEtud}/{idUE}: remove ajustement from DB
- Requires employee role
- Returns 204 on success
2026-04-23 13:55:24 +02:00
anys 49876339bf feat(api): implement ajustement update endpoint
- PUT /ajustements/{numEtud}/{idUE}: update ajustement valeur
- Requires employee role
2026-04-23 11:48:31 +00:00
Clément Oudelet eeb087ea76 PMPR-36 : DELETE /ues/{idUE} - supprimer une UE 2026-04-23 13:44:43 +02:00
Clément Oudelet 7ad70c4525 GET /notes/{numEtud}/{idModule} - récupérer le détail d'une note pour un étudiant dans un module 2026-04-23 13:11:48 +02:00
Clément Oudelet 79669d60cf PMPR-38 : POST /ue-modules - associer un module à une UE 2026-04-22 20:40:28 +02:00
anys d3f1f433e1 feat(api): implement single ajustement retrieval endpoint
- GET /ajustements/{numEtud}/{idUE}: get ajustement by student numEtud
  and UE id
- Requires employee role
2026-04-22 17:24:39 +00:00
anys 022994e5a7 feat(api): implement ajustements list and create endpoints
- GET /ajustements: list all ajustements with optional numEtud/idUE
  filters
- POST /ajustements: create new ajustement for student in UE
- Both require employee role
2026-04-22 17:24:07 +00:00
Clément Oudelet 33d023986c PMPR-34 : GET /ues/{idUE} - récupérer une UE par son id 2026-04-22 17:20:20 +00:00
Clément Oudelet bbc9ea58e2 PMPR-37 : GET /ue-modules - liste les associations UE-Module 2026-04-22 17:15:54 +00:00
Clément Oudelet 96b7edf77f PMPR-43 : POST /notes - créer une note 2026-04-22 17:14:45 +00:00
anys a19a1e6c13 test(api): remove enseignements unit tests
Unit tests removed as they only used mocks without real value.
2026-04-22 17:13:14 +00:00
anys 2739a01ab5 fix(api): align enseignements route with Fresh file routing
- Replace flat file `[idProf]_[idModule]_[idPromo].ts`
  with nested structure `[idProf]/[idModule]/[idPromo].ts`
- Ensures URL matches `/enseignements/{idProf}/{idModule}/{idPromo}`
2026-04-22 17:13:14 +00:00
anys f3c1f10999 feat(api): implement enseignements CRUD endpoints
Add CRUD API for enseignements (prof-module-promo associations):

- POST /enseignements: Create with validation (201/409)
- GET /enseignements/{idProf}/{idModule}/{idPromo}: Read by composite
  key (200/404)
- DELETE /enseignements/{idProf}/{idModule}/{idPromo}: Delete by
  composite key (204/404)

Access control: Employee-only (403 Forbidden)
Tests: 7 unit tests added

Note: RBAC implementation pending (current access control is temporary)
2026-04-22 17:13:14 +00:00
djalim 92182b952f feat(modules): add CRUD endpoints for module resource
Implement GET, PUT, DELETE for /modules/{idModule} with 404 handling.
2026-04-22 14:47:08 +02:00
djalim cf3c7c0693 feat(admin/api): add modules endpoint with GET and POST handlers 2026-04-22 14:46:00 +02:00
djalim 5229453169 chore(drizzle.config.ts): import process for env variable support 2026-04-22 14:40:19 +02:00
djalim 6c18189d9f chore(deps): update drizzle-orm to 0.45.2 and pg to 8.20.0 2026-04-22 14:40:19 +02:00
djalim 2c1fd7e5ad feat(promotions): add CRUD endpoints for promotion by id
- GET /promotions/{idPromo} returns promotion or 404
- PUT /promotions/{idPromo} updates year or 404
- DELETE /promotions/{idPromo} deletes promotion or 404
- Only employees allowed, otherwise 403
2026-04-22 14:40:19 +02:00
Clément Oudelet 2f15efe21e PMPR-33 : POST /ues - créer une UE 2026-04-22 14:28:03 +02:00
Clément Oudelet b2847a4a7d PMPR-42 : GET /notes - récupère les notes 2026-04-22 12:20:59 +00:00
djalim 3f0c8d079f feat(students): add promotions API for employees 2026-04-22 14:13:59 +02:00
djalim 4eaea48ebd feat(students): add CRUD endpoints for student by numEtud 2026-04-22 14:11:29 +02:00
djalim f959cf0d3a feat(students): add CSV import endpoint for student data 2026-04-22 14:10:18 +02:00
djalim 0d45bd4c1c refactor(students): simplify API, remove unused imports and helpers
refactor(students): add query param filtering, enforce employee role for POST
refactor(students): return created student in POST response
2026-04-22 14:06:01 +02:00
djalim b5f134d016 feat(roles): add CRUD endpoints for role by id 2026-04-22 13:45:59 +02:00
djalim 9a3f49ecfe feat(admin/api): add roles endpoint with GET and POST 2026-04-22 13:44:30 +02:00
djalim 5a86f69093 feat: add CRUD endpoints for users by id 2026-04-22 13:42:29 +02:00
djalim 03b58e7b0a feat(admin/api/users): add GET and POST endpoints for users 2026-04-22 13:41:33 +02:00
djalim 9168ca53da feat(admin): scaffold admin module and add GET /permissions endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:30:19 +02:00
djalim b8d359a507 feat(database): add roles, permissions, users, modules, and related tables
Add tables for role-based access control and academic entities.
Includes modules, UEs, notes, and adjustments.
Update students and mobility tables to reference new primary keys.
This enables richer data modeling for the application.
2026-04-22 13:17:08 +02:00
Clément Oudelet 32ffbb7cda PMPR-32 : GET /ues - liste toutes les UEs 2026-04-22 12:50:46 +02:00
176 changed files with 20653 additions and 960 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
.git
coverage
.env
+8
View File
@@ -0,0 +1,8 @@
#Local mode, set to true to access admin pages with any users
LOCAL=true
POSTGRES_HOST = db
POSTGRES_PORT = 5432
POSTGRES_PASS = astrongpass
POSTGRES_USER = postgres
POSTGRES_DB = polympr
+17
View File
@@ -6,9 +6,26 @@ on:
- main - main
jobs: jobs:
check-code:
name: "Check Deno code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Check formatting
run: deno fmt --check
- name: Check linting
run: deno lint
deploy: deploy:
name: "Build Docker image" name: "Build Docker image"
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: check-code
steps: steps:
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
+4 -3
View File
@@ -4,6 +4,10 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- develop
push:
branches:
- develop
permissions: permissions:
contents: read contents: read
@@ -24,6 +28,3 @@ jobs:
- name: Check linting - name: Check linting
run: deno lint run: deno lint
- name: Run tests
run: deno test -A --no-check tests/
+83
View File
@@ -0,0 +1,83 @@
name: "Tests"
on:
pull_request:
branches:
- main
- develop
push:
branches:
- develop
jobs:
unit:
name: "Unit tests"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Install dependencies
run: deno install
- name: Run unit tests
run: deno task test:unit
integration:
name: "Integration tests"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Start postgres
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq postgresql > /dev/null
PG_VER=$(ls /etc/postgresql/)
sudo sed -i "s/^#*listen_addresses\s*=.*/listen_addresses = '127.0.0.1'/" /etc/postgresql/$PG_VER/main/postgresql.conf
echo "host all all 127.0.0.1/32 md5" | sudo tee -a /etc/postgresql/$PG_VER/main/pg_hba.conf
sudo pg_ctlcluster $PG_VER main restart
until sudo -u postgres pg_isready -h 127.0.0.1; do sleep 1; done
sudo -u postgres psql -c "CREATE USER test WITH PASSWORD 'test';"
sudo -u postgres psql -c "CREATE DATABASE polympr_test OWNER test;"
sudo -u postgres psql -d polympr_test -c "GRANT ALL ON SCHEMA public TO test;"
- name: Apply migrations
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
- name: Run integration tests
env:
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: 5432
POSTGRES_USER: test
POSTGRES_PASS: test
POSTGRES_DB: polympr_test
run: deno task test:integration
- name: Run e2e tests
env:
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: 5432
POSTGRES_USER: test
POSTGRES_PASS: test
POSTGRES_DB: polympr_test
run: deno task test:e2e
+5
View File
@@ -1,7 +1,12 @@
FROM denoland/deno:alpine FROM denoland/deno:alpine
RUN apk add --no-cache nodejs npm
WORKDIR /app WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY . . COPY . .
RUN deno cache main.ts --allow-import RUN deno cache main.ts --allow-import
RUN deno task build RUN deno task build
+158
View File
@@ -0,0 +1,158 @@
# Bug Report — PolyMPR
> Généré le 2026-04-23
---
## 🔴 Critique
### #1 — Schema mismatch : module mobility entièrement cassé
**Fichier** : `routes/(apps)/mobility/api/insert_mobility.ts`
Références à des colonnes inexistantes dans le schéma Drizzle :
| Utilisé dans le code | Colonne réelle |
| ---------------------- | ------------------ |
| `students.userId` | `students.numEtud` |
| `students.firstName` | `students.nom` |
| `students.lastName` | `students.prenom` |
| `students.promotionId` | `students.idPromo` |
| `promotions.endyear` | `promotions.annee` |
| `promotions.current` | _(n'existe pas)_ |
Le module crashe à l'exécution. À corriger en alignant les noms de colonnes avec
le schéma.
---
### #2 — Auth manquante sur de nombreux endpoints
Les endpoints suivants n'ont aucune vérification `eduPersonPrimaryAffiliation` :
- `routes/(apps)/notes/api/notes.ts` (GET, POST)
- `routes/(apps)/notes/api/ue-modules.ts` (GET, POST)
- `routes/(apps)/notes/api/ues.ts` (GET, POST)
- `routes/(apps)/notes/api/ues/[idUE].ts` (GET, PUT, DELETE)
- `routes/(apps)/admin/api/users.ts` (GET, POST)
- `routes/(apps)/admin/api/users/[id].ts` (GET, PUT, DELETE)
- `routes/(apps)/admin/api/modules/[idModule].ts` (GET, PUT, DELETE)
- `routes/(apps)/admin/api/roles.ts` (GET, POST)
- `routes/(apps)/admin/api/roles/[idRole].ts` (GET, PUT, DELETE)
- `routes/(apps)/admin/api/permissions.ts` (GET)
- `routes/(apps)/mobility/api/insert_mobility.ts`
Tous ces endpoints exposent des données sensibles sans vérifier les permissions.
---
## 🟠 Haut
### #3 — Bug Drizzle ORM : `.where()` avec plusieurs `eq()` sans `and()`
**Fichier** : `routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts` — lignes
34, 72, 100
`.where()` n'accepte qu'un seul argument. Passer plusieurs `eq()` séparés par
des virgules ne génère pas le SQL attendu (seule la première condition est prise
en compte).
```ts
// ❌ Incorrect
.where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))
// ✅ Correct
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
```
---
### #4 — Bug Drizzle ORM : `.where()` à 3 conditions sans `and()`
**Fichier** :
`routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts` — handler
GET (~ligne 41)
Même problème que #3, mais avec 3 conditions. Les handlers PUT et DELETE ont
déjà `and()`, seul le GET est affecté.
```ts
// ❌ Incorrect
.where(
eq(ueModules.idModule, idModule),
eq(ueModules.idUE, idUE),
eq(ueModules.idPromo, idPromo),
)
// ✅ Correct
.where(
and(
eq(ueModules.idModule, idModule),
eq(ueModules.idUE, idUE),
eq(ueModules.idPromo, idPromo),
),
)
```
---
## 🟡 Moyen
### #5 — `and()` passé avec des valeurs `undefined`
**Fichier** : `routes/(apps)/notes/api/ue-modules.ts`
```ts
and(
idPromo ? eq(ueModules.idPromo, idPromo) : undefined,
idUE ? eq(ueModules.idUE, idUE) : undefined,
);
```
Drizzle tolère les `undefined` dans `and()` dans certaines versions, mais ce
n'est pas garanti. Mieux vaut construire les conditions dynamiquement avant de
les passer.
---
### #6 — Validation `!numEtud` rejette faussement `0`
**Fichier** : `routes/(apps)/notes/api/notes.ts` — handler POST
```ts
// ❌ Rejette numEtud = 0
if (note === undefined || !numEtud || !idModule)
// ✅ Correct
if (note === undefined || numEtud === undefined || numEtud === null || !idModule)
```
---
### #7 — `Number(idRole)` sans vérification `isNaN`
**Fichier** : `routes/(apps)/admin/api/users.ts`
Si `idRole` est une chaîne non numérique, `Number()` retourne `NaN` ce qui
provoque une erreur SQL.
```ts
// ❌ Pas de vérification
const rows = idRole
? await db.select().from(users).where(eq(users.idRole, Number(idRole)))
: await db.select().from(users);
// ✅ Valider avant usage
const role = Number(idRole);
if (isNaN(role)) return new Response(..., { status: 400 });
```
---
### #8 — Réponses d'erreur en texte brut au lieu de JSON
**Fichier** : `routes/(apps)/notes/api/notes.ts`
Certaines réponses d'erreur retournent une string sans
`content-type: application/json`, incohérent avec le reste de l'API qui retourne
`{ error: "..." }`.
+41
View File
@@ -0,0 +1,41 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASS}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-polympr}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 5s
timeout: 5s
retries: 10
migrate:
image: registry.docker.polytech.djalim.fr/polympr:latest
working_dir: /app
restart: "no"
command: ["node", "node_modules/.bin/drizzle-kit", "migrate"]
env_file: .env
depends_on:
db:
condition: service_healthy
app:
image: registry.docker.polytech.djalim.fr/polympr:latest
restart: unless-stopped
ports:
- "4430:443"
env_file: .env
volumes:
- contracts:/app/uploads/contracts
depends_on:
migrate:
condition: service_completed_successfully
volumes:
db_data:
contracts:
+56
View File
@@ -0,0 +1,56 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: testpass
POSTGRES_USER: postgres
POSTGRES_DB: polympr_test
volumes:
- db_data_test:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
migrate:
image: node:alpine
working_dir: /app
restart: "no"
volumes:
- .:/app
command: node_modules/.bin/drizzle-kit migrate
environment:
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
POSTGRES_USER: postgres
POSTGRES_PASS: testpass
POSTGRES_DB: polympr_test
depends_on:
db:
condition: service_healthy
app:
image: denoland/deno:alpine
working_dir: /app
volumes:
- .:/app
- deno_cache:/deno-dir
command: run -A --unstable-ffi main.ts
ports:
- "4430:443"
environment:
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
POSTGRES_USER: postgres
POSTGRES_PASS: testpass
POSTGRES_DB: polympr_test
LOCAL: "true"
depends_on:
migrate:
condition: service_completed_successfully
volumes:
db_data_test:
deno_cache:
-26
View File
@@ -1,26 +0,0 @@
services:
app:
image: registry.docker.polytech.djalim.fr/polympr:latest
ports:
- "8008:80"
- "4430:443"
volumes:
- /home/kevin/PolyMPR/:/app
command: deno run -A main.ts
deploy:
replicas: 1
placement:
constraints: [node.role == manager]
db:
image: postgres
restart: always
shm_size: 128mb
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASS}
deploy:
replicas: 1
placement:
constraints: [node.role == manager]
+2 -2
View File
@@ -1,5 +1,5 @@
import { drizzle } from "npm:drizzle-orm/node-postgres"; import { drizzle } from "npm:drizzle-orm@0.45.2/node-postgres";
import pg from "npm:pg"; import pg from "npm:pg@8.20.0";
const { Pool } = pg; const { Pool } = pg;
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Applied by postgres on first container startup via /docker-entrypoint-initdb.d.
# drizzle-kit migration files use "--> statement-breakpoint" markers which are
# not valid SQL — strip them before applying.
set -e
for f in /migrations/*.sql; do
echo "Applying $f..."
sed '/^-->/d' "$f" | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"
done
echo "All migrations applied."
@@ -0,0 +1,100 @@
CREATE TABLE "ajustements" (
"numEtud" integer NOT NULL,
"idUE" integer NOT NULL,
"valeur" double precision NOT NULL,
CONSTRAINT "ajustements_numEtud_idUE_pk" PRIMARY KEY("numEtud","idUE")
);
--> statement-breakpoint
CREATE TABLE "enseignements" (
"idProf" text NOT NULL,
"idModule" text NOT NULL,
"idPromo" text NOT NULL,
CONSTRAINT "enseignements_idProf_idModule_idPromo_pk" PRIMARY KEY("idProf","idModule","idPromo")
);
--> statement-breakpoint
CREATE TABLE "mobility" (
"id" serial PRIMARY KEY NOT NULL,
"studentId" integer,
"startDate" date,
"endDate" date,
"weeksCount" integer,
"destinationCountry" text,
"destinationName" text,
"mobilityStatus" text DEFAULT 'N/A'
);
--> statement-breakpoint
CREATE TABLE "modules" (
"id" text PRIMARY KEY NOT NULL,
"nom" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notes" (
"numEtud" integer NOT NULL,
"idModule" text NOT NULL,
"note" double precision NOT NULL,
CONSTRAINT "notes_numEtud_idModule_pk" PRIMARY KEY("numEtud","idModule")
);
--> statement-breakpoint
CREATE TABLE "permissions" (
"id" text PRIMARY KEY NOT NULL,
"nom" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "promotions" (
"idPromo" text PRIMARY KEY NOT NULL,
"annee" text
);
--> statement-breakpoint
CREATE TABLE "role_permissions" (
"idRole" integer NOT NULL,
"idPermission" text NOT NULL,
CONSTRAINT "role_permissions_idRole_idPermission_pk" PRIMARY KEY("idRole","idPermission")
);
--> statement-breakpoint
CREATE TABLE "roles" (
"id" serial PRIMARY KEY NOT NULL,
"nom" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "students" (
"numEtud" serial PRIMARY KEY NOT NULL,
"nom" text NOT NULL,
"prenom" text NOT NULL,
"idPromo" text
);
--> statement-breakpoint
CREATE TABLE "ue_modules" (
"idModule" text NOT NULL,
"idUE" integer NOT NULL,
"idPromo" text NOT NULL,
"coeff" double precision NOT NULL,
CONSTRAINT "ue_modules_idModule_idUE_idPromo_pk" PRIMARY KEY("idModule","idUE","idPromo")
);
--> statement-breakpoint
CREATE TABLE "ues" (
"id" serial PRIMARY KEY NOT NULL,
"nom" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"nom" text NOT NULL,
"prenom" text NOT NULL,
"idRole" integer
);
--> statement-breakpoint
ALTER TABLE "ajustements" ADD CONSTRAINT "ajustements_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ajustements" ADD CONSTRAINT "ajustements_idUE_ues_id_fk" FOREIGN KEY ("idUE") REFERENCES "public"."ues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idProf_users_id_fk" FOREIGN KEY ("idProf") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mobility" ADD CONSTRAINT "mobility_studentId_students_numEtud_fk" FOREIGN KEY ("studentId") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notes" ADD CONSTRAINT "notes_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notes" ADD CONSTRAINT "notes_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_idRole_roles_id_fk" FOREIGN KEY ("idRole") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_idPermission_permissions_id_fk" FOREIGN KEY ("idPermission") REFERENCES "public"."permissions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "students" ADD CONSTRAINT "students_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idUE_ues_id_fk" FOREIGN KEY ("idUE") REFERENCES "public"."ues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_idRole_roles_id_fk" FOREIGN KEY ("idRole") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;
@@ -0,0 +1,11 @@
--> statement-breakpoint
INSERT INTO "permissions" ("id", "nom") VALUES
('note_read', 'Consulter les notes des étudiants'),
('note_write', 'Saisir et modifier les notes'),
('student_read', 'Consulter la liste des étudiants'),
('student_write','Gérer les étudiants (ajout, modification, suppression)'),
('module_read', 'Consulter les modules et enseignements'),
('module_write', 'Gérer les modules et enseignements'),
('user_read', 'Consulter les utilisateurs et leurs rôles'),
('user_write', 'Gérer les utilisateurs et leurs rôles'),
('role_write', 'Gérer les rôles et leurs permissions');
@@ -0,0 +1,14 @@
-- Update permission names to French
-- This migration inserts or updates the permission labels used by the API.
--> statement-breakpoint
INSERT INTO "permissions" ("id", "nom") VALUES
('note_read', 'Consulter les notes des étudiants'),
('note_write', 'Saisir et modifier les notes'),
('student_read', 'Consulter la liste des étudiants'),
('student_write','Gérer les étudiants (ajout, modification, suppression)'),
('module_read', 'Consulter les modules et enseignements'),
('module_write', 'Gérer les modules et enseignements'),
('user_read', 'Consulter les utilisateurs et leurs rôles'),
('user_write', 'Gérer les utilisateurs et leurs rôles'),
('role_write', 'Gérer les rôles et leurs permissions')
ON CONFLICT ("id") DO UPDATE SET "nom" = EXCLUDED."nom";
@@ -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;
@@ -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;
@@ -0,0 +1,680 @@
{
"id": "bd317b68-1c46-4e83-b4d3-a14f68751afb",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ajustements": {
"name": "ajustements",
"schema": "",
"columns": {
"numEtud": {
"name": "numEtud",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"idUE": {
"name": "idUE",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"valeur": {
"name": "valeur",
"type": "double precision",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"ajustements_numEtud_students_numEtud_fk": {
"name": "ajustements_numEtud_students_numEtud_fk",
"tableFrom": "ajustements",
"tableTo": "students",
"columnsFrom": [
"numEtud"
],
"columnsTo": [
"numEtud"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"ajustements_idUE_ues_id_fk": {
"name": "ajustements_idUE_ues_id_fk",
"tableFrom": "ajustements",
"tableTo": "ues",
"columnsFrom": [
"idUE"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"ajustements_numEtud_idUE_pk": {
"name": "ajustements_numEtud_idUE_pk",
"columns": [
"numEtud",
"idUE"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.enseignements": {
"name": "enseignements",
"schema": "",
"columns": {
"idProf": {
"name": "idProf",
"type": "text",
"primaryKey": false,
"notNull": true
},
"idModule": {
"name": "idModule",
"type": "text",
"primaryKey": false,
"notNull": true
},
"idPromo": {
"name": "idPromo",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"enseignements_idProf_users_id_fk": {
"name": "enseignements_idProf_users_id_fk",
"tableFrom": "enseignements",
"tableTo": "users",
"columnsFrom": [
"idProf"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"enseignements_idModule_modules_id_fk": {
"name": "enseignements_idModule_modules_id_fk",
"tableFrom": "enseignements",
"tableTo": "modules",
"columnsFrom": [
"idModule"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"enseignements_idPromo_promotions_idPromo_fk": {
"name": "enseignements_idPromo_promotions_idPromo_fk",
"tableFrom": "enseignements",
"tableTo": "promotions",
"columnsFrom": [
"idPromo"
],
"columnsTo": [
"idPromo"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"enseignements_idProf_idModule_idPromo_pk": {
"name": "enseignements_idProf_idModule_idPromo_pk",
"columns": [
"idProf",
"idModule",
"idPromo"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.mobility": {
"name": "mobility",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"studentId": {
"name": "studentId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"startDate": {
"name": "startDate",
"type": "date",
"primaryKey": false,
"notNull": false
},
"endDate": {
"name": "endDate",
"type": "date",
"primaryKey": false,
"notNull": false
},
"weeksCount": {
"name": "weeksCount",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"destinationCountry": {
"name": "destinationCountry",
"type": "text",
"primaryKey": false,
"notNull": false
},
"destinationName": {
"name": "destinationName",
"type": "text",
"primaryKey": false,
"notNull": false
},
"mobilityStatus": {
"name": "mobilityStatus",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "'N/A'"
}
},
"indexes": {},
"foreignKeys": {
"mobility_studentId_students_numEtud_fk": {
"name": "mobility_studentId_students_numEtud_fk",
"tableFrom": "mobility",
"tableTo": "students",
"columnsFrom": [
"studentId"
],
"columnsTo": [
"numEtud"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.modules": {
"name": "modules",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"nom": {
"name": "nom",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notes": {
"name": "notes",
"schema": "",
"columns": {
"numEtud": {
"name": "numEtud",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"idModule": {
"name": "idModule",
"type": "text",
"primaryKey": false,
"notNull": true
},
"note": {
"name": "note",
"type": "double precision",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"notes_numEtud_students_numEtud_fk": {
"name": "notes_numEtud_students_numEtud_fk",
"tableFrom": "notes",
"tableTo": "students",
"columnsFrom": [
"numEtud"
],
"columnsTo": [
"numEtud"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"notes_idModule_modules_id_fk": {
"name": "notes_idModule_modules_id_fk",
"tableFrom": "notes",
"tableTo": "modules",
"columnsFrom": [
"idModule"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"notes_numEtud_idModule_pk": {
"name": "notes_numEtud_idModule_pk",
"columns": [
"numEtud",
"idModule"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.permissions": {
"name": "permissions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"nom": {
"name": "nom",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.promotions": {
"name": "promotions",
"schema": "",
"columns": {
"idPromo": {
"name": "idPromo",
"type": "text",
"primaryKey": true,
"notNull": true
},
"annee": {
"name": "annee",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.role_permissions": {
"name": "role_permissions",
"schema": "",
"columns": {
"idRole": {
"name": "idRole",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"idPermission": {
"name": "idPermission",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"role_permissions_idRole_roles_id_fk": {
"name": "role_permissions_idRole_roles_id_fk",
"tableFrom": "role_permissions",
"tableTo": "roles",
"columnsFrom": [
"idRole"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"role_permissions_idPermission_permissions_id_fk": {
"name": "role_permissions_idPermission_permissions_id_fk",
"tableFrom": "role_permissions",
"tableTo": "permissions",
"columnsFrom": [
"idPermission"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"role_permissions_idRole_idPermission_pk": {
"name": "role_permissions_idRole_idPermission_pk",
"columns": [
"idRole",
"idPermission"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.roles": {
"name": "roles",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"nom": {
"name": "nom",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.students": {
"name": "students",
"schema": "",
"columns": {
"numEtud": {
"name": "numEtud",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"nom": {
"name": "nom",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prenom": {
"name": "prenom",
"type": "text",
"primaryKey": false,
"notNull": true
},
"idPromo": {
"name": "idPromo",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"students_idPromo_promotions_idPromo_fk": {
"name": "students_idPromo_promotions_idPromo_fk",
"tableFrom": "students",
"tableTo": "promotions",
"columnsFrom": [
"idPromo"
],
"columnsTo": [
"idPromo"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ue_modules": {
"name": "ue_modules",
"schema": "",
"columns": {
"idModule": {
"name": "idModule",
"type": "text",
"primaryKey": false,
"notNull": true
},
"idUE": {
"name": "idUE",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"idPromo": {
"name": "idPromo",
"type": "text",
"primaryKey": false,
"notNull": true
},
"coeff": {
"name": "coeff",
"type": "double precision",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"ue_modules_idModule_modules_id_fk": {
"name": "ue_modules_idModule_modules_id_fk",
"tableFrom": "ue_modules",
"tableTo": "modules",
"columnsFrom": [
"idModule"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"ue_modules_idUE_ues_id_fk": {
"name": "ue_modules_idUE_ues_id_fk",
"tableFrom": "ue_modules",
"tableTo": "ues",
"columnsFrom": [
"idUE"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"ue_modules_idPromo_promotions_idPromo_fk": {
"name": "ue_modules_idPromo_promotions_idPromo_fk",
"tableFrom": "ue_modules",
"tableTo": "promotions",
"columnsFrom": [
"idPromo"
],
"columnsTo": [
"idPromo"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"ue_modules_idModule_idUE_idPromo_pk": {
"name": "ue_modules_idModule_idUE_idPromo_pk",
"columns": [
"idModule",
"idUE",
"idPromo"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ues": {
"name": "ues",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"nom": {
"name": "nom",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"nom": {
"name": "nom",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prenom": {
"name": "prenom",
"type": "text",
"primaryKey": false,
"notNull": true
},
"idRole": {
"name": "idRole",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"users_idRole_roles_id_fk": {
"name": "users_idRole_roles_id_fk",
"tableFrom": "users",
"tableTo": "roles",
"columnsFrom": [
"idRole"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1777155028708,
"tag": "0000_square_jetstream",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777155028709,
"tag": "0001_seed_permissions",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1777155028710,
"tag": "0002_update_permission_names",
"breakpoints": true
},
{
"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
}
]
}
+99
View File
@@ -0,0 +1,99 @@
import {
date,
doublePrecision,
integer,
pgTable,
primaryKey,
serial,
text,
} from "drizzle-orm/pg-core";
export const roles = pgTable("roles", {
id: serial("id").primaryKey(),
nom: text("nom").notNull(),
});
export const permissions = pgTable("permissions", {
id: text("id").primaryKey(),
nom: text("nom").notNull(),
});
export const rolePermissions = pgTable("role_permissions", {
idRole: integer("idRole").notNull().references(() => roles.id),
idPermission: text("idPermission").notNull().references(() => permissions.id),
}, (t) => ({
pk: primaryKey({ columns: [t.idRole, t.idPermission] }),
}));
export const users = pgTable("users", {
id: text("id").primaryKey(),
nom: text("nom").notNull(),
prenom: text("prenom").notNull(),
idRole: integer("idRole").references(() => roles.id),
});
export const promotions = pgTable("promotions", {
id: text("idPromo").primaryKey(),
annee: text("annee"),
});
export const students = pgTable("students", {
numEtud: serial("numEtud").primaryKey(),
nom: text("nom").notNull(),
prenom: text("prenom").notNull(),
idPromo: text("idPromo").references(() => promotions.id),
});
export const modules = pgTable("modules", {
id: text("id").primaryKey(),
nom: text("nom").notNull(),
});
export const enseignements = pgTable("enseignements", {
idProf: text("idProf").notNull().references(() => users.id),
idModule: text("idModule").notNull().references(() => modules.id),
idPromo: text("idPromo").notNull().references(() => promotions.id),
}, (t) => ({
pk: primaryKey({ columns: [t.idProf, t.idModule, t.idPromo] }),
}));
export const ues = pgTable("ues", {
id: serial("id").primaryKey(),
nom: text("nom").notNull(),
});
export const ueModules = pgTable("ue_modules", {
idModule: text("idModule").notNull().references(() => modules.id),
idUE: integer("idUE").notNull().references(() => ues.id),
idPromo: text("idPromo").notNull().references(() => promotions.id),
coeff: doublePrecision("coeff").notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.idModule, t.idUE, t.idPromo] }),
}));
export const notes = pgTable("notes", {
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
idModule: text("idModule").notNull().references(() => modules.id),
note: doublePrecision("note").notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.numEtud, t.idModule] }),
}));
export const ajustements = pgTable("ajustements", {
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
idUE: integer("idUE").notNull().references(() => ues.id),
valeur: doublePrecision("valeur").notNull(),
}, (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"),
});
+104 -19
View File
@@ -1,32 +1,117 @@
import { import {
date, doublePrecision,
integer, integer,
pgEnum,
pgTable, pgTable,
primaryKey,
serial, serial,
text, text,
} from "npm:drizzle-orm/pg-core"; } from "npm:drizzle-orm@0.45.2/pg-core";
export const roles = pgTable("roles", {
id: serial("id").primaryKey(),
nom: text("nom").notNull(),
});
export const permissions = pgTable("permissions", {
id: text("id").primaryKey(),
nom: text("nom").notNull(),
});
export const rolePermissions = pgTable("role_permissions", {
idRole: integer("idRole").notNull().references(() => roles.id),
idPermission: text("idPermission").notNull().references(() => permissions.id),
}, (t) => ({
pk: primaryKey({ columns: [t.idRole, t.idPermission] }),
}));
export const users = pgTable("users", {
id: text("id").primaryKey(),
nom: text("nom").notNull(),
prenom: text("prenom").notNull(),
idRole: integer("idRole").references(() => roles.id),
});
export const promotions = pgTable("promotions", { export const promotions = pgTable("promotions", {
id: serial("id").primaryKey(), id: text("idPromo").primaryKey(),
endyear: integer("endyear"), annee: text("annee"),
current: integer("current"),
}); });
export const students = pgTable("students", { export const students = pgTable("students", {
userId: text("userId").primaryKey(), numEtud: serial("numEtud").primaryKey(),
firstName: text("firstName"), nom: text("nom").notNull(),
lastName: text("lastName"), prenom: text("prenom").notNull(),
mail: text("mail"), idPromo: text("idPromo").references(() => promotions.id),
promotionId: integer("promotionId").references(() => promotions.id),
}); });
export const mobility = pgTable("mobility", { export const modules = pgTable("modules", {
id: serial("id").primaryKey(), id: text("id").primaryKey(),
studentId: text("studentId").references(() => students.userId), nom: text("nom").notNull(),
startDate: date("startDate"), });
endDate: date("endDate"),
weeksCount: integer("weeksCount"), export const enseignements = pgTable("enseignements", {
destinationCountry: text("destinationCountry"), idProf: text("idProf").notNull().references(() => users.id),
destinationName: text("destinationName"), idModule: text("idModule").notNull().references(() => modules.id),
mobilityStatus: text("mobilityStatus").default("N/A"), idPromo: text("idPromo").notNull().references(() => promotions.id),
}, (t) => ({
pk: primaryKey({ columns: [t.idProf, t.idModule, t.idPromo] }),
}));
export const ues = pgTable("ues", {
id: serial("id").primaryKey(),
nom: text("nom").notNull(),
});
export const ueModules = pgTable("ue_modules", {
idModule: text("idModule").notNull().references(() => modules.id),
idUE: integer("idUE").notNull().references(() => ues.id),
idPromo: text("idPromo").notNull().references(() => promotions.id),
coeff: doublePrecision("coeff").notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.idModule, t.idUE, t.idPromo] }),
}));
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] }),
}));
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 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),
}); });
+102
View File
@@ -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 (
<div class="import-popup-overlay" onClick={onClose}>
<div class="import-popup" onClick={(e) => e.stopPropagation()}>
<div class="import-popup-header">
<h3 class="import-popup-title">Resultats de l'import</h3>
<span
class={`import-popup-badge ${
hasErrors ? "badge-error" : "badge-success"
}`}
>
{hasErrors ? "Erreur" : "Succes"}
</span>
</div>
<div class="import-popup-stats">
<div class="import-stat-row">
<span class="import-stat-label">Ajoutes</span>
<span class="import-stat-value stat-added">
{result.added} note{result.added !== 1 ? "s" : ""}
</span>
</div>
<div class="import-stat-row">
<span class="import-stat-label">Modifies</span>
<span class="import-stat-value stat-modified">
{result.modified} note{result.modified !== 1 ? "s" : ""}
</span>
</div>
<div class="import-stat-row">
<span class="import-stat-label">Ignores</span>
<span class="import-stat-value stat-ignored">
{result.ignored} note{result.ignored !== 1 ? "s" : ""}
</span>
</div>
<div class="import-stat-row">
<span class="import-stat-label">Erreurs</span>
<span class="import-stat-value stat-errors">
{result.errors} note{result.errors !== 1 ? "s" : ""}
</span>
</div>
</div>
<div class="import-popup-actions">
{result.details.length > 0 && (
<button
type="button"
class="btn btn-secondary"
onClick={() => setShowDetails(!showDetails)}
>
Details {showDetails ? "\u25B3" : "\u25BD"}
</button>
)}
<button
type="button"
class="btn btn-primary"
onClick={onClose}
>
Ok
</button>
</div>
{showDetails && result.details.length > 0 && (
<div class="import-popup-details">
{changes.length > 0 &&
changes.map((d, i) => (
<p key={`c-${i}`} class="import-detail-change">{d.message}</p>
))}
{errors.length > 0 &&
errors.map((d, i) => (
<p key={`e-${i}`} class="import-detail-error">{d.message}</p>
))}
</div>
)}
</div>
</div>
);
}
+7
View File
@@ -19,6 +19,8 @@ export interface AppProperties {
icon: string; icon: string;
pages: Record<string, string>; pages: Record<string, string>;
adminOnly: string[]; adminOnly: string[];
studentOnly?: string[];
employeeOnly?: boolean;
hint: string; hint: string;
} }
@@ -61,6 +63,11 @@ export interface LoginJWT {
user: CasContent; user: CasContent;
} }
export function isEmployee(session: CasContent): boolean {
return session.eduPersonPrimaryAffiliation === "employee" ||
session.eduPersonPrimaryAffiliation === "faculty";
}
export type EmptyObject = Record<string | number | symbol, never>; export type EmptyObject = Record<string | number | symbol, never>;
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
+62
View File
@@ -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<State>,
): Promise<ComponentChildren | Response> {
const slug = context.params.slug;
// Try partials/<slug>.tsx, then partials/(admin)/<slug>.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/<dir>/[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);
};
}
+8 -1
View File
@@ -10,7 +10,14 @@
"build": "deno run -A --unstable-ffi dev.ts build", "build": "deno run -A --unstable-ffi dev.ts build",
"preview": "deno run -A --unstable-ffi main.ts", "preview": "deno run -A --unstable-ffi main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update .", "update": "deno run -A -r https://fresh.deno.dev/update .",
"test": "deno test -A --no-check tests/" "test": "deno test -A --no-check tests/",
"test:unit": "deno test -A --no-check tests/unit/",
"test:database": "deno test -A --no-check tests/database/",
"test:integration": "deno test -A --no-check tests/integration/",
"test:e2e": "deno test -A --no-check tests/e2e/",
"test:coverage": "deno test -A --no-check --coverage=coverage tests/ && deno coverage coverage --exclude=tests/",
"test:coverage:html": "deno test -A --no-check --coverage=coverage tests/ && deno coverage coverage --exclude=tests/ --html",
"migrate": "node_modules/.bin/drizzle-kit migrate"
}, },
"lint": { "lint": {
"rules": { "rules": {
+9 -6
View File
@@ -1,14 +1,17 @@
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
import process from "node:process";
const url = process.env.DATABASE_URL ??
`postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASS}@${
process.env.POSTGRES_HOST ?? "localhost"
}:${process.env.POSTGRES_PORT ?? 5432}/${process.env.POSTGRES_DB}`;
export default defineConfig({ export default defineConfig({
dialect: "postgresql", dialect: "postgresql",
schema: "./databases/schema.ts", schema: "./databases/schema.kit.ts",
out: "./databases/migrations", out: "./databases/migrations",
dbCredentials: { dbCredentials: {
host: process.env.POSTGRES_HOST!, url,
port: Number(process.env.POSTGRES_PORT ?? 5432), ssl: false,
user: process.env.POSTGRES_USER!,
password: process.env.POSTGRES_PASS!,
database: process.env.POSTGRES_DB!,
}, },
}); });
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1776548001,
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+62
View File
@@ -0,0 +1,62 @@
{
description = "PolyMPR CLI - A tool for managing PolyMPR modules";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages.pmpr = pkgs.stdenv.mkDerivation {
pname = "pmpr";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [
pkgs.deno
pkgs.autoPatchelfHook
];
buildInputs = [
pkgs.stdenv.cc.cc.lib
];
buildPhase = ''
export HOME=$TMPDIR
deno cache toolbox/cli.ts
deno compile -A --output pmpr toolbox/cli.ts
'';
installPhase = ''
mkdir -p $out/bin
cp pmpr $out/bin/pmpr
'';
};
packages.default = self.packages.${system}.pmpr;
devShells.default = pkgs.mkShell {
nativeBuildInputs = [
pkgs.deno
pkgs.patchelf
];
buildInputs = [
pkgs.stdenv.cc.cc.lib
];
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH"
export NIX_LD_INTERPRETER=$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)
echo "Welcome to PolyMPR development shell!"
echo "Use 'deno task compile' to build the CLI."
'';
};
}
);
}
+1 -3
View File
@@ -6,8 +6,6 @@ await load({ envPath: "./.env", export: true });
await ensureDatabases(); await ensureDatabases();
export default defineConfig({ export default defineConfig({
server: { server: {
cert: await Deno.readTextFile("certs/cert.pem"), port: 80,
key: await Deno.readTextFile("certs/key.pem"),
port: 443,
}, },
}); });
+190 -15
View File
@@ -4,16 +4,71 @@
import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_layout from "./routes/(apps)/_layout.tsx";
import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts";
import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.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";
import * as $_apps_admin_api_modules from "./routes/(apps)/admin/api/modules.ts";
import * as $_apps_admin_api_modules_idModule_ from "./routes/(apps)/admin/api/modules/[idModule].ts";
import * as $_apps_admin_api_permissions from "./routes/(apps)/admin/api/permissions.ts";
import * as $_apps_admin_api_roles from "./routes/(apps)/admin/api/roles.ts";
import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles/[idRole].ts";
import * as $_apps_admin_api_ue_modules from "./routes/(apps)/admin/api/ue-modules.ts";
import * as $_apps_admin_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
import * as $_apps_admin_api_ues from "./routes/(apps)/admin/api/ues.ts";
import * as $_apps_admin_api_ues_idUE_ from "./routes/(apps)/admin/api/ues/[idUE].ts";
import * as $_apps_admin_api_users from "./routes/(apps)/admin/api/users.ts";
import * as $_apps_admin_api_users_id_ from "./routes/(apps)/admin/api/users/[id].ts";
import * as $_apps_admin_index from "./routes/(apps)/admin/index.tsx";
import * as $_apps_admin_modules_idModule_ from "./routes/(apps)/admin/modules/[idModule].tsx";
import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx";
import * as $_apps_admin_partials_import_maquette from "./routes/(apps)/admin/partials/import-maquette.tsx";
import * as $_apps_admin_partials_index from "./routes/(apps)/admin/partials/index.tsx";
import * as $_apps_admin_partials_modules from "./routes/(apps)/admin/partials/modules.tsx";
import * as $_apps_admin_partials_permissions from "./routes/(apps)/admin/partials/permissions.tsx";
import * as $_apps_admin_partials_promotions from "./routes/(apps)/admin/partials/promotions.tsx";
import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx";
import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx";
import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx";
import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx";
import * as $_apps_mobility_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_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_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 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_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_index from "./routes/(apps)/notes/index.tsx";
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_admin_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_index from "./routes/(apps)/notes/partials/index.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_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"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts";
import * as $_apps_students_api_students_numEtud_ from "./routes/(apps)/students/api/students/[numEtud].ts";
import * as $_apps_students_api_students_import_csv from "./routes/(apps)/students/api/students/import-csv.ts";
import * as $_apps_students_edit_numEtud_ from "./routes/(apps)/students/edit/[numEtud].tsx";
import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_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_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx";
import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx";
@@ -24,14 +79,28 @@ import * as $_app from "./routes/_app.tsx";
import * as $_middleware from "./routes/_middleware.ts"; import * as $_middleware from "./routes/_middleware.ts";
import * as $about from "./routes/about.tsx"; import * as $about from "./routes/about.tsx";
import * as $apps from "./routes/apps.tsx"; import * as $apps from "./routes/apps.tsx";
import * as $dev_login from "./routes/dev-login.ts";
import * as $index from "./routes/index.tsx"; import * as $index from "./routes/index.tsx";
import * as $login from "./routes/login.tsx"; import * as $login from "./routes/login.tsx";
import * as $logout from "./routes/logout.tsx"; import * as $logout from "./routes/logout.tsx";
import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx"; import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx";
import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx"; import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx";
import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; import * as $_apps_admin_islands_AdminEnseignements from "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx";
import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx"; import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_islands)/AdminModules.tsx";
import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx"; import * as $_apps_admin_islands_AdminPermissions from "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx";
import * as $_apps_admin_islands_AdminPromotions from "./routes/(apps)/admin/(_islands)/AdminPromotions.tsx";
import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx";
import * as $_apps_admin_islands_AdminUEs from "./routes/(apps)/admin/(_islands)/AdminUEs.tsx";
import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx";
import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx";
import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx";
import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx";
import * as $_apps_mobility_islands_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_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_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx";
import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx"; import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx";
@@ -41,21 +110,100 @@ const manifest = {
routes: { routes: {
"./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_layout.tsx": $_apps_layout,
"./routes/(apps)/_middleware.ts": $_apps_middleware, "./routes/(apps)/_middleware.ts": $_apps_middleware,
"./routes/(apps)/mobility/api/insert_mobility.ts": "./routes/(apps)/admin/[slug].tsx": $_apps_admin_slug_,
$_apps_mobility_api_insert_mobility, "./routes/(apps)/admin/api/enseignements.ts":
$_apps_admin_api_enseignements,
"./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts":
$_apps_admin_api_enseignements_idProf_idModule_idPromo_,
"./routes/(apps)/admin/api/example.ts": $_apps_admin_api_example,
"./routes/(apps)/admin/api/modules.ts": $_apps_admin_api_modules,
"./routes/(apps)/admin/api/modules/[idModule].ts":
$_apps_admin_api_modules_idModule_,
"./routes/(apps)/admin/api/permissions.ts": $_apps_admin_api_permissions,
"./routes/(apps)/admin/api/roles.ts": $_apps_admin_api_roles,
"./routes/(apps)/admin/api/roles/[idRole].ts":
$_apps_admin_api_roles_idRole_,
"./routes/(apps)/admin/api/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)/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/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": "./routes/(apps)/mobility/partials/index.tsx":
$_apps_mobility_partials_index, $_apps_mobility_partials_index,
"./routes/(apps)/mobility/partials/overview.tsx": "./routes/(apps)/mobility/partials/overview.tsx":
$_apps_mobility_partials_overview, $_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/ues.ts": $_apps_notes_api_ues,
"./routes/(apps)/notes/edition/[numEtud].tsx":
$_apps_notes_edition_numEtud_,
"./routes/(apps)/notes/index.tsx": $_apps_notes_index, "./routes/(apps)/notes/index.tsx": $_apps_notes_index,
"./routes/(apps)/notes/partials/(admin)/courses.tsx": "./routes/(apps)/notes/partials/(admin)/courses.tsx":
$_apps_notes_partials_admin_courses, $_apps_notes_partials_admin_courses,
"./routes/(apps)/notes/partials/(admin)/import.tsx":
$_apps_notes_partials_admin_import,
"./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index,
"./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./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":
$_apps_students_api_promotions_idPromo_,
"./routes/(apps)/students/api/students.ts": $_apps_students_api_students, "./routes/(apps)/students/api/students.ts": $_apps_students_api_students,
"./routes/(apps)/students/api/students/[numEtud].ts":
$_apps_students_api_students_numEtud_,
"./routes/(apps)/students/api/students/import-csv.ts":
$_apps_students_api_students_import_csv,
"./routes/(apps)/students/edit/[numEtud].tsx":
$_apps_students_edit_numEtud_,
"./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/index.tsx": $_apps_students_index,
"./routes/(apps)/students/partials/(admin)/consult.tsx": "./routes/(apps)/students/partials/(admin)/consult.tsx":
$_apps_students_partials_admin_consult, $_apps_students_partials_admin_consult,
@@ -69,6 +217,7 @@ const manifest = {
"./routes/_middleware.ts": $_middleware, "./routes/_middleware.ts": $_middleware,
"./routes/about.tsx": $about, "./routes/about.tsx": $about,
"./routes/apps.tsx": $apps, "./routes/apps.tsx": $apps,
"./routes/dev-login.ts": $dev_login,
"./routes/index.tsx": $index, "./routes/index.tsx": $index,
"./routes/login.tsx": $login, "./routes/login.tsx": $login,
"./routes/logout.tsx": $logout, "./routes/logout.tsx": $logout,
@@ -76,12 +225,38 @@ const manifest = {
islands: { islands: {
"./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator, "./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator,
"./routes/(_islands)/Navbar.tsx": $_islands_Navbar, "./routes/(_islands)/Navbar.tsx": $_islands_Navbar,
"./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx": "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx":
$_apps_mobility_islands_ConsultMobility, $_apps_admin_islands_AdminEnseignements,
"./routes/(apps)/mobility/(_islands)/EditMobility.tsx": "./routes/(apps)/admin/(_islands)/AdminModules.tsx":
$_apps_mobility_islands_EditMobility, $_apps_admin_islands_AdminModules,
"./routes/(apps)/mobility/(_islands)/ImportFile.tsx": "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx":
$_apps_mobility_islands_ImportFile, $_apps_admin_islands_AdminPermissions,
"./routes/(apps)/admin/(_islands)/AdminPromotions.tsx":
$_apps_admin_islands_AdminPromotions,
"./routes/(apps)/admin/(_islands)/AdminRoles.tsx":
$_apps_admin_islands_AdminRoles,
"./routes/(apps)/admin/(_islands)/AdminUEs.tsx":
$_apps_admin_islands_AdminUEs,
"./routes/(apps)/admin/(_islands)/AdminUsers.tsx":
$_apps_admin_islands_AdminUsers,
"./routes/(apps)/admin/(_islands)/EditModule.tsx":
$_apps_admin_islands_EditModule,
"./routes/(apps)/admin/(_islands)/EditUser.tsx":
$_apps_admin_islands_EditUser,
"./routes/(apps)/admin/(_islands)/ImportMaquette.tsx":
$_apps_admin_islands_ImportMaquette,
"./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx":
$_apps_mobility_islands_MobilityOverview,
"./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx":
$_apps_notes_islands_AdminConsultNotes,
"./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)/stages/(_islands)/StagesOverview.tsx":
$_apps_stages_islands_StagesOverview,
"./routes/(apps)/students/(_islands)/ConsultStudents.tsx": "./routes/(apps)/students/(_islands)/ConsultStudents.tsx":
$_apps_students_islands_ConsultStudents, $_apps_students_islands_ConsultStudents,
"./routes/(apps)/students/(_islands)/EditStudents.tsx": "./routes/(apps)/students/(_islands)/EditStudents.tsx":
+85
View File
@@ -0,0 +1,85 @@
/**
* Logique métier pour le calcul des notes et moyennes.
*/
export interface Note {
note: number;
noteSession2: number | null;
}
export interface UEModule {
idModule: string;
coeff: number;
}
export interface Ajustement {
valeur: number;
malus: number;
}
/**
* Retourne la note effective (Session 2 si présente, sinon Session 1).
*/
export function getEffectiveNote(n: Note): number {
return n.noteSession2 ?? n.note;
}
/**
* Calcule la moyenne pondérée d'une liste de modules.
* Retourne null si aucun module n'est noté.
*/
export function calculateWeightedAverage(
ueModules: UEModule[],
notesMap: Record<string, Note>,
): number | null {
let weightedSum = 0;
let coveredCoeff = 0;
for (const um of ueModules) {
const noteObj = notesMap[um.idModule];
if (noteObj) {
const val = getEffectiveNote(noteObj);
weightedSum += val * um.coeff;
coveredCoeff += um.coeff;
}
}
if (coveredCoeff === 0) return null;
return weightedSum / coveredCoeff;
}
/**
* Applique l'ajustement et le malus à une moyenne.
* L'ajustement REMPLACE la moyenne calculée si présent.
*/
export function applyAjustement(
calculatedAvg: number | null,
ajustement: Ajustement | null,
): number | null {
let finalAvg = calculatedAvg;
if (ajustement) {
// L'ajustement remplace la moyenne
finalAvg = ajustement.valeur;
if (ajustement.malus > 0) {
finalAvg = (finalAvg ?? 0) - ajustement.malus;
}
}
return finalAvg;
}
/**
* Arrondit une note à 2 décimales.
*/
export function roundGrade(grade: number): number {
return Math.round(grade * 100) / 100;
}
/**
* Formate une note pour l'affichage (2 décimales).
*/
export function formatGrade(grade: number | null): string {
if (grade === null) return "—";
return grade.toFixed(2);
}
+90
View File
@@ -0,0 +1,90 @@
// @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";
export type ParsedUE = {
code: string | null;
name: string;
ects: number | null;
modules: ParsedModule[];
};
export type ParsedModule = {
code: string;
name: string;
coeff: number;
};
export type ParsedYear = {
label: string;
ues: ParsedUE[];
};
/**
* Analyse un classeur Excel pour en extraire la maquette pédagogique.
*/
export 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;
}
+1536
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"dependencies": { "dependencies": {
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"pg": "^8.20.0" "pg": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"drizzle-kit": "^0.31.10",
"tsx": "^4.21.0" "tsx": "^4.21.0"
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Footer(_props: FooterProps) {
return ( return (
<footer> <footer>
<p> <p>
&copy; 2025 PolyMPR - <a href="/about" f-client-nav={false}>About</a> &copy; 2026 PolyMPR - <a href="/about" f-client-nav={false}>About</a>
</p> </p>
</footer> </footer>
); );
+8
View File
@@ -11,6 +11,14 @@ export default function Header(props: HeaderProps) {
<nav> <nav>
<a href="/apps" f-client-nav={false}>Catalog</a> <a href="/apps" f-client-nav={false}>Catalog</a>
<a href={`/log${props.link}`} f-client-nav={false}>Log {props.link}</a> <a href={`/log${props.link}`} f-client-nav={false}>Log {props.link}</a>
<button
id="theme-toggle"
type="button"
title="Changer de theme"
style="background:none;border:none;cursor:pointer;font-size:1.2rem;padding:0;line-height:1;"
>
<span class="material-symbols-outlined">dark_mode</span>
</button>
</nav> </nav>
</header> </header>
); );
+20 -5
View File
@@ -21,14 +21,29 @@ export const handler: MiddlewareHandler<AuthenticatedState>[] = [
`./${currentApp}/(_props)/props.ts` `./${currentApp}/(_props)/props.ts`
)).default; )).default;
context.state.availablePages = properties.pages; const isStudent =
if ( context.state.session.eduPersonPrimaryAffiliation === "student";
context.state.session.eduPersonPrimaryAffiliation == "student" && const isLocal = Deno.env.get("LOCAL") === "true";
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) => properties.adminOnly.forEach((page) =>
delete context.state.availablePages[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(); return await context.next();
@@ -0,0 +1,331 @@
import { useEffect, useState } from "preact/hooks";
type Enseignement = { idProf: string; idModule: string; idPromo: string };
type Module = { id: string; nom: string };
type Promo = { id: string; annee: string };
export default function AdminEnseignements() {
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
const [modules, setModules] = useState<Module[]>([]);
const [promos, setPromos] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [filterPromo, setFilterPromo] = useState("");
const [filterModule, setFilterModule] = useState("");
const [filterEnseignant, setFilterEnseignant] = useState("");
// Add form
const [showAdd, setShowAdd] = useState(false);
const [addPromo, setAddPromo] = useState("");
const [addModule, setAddModule] = useState("");
const [addProf, setAddProf] = useState("");
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
async function load() {
try {
const [eRes, mRes, pRes] = await Promise.all([
fetch("/admin/api/enseignements"),
fetch("/admin/api/modules"),
fetch("/students/api/promotions"),
]);
if (!eRes.ok) throw new Error("Impossible de charger les enseignements");
setEnseignements(await eRes.json());
if (mRes.ok) setModules(await mRes.json());
if (pRes.ok) setPromos(await pRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function deleteEnseignement(
idProf: string,
idModule: string,
idPromo: string,
) {
if (
!confirm(
`Supprimer l'assignation ${idProf}${idModule} / ${idPromo} ?`,
)
) return;
try {
const res = await fetch(
`/admin/api/enseignements/${encodeURIComponent(idProf)}/${
encodeURIComponent(idModule)
}/${encodeURIComponent(idPromo)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function addEnseignement() {
if (!addProf.trim() || !addModule || !addPromo) {
setAddError("Tous les champs sont requis");
return;
}
setAdding(true);
setAddError(null);
try {
const res = await fetch("/admin/api/enseignements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idProf: addProf.trim(),
idModule: addModule,
idPromo: addPromo,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setAddProf("");
setAddModule("");
setAddPromo("");
setShowAdd(false);
await load();
} catch (e) {
setAddError(e instanceof Error ? e.message : "Erreur");
} finally {
setAdding(false);
}
}
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
const filtered = enseignements.filter((e) => {
const matchPromo = !filterPromo || e.idPromo === filterPromo;
const matchModule = !filterModule || e.idModule === filterModule;
const matchEns = !filterEnseignant ||
e.idProf.toLowerCase().includes(filterEnseignant.toLowerCase());
return matchPromo && matchModule && matchEns;
});
return (
<div class="page-content">
<h2 class="page-title">Assignations Enseignant ECUE / Promo</h2>
{error && <p class="state-error">{error}</p>}
<div class="filters">
<select
class="filter-select"
value={filterPromo}
onChange={(e) =>
setFilterPromo((e.target as HTMLSelectElement).value)}
>
<option value="">Promo </option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<select
class="filter-select"
value={filterModule}
onChange={(e) =>
setFilterModule((e.target as HTMLSelectElement).value)}
>
<option value="">ECUE </option>
{modules.map((m) => (
<option key={m.id} value={m.id}>{m.id} {m.nom}</option>
))}
</select>
<input
class="filter-input"
placeholder="Enseignant ▾"
value={filterEnseignant}
onInput={(e) =>
setFilterEnseignant((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="btn btn-secondary"
onClick={() => {
setFilterPromo("");
setFilterModule("");
setFilterEnseignant("");
}}
>
Filtrer
</button>
<button
type="button"
class="btn btn-primary"
onClick={() => setShowAdd((v) => !v)}
style="margin-left: auto"
>
+ Assigner
</button>
</div>
{showAdd && (
<div class="modal-overlay" onClick={() => setShowAdd(false)}>
<div class="modal-box" onClick={(e) => e.stopPropagation()}>
<p class="modal-title">Assigner un enseignement</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</p>
)}
<div class="modal-form">
<div class="form-field">
<label>Promo</label>
<select
class="filter-select"
value={addPromo}
onChange={(e) =>
setAddPromo((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%"
>
<option value="">Promo...</option>
{promos.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
</div>
<div class="form-field">
<label>ECUE</label>
<select
class="filter-select"
value={addModule}
onChange={(e) =>
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%"
>
<option value="">ECUE...</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} -- {m.nom}
</option>
))}
</select>
</div>
<div class="form-field">
<label>User ID enseignant</label>
<input
class="form-input"
placeholder="User ID enseignant..."
value={addProf}
onInput={(e) =>
setAddProf((e.target as HTMLInputElement).value)}
style="min-width: 0; width: 100%"
/>
</div>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
onClick={() => setShowAdd(false)}
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
onClick={addEnseignement}
disabled={adding}
>
{adding ? "..." : "+ Assigner"}
</button>
</div>
</div>
</div>
)}
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Promo</th>
<th>ECUE</th>
<th>Enseignant (User.id)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0
? (
<tr>
<td colspan={4} class="state-empty">
Aucun enseignement trouvé
</td>
</tr>
)
: filtered.map((e) => {
const mod = moduleMap[e.idModule];
return (
<tr key={`${e.idProf}-${e.idModule}-${e.idPromo}`}>
<td>
<span class="promo-chip">{e.idPromo}</span>
</td>
<td class="col-promo">
{mod ? `${mod.id} ${mod.nom}` : e.idModule}
</td>
<td>{e.idProf}</td>
<td>
<div class="col-actions">
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
deleteEnseignement(
e.idProf,
e.idModule,
e.idPromo,
)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div class="info-note">
<p>
Un même ECUE peut être enseigné par plusieurs utilisateurs sur une
même promo.
</p>
<p class="info-note-dim">
Clé composite = idProf (User.Id) + idModule + idPromo
</p>
</div>
</div>
);
}
@@ -0,0 +1,254 @@
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<Module[]>([]);
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newId, setNewId] = useState("");
const [newNom, setNewNom] = useState("");
const [creating, setCreating] = useState(false);
const [filterNom, setFilterNom] = useState("");
async function load() {
try {
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 {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createModule() {
if (!newId.trim() || !newNom.trim()) return;
setCreating(true);
try {
const res = await fetch("/admin/api/modules", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: newId.trim(), nom: newNom.trim() }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setNewId("");
setNewNom("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreating(false);
}
}
async function deleteModule(id: string) {
if (!confirm(`Supprimer l'ECUE ${id} ?`)) return;
try {
const res = await fetch(
`/admin/api/modules/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
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 (
<div class="page-content">
<h2 class="page-title">Gestion des ECUEs</h2>
{error && <p class="state-error">{error}</p>}
<div class="filters">
<input
class="filter-input"
placeholder="Rechercher..."
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="btn btn-primary"
onClick={() => {
const el = document.getElementById("new-module-section");
if (el) el.scrollIntoView({ behavior: "smooth" });
}}
style="margin-left: auto"
>
+ Ajouter ECUE
</button>
</div>
{loading
? <p class="state-loading">Chargement...</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>id (code)</th>
<th>Nom de l'ECUE</th>
<th>Enseignants assignes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0
? (
<tr>
<td colspan={4} class="state-empty">
Aucun ECUE enregistré
</td>
</tr>
)
: filtered.map((m) => {
const profs = enseignantsForModule(m.id);
return (
<tr key={m.id}>
<td class="col-dim">{m.id}</td>
<td>{m.nom}</td>
<td>
{profs
? (
<span style="font-size: 0.78rem">
{profs}
</span>
)
: <span class="col-dim">--</span>}
</td>
<td>
<div class="col-actions">
<a
class="btn btn-sm btn-secondary"
href={`/admin/modules/${
encodeURIComponent(m.id)
}`}
f-client-nav={false}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>{" "}
edit
</a>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteModule(m.id)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Nouvel ECUE */}
<div
id="new-module-section"
class="edit-section"
style="margin-top: 1.5rem"
>
<p class="edit-section-title">Nouvel ECUE</p>
<div class="form-row">
<input
class="form-input"
placeholder="Code"
value={newId}
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
style="min-width: 8rem; max-width: 10rem"
/>
<input
class="form-input"
placeholder="Nom de l'ECUE"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="btn btn-primary"
onClick={createModule}
disabled={creating}
>
{creating ? "..." : "+ Créer"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,128 @@
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<Perm[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const [pRes, rRes] = await Promise.all([
fetch("/admin/api/permissions"),
fetch("/admin/api/roles"),
]);
if (!pRes.ok) throw new Error("Impossible de charger les permissions");
setPermissions(await pRes.json());
if (rRes.ok) setRoles(await rRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
load();
}, []);
function rolesForPerm(permId: string): Role[] {
return roles.filter((r) => r.permissions.includes(permId));
}
const MAX_ROLE_CHIPS = 2;
return (
<div class="page-content">
<h2 class="page-title">Permissions</h2>
<div class="info-note" style="margin-top: 0; margin-bottom: 1.25rem">
<p>
Les permissions sont définies statiquement par le serveur.
</p>
<p class="info-note-dim">
Elles ne peuvent pas être créées ou supprimées via l'API.
</p>
</div>
{error && <p class="state-error">{error}</p>}
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>idPermission</th>
<th>nomPermission</th>
<th>Rôles associés</th>
</tr>
</thead>
<tbody>
{permissions.map((p) => {
const associated = rolesForPerm(p.id);
const shown = associated.slice(0, MAX_ROLE_CHIPS);
const overflow = associated.length - MAX_ROLE_CHIPS;
return (
<tr key={p.id}>
<td>
<span
class="col-promo"
style="font-family: monospace"
>
{p.id}
</span>
</td>
<td>{p.nom}</td>
<td>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
{shown.map((r) => (
<span
key={r.id}
class="role-chip"
style={`border-color: ${
roleColor(r.id)
}; color: ${roleColor(r.id)}`}
>
{r.nom}
</span>
))}
{overflow > 0 && (
<span
class="col-dim"
style="font-size: 0.72rem; margin-left: 0.2rem"
>
+{overflow}
</span>
)}
{associated.length === 0 && (
<span class="col-dim"></span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,263 @@
import { useEffect, useState } from "preact/hooks";
type Promotion = { id: string; annee: string | null };
type Student = { numEtud: number; idPromo: string };
function parsePromo(id: string) {
const m = id.match(/^(\d+A)(FISE|FISA)(.+)$/);
if (!m) return { annee: id, filiere: "?", anneeSco: "?" };
return { annee: m[1], filiere: m[2], anneeSco: m[3] };
}
const ANNEES = ["3A", "4A", "5A"];
const FILIERES = ["FISE", "FISA"];
export default function AdminPromotions() {
const [promos, setPromos] = useState<Promotion[]>([]);
const [students, setStudents] = useState<Student[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
// PromoBuilder state
const [selectedAnnee, setSelectedAnnee] = useState("4A");
const [selectedFiliere, setSelectedFiliere] = useState("FISE");
const [anneeSco, setAnneeSco] = useState("");
const generatedId = anneeSco.trim()
? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}`
: "";
async function load() {
try {
const [pRes, sRes] = await Promise.all([
fetch("/students/api/promotions"),
fetch("/students/api/students"),
]);
if (!pRes.ok) throw new Error("Impossible de charger les promotions");
setPromos(await pRes.json());
if (sRes.ok) setStudents(await sRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createPromo() {
if (!generatedId) return;
setCreating(true);
try {
const res = await fetch("/students/api/promotions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idPromo: generatedId,
annee: selectedAnnee,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setAnneeSco("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreating(false);
}
}
async function deletePromo(id: string) {
if (studentCount(id) > 0) {
setError(
`Impossible de supprimer ${id} : des étudiants y sont encore assignés. Réassignez-les d'abord.`,
);
return;
}
if (
!confirm(`Supprimer la promotion ${id} et toutes ses données liées ?`)
) {
return;
}
try {
const res = await fetch(
`/students/api/promotions/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) {
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");
}
}
function studentCount(idPromo: string) {
return students.filter((s) => s.idPromo === idPromo).length;
}
return (
<div class="page-content">
<h2 class="page-title">Gestion des Promotions</h2>
{error && <p class="state-error">{error}</p>}
{/* PromoBuilder */}
<div class="promo-builder">
<p class="promo-builder-title">Créer une promotion</p>
<p class="promo-builder-subtitle">
idPromo est généré automatiquement
</p>
<div class="promo-builder-row">
<div class="promo-builder-field">
<label>Année</label>
<div class="pill-group">
{ANNEES.map((a) => (
<button
key={a}
type="button"
class={`pill-btn${selectedAnnee === a ? " active" : ""}`}
onClick={() => setSelectedAnnee(a)}
>
{a}
</button>
))}
</div>
</div>
<div class="promo-builder-field">
<label>Filière</label>
<div class="pill-group">
{FILIERES.map((f) => (
<button
key={f}
type="button"
class={`pill-btn${selectedFiliere === f ? " active" : ""}`}
onClick={() => setSelectedFiliere(f)}
>
{f}
</button>
))}
</div>
</div>
<div class="promo-builder-field">
<label>Année scolaire</label>
<input
class="form-input"
placeholder="ex: 25-26, 24-27…"
value={anneeSco}
onInput={(e) => setAnneeSco((e.target as HTMLInputElement).value)}
style="min-width: 9rem"
/>
</div>
</div>
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap">
<div style="display: flex; align-items: center; gap: 0.5rem">
<span style="font-size: 0.78rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
idPromo généré :
</span>
<span class="promo-id-preview">
{generatedId || "—"}
</span>
</div>
<button
type="button"
class="btn btn-primary"
onClick={createPromo}
disabled={creating || !generatedId}
>
{creating ? "…" : "+ Créer la promo"}
</button>
</div>
</div>
{/* Existing promotions table */}
<p style="font-size: 0.82rem; font-weight: var(--font-weight-bold); margin-bottom: 0.5rem">
Promotions existantes
</p>
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>idPromo</th>
<th>Année</th>
<th>Filière</th>
<th>Année sco.</th>
<th>Nb étudiants</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{promos.length === 0
? (
<tr>
<td colspan={6} class="state-empty">
Aucune promotion enregistrée
</td>
</tr>
)
: promos.map((p) => {
const parsed = parsePromo(p.id);
const count = studentCount(p.id);
return (
<tr key={p.id}>
<td>
<span class="promo-chip">{p.id}</span>
</td>
<td>{parsed.annee}</td>
<td>
<span class="filiere-chip">{parsed.filiere}</span>
</td>
<td>{parsed.anneeSco}</td>
<td class="col-dim">
{count} étudiant{count !== 1 ? "s" : ""}
</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
disabled={count > 0}
title={count > 0
? "Réassignez les étudiants avant de supprimer"
: "Supprimer la promotion"}
onClick={() => deletePromo(p.id)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect x="5" y="6" width="14" height="16" rx="1" />
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,323 @@
import { useEffect, useState } from "preact/hooks";
type Role = { id: number; nom: string; permissions: string[] };
type Perm = { id: string; nom: string };
const MAX_CHIPS = 3;
export default function AdminRoles() {
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Perm[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newNom, setNewNom] = useState("");
const [creating, setCreating] = useState(false);
// Manage-perms sub-view
const [managingRole, setManagingRole] = useState<Role | null>(null);
const [editPerms, setEditPerms] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
async function load() {
try {
const [rRes, pRes] = await Promise.all([
fetch("/admin/api/roles"),
fetch("/admin/api/permissions"),
]);
if (!rRes.ok) throw new Error("Impossible de charger les rôles");
setRoles(await rRes.json());
if (pRes.ok) setPermissions(await pRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createRole() {
if (!newNom.trim()) return;
setCreating(true);
try {
const res = await fetch("/admin/api/roles", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: newNom.trim() }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setNewNom("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreating(false);
}
}
async function deleteRole(id: number) {
if (!confirm("Supprimer ce rôle ?")) return;
try {
const res = await fetch(`/admin/api/roles/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
function openManage(role: Role) {
setManagingRole(role);
setEditPerms(new Set(role.permissions));
setSaveError(null);
}
function togglePerm(permId: string) {
setEditPerms((prev) => {
const next = new Set(prev);
if (next.has(permId)) next.delete(permId);
else next.add(permId);
return next;
});
}
async function savePerms() {
if (!managingRole) return;
setSaving(true);
setSaveError(null);
try {
const res = await fetch(`/admin/api/roles/${managingRole.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
nom: managingRole.nom,
permissions: [...editPerms],
}),
});
if (!res.ok) throw new Error("Enregistrement échoué");
await load();
setManagingRole(null);
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Erreur");
} finally {
setSaving(false);
}
}
// ---- Manage-perms view ----
if (managingRole) {
const activeCount = editPerms.size;
return (
<div class="page-content">
<a
class="back-link"
href="#"
onClick={(e) => {
e.preventDefault();
setManagingRole(null);
}}
>
Retour à la liste des rôles
</a>
<h2 class="page-title">
Permissions du rôle {managingRole.nom}
</h2>
{saveError && <p class="state-error">{saveError}</p>}
<div class="perm-header-bar">
<div style="display: flex; align-items: center; gap: 0.6rem">
<span class="numEtud-chip">idRole : {managingRole.id}</span>
<span style="font-weight: var(--font-weight-bold); font-size: 0.9rem">
{managingRole.nom}
</span>
<span style="font-size: 0.8rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color))">
{activeCount} permission{activeCount !== 1 ? "s" : ""} active
{activeCount !== 1 ? "s" : ""}
</span>
</div>
<button
type="button"
class="btn btn-primary"
onClick={savePerms}
disabled={saving}
>
{saving ? "..." : "Enregistrer"}
</button>
</div>
<div style="margin-bottom: 0.5rem; display: flex; justify-content: space-between">
<span style="font-size: 0.78rem; font-weight: var(--font-weight-bold)">
Permissions disponibles
</span>
<span style="font-size: 0.72rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
Activer = inclure dans le rôle
</span>
</div>
<div class="perm-toggle-grid">
{permissions.map((p) => {
const active = editPerms.has(p.id);
return (
<label
key={p.id}
class={`perm-toggle-card${active ? " active" : ""}`}
>
<div class="perm-toggle-label">
<span class="perm-toggle-id">{p.id}</span>
<span class="perm-toggle-nom">{p.nom}</span>
</div>
<span class="toggle-switch">
<input
type="checkbox"
checked={active}
onChange={() => togglePerm(p.id)}
/>
<span class="toggle-slider" />
</span>
</label>
);
})}
</div>
</div>
);
}
const permMap = Object.fromEntries(permissions.map((p) => [p.id, p.nom]));
// ---- Main list view ----
return (
<div class="page-content">
<h2 class="page-title">Gestion des Rôles</h2>
{error && <p class="state-error">{error}</p>}
<div class="toolbar">
<input
class="form-input"
placeholder="Nom du rôle..."
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && createRole()}
style="min-width: 14rem"
/>
<button
type="button"
class="btn btn-primary"
onClick={createRole}
disabled={creating}
>
+ Créer rôle
</button>
</div>
{loading
? <p class="state-loading">Chargement...</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>idRole</th>
<th>Nom du rôle</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{roles.length === 0
? (
<tr>
<td colspan={4} class="state-empty">
Aucun rôle enregistré
</td>
</tr>
)
: roles.map((r) => {
const shown = r.permissions.slice(0, MAX_CHIPS);
const overflow = r.permissions.length - MAX_CHIPS;
return (
<tr key={r.id}>
<td class="col-dim">{r.id}</td>
<td style="font-weight: var(--font-weight-bold)">
{r.nom}
</td>
<td>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
{shown.map((p) => (
<span key={p} class="perm-chip">
{permMap[p] ?? p}
</span>
))}
{overflow > 0 && (
<span
class="col-dim"
style="font-size: 0.72rem; margin-left: 0.2rem"
>
+{overflow}
</span>
)}
</div>
</td>
<td>
<div class="col-actions">
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => openManage(r)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>{" "}
Gérer perms
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteRole(r.id)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+516
View File
@@ -0,0 +1,516 @@
import { useEffect, useState } from "preact/hooks";
type UE = { id: number; nom: string };
type UEModule = {
idModule: string;
idUE: number;
idPromo: string;
coeff: number;
};
type Module = { id: string; nom: string };
type Promo = { id: string; annee: string };
export default function AdminUEs() {
const [ues, setUes] = useState<UE[]>([]);
const [ueModules, setUeModules] = useState<UEModule[]>([]);
const [modules, setModules] = useState<Module[]>([]);
const [promos, setPromos] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUe, setSelectedUe] = useState<UE | null>(null);
const [filterPromo, setFilterPromo] = useState("");
// New UE form
const [newUeNom, setNewUeNom] = useState("");
const [creatingUe, setCreatingUe] = useState(false);
// Add UE-module form
const [addModuleId, setAddModuleId] = useState("");
const [addPromoId, setAddPromoId] = useState("");
const [addCoeff, setAddCoeff] = useState("1");
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
// Inline coeff editing
const [editingCoeff, setEditingCoeff] = useState<string | null>(null);
const [editCoeffValue, setEditCoeffValue] = useState("");
async function load() {
try {
const [uRes, umRes, mRes, pRes] = await Promise.all([
fetch("/admin/api/ues"),
fetch("/admin/api/ue-modules"),
fetch("/admin/api/modules"),
fetch("/students/api/promotions"),
]);
if (!uRes.ok) throw new Error("Impossible de charger les UEs");
const uesData: UE[] = await uRes.json();
setUes(uesData);
if (umRes.ok) setUeModules(await umRes.json());
if (mRes.ok) setModules(await mRes.json());
if (pRes.ok) setPromos(await pRes.json());
// Keep selection in sync
setSelectedUe((prev) =>
prev ? uesData.find((u) => u.id === prev.id) ?? null : null
);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createUE() {
if (!newUeNom.trim()) return;
setCreatingUe(true);
try {
const res = await fetch("/admin/api/ues", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: newUeNom.trim() }),
});
if (!res.ok) throw new Error("Création échouée");
setNewUeNom("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreatingUe(false);
}
}
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 cet ECUE de la UE ?")) return;
try {
const res = await fetch(
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
encodeURIComponent(idPromo)
}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function addUeModule() {
if (!selectedUe || !addModuleId || !addPromoId) {
setAddError("ECUE et Promo sont requis");
return;
}
const coeff = parseFloat(addCoeff);
if (isNaN(coeff) || coeff <= 0) {
setAddError("Coefficient invalide");
return;
}
setAdding(true);
setAddError(null);
try {
const res = await fetch("/admin/api/ue-modules", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idModule: addModuleId,
idUE: selectedUe.id,
idPromo: addPromoId,
coeff,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setAddModuleId("");
setAddPromoId("");
setAddCoeff("1");
await load();
} catch (e) {
setAddError(e instanceof Error ? e.message : "Erreur");
} finally {
setAdding(false);
}
}
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)
: [];
return (
<div class="page-content">
<h2 class="page-title">Gestion des UEs</h2>
<p
class="col-dim"
style="font-size: 0.78rem; margin: -0.5rem 0 1rem"
>
UE = Unité d'Enseignement regroupant plusieurs ECUEs
</p>
{error && <p class="state-error">{error}</p>}
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="ue-split">
{/* Left panel UE list */}
<div class="ue-panel-left">
<div class="panel-box">
<p class="panel-box-title">UEs existantes</p>
<select
class="filter-select"
value={filterPromo}
onChange={(e) =>
setFilterPromo(
(e.target as HTMLSelectElement).value,
)}
style="width: 100%; margin-bottom: 0.5rem"
>
<option value="">Toutes les promos</option>
{promos.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
<div class="form-row" style="margin-bottom: 0.75rem">
<input
class="form-input"
placeholder="Nom de la nouvelle UE…"
value={newUeNom}
onInput={(e) =>
setNewUeNom((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && createUE()}
style="min-width: 0; flex: 1"
/>
</div>
<button
type="button"
class="btn btn-primary"
onClick={createUE}
disabled={creatingUe}
style="width: 100%; justify-content: center; margin-bottom: 0.5rem"
>
+ Nouvelle UE
</button>
<div>
{filteredUes.map((ue) => (
<div
key={ue.id}
class={`ue-list-item${
selectedUe?.id === ue.id ? " active" : ""
}`}
style="display: flex; align-items: center; justify-content: space-between"
>
<span
style="flex: 1; cursor: pointer"
onClick={() => {
setSelectedUe(ue);
setAddError(null);
}}
>
{ue.nom}
</span>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={(e) => {
e.stopPropagation();
deleteUE(ue);
}}
title="Supprimer cette UE"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</div>
))}
{filteredUes.length === 0 && (
<p class="state-empty" style="padding: 1rem 0">
{filterPromo ? "Aucune UE pour cette promo" : "Aucune UE"}
</p>
)}
</div>
</div>
</div>
{/* Right panel UE detail */}
<div class="ue-panel-right">
{selectedUe
? (
<div class="panel-box">
<p class="panel-box-title">{selectedUe.nom}</p>
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
ECUEs assignés (UE_Module)
</p>
<div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table">
<thead>
<tr>
<th>ECUE</th>
<th>Promo</th>
<th>Coeff</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{selectedUeModules.length === 0
? (
<tr>
<td colspan={4} class="state-empty">
Aucun ECUE assigné
</td>
</tr>
)
: selectedUeModules.map((um) => {
const mod = moduleMap[um.idModule];
return (
<tr
key={`${um.idModule}-${um.idPromo}`}
>
<td class="col-promo">
{mod
? `${mod.id} ${mod.nom}`
: um.idModule}
</td>
<td>
<span class="promo-chip">{um.idPromo}</span>
</td>
<td
onClick={() => {
const key =
`${um.idModule}-${um.idUE}-${um.idPromo}`;
setEditingCoeff(key);
setEditCoeffValue(String(um.coeff));
}}
style="cursor: pointer"
>
{editingCoeff ===
`${um.idModule}-${um.idUE}-${um.idPromo}`
? (
<input
type="number"
class="form-input"
value={editCoeffValue}
min="0.1"
step="0.5"
style="width: 5rem; padding: 0.2rem 0.4rem; font-size: 0.82rem"
autoFocus
onInput={(e) =>
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}
</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
deleteUeModule(
um.idModule,
um.idUE,
um.idPromo,
)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un ECUE à cette UE
</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</p>
)}
<div class="form-row">
<select
class="filter-select"
value={addModuleId}
onChange={(e) =>
setAddModuleId(
(e.target as HTMLSelectElement).value,
)}
style="min-width: 12rem"
>
<option value="">ECUE </option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} {m.nom}
</option>
))}
</select>
<select
class="filter-select"
value={addPromoId}
onChange={(e) =>
setAddPromoId(
(e.target as HTMLSelectElement).value,
)}
style="min-width: 9rem"
>
<option value="">Promo </option>
{promos.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
<input
type="number"
class="form-input"
placeholder="Coeff"
value={addCoeff}
min="0.1"
step="0.5"
onInput={(e) =>
setAddCoeff((e.target as HTMLInputElement).value)}
style="min-width: 5rem; max-width: 6rem"
/>
<button
type="button"
class="btn btn-primary"
onClick={addUeModule}
disabled={adding}
>
{adding ? "…" : "+ Ajouter"}
</button>
</div>
</div>
)
: (
<div class="panel-box">
<p class="state-empty" style="padding: 2rem 0">
Sélectionnez une UE pour voir ses ECUEs
</p>
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,308 @@
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<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newId, setNewId] = useState("");
const [newNom, setNewNom] = useState("");
const [newPrenom, setNewPrenom] = useState("");
const [newIdRole, setNewIdRole] = useState("");
const [creating, setCreating] = useState(false);
const [filterNom, setFilterNom] = useState("");
const [filterRole, setFilterRole] = useState("");
async function load() {
try {
const [uRes, rRes] = await Promise.all([
fetch("/admin/api/users"),
fetch("/admin/api/roles"),
]);
if (!uRes.ok) throw new Error("Impossible de charger les utilisateurs");
setUsers(await uRes.json());
if (rRes.ok) setRoles(await rRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createUser() {
if (!newId.trim() || !newNom.trim() || !newPrenom.trim()) return;
setCreating(true);
try {
const res = await fetch("/admin/api/users", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: newId.trim(),
nom: newNom.trim(),
prenom: newPrenom.trim(),
idRole: newIdRole ? Number(newIdRole) : null,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setNewId("");
setNewNom("");
setNewPrenom("");
setNewIdRole("");
setShowCreate(false);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreating(false);
}
}
async function deleteUser(id: string) {
if (!confirm(`Supprimer l'utilisateur ${id} ?`)) return;
try {
const res = await fetch(`/admin/api/users/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom]));
const filtered = users.filter((u) => {
const matchNom = !filterNom ||
`${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes(
filterNom.toLowerCase(),
);
const matchRole = !filterRole || String(u.idRole) === filterRole;
return matchNom && matchRole;
});
return (
<div class="page-content">
<h2 class="page-title">Gestion des Utilisateurs</h2>
{error && <p class="state-error">{error}</p>}
<div class="filters">
<input
class="filter-input"
placeholder="Rechercher..."
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
<select
class="filter-select"
value={filterRole}
onChange={(e) => setFilterRole((e.target as HTMLSelectElement).value)}
>
<option value="">Role</option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}</option>)}
</select>
<button
type="button"
class="btn btn-primary"
onClick={() => setShowCreate(true)}
style="margin-left: auto"
>
+ Créer utilisateur
</button>
</div>
{/* Creation modal */}
{showCreate && (
<div
class="modal-overlay"
onClick={() => setShowCreate(false)}
>
<div class="modal-box" onClick={(e) => e.stopPropagation()}>
<p class="modal-title">Créer un utilisateur</p>
<div class="modal-form">
<div class="form-field">
<label>Login (uid)</label>
<input
class="form-input"
placeholder="Login (uid)"
value={newId}
onInput={(e) =>
setNewId((e.target as HTMLInputElement).value)}
style="min-width: 0; width: 100%"
/>
</div>
<div class="form-field">
<label>Nom</label>
<input
class="form-input"
placeholder="Nom"
value={newNom}
onInput={(e) =>
setNewNom((e.target as HTMLInputElement).value)}
style="min-width: 0; width: 100%"
/>
</div>
<div class="form-field">
<label>Prénom</label>
<input
class="form-input"
placeholder="Prénom"
value={newPrenom}
onInput={(e) =>
setNewPrenom((e.target as HTMLInputElement).value)}
style="min-width: 0; width: 100%"
/>
</div>
<div class="form-field">
<label>Rôle</label>
<select
class="filter-select"
value={newIdRole}
onChange={(e) =>
setNewIdRole((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%"
>
<option value="">Aucun rôle</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>{r.nom}</option>
))}
</select>
</div>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
onClick={() => setShowCreate(false)}
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
onClick={createUser}
disabled={creating}
>
{creating ? "..." : "+ Créer"}
</button>
</div>
</div>
</div>
)}
{loading
? <p class="state-loading">Chargement...</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>id (login)</th>
<th>Nom</th>
<th>Prénom</th>
<th>Rôle(s)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0
? (
<tr>
<td colspan={5} class="state-empty">
Aucun utilisateur trouvé
</td>
</tr>
)
: filtered.map((u) => (
<tr key={u.id}>
<td class="col-dim">{u.id}</td>
<td>{u.nom}</td>
<td>{u.prenom}</td>
<td>
{u.idRole
? (
<span
class="role-chip"
style={`border-color: ${
roleColor(u.idRole)
}; color: ${roleColor(u.idRole)}`}
>
{roleMap[u.idRole] ?? `#${u.idRole}`}
</span>
)
: <span class="col-dim">--</span>}
</td>
<td>
<div class="col-actions">
<a
class="btn btn-sm btn-secondary"
href={`/admin/users/${encodeURIComponent(u.id)}`}
f-client-nav={false}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>{" "}
edit
</a>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteUser(u.id)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect x="5" y="6" width="14" height="16" rx="1" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -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<Module | null>(null);
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [promos, setPromos] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<string | null>(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<string | null>(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 (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error && !mod) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
if (!mod) return null;
return (
<div class="page-content">
<a
class="back-link"
href="/admin/modules"
f-partial="/admin/partials/modules"
>
&larr; Retour a la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
ECUE -- {mod.id}
</h2>
<div class="info-bar">
<span class="module-chip">{mod.id}</span>
<span>{mod.nom}</span>
</div>
{error && <p class="state-error">{error}</p>}
{saveMsg && (
<p style="font-size: 0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.5rem">
{saveMsg}
</p>
)}
{/* Section 1: Infos */}
<div class="edit-section">
<p class="edit-section-title">Informations</p>
<div class="form-grid">
<div class="form-field">
<label>Code</label>
<input
class="form-input"
value={mod.id}
disabled
style="opacity: 0.6"
/>
</div>
<div class="form-field">
<label>Nom de l'ECUE</label>
<input
class="form-input"
value={nom}
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
/>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: space-between; flex-wrap: wrap">
<button
type="button"
class="btn btn-primary"
onClick={saveInfos}
disabled={saving}
>
{saving ? "..." : "Enregistrer"}
</button>
<button
type="button"
class="btn btn-danger"
onClick={deleteModule}
>
Supprimer l'ECUE
</button>
</div>
</div>
{/* Section 2: Enseignements */}
<div class="edit-section">
<p class="edit-section-title">Enseignants assignes</p>
{enseignements.length > 0
? (
<div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table">
<thead>
<tr>
<th>Enseignant</th>
<th>Promo</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{enseignements.map((e) => {
const u = userMap[e.idProf];
return (
<tr key={`${e.idProf}-${e.idPromo}`}>
<td>
{u ? `${u.nom} ${u.prenom.charAt(0)}.` : e.idProf}
</td>
<td>
<span class="promo-chip">{e.idPromo}</span>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
removeEnseignement(e.idProf, e.idPromo)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)
: (
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
>
Aucun enseignant assigne.
</p>
)}
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un enseignant
</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</p>
)}
<div class="form-row">
<select
class="filter-select"
value={addProf}
onChange={(e) => setAddProf((e.target as HTMLSelectElement).value)}
style="min-width: 12rem"
>
<option value="">Enseignant</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.nom} {u.prenom} ({u.id})
</option>
))}
</select>
<select
class="filter-select"
value={addPromo}
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
style="min-width: 9rem"
>
<option value="">Promo</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<button
type="button"
class="btn btn-primary"
onClick={addEnseignement}
disabled={adding}
>
{adding ? "..." : "+ Ajouter"}
</button>
</div>
</div>
</div>
);
}
+391
View File
@@ -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<User | null>(null);
const [roles, setRoles] = useState<Role[]>([]);
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
const [modules, setModules] = useState<Module[]>([]);
const [promos, setPromos] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<string | null>(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<string | null>(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 (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error && !user) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
if (!user) return null;
return (
<div class="page-content">
<a
class="back-link"
href="/admin/users"
f-partial="/admin/partials/users"
>
&larr; Retour a la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Edition -- {user.prenom} {user.nom}
</h2>
<div class="info-bar">
<span class="numEtud-chip">{user.id}</span>
<span>
{user.idRole
? (roleMap[user.idRole] ?? `Role #${user.idRole}`)
: "Aucun role"}
</span>
</div>
{error && <p class="state-error">{error}</p>}
{saveMsg && (
<p style="font-size: 0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.5rem">
{saveMsg}
</p>
)}
{/* Section 1: Informations generales */}
<div class="edit-section">
<p class="edit-section-title">Informations generales</p>
<div class="form-grid">
<div class="form-field">
<label>Nom</label>
<input
class="form-input"
value={nom}
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>Prenom</label>
<input
class="form-input"
value={prenom}
onInput={(e) => setPrenom((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>Login</label>
<input
class="form-input"
value={user.id}
disabled
style="opacity: 0.6"
/>
</div>
<div class="form-field">
<label>Role</label>
<select
class="filter-select"
value={idRole}
onChange={(e) => setIdRole((e.target as HTMLSelectElement).value)}
style="min-width: 0"
>
<option value="">Aucun role</option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}
</option>)}
</select>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: space-between; flex-wrap: wrap">
<button
type="button"
class="btn btn-primary"
onClick={saveInfos}
disabled={saving}
>
{saving ? "..." : "Enregistrer"}
</button>
<button
type="button"
class="btn btn-danger"
onClick={deleteUser}
>
Supprimer l'utilisateur
</button>
</div>
</div>
{/* Section 2: Enseignements */}
<div class="edit-section">
<p class="edit-section-title">Enseignements</p>
<p
class="col-dim"
style="font-size: 0.75rem; margin: 0 0 0.75rem"
>
ECUEs enseignes par cet utilisateur
</p>
{enseignements.length > 0
? (
<div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table">
<thead>
<tr>
<th>ECUE</th>
<th>Promo</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{enseignements.map((e) => {
const mod = moduleMap[e.idModule];
return (
<tr key={`${e.idModule}-${e.idPromo}`}>
<td class="col-promo">
{mod ? `${mod.id} -- ${mod.nom}` : e.idModule}
</td>
<td>
<span class="promo-chip">{e.idPromo}</span>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
removeEnseignement(e.idModule, e.idPromo)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)
: (
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
>
Aucun enseignement assigne.
</p>
)}
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un enseignement
</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</p>
)}
<div class="form-row">
<select
class="filter-select"
value={addModule}
onChange={(e) =>
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 12rem"
>
<option value="">ECUE</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} -- {m.nom}
</option>
))}
</select>
<select
class="filter-select"
value={addPromo}
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
style="min-width: 9rem"
>
<option value="">Promo</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<button
type="button"
class="btn btn-primary"
onClick={addEnseignement}
disabled={adding}
>
{adding ? "..." : "+ Ajouter"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,478 @@
// @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 {
parseMaquette,
type ParsedModule,
type ParsedUE,
type ParsedYear,
} from "$root/logic/maquette.ts";
import ImportResultPopup, {
type ImportDetail,
type ImportResult,
} from "$root/defaults/ImportResultPopup.tsx";
type Promo = { id: string; annee: string | null };
export default function ImportMaquette() {
const file = useSignal<File | null>(null);
const dragging = useSignal(false);
const uploading = useSignal(false);
const error = useSignal<string | null>(null);
const importResult = useSignal<ImportResult | null>(null);
const preview = useSignal<ParsedYear[] | null>(null);
const promos = useSignal<Promo[]>([]);
// Map: year label -> selected promo id
const yearPromos = useSignal<Record<string, string>>({});
// Inline promo creation
const newPromoId = useSignal("");
const newPromoAnnee = useSignal("");
const creatingPromo = useSignal(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div>
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls"
style="display:none"
onChange={(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) pickFile(f);
}}
/>
<div
class={`drop-zone${dragging.value ? " dragging" : ""}`}
onDragOver={(e) => {
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()}
>
<span class="drop-zone-icon"></span>
{file.value ? <span class="drop-zone-file">{file.value.name}</span> : (
<>
<span class="drop-zone-text">
Glisser le fichier maquette .xlsx ici
</span>
<span class="drop-zone-hint">ou cliquer pour parcourir</span>
</>
)}
</div>
{error.value && <p class="state-error">{error.value}</p>}
{importResult.value && (
<ImportResultPopup
result={importResult.value}
onClose={() => (importResult.value = null)}
/>
)}
{/* Create promo inline */}
<div class="create-promo-inline">
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
Creer une promotion
</label>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap">
<input
type="text"
class="filter-select"
placeholder="ID (ex: 3AFISE24-25)"
value={newPromoId.value}
onInput={(
e,
) => (newPromoId.value = (e.target as HTMLInputElement).value)}
style="min-width: 10rem"
/>
<input
type="text"
class="filter-select"
placeholder="Annee (ex: 2024-2025)"
value={newPromoAnnee.value}
onInput={(
e,
) => (newPromoAnnee.value = (e.target as HTMLInputElement).value)}
style="min-width: 8rem"
/>
<button
type="button"
class="btn btn-secondary"
onClick={createPromo}
disabled={creatingPromo.value || !newPromoId.value.trim() ||
!newPromoAnnee.value.trim()}
style="white-space: nowrap"
>
{creatingPromo.value ? "..." : "+ Creer"}
</button>
</div>
</div>
{/* Preview grouped by year */}
{preview.value && preview.value.length > 0 && (
<div style="margin-bottom: 1rem">
{preview.value.map((year) => {
const totalMods = year.ues.reduce(
(s, ue) => s + ue.modules.length,
0,
);
return (
<div key={year.label} style="margin-bottom: 1.25rem">
<div style="display: flex; gap: 1rem; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap">
<p style="font-size: 0.85rem; font-weight: 700; margin: 0">
{year.label}
<span class="col-dim" style="font-weight: 400">
{year.ues.length} UE, {totalMods} ECUEs
</span>
</p>
<select
class="filter-select"
value={yearPromos.value[year.label] || ""}
onChange={(e) =>
setYearPromo(
year.label,
(e.target as HTMLSelectElement).value,
)}
>
<option value=""> Ignorer </option>
{promos.value.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
</div>
<div
class="data-table-wrap"
style="max-height: 15rem; overflow-y: auto"
>
<table class="data-table">
<thead>
<tr>
<th>UE</th>
<th>ECUE</th>
<th>Code</th>
<th>Coeff</th>
</tr>
</thead>
<tbody>
{year.ues.map((ue, i) =>
ue.modules.length === 0
? (
<tr key={`ue-${i}`}>
<td style="font-weight: 600">{ue.name}</td>
<td class="col-dim" colspan={3}>
Aucun ECUE
</td>
</tr>
)
: ue.modules.map((mod, j) => (
<tr key={`${i}-${j}`}>
{j === 0 && (
<td
rowSpan={ue.modules.length}
style="font-weight: 600; vertical-align: top"
>
{ue.name}
{ue.ects != null && (
<span class="col-dim">
({ue.ects} ECTS)
</span>
)}
</td>
)}
<td>{mod.name}</td>
<td class="col-dim">{mod.code}</td>
<td>{mod.coeff}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
})}
</div>
)}
<div class="upload-actions">
<button
type="button"
class="btn btn-primary"
onClick={doImport}
disabled={!canImport()}
>
{uploading.value ? "..." : "+ Importer"}
</button>
<button
type="button"
class="btn btn-secondary"
onClick={downloadTemplate}
>
Telecharger Modele
</button>
{
/* TODO: fix blob download in Fresh
<button
type="button"
class="btn btn-secondary"
onClick={downloadExport}
>
Exporter Maquette
</button>
*/
}
</div>
<p class="upload-format">
Format : fichier maquette FISE / FISA avec lignes <strong>UE</strong>
et <strong>ECUEs</strong> (colonnes code, nom, coefficient)
</p>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { AppProperties } from "$root/defaults/interfaces.ts";
const properties: AppProperties = {
name: "Admin",
icon: "school",
pages: {
index: "Accueil",
users: "Utilisateurs",
roles: "Rôles",
permissions: "Permissions",
modules: "ECUEs",
enseignements: "Enseignements",
promotions: "Promotions",
ues: "UEs",
"import-maquette": "Import Maquette",
},
adminOnly: [
"users",
"roles",
"permissions",
"modules",
"enseignements",
"promotions",
"ues",
"import-maquette",
],
employeeOnly: true,
hint: "PolyMPR ECUE",
};
export default properties;
+2
View File
@@ -0,0 +1,2 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
+89
View File
@@ -0,0 +1,89 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { enseignements } from "$root/databases/schema.ts";
import { AuthenticatedState, isEmployee } 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 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" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// GET /enseignements
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return new Response(JSON.stringify([]), {
headers: { "content-type": "application/json" },
});
}
const rows = await db.select().from(enseignements);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
});
},
// #29 POST /enseignements
async POST(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
let body: { idProf: string; idModule: string; idPromo: string };
try {
body = await request.json();
} catch {
return new Response(null, { status: 500 });
}
if (!body.idProf || !body.idModule || !body.idPromo) {
return new Response(null, { status: 400 });
}
// Check if enseignement already exists
const existing = await db
.select()
.from(enseignements)
.where(
and(
eq(enseignements.idProf, body.idProf),
eq(enseignements.idModule, body.idModule),
eq(enseignements.idPromo, body.idPromo),
),
)
.then((rows) => rows[0] ?? null);
if (existing) {
return CONFLICT();
}
const [created] = await db
.insert(enseignements)
.values({
idProf: body.idProf,
idModule: body.idModule,
idPromo: body.idPromo,
})
.returning();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
},
};
@@ -0,0 +1,76 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { enseignements } from "$root/databases/schema.ts";
import { AuthenticatedState, isEmployee } 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 FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #30 GET /enseignements/{idProf}/{idModule}/{idPromo}
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
const idProf = context.params.idProf;
const idModule = context.params.idModule;
const idPromo = context.params.idPromo;
const enseignement = await db
.select()
.from(enseignements)
.where(
and(
eq(enseignements.idProf, idProf),
eq(enseignements.idModule, idModule),
eq(enseignements.idPromo, idPromo),
),
)
.then((rows) => rows[0] ?? null);
if (!enseignement) return NOT_FOUND();
return new Response(JSON.stringify(enseignement), {
headers: { "content-type": "application/json" },
});
},
// #31 DELETE /enseignements/{idProf}/{idModule}/{idPromo}
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
const idProf = context.params.idProf;
const idModule = context.params.idModule;
const idPromo = context.params.idPromo;
const [deleted] = await db
.delete(enseignements)
.where(
and(
eq(enseignements.idProf, idProf),
eq(enseignements.idModule, idModule),
eq(enseignements.idPromo, idPromo),
),
)
.returning();
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
};
+22
View File
@@ -0,0 +1,22 @@
import { Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
async POST(request, context) {
if (request.headers.get("content-type") != "application/json") {
return new Response(null, {
status: 400,
});
}
const responseBody = {
requestBody: await request.json(),
context,
};
return new Response(JSON.stringify(responseBody), {
headers: {
"content-type": "application/json",
},
});
},
};
+62
View File
@@ -0,0 +1,62 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { modules } from "$root/databases/schema.ts";
import { AuthenticatedState, isEmployee } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers<null, AuthenticatedState> = {
// #23 GET /modules
async GET(
_request: Request,
_context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const rows = await db.select().from(modules);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
});
},
// #24 POST /modules
async POST(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return new Response(null, { status: 403 });
}
let body: { id: string; nom: string };
try {
body = await request.json();
} catch {
return new Response(null, { status: 500 });
}
if (!body.id || !body.id.trim() || !body.nom || !body.nom.trim()) {
return new Response(null, { status: 400 });
}
const existing = await db
.select()
.from(modules)
.where(eq(modules.id, body.id))
.then((rows) => rows[0] ?? null);
if (existing) {
return new Response(
JSON.stringify({ error: "Un ECUE avec cet identifiant existe déjà" }),
{ status: 409, headers: { "content-type": "application/json" } },
);
}
const [created] = await db
.insert(modules)
.values({ id: body.id, nom: body.nom })
.returning();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
},
};
@@ -0,0 +1,93 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.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" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// #25 GET /modules/{idModule}
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const module = await db
.select()
.from(modules)
.where(eq(modules.id, context.params.idModule))
.then((rows) => rows[0] ?? null);
if (!module) return NOT_FOUND();
return new Response(JSON.stringify(module), {
headers: { "content-type": "application/json" },
});
},
// #26 PUT /modules/{idModule}
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
let body: { nom: string };
try {
body = await request.json();
} catch {
return new Response(null, { status: 500 });
}
if (typeof body.nom !== "string") {
return new Response(null, { status: 400 });
}
const [updated] = await db
.update(modules)
.set({ nom: body.nom })
.where(eq(modules.id, context.params.idModule))
.returning();
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
});
},
// #27 DELETE /modules/{idModule}
// Cascade: deletes notes, ue_modules, enseignements for this module.
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const idModule = context.params.idModule;
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 });
},
};
+16
View File
@@ -0,0 +1,16 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { permissions } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
export const handler: Handlers<null, AuthenticatedState> = {
async GET(
_request: Request,
_context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const result = await db.select().from(permissions);
return new Response(JSON.stringify(result), {
headers: { "content-type": "application/json" },
});
},
};
+68
View File
@@ -0,0 +1,68 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { rolePermissions, roles } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
async function getRoleWithPermissions(
id: number,
): Promise<{ id: number; nom: string; permissions: string[] } | null> {
const role = await db
.select()
.from(roles)
.where(eq(roles.id, id))
.then((rows) => rows[0] ?? null);
if (!role) return null;
const perms = await db
.select({ idPermission: rolePermissions.idPermission })
.from(rolePermissions)
.where(eq(rolePermissions.idRole, id));
return {
id: role.id,
nom: role.nom,
permissions: perms.map((p) => p.idPermission),
};
}
export const handler: Handlers<null, AuthenticatedState> = {
// #65 GET /roles
async GET(
_request: Request,
_context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const allRoles = await db.select().from(roles);
const result = await Promise.all(
allRoles.map((r) => getRoleWithPermissions(r.id)),
);
return new Response(JSON.stringify(result), {
headers: { "content-type": "application/json" },
});
},
// #66 POST /roles
async POST(
request: Request,
_context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const body: { nom: string } = await request.json();
if (!body.nom) {
return new Response(null, { status: 400 });
}
const [created] = await db
.insert(roles)
.values({ nom: body.nom })
.returning();
return new Response(
JSON.stringify({ id: created.id, nom: created.nom, permissions: [] }),
{ status: 201, headers: { "content-type": "application/json" } },
);
},
};
+110
View File
@@ -0,0 +1,110 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.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" } },
);
async function getRoleWithPermissions(
id: number,
): Promise<{ id: number; nom: string; permissions: string[] } | null> {
const role = await db
.select()
.from(roles)
.where(eq(roles.id, id))
.then((rows) => rows[0] ?? null);
if (!role) return null;
const perms = await db
.select({ idPermission: rolePermissions.idPermission })
.from(rolePermissions)
.where(eq(rolePermissions.idRole, id));
return {
id: role.id,
nom: role.nom,
permissions: perms.map((p) => p.idPermission),
};
}
export const handler: Handlers<null, AuthenticatedState> = {
// #67 GET /roles/{idRole}
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const id = Number(context.params.idRole);
const role = await getRoleWithPermissions(id);
if (!role) return NOT_FOUND();
return new Response(JSON.stringify(role), {
headers: { "content-type": "application/json" },
});
},
// #68 PUT /roles/{idRole}
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const id = Number(context.params.idRole);
const body: { nom: string; permissions: string[] } = await request.json();
const [updated] = await db
.update(roles)
.set({ nom: body.nom })
.where(eq(roles.id, id))
.returning();
if (!updated) return NOT_FOUND();
// Reset permissions
await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id));
if (body.permissions?.length) {
await db.insert(rolePermissions).values(
body.permissions.map((p) => ({ idRole: id, idPermission: p })),
);
}
const role = await getRoleWithPermissions(id);
return new Response(JSON.stringify(role), {
headers: { "content-type": "application/json" },
});
},
// #69 DELETE /roles/{idRole}
// Cascade: deletes role_permissions, detaches users (idRole set to null).
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const id = Number(context.params.idRole);
const role = await db
.select()
.from(roles)
.where(eq(roles.id, id))
.then((r) => r[0] ?? null);
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 });
},
};
+72
View File
@@ -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 });
}
},
};
@@ -0,0 +1,141 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { ueModules } from "$root/databases/schema.ts";
import { AuthenticatedState, isEmployee } 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-ECUE introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
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" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// #39 GET /ue-modules/{idModule}/{idUE}/{idPromo}
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
const idModule = context.params.idModule;
const idUE = Number(context.params.idUE);
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST();
}
const ueModuleAssociation = await db
.select()
.from(ueModules)
.where(
eq(ueModules.idModule, idModule),
eq(ueModules.idUE, idUE),
eq(ueModules.idPromo, idPromo),
)
.then((rows) => rows[0] ?? null);
if (!ueModuleAssociation) return NOT_FOUND();
return new Response(JSON.stringify(ueModuleAssociation), {
headers: { "content-type": "application/json" },
});
},
// #40 PUT /ue-modules/{idModule}/{idUE}/{idPromo}
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
const idModule = context.params.idModule;
const idUE = Number(context.params.idUE);
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST();
}
const body: { coeff: number } = await request.json();
if (typeof body.coeff !== "number") {
return new Response(
JSON.stringify({ error: "Le champ 'coeff' doit être un nombre" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [updated] = await db
.update(ueModules)
.set({ coeff: body.coeff })
.where(
and(
eq(ueModules.idModule, idModule),
eq(ueModules.idUE, idUE),
eq(ueModules.idPromo, idPromo),
),
)
.returning();
if (!updated) return NOT_FOUND();
return new Response(
JSON.stringify({
idModule: updated.idModule,
idUE: updated.idUE,
idPromo: updated.idPromo,
coeff: updated.coeff,
}),
{
headers: { "content-type": "application/json" },
},
);
},
// #41 DELETE /ue-modules/{idModule}/{idUE}/{idPromo}
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
const idModule = context.params.idModule;
const idUE = Number(context.params.idUE);
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST();
}
const [deleted] = await db
.delete(ueModules)
.where(
and(
eq(ueModules.idModule, idModule),
eq(ueModules.idUE, idUE),
eq(ueModules.idPromo, idPromo),
),
)
.returning();
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
};
+42
View File
@@ -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 });
}
},
};
+133
View File
@@ -0,0 +1,133 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "../../../../../databases/db.ts";
import {
ajustements,
ueModules,
ues,
} from "../../../../../databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers = {
// # 34 GET /ues/:idUE
async GET(_request, context) {
try {
const idUE = parseInt(context.params.idUE);
if (isNaN(idUE)) {
return new Response(
JSON.stringify({ error: "Paramètre idUE invalide" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const result = await db.select().from(ues).where(eq(ues.id, idUE));
if (result.length === 0) {
return new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify(result[0]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching UE:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
// #35 PUT /ues/:idUE
async PUT(request, context) {
try {
const idUE = parseInt(context.params.idUE);
if (isNaN(idUE)) {
return new Response(
JSON.stringify({ error: "Paramètre idUE invalide" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const body = await request.json();
const { nom } = body;
if (!nom) {
return new Response("Champ 'nom' manquant", { status: 400 });
}
const result = await db.update(ues).set({ nom }).where(eq(ues.id, idUE))
.returning();
if (result.length === 0) {
return new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify(result[0]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error updating UE:", error);
return new Response("Failed to update UE", { status: 500 });
}
},
// #36 DELETE /ues/:idUE
// Cascade: deletes ajustements, ue_modules for this UE.
async DELETE(_request, context) {
try {
const idUE = parseInt(context.params.idUE);
if (isNaN(idUE)) {
return new Response(
JSON.stringify({ error: "Paramètre idUE invalide" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const existing = await db.select().from(ues).where(eq(ues.id, idUE));
if (existing.length === 0) {
return new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
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);
return new Response("Failed to delete UE", { status: 500 });
}
},
};
+74
View File
@@ -0,0 +1,74 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { users } 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<null, AuthenticatedState> = {
// #60 GET /users
async GET(
request: Request,
_context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const url = new URL(request.url);
const idRole = url.searchParams.get("idRole");
const rows = idRole
? await db.select().from(users).where(eq(users.idRole, Number(idRole)))
: await db.select().from(users);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
});
},
// #61 POST /users
async POST(
request: Request,
_context: FreshContext<AuthenticatedState>,
): Promise<Response> {
let body: { id: string; nom: string; prenom: string; idRole: number };
try {
body = await request.json();
} catch {
return new Response(null, { status: 500 });
}
if (
!body.id || !body.id.trim() || !body.nom || !body.nom.trim() ||
!body.prenom || !body.prenom.trim()
) {
return new Response(null, { status: 400 });
}
const existing = await db
.select()
.from(users)
.where(eq(users.id, body.id))
.then((rows) => rows[0] ?? null);
if (existing) {
return new Response(
JSON.stringify({
error: "Un utilisateur avec cet identifiant existe déjà",
}),
{ status: 409, headers: { "content-type": "application/json" } },
);
}
const [created] = await db
.insert(users)
.values({
id: body.id,
nom: body.nom,
prenom: body.prenom,
idRole: body.idRole,
})
.returning();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
},
};
+76
View File
@@ -0,0 +1,76 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.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" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// #62 GET /users/{id}
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const user = await db
.select()
.from(users)
.where(eq(users.id, context.params.id))
.then((rows) => rows[0] ?? null);
if (!user) return NOT_FOUND();
return new Response(JSON.stringify(user), {
headers: { "content-type": "application/json" },
});
},
// #63 PUT /users/{id}
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const body: { nom: string; prenom: string; idRole: number } = await request
.json();
const [updated] = await db
.update(users)
.set({ nom: body.nom, prenom: body.prenom, idRole: body.idRole })
.where(eq(users.id, context.params.id))
.returning();
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
});
},
// #64 DELETE /users/{id}
// Cascade: deletes enseignements for this user.
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const id = context.params.id;
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 });
},
};
+2
View File
@@ -0,0 +1,2 @@
import makeIndex from "$root/defaults/makeIndex.ts";
export default makeIndex(import.meta.dirname!);
@@ -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<AuthenticatedState>,
) {
return <EditModule moduleId={context.params.idModule} />;
}
@@ -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 AdminEnseignements from "../(_islands)/AdminEnseignements.tsx";
// deno-lint-ignore require-await
async function Enseignements(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminEnseignements />;
}
export { Enseignements as Page };
export const config = getPartialsConfig();
export default makePartials(Enseignements);
@@ -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<State>,
) {
return (
<div class="page-content">
<h2 class="page-title">Importer une Maquette (UE & Modules)</h2>
<ImportMaquette />
</div>
);
}
export { ImportMaquettePage as Page };
export const config = getPartialsConfig();
export default makePartials(ImportMaquettePage);
+44
View File
@@ -0,0 +1,44 @@
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<State>,
) {
return (
<div class="page-content">
<h2 class="page-title">Administration</h2>
<p>
Bienvenue{" "}
<strong>
{(context.state as unknown as { session: Record<string, string> })
.session.displayName}
</strong>
.
</p>
<p>
Gérez les{" "}
<a href="/admin/modules" f-partial="/admin/partials/modules">
modules
</a>
,{" "}
<a href="/admin/users" f-partial="/admin/partials/users">
utilisateurs
</a>
,{" "}
<a href="/admin/roles" f-partial="/admin/partials/roles">
rôles
</a>{" "}
depuis la barre de navigation.
</p>
</div>
);
}
export const config = getPartialsConfig();
export default makePartials(Index);
+19
View File
@@ -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 AdminModules from "../(_islands)/AdminModules.tsx";
// deno-lint-ignore require-await
async function Modules(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminModules />;
}
export { Modules as Page };
export const config = getPartialsConfig();
export default makePartials(Modules);
@@ -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 AdminPermissions from "../(_islands)/AdminPermissions.tsx";
// deno-lint-ignore require-await
async function Permissions(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminPermissions />;
}
export { Permissions as Page };
export const config = getPartialsConfig();
export default makePartials(Permissions);
@@ -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 AdminPromotions from "../(_islands)/AdminPromotions.tsx";
// deno-lint-ignore require-await
async function Promotions(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminPromotions />;
}
export { Promotions as Page };
export const config = getPartialsConfig();
export default makePartials(Promotions);
+19
View File
@@ -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 AdminRoles from "../(_islands)/AdminRoles.tsx";
// deno-lint-ignore require-await
async function Roles(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminRoles />;
}
export { Roles as Page };
export const config = getPartialsConfig();
export default makePartials(Roles);
+19
View File
@@ -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 AdminUEs from "../(_islands)/AdminUEs.tsx";
// deno-lint-ignore require-await
async function UEs(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminUEs />;
}
export { UEs as Page };
export const config = getPartialsConfig();
export default makePartials(UEs);
+19
View File
@@ -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 AdminUsers from "../(_islands)/AdminUsers.tsx";
// deno-lint-ignore require-await
async function Users(
_request: Request,
_context: FreshContext<State>,
) {
return <AdminUsers />;
}
export { Users as Page };
export const config = getPartialsConfig();
export default makePartials(Users);
+11
View File
@@ -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<AuthenticatedState>,
) {
return <EditUser userId={context.params.id} />;
}
@@ -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<string | null>(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 <p className="error">{error}</p>;
}
if (!data?.promotions) {
return <p>No promotions found.</p>;
}
return (
<section>
<h2>Consult Mobility</h2>
{data.promotions.map((promo) => (
<div key={promo.id}>
<h3>Promotion: {promo.name}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Start Date</th>
<th>End Date</th>
<th>Weeks Count</th>
<th>Destination Country</th>
<th>Destination Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{data.students
?.filter((student) => student.promotionId === promo.id)
.map((student) => {
const mobility = data.mobilities?.find((mob) =>
mob.studentId === student.id
);
return (
<tr key={student.id}>
<td>{student.id}</td>
<td>{student.firstName}</td>
<td>{student.lastName}</td>
<td>{mobility?.startDate || "N/A"}</td>
<td>{mobility?.endDate || "N/A"}</td>
<td>{mobility?.weeksCount ?? "N/A"}</td>
<td>{mobility?.destinationCountry || "N/A"}</td>
<td>{mobility?.destinationName || "N/A"}</td>
<td>{mobility?.mobilityStatus || "N/A"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
</section>
);
}
@@ -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<string | null>(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 (
<section>
<h2>Consult Students</h2>
{error && <p className="error">{error}</p>}
{data?.promotions.map((promo) => (
<div key={promo.id}>
<h3>Promotion: {promo.id}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{data.students
.filter((student) => student.promotionId === promo.id)
.map((student) => (
<tr key={student.id}>
<td>{student.id}</td>
<td>{student.firstName}</td>
<td>{student.lastName}</td>
<td>{student.mail}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</section>
);
}
@@ -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<string | null>(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 <p className="error">{error}</p>;
}
if (!data?.promotions) {
return <p>Loading data...</p>;
}
return (
<section>
<h2>Edit Mobility</h2>
{data.promotions.map((promo) => (
<div key={promo.id}>
<h3>Promotion: {promo.name}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Start Date</th>
<th>End Date</th>
<th>Weeks Count</th>
<th>Destination Country</th>
<th>Destination Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={student.id}>
<td>{student.id}</td>
<td>{student.firstName}</td>
<td>{student.lastName}</td>
<td>
<input
type="date"
value={mobility.startDate || ""}
onChange={(e) =>
handleChange(
student.id,
"startDate",
e.target.value,
)}
/>
</td>
<td>
<input
type="date"
value={mobility.endDate || ""}
onChange={(e) =>
handleChange(student.id, "endDate", e.target.value)}
/>
</td>
<td>{mobility.weeksCount ?? "N/A"}</td>
<td>
<input
type="text"
value={mobility.destinationCountry || ""}
onChange={(e) =>
handleChange(
student.id,
"destinationCountry",
e.target.value,
)}
/>
</td>
<td>
<input
type="text"
value={mobility.destinationName || ""}
onChange={(e) =>
handleChange(
student.id,
"destinationName",
e.target.value,
)}
/>
</td>
<td>
<select
value={mobility.mobilityStatus}
onChange={(e) =>
handleChange(
student.id,
"mobilityStatus",
e.target.value,
)}
>
<option value="N/A">N/A</option>
<option value="Planned">Planned</option>
<option value="In Progress">In Progress</option>
<option value="Completed">Completed</option>
<option value="Validated">Validated</option>
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
<button type="button" onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Confirm"}
</button>
</section>
);
}
@@ -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<string, string> = {
contracts_received: "Contrats reçus",
under_revision: "En révision",
done: "Signé",
validated: "Validé",
canceled: "Annulé",
};
const STATUS_COLORS: Record<string, string> = {
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<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]);
const [mobilites, setMobilites] = useState<Mobilite[]>([]);
const [stagesMap, setStagesMap] = useState<Record<number, Stage>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"liste" | "kanban">("liste");
const [filterPromo, setFilterPromo] = useState("");
const [filterNom, setFilterNom] = useState("");
// Detail view state
const [detailStudent, setDetailStudent] = useState<Student | null>(null);
const [editingMob, setEditingMob] = useState<Mobilite | null>(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 (
<DetailView
student={detailStudent}
mobilites={mobilites.filter((m) => m.numEtud === detailStudent.numEtud)}
allMobilites={mobilites}
stagesMap={stagesMap}
editingMob={editingMob}
setEditingMob={setEditingMob}
showAddForm={showAddForm}
setShowAddForm={setShowAddForm}
onBack={closeStudent}
onReload={load}
/>
);
}
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
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 (
<div class="page-content">
<h2 class="page-title">Suivi des mobilités</h2>
<div class="tabs">
<button
type="button"
class={`tab-btn${tab === "liste" ? " active" : ""}`}
onClick={() => setTab("liste")}
>
Liste
</button>
<button
type="button"
class={`tab-btn${tab === "kanban" ? " active" : ""}`}
onClick={() => setTab("kanban")}
>
Kanban
</button>
</div>
<div class="filters">
<select
class="filter-select"
value={filterPromo}
onChange={(e) =>
setFilterPromo((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les promos</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<input
class="filter-input"
placeholder="Rechercher..."
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
</div>
{tab === "liste"
? (
<ListView
students={filtered}
mobsByStudent={mobsByStudent}
onConsult={(s) => openStudent(s)}
/>
)
: (
<KanbanView
students={filtered}
mobsByStudent={mobsByStudent}
onConsult={(s) => openStudent(s)}
/>
)}
</div>
);
}
// ─── Liste View ─────────────────────────────────────────────
function ListView(
{ students, mobsByStudent, onConsult }: {
students: Student[];
mobsByStudent: (n: number) => Mobilite[];
onConsult: (s: Student) => void;
},
) {
return (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>N° étud.</th>
<th>Nom</th>
<th>Prénom</th>
<th>Semaines</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{students.length === 0
? (
<tr>
<td colspan={5} class="state-empty">Aucun élève trouvé</td>
</tr>
)
: students.map((s) => {
const mobs = mobsByStudent(s.numEtud);
const weeks = validatedWeeks(mobs);
const ok = weeks >= REQUIRED_WEEKS;
return (
<tr key={s.numEtud}>
<td class="col-dim">{s.numEtud}</td>
<td>{s.nom}</td>
<td>{s.prenom}</td>
<td>
<span
style={{
color: ok ? "var(--ok-color,#22c55e)" : "#dc2626",
fontWeight: "var(--font-weight-bold)",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS}
</span>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => onConsult(s)}
>
Consulter
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── 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<string, Student[]> = {};
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 (
<div
style={{
display: "flex",
gap: "0.75rem",
overflowX: "auto",
paddingBottom: "0.5rem",
}}
>
{STATUS_ORDER.map((status) => (
<div
key={status}
style={{
minWidth: "14rem",
flex: "1",
borderRadius: "4px",
border:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
background: "light-dark(white, #141228)",
}}
>
<div
style={{
padding: "0.6rem 0.75rem",
borderBottom:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span
style={{
width: "0.6rem",
height: "0.6rem",
borderRadius: "50%",
background: STATUS_COLORS[status],
display: "inline-block",
}}
/>
<span
style={{
fontWeight: "var(--font-weight-bold)",
fontSize: "0.82rem",
}}
>
{STATUS_LABELS[status]}
</span>
<span
style={{
fontSize: "0.7rem",
opacity: "0.7",
marginLeft: "auto",
}}
>
{columns[status].length}
</span>
</div>
<div style={{ padding: "0.5rem" }}>
{columns[status].length === 0
? (
<p
style={{
fontSize: "0.75rem",
opacity: "0.5",
textAlign: "center",
margin: "1rem 0",
}}
>
Aucun
</p>
)
: columns[status].map((s) => {
const mobs = mobsByStudent(s.numEtud);
const weeks = validatedWeeks(mobs);
return (
<div
key={s.numEtud}
style={{
padding: "0.5rem 0.6rem",
marginBottom: "0.4rem",
borderRadius: "3px",
border:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
cursor: "pointer",
fontSize: "0.8rem",
}}
onClick={() => onConsult(s)}
>
<div
style={{
fontWeight: "var(--font-weight-bold)",
marginBottom: "0.15rem",
}}
>
{s.nom} {s.prenom}
</div>
<div
style={{
fontSize: "0.72rem",
display: "flex",
justifyContent: "space-between",
}}
>
<span style={{ opacity: "0.7" }}>{s.numEtud}</span>
<span
style={{
color: weeks >= REQUIRED_WEEKS
? "#22c55e"
: "#dc2626",
fontWeight: "var(--font-weight-bold)",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS} sem.
</span>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// ─── Detail View ────────────────────────────────────────────
function DetailView(
{
student,
mobilites,
allMobilites,
stagesMap,
editingMob,
setEditingMob,
showAddForm,
setShowAddForm,
onBack,
onReload,
}: {
student: Student;
mobilites: Mobilite[];
allMobilites: Mobilite[];
stagesMap: Record<number, Stage>;
editingMob: Mobilite | null;
setEditingMob: (m: Mobilite | null) => void;
showAddForm: boolean;
setShowAddForm: (v: boolean) => void;
onBack: () => void;
onReload: () => Promise<void>;
},
) {
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 (
<div class="page-content">
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onBack}
style={{ marginBottom: "0.75rem" }}
>
Retour
</button>
<h2 class="page-title">
Consulter : {student.prenom} {student.nom}
<span
style={{
fontSize: "0.8rem",
fontWeight: "normal",
marginLeft: "0.75rem",
color: weeks >= REQUIRED_WEEKS ? "#22c55e" : "#dc2626",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS} semaines validées
</span>
</h2>
{mobilites.length === 0 && (
<p class="state-empty">Aucune mobilité enregistrée.</p>
)}
{mobilites.map((mob, i) => {
const stage = mob.idStage ? stagesMap[mob.idStage] : null;
const isEditing = editingMob?.id === mob.id;
if (isEditing) {
return (
<MobEditForm
key={mob.id}
mob={mob}
ecoles={ecoles}
paysList={paysList}
onCancel={() => setEditingMob(null)}
onSave={async () => {
setEditingMob(null);
await onReload();
}}
/>
);
}
return (
<div key={mob.id} class="ue-card" style={{ marginBottom: "1rem" }}>
<div class="ue-card-header">
<p class="ue-card-title">
Mobilité {i + 1}
{stage ? " : Stage" : " : Étude"}
</p>
<p
class="ue-card-avg"
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
}}
>
<span
class={`note-chip ${
mob.status === "validated"
? "note-chip--ok"
: mob.status === "canceled"
? "note-chip--fail"
: "note-chip--none"
}`}
>
{STATUS_LABELS[mob.status] ?? mob.status}
</span>
<span>Durée : {mob.duree} semaine(s)</span>
</p>
</div>
<div style={{ padding: "0.6rem 1.1rem" }}>
{stage
? (
<p style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
Entreprise : <strong>{stage.nomEntreprise}</strong>
{stage.mission && <span> {stage.mission}</span>}
</p>
)
: (
<p style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
{mob.ecole && (
<>
École : <strong>{mob.ecole}</strong>
</>
)}
{mob.ecole && mob.pays && <span>,</span>}
{mob.pays && (
<>
Pays : <strong>{mob.pays}</strong>
</>
)}
</p>
)}
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginTop: "0.5rem",
}}
>
{mob.contratMob && (
<a
class="btn btn-sm btn-primary"
href={`/mobility/api/mobilites/${mob.id}/contrat`}
target="_blank"
>
Télécharger contrat
</a>
)}
{!mob.idStage && (
<UploadContratBtn
mobId={mob.id}
hasContrat={!!mob.contratMob}
onDone={onReload}
/>
)}
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() =>
setEditingMob(mob)}
>
Modifier
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
deleteMob(mob.id)}
>
Supprimer
</button>
</div>
</div>
</div>
);
})}
{showAddForm
? (
<MobAddForm
numEtud={student.numEtud}
ecoles={ecoles}
paysList={paysList}
availableStages={Object.values(stagesMap)
.filter((s) => s.numEtud === student.numEtud)
.filter((s) => !mobilites.some((m) => m.idStage === s.id))}
onCancel={() => setShowAddForm(false)}
onSave={async () => {
setShowAddForm(false);
await onReload();
}}
/>
)
: (
<button
type="button"
class="btn btn-primary"
onClick={() => setShowAddForm(true)}
>
+ Nouvelle mobilité
</button>
)}
</div>
);
}
// ─── Inline forms ───────────────────────────────────────────
function MobEditForm(
{ mob, ecoles, paysList, onCancel, onSave }: {
mob: Mobilite;
ecoles: string[];
paysList: string[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
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 (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Modifier la mobilité #{mob.id}</p>
<div class="form-grid">
<div class="form-field">
<label>Durée (semaines)</label>
<input
class="form-input"
type="number"
min="1"
value={duree}
onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/>
</div>
{!mob.idStage && (
<>
<div class="form-field">
<label>École</label>
<input
class="form-input"
list="edit-ecoles"
value={ecole}
onInput={(e) => setEcole((e.target as HTMLInputElement).value)}
/>
<datalist id="edit-ecoles">
{ecoles.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Pays</label>
<input
class="form-input"
list="edit-pays"
value={pays}
onInput={(e) => setPays((e.target as HTMLInputElement).value)}
/>
<datalist id="edit-pays">
{paysList.map((p) => <option key={p} value={p} />)}
</datalist>
</div>
<div class="form-field">
<label>Status</label>
<select
class="filter-select"
value={status}
onChange={(e) =>
setStatus((e.target as HTMLSelectElement).value)}
>
{STATUS_ORDER.map((s) => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</div>
</>
)}
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy}
onClick={submit}
>
Enregistrer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
function MobAddForm(
{ numEtud, ecoles, paysList, availableStages, onCancel, onSave }: {
numEtud: number;
ecoles: string[];
paysList: string[];
availableStages: Stage[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
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 (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Nouvelle mobilité</p>
<div class="form-grid">
{availableStages.length > 0 && (
<div class="form-field">
<label>Lier à un stage</label>
<select
class="filter-select"
value={selectedStageId}
onChange={(e) =>
onStageChange((e.target as HTMLSelectElement).value)}
>
<option value=""> Mobilité d'étude —</option>
{availableStages.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.nomEntreprise} ({s.duree} sem.)
</option>
))}
</select>
</div>
)}
<div class="form-field">
<label>Durée (semaines)</label>
<input
class="form-input"
type="number"
min="1"
value={duree}
onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/>
</div>
{!isStageLinked && (
<>
<div class="form-field">
<label>École</label>
<input
class="form-input"
list="add-ecoles"
value={ecole}
onInput={(e) => setEcole((e.target as HTMLInputElement).value)}
/>
<datalist id="add-ecoles">
{ecoles.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Pays</label>
<input
class="form-input"
list="add-pays"
value={pays}
onInput={(e) => setPays((e.target as HTMLInputElement).value)}
/>
<datalist id="add-pays">
{paysList.map((p) => <option key={p} value={p} />)}
</datalist>
</div>
<div class="form-field">
<label>Status</label>
<select
class="filter-select"
value={status}
onChange={(e) =>
setStatus((e.target as HTMLSelectElement).value)}
>
{STATUS_ORDER.map((s) => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</div>
</>
)}
</div>
{isStageLinked && (
<p
style={{
fontSize: "0.8rem",
opacity: 0.7,
margin: "0.4rem 0",
}}
>
Mobilité liée à un stage — status automatiquement « Validé »
</p>
)}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy || !duree}
onClick={submit}
>
Créer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
function UploadContratBtn(
{ mobId, hasContrat, onDone }: {
mobId: number;
hasContrat: boolean;
onDone: () => Promise<void>;
},
) {
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 (
<button
type="button"
class="btn btn-sm btn-secondary"
disabled={busy}
onClick={upload}
>
{busy ? "..." : hasContrat ? "Remplacer contrat" : "Ajouter contrat"}
</button>
);
}
+7 -6
View File
@@ -3,14 +3,15 @@ import { AppProperties } from "$root/defaults/interfaces.ts";
const properties: AppProperties = { const properties: AppProperties = {
name: "PolyMobility", name: "PolyMobility",
icon: "flight_takeoff", icon: "flight_takeoff",
hint: "Student mobility management", hint: "Suivi des mobilités internationales",
pages: { pages: {
index: "Homepage", index: "Accueil",
overview: "Mobility overview", overview: "Suivi des mobilités",
edit_mobility: "Mobility management", // "my-mobility": "Ma mobilité", // TODO Fix ma mobilité page, so it renders correctly for students
consult_students_test: "Test consult students",
}, },
adminOnly: ["edit_mobility", "consult_students_test"], adminOnly: ["overview"],
studentOnly: ["my-mobility"],
employeeOnly: true, // TODO Fix ma mobilité page, so it renders correctly for students
}; };
export default properties; export default properties;
+2
View File
@@ -0,0 +1,2 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
@@ -1,116 +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";
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 });
}
},
};
+115
View File
@@ -0,0 +1,115 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites } from "$root/databases/schema.ts";
import { AuthenticatedState, isEmployee } 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<null, AuthenticatedState> = {
// 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<AuthenticatedState>,
): Promise<Response> {
const employeeCheck = isEmployee(context.state.session);
try {
const body = await request.json();
const { numEtud, duree, ecole, pays, status, idStage } = body;
// Students can only create mobilites for themselves
if (!employeeCheck && 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 });
}
},
};
@@ -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, isEmployee } 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<null, AuthenticatedState> = {
// GET /mobilites/:idMob
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
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<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
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<string, unknown> = {};
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<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
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 });
},
};
@@ -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, isEmployee } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const CONTRACTS_DIR = "uploads/contracts";
export const handler: Handlers<null, AuthenticatedState> = {
// GET /mobilites/:idMob/contrat — download contract PDF
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
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<AuthenticatedState>,
): Promise<Response> {
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<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
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 });
},
};
@@ -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<State>) {
return (
<>
<h1>Test consult students</h1>
<ConsultStudents_test />
</>
);
}
export const config = getPartialsConfig();
export default makePartials(Mobility);
@@ -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<State>) {
return (
<>
<h1>Edit mobility</h1>
<EditMobility />
</>
);
}
export const config = getPartialsConfig();
export default makePartials(Mobility);
+19 -3
View File
@@ -3,11 +3,27 @@ import {
makePartials, makePartials,
} from "$root/defaults/makePartials.tsx"; } from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts"; 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 // deno-lint-ignore require-await
export async function Index(_request: Request, context: FreshContext<State>) { export async function Index(
return <h2>Welcome to {context.state.session?.displayName}.</h2>; _request: Request,
context: FreshContext<State>,
) {
return (
<div class="page-content">
<h2 class="page-title">Mobilité internationale</h2>
<p>
Bienvenue{" "}
<strong>
{(context.state as unknown as { session: Record<string, string> })
.session.displayName}
</strong>
.
</p>
<p>Suivi des mobilités : 12 semaines validées requises par élève.</p>
</div>
);
} }
export const config = getPartialsConfig(); export const config = getPartialsConfig();
+9 -10
View File
@@ -1,20 +1,19 @@
import ConsultMobility from "$root/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
import { import {
getPartialsConfig, getPartialsConfig,
makePartials, makePartials,
} from "$root/defaults/makePartials.tsx"; } from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts"; 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 // deno-lint-ignore require-await
async function Mobility(_request: Request, _context: FreshContext<State>) { async function Overview(
return ( _request: Request,
<> _context: FreshContext<State>,
<h1>Edit mobility</h1> ) {
<ConsultMobility /> return <MobilityOverview />;
</>
);
} }
export { Overview as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Mobility); export default makePartials(Overview);
@@ -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<State>,
) {
const numEtud = Number(context.params.numEtud);
return <MobilityOverview initialNumEtud={numEtud} />;
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
@@ -0,0 +1,155 @@
import { useEffect, useState } from "preact/hooks";
type Student = {
numEtud: number;
nom: string;
prenom: string;
idPromo: string;
};
type Promotion = { id: string; annee: string | null };
export default function AdminConsultNotes() {
const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterPromo, setFilterPromo] = useState("");
const [filterNom, setFilterNom] = useState("");
const [filterPrenom, setFilterPrenom] = useState("");
const [applied, setApplied] = useState({
promo: "",
nom: "",
prenom: "",
});
useEffect(() => {
async function load() {
try {
const [sRes, pRes] = await Promise.all([
fetch("/students/api/students"),
fetch("/students/api/promotions"),
]);
if (!sRes.ok) throw new Error("Impossible de charger les étudiants");
setStudents(await sRes.json());
if (pRes.ok) setPromos(await pRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
load();
}, []);
const filtered = students.filter((s) => {
if (applied.promo && s.idPromo !== applied.promo) return false;
if (
applied.nom &&
!s.nom.toLowerCase().includes(applied.nom.toLowerCase())
) return false;
if (
applied.prenom &&
!s.prenom.toLowerCase().includes(applied.prenom.toLowerCase())
) return false;
return true;
});
function applyFilters() {
setApplied({ promo: filterPromo, nom: filterNom, prenom: filterPrenom });
}
return (
<div class="page-content">
<div class="toolbar">
<h2 class="page-title">Consulter les Notes</h2>
</div>
{error && <p class="state-error">{error}</p>}
<div class="filters">
<select
class="filter-select"
value={filterPromo}
onChange={(e) =>
setFilterPromo((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les promos</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<input
class="filter-input"
placeholder="Nom"
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
<input
class="filter-input"
placeholder="Prénom"
value={filterPrenom}
onInput={(e) => setFilterPrenom((e.target as HTMLInputElement).value)}
/>
<button type="button" class="btn btn-primary" onClick={applyFilters}>
Filtrer
</button>
</div>
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Promo</th>
<th>Nom</th>
<th>Prénom</th>
<th>N° Étudiant</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{filtered.length === 0
? (
<tr>
<td colspan={5} class="state-empty">
Aucun étudiant trouvé
</td>
</tr>
)
: filtered.map((s) => (
<tr key={s.numEtud}>
<td class="col-promo">{s.idPromo}</td>
<td>{s.nom}</td>
<td>{s.prenom}</td>
<td class="col-dim">{s.numEtud}</td>
<td>
<div class="col-actions">
<a
class="btn btn-sm btn-secondary"
href={`/notes/edition/${s.numEtud}`}
f-client-nav={false}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>{" "}
édit
</a>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,630 @@
// @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 {
calculateWeightedAverage,
getEffectiveNote,
roundGrade,
} from "$root/logic/grades.ts";
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<File | null>(null);
const dragging = useSignal(false);
const uploading = useSignal(false);
const error = useSignal<string | null>(null);
const importResult = useSignal<ImportResult | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const students = useSignal<Student[]>([]);
const columns = useSignal<ColumnInfo[]>([]);
const sheetNames = useSignal<string[]>([]);
const selectedSheet = useSignal("");
const session = useSignal<"1" | "2">("1");
const workbookRef = useRef<XLSX.WorkBook | null>(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)) {
error.value = "Fichier invalide — format attendu : .xlsx";
return;
}
file.value = f;
error.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 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 onSheetChange(name: string) {
selectedSheet.value = name;
if (workbookRef.current) {
parseSheet(workbookRef.current, name);
}
}
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 (!workbookRef.current || !selectedSheet.value) return;
uploading.value = true;
error.value = null;
importResult.value = null;
try {
const sheet = workbookRef.current.Sheets[selectedSheet.value];
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
header: 1,
});
const moduleCols = columns.value.filter((c) => c.type === "module");
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`,
});
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<string, unknown> = {
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`,
});
}
}
}
importResult.value = { added, modified, ignored, errors, details };
} catch {
error.value = "Erreur lors de l'import.";
} finally {
uploading.value = false;
}
}
function downloadTemplate() {
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<string, string>(
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<number, string>(
uesData.map((u: { id: number; nom: string }) => [u.id, u.nom]),
);
const umByUE = new Map<number, typeof ueModulesData>();
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<string>();
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<string, { note: number; noteSession2: number | null }>
>();
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,
);
const notesRecord: Record<string, { note: number; noteSession2: number | null }> = {};
ueMods.forEach(um => {
const n = sNotes.get(um.id);
if (n) notesRecord[um.id] = n;
});
const avg = calculateWeightedAverage(
ueMods.map(m => ({ idModule: m.id, coeff: m.coeff ?? 0 })),
notesRecord
);
row.push(avg !== null ? roundGrade(avg) : 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,
);
const notesRecord: Record<string, { note: number; noteSession2: number | null }> = {};
ueMods.forEach(um => {
const n = sNotes.get(um.id);
if (n) notesRecord[um.id] = n;
});
const avg = calculateWeightedAverage(
ueMods.map(m => ({ idModule: m.id, coeff: m.coeff ?? 0 })),
notesRecord
);
row.push(avg !== null ? roundGrade(avg) : 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 (
<div>
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls"
style="display:none"
onChange={(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) pickFile(f);
}}
/>
<div
class={`drop-zone${dragging.value ? " dragging" : ""}`}
onDragOver={(e) => {
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()}
>
<span class="drop-zone-icon"></span>
{file.value ? <span class="drop-zone-file">{file.value.name}</span> : (
<>
<span class="drop-zone-text">Glisser le fichier .xlsx ici</span>
<span class="drop-zone-hint">ou cliquer pour parcourir</span>
</>
)}
</div>
{error.value && <p class="state-error">{error.value}</p>}
{importResult.value && (
<ImportResultPopup
result={importResult.value}
onClose={() => (importResult.value = null)}
/>
)}
{/* Sheet + session selector */}
{sheetNames.value.length > 0 && (
<div style="display: flex; gap: 1rem; margin-bottom: 0.75rem; flex-wrap: wrap">
<div>
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
Feuille
</label>
<select
class="filter-select"
value={selectedSheet.value}
onChange={(e) =>
onSheetChange((e.target as HTMLSelectElement).value)}
>
{sheetNames.value.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div>
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
Importer en tant que
</label>
<select
class="filter-select"
value={session.value}
onChange={(e) => (session.value = (e.target as HTMLSelectElement)
.value as "1" | "2")}
>
<option value="1">Session 1 (note)</option>
<option value="2">Session 2 (noteSession2)</option>
</select>
</div>
</div>
)}
{/* Column preview */}
{columns.value.length > 0 && (
<div style="margin-bottom: 1rem">
<p style="font-size: 0.82rem; font-weight: 600; margin-bottom: 0.5rem">
Colonnes detectees :
</p>
<div style="display: flex; flex-wrap: wrap; gap: 0.35rem">
{columns.value.map((col) => (
<span
key={col.index}
class={`numEtud-chip${
col.type === "module"
? ""
: col.type === "malus"
? " note-chip--fail"
: " note-chip--promo"
}`}
style="font-size: 0.72rem"
title={`${col.type}${col.name}${
col.coeff != null ? ` (coef ${col.coeff})` : ""
}`}
>
{col.type === "module"
? "M"
: col.type === "ue"
? "UE"
: col.type === "malus"
? "X"
: "?"} {col.code}
</span>
))}
</div>
<p class="col-dim" style="font-size: 0.72rem; margin-top: 0.35rem">
M = ECUE (importe) | UE = moyenne UE (ignore) | X = malus
</p>
</div>
)}
<div class="upload-actions">
<button
type="button"
class="btn btn-primary"
onClick={doImport}
disabled={!file.value || uploading.value ||
columns.value.filter((c) => c.type === "module").length === 0}
>
{uploading.value ? "..." : "+ Importer"}
</button>
<button
type="button"
class="btn btn-secondary"
onClick={downloadTemplate}
>
Telecharger Modele
</button>
{
/* TODO: fix blob download in Fresh
<button
type="button"
class="btn btn-secondary"
onClick={downloadExport}
>
Exporter Notes
</button>
*/
}
</div>
<p class="upload-format">
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
<strong>CODE - ECUE</strong> (colonnes notes){" "}
les colonnes UE et MALUS sont auto-detectees
</p>
</div>
);
}
@@ -0,0 +1,568 @@
import { useEffect, useState } from "preact/hooks";
import {
applyAjustement,
calculateWeightedAverage,
getEffectiveNote,
} from "$root/logic/grades.ts";
type Student = {
// ...
numEtud: number;
nom: string;
prenom: string;
idPromo: string;
};
type UE = { id: number; nom: string };
type UEModule = {
idModule: string;
idUE: number;
idPromo: string;
coeff: number;
};
type Module = { id: string; nom: string };
type Note = {
numEtud: number;
idModule: string;
note: number;
noteSession2: number | null;
};
type Ajustement = {
numEtud: number;
idUE: number;
valeur: number;
malus: number;
};
type Props = { numEtud: number };
function fmt(n: number): string {
return `${Math.round(n * 10) / 10}/20`;
}
function noteClass(n: number): string {
return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail";
}
export default function NoteRecap({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null);
const [ueList, setUeList] = useState<UE[]>([]);
const [ueModules, setUeModules] = useState<UEModule[]>([]);
const [moduleMap, setModuleMap] = useState<Map<string, string>>(new Map());
const [noteMap, setNoteMap] = useState<Map<string, Note>>(new Map());
const [ajustements, setAjustements] = useState<Ajustement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingNote, setEditingNote] = useState<
{ idModule: string; field: "note" | "noteSession2"; value: string } | null
>(null);
const [ajustInputs, setAjustInputs] = useState<
Record<number, { valeur: string; malus: string }>
>({});
async function load() {
try {
const sRes = await fetch(`/students/api/students/${numEtud}`);
if (!sRes.ok) throw new Error("Eleve introuvable");
const s: Student = await sRes.json();
setStudent(s);
const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([
fetch("/notes/api/ues"),
fetch(
`/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
),
fetch("/notes/api/modules"),
fetch(`/notes/api/notes?numEtud=${numEtud}`),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]);
if (uesRes.ok) setUeList(await uesRes.json());
if (umRes.ok) setUeModules(await umRes.json());
if (mRes.ok) {
const mods: Module[] = await mRes.json();
setModuleMap(new Map(mods.map((m) => [m.id, m.nom])));
}
if (notesRes.ok) {
const ns: Note[] = await notesRes.json();
setNoteMap(new Map(ns.map((n) => [n.idModule, n])));
}
if (ajustRes.ok) {
const aj: Ajustement[] = await ajustRes.json();
setAjustements(aj);
const inputs: Record<number, { valeur: string; malus: string }> = {};
for (const a of aj) {
inputs[a.idUE] = {
valeur: String(a.valeur),
malus: String(a.malus),
};
}
setAjustInputs(inputs);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, [numEtud]);
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 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<string, unknown> = {
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(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 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, malus }),
})
: await fetch("/notes/api/ajustements", {
method: "POST",
headers: { "content-type": "application/json" },
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, updated]
);
}
}
async function resetAjust(idUE: number) {
const res = await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, {
method: "DELETE",
});
if (res.ok) {
setAjustements((prev) => prev.filter((a) => a.idUE !== idUE));
setAjustInputs((prev) => {
const c = { ...prev };
delete c[idUE];
return c;
});
}
}
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error && !student) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
if (!student) return null;
return (
<div class="page-content">
<a
class="back-link"
href="/notes/courses"
f-partial="/notes/partials/courses"
>
Retour a la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Recap notes {student.prenom} {student.nom}
</h2>
<div class="info-bar" style="margin-bottom: 1.25rem">
<span class="numEtud-chip">{student.numEtud}</span>
<span style="font-weight: 600">
{student.prenom} {student.nom}
</span>
<span class="note-chip note-chip--promo">{student.idPromo}</span>
</div>
{error && <p class="state-error">{error}</p>}
{ueList.length === 0
? (
<p class="state-empty">
Aucune UE configuree pour cette promotion.
</p>
)
: ueList.map((ue) => {
const ueMods = ueList.length > 0 ? ueModules.filter((um) => um.idUE === ue.id) : [];
const notesRecord = Object.fromEntries(noteMap);
const avg = calculateWeightedAverage(ueMods, notesRecord);
const ajust = ajustements.find((a) => a.idUE === ue.id) ?? null;
const finalAvg = applyAjustement(avg, ajust);
return (
<div key={ue.id} class="edit-section">
{/* UE header */}
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap">
<p class="edit-section-title" style="margin: 0">{ue.nom}</p>
{avg !== null && (
<span
class={noteClass(avg)}
style="font-size: 0.78rem"
>
Moy. calculee : {fmt(avg)}
</span>
)}
{ajust && (
<span
class="note-chip note-chip--ajust"
style="font-size: 0.78rem"
>
Ajust. actif : {fmt(ajust.valeur)}
</span>
)}
{ajust && ajust.malus > 0 && (
<span
class="note-chip note-chip--fail"
style="font-size: 0.78rem"
>
Malus : -{ajust.malus}
</span>
)}
</div>
{/* ECUE rows */}
{ueMods.length === 0
? (
<p
class="col-dim"
style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem"
>
Aucun ECUE associe a cette UE pour cette promotion.
</p>
)
: (
<div style="margin-bottom: 0.75rem">
{ueMods.map((um) => {
const noteObj = noteMap.get(um.idModule);
const noteVal = noteObj?.note;
const noteS2 = noteObj?.noteSession2;
const effective = noteObj
? getEffectiveNote(noteObj)
: undefined;
const nomMod = moduleMap.get(um.idModule) ?? um.idModule;
return (
<div key={um.idModule} class="note-row">
<span class="note-row-label">
<span class="numEtud-chip note-row-chip">
{um.idModule}
</span>
{nomMod}
</span>
<span class="col-dim note-row-coef">
coef {um.coeff}
</span>
{/* Session 1 note */}
{editingNote?.idModule === um.idModule &&
editingNote.field === "note"
? (
<div style="display: flex; align-items: center; gap: 0.25rem">
<input
class="form-input"
style="width: 5rem; text-align: center; font-size: 0.85rem"
value={editingNote.value}
autoFocus
onInput={(e) =>
setEditingNote({
...editingNote,
value:
(e.target as HTMLInputElement).value,
})}
onKeyDown={(e) => {
if (e.key === "Enter") {
saveNote(
um.idModule,
"note",
editingNote.value,
);
}
if (e.key === "Escape") {
setEditingNote(null);
}
}}
onBlur={() =>
saveNote(
um.idModule,
"note",
editingNote.value,
)}
/>
<span
class="col-dim"
style="font-size: 0.75rem"
>
/20
</span>
</div>
)
: (
<span
class={noteVal !== undefined
? noteClass(noteVal)
: "note-chip note-chip--none"}
style="font-size: 0.78rem; cursor: pointer"
title="S1 — Cliquer pour modifier"
onClick={() =>
setEditingNote({
idModule: um.idModule,
field: "note",
value: noteVal !== undefined
? String(noteVal)
: "",
})}
>
S1:{" "}
{noteVal !== undefined ? fmt(noteVal) : "—/20"}
</span>
)}
{/* Session 2 note */}
{editingNote?.idModule === um.idModule &&
editingNote.field === "noteSession2"
? (
<div style="display: flex; align-items: center; gap: 0.25rem">
<input
class="form-input"
style="width: 5rem; text-align: center; font-size: 0.85rem"
value={editingNote.value}
autoFocus
placeholder="vide = suppr"
onInput={(e) =>
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,
)}
/>
<span
class="col-dim"
style="font-size: 0.75rem"
>
/20
</span>
</div>
)
: (
<span
class={noteS2 != null
? noteClass(noteS2)
: "note-chip note-chip--none"}
style="font-size: 0.78rem; cursor: pointer"
title="S2 — Cliquer pour modifier (vide = pas de session 2)"
onClick={() =>
setEditingNote({
idModule: um.idModule,
field: "noteSession2",
value: noteS2 != null ? String(noteS2) : "",
})}
>
S2: {noteS2 != null ? fmt(noteS2) : "—"}
</span>
)}
{/* Effective note indicator */}
{noteS2 != null && (
<span
class="col-dim"
style="font-size: 0.72rem; font-style: italic"
>
{fmt(effective!)}
</span>
)}
</div>
);
})}
</div>
)}
{/* Ajustement + Malus */}
<div class="ajust-section">
<p class="ajust-title">Ajustement de la moyenne UE</p>
<p class="ajust-hint">
La valeur remplace la moyenne calculee. Le malus est
soustrait.
</p>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap">
<div style="display: flex; align-items: center; gap: 0.25rem">
<span class="col-dim" style="font-size: 0.8rem">
Val:
</span>
<input
class="form-input"
style="width: 4.5rem; text-align: center"
placeholder="—"
value={ajustInputs[ue.id]?.valeur ?? ""}
onInput={(e) =>
setAjustInputs((prev) => ({
...prev,
[ue.id]: {
valeur: (e.target as HTMLInputElement).value,
malus: prev[ue.id]?.malus ?? "0",
},
}))}
/>
<span class="col-dim" style="font-size: 0.8rem">/20</span>
</div>
<div style="display: flex; align-items: center; gap: 0.25rem">
<span class="col-dim" style="font-size: 0.8rem">
Malus:
</span>
<input
type="number"
class="form-input"
style="width: 4rem; text-align: center"
placeholder="0"
min="0"
value={ajustInputs[ue.id]?.malus ?? ""}
onInput={(e) =>
setAjustInputs((prev) => ({
...prev,
[ue.id]: {
valeur: prev[ue.id]?.valeur ?? "",
malus: (e.target as HTMLInputElement).value,
},
}))}
/>
</div>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => applyAjust(ue.id)}
>
Appliquer
</button>
{ajust && (
<>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => resetAjust(ue.id)}
>
Reinitialiser
</button>
<span
class="col-dim"
style="font-size: 0.75rem; font-family: monospace"
>
Affiche : {fmt(ajust.valeur)}
{ajust.malus > 0
? ` - ${ajust.malus} = ${
fmt(ajust.valeur - ajust.malus)
}`
: ""}
{avg !== null ? ` (calculee : ${fmt(avg)})` : ""}
</span>
</>
)}
</div>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,230 @@
import { useEffect, useState } from "preact/hooks";
import {
applyAjustement,
calculateWeightedAverage,
getEffectiveNote,
} from "$root/logic/grades.ts";
type Note = {
numEtud: number;
idModule: string;
note: number;
noteSession2: number | null;
};
// ... rest of types unchanged ...
type UE = { id: number; nom: string };
type UEModule = {
idModule: string;
idUE: number;
idPromo: string;
coeff: number;
};
type Module = { id: string; nom: string };
type Ajustement = {
numEtud: number;
idUE: number;
valeur: number;
malus: number;
};
type Props = {
numEtud: number | null;
prenom: string;
};
function scoreClass(score: number | null): string {
if (score === null) return "score-none";
return score >= 10 ? "score-good" : "score-warn";
}
function avgClass(avg: number | null): string {
if (avg === null) return "";
return avg >= 10 ? "avg-good" : "avg-warn";
}
export default function NotesView({ numEtud, prenom }: Props) {
const [notes, setNotes] = useState<Note[]>([]);
const [ues, setUes] = useState<UE[]>([]);
const [ueModules, setUeModules] = useState<UEModule[]>([]);
const [modules, setModules] = useState<Module[]>([]);
const [ajustements, setAjustements] = useState<Ajustement[]>([]);
const [promos, setPromos] = useState<string[]>([]);
const [activePromo, setActivePromo] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (numEtud === null) {
setLoading(false);
return;
}
async function load() {
try {
const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([
fetch(`/notes/api/notes?numEtud=${numEtud}`),
fetch("/notes/api/ues"),
fetch("/notes/api/ue-modules"),
fetch("/notes/api/modules"),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]);
if (!notesRes.ok || !uesRes.ok || !ueModRes.ok) {
throw new Error("Erreur lors du chargement");
}
const [notesData, uesData, ueModData, modData, ajData] = await Promise
.all([
notesRes.json(),
uesRes.json(),
ueModRes.json(),
modRes.ok ? modRes.json() : [],
ajRes.ok ? ajRes.json() : [],
]);
setNotes(notesData);
setUes(uesData);
setUeModules(ueModData);
setModules(modData);
setAjustements(ajData);
const noteModuleIds = new Set(notesData.map((n: Note) => n.idModule));
const relevantPromos = [
...new Set(
ueModData
.filter((um: UEModule) => noteModuleIds.has(um.idModule))
.map((um: UEModule) => um.idPromo),
),
] as string[];
setPromos(relevantPromos);
if (relevantPromos.length > 0) setActivePromo(relevantPromos[0]);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur inconnue");
} finally {
setLoading(false);
}
}
load();
}, [numEtud]);
if (numEtud === null) {
return (
<div class="page-content">
<p class="state-empty">
Bonjour {prenom}{" "}
aucun dossier etudiant n'est associe a votre compte.
</p>
</div>
);
}
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
const filteredUeModules = activePromo
? ueModules.filter((um) => um.idPromo === activePromo)
: ueModules;
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]),
);
const ajMap = Object.fromEntries(
ajustements.map((a) => [a.idUE, a]),
);
return (
<div class="page-content">
{promos.length > 1 && (
<div class="tabs">
{promos.map((p) => (
<button
type="button"
key={p}
class={`tab-btn${activePromo === p ? " active" : ""}`}
onClick={() => setActivePromo(p)}
>
{p}
</button>
))}
</div>
)}
{ueIds.length === 0 && (
<p class="state-empty">Aucune note disponible pour cette periode.</p>
)}
{ueIds.map((ueId) => {
const ue = ues.find((u) => u.id === ueId);
if (!ue) return null;
const ueModsForUE = filteredUeModules.filter((um) => um.idUE === ueId);
const avg = calculateWeightedAverage(ueModsForUE, noteMap);
const ajust = ajMap[ueId] ?? null;
const finalAvg = applyAjustement(avg, ajust);
return (
<div key={ueId} class="ue-card">
<div class="ue-card-header">
<p class="ue-card-title">UE : {ue.nom}</p>
{finalAvg !== null
? (
<p class={`ue-card-avg ${avgClass(finalAvg)}`}>
Moyenne : {finalAvg.toFixed(2)}/20
{ajust && ajust.malus > 0 && (
<span>(malus : -{ajust.malus})</span>
)}
</p>
)
: <p class="ue-card-avg avg-warn">Notes non disponibles</p>}
</div>
{ueModsForUE.map((um) => {
const mod = moduleMap[um.idModule];
const noteObj = noteMap[um.idModule] ?? null;
const effective = noteObj ? getEffectiveNote(noteObj) : null;
const hasS2 = noteObj?.noteSession2 != null;
return (
<div key={um.idModule} class="ue-module-row">
<span class="ue-module-name">
{mod ? mod.id : um.idModule} {" "}
{mod ? mod.nom : "ECUE inconnu"} (coef {um.coeff})
</span>
<span class={`score-chip ${scoreClass(effective)}`}>
{effective !== null ? `${effective}/20` : "—"}
{hasS2 && (
<span
style="font-size: 0.7rem; opacity: 0.7; margin-left: 0.35rem"
title={`Session 1 : ${noteObj!.note}/20`}
>
(S1: {noteObj!.note})
</span>
)}
</span>
</div>
);
})}
</div>
);
})}
</div>
);
}
+6 -4
View File
@@ -4,11 +4,13 @@ const properties: AppProperties = {
name: "PolyNotes", name: "PolyNotes",
icon: "school", icon: "school",
pages: { pages: {
index: "Homepage", index: "Accueil",
notes: "Notes", notes: "Mes notes",
courses: "Courses management", courses: "Consulter",
import: "Import Notes",
}, },
adminOnly: ["courses", "students"], adminOnly: ["courses", "import"],
studentOnly: ["notes"],
hint: "Student grading management", hint: "Student grading management",
}; };
+2
View File
@@ -0,0 +1,2 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
+98
View File
@@ -0,0 +1,98 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { ajustements } from "$root/databases/schema.ts";
import { AuthenticatedState, isEmployee } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers<null, AuthenticatedState> = {
// #48 GET /ajustements
async GET(request) {
try {
const url = new URL(request.url);
const numEtudParam = url.searchParams.get("numEtud");
const idUEParam = url.searchParams.get("idUE");
let query = db.select().from(ajustements).$dynamic();
if (numEtudParam) {
const numEtud = parseInt(numEtudParam);
if (isNaN(numEtud)) {
return new Response("Paramètre numEtud invalide", { status: 400 });
}
query = query.where(eq(ajustements.numEtud, numEtud));
}
if (idUEParam) {
const idUE = parseInt(idUEParam);
if (isNaN(idUE)) {
return new Response("Paramètre idUE invalide", { status: 400 });
}
query = query.where(eq(ajustements.idUE, idUE));
}
const result = await query;
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching ajustements:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
// #49 POST /ajustements
async POST(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return new Response(null, { status: 403 });
}
try {
const body: {
numEtud: number;
idUE: number;
valeur: number;
malus?: number;
} = await request.json();
if (!body.numEtud || !body.idUE || body.valeur === undefined) {
return new Response(
JSON.stringify({ error: "Champs requis: numEtud, idUE, valeur" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
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();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
} catch (error) {
console.error("Error creating ajustement:", error);
return new Response("Failed to create ajustement", { status: 500 });
}
},
};
@@ -0,0 +1,123 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { ajustements } from "$root/databases/schema.ts";
import { AuthenticatedState, isEmployee } 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 FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #50 GET /ajustements/{numEtud}/{idUE}
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
const idUE = Number(context.params.idUE);
if (isNaN(numEtud) || isNaN(idUE)) {
return new Response("Paramètres invalides", { status: 400 });
}
const ajustement = await db
.select()
.from(ajustements)
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.then((rows) => rows[0] ?? null);
if (!ajustement) return NOT_FOUND();
return new Response(JSON.stringify(ajustement), {
headers: { "content-type": "application/json" },
});
},
// #51 PUT /ajustements/{numEtud}/{idUE}
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
const idUE = Number(context.params.idUE);
if (isNaN(numEtud) || isNaN(idUE)) {
return new Response("Paramètres invalides", { status: 400 });
}
const body: { valeur: number; malus?: number } = await request.json();
if (body.valeur === undefined) {
return new Response(JSON.stringify({ error: "Champ requis: valeur" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
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(set)
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.returning();
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
});
},
// #52 DELETE /ajustements/{numEtud}/{idUE}
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (!isEmployee(context.state.session)) {
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
const idUE = Number(context.params.idUE);
if (isNaN(numEtud) || isNaN(idUE)) {
return new Response("Paramètres invalides", { status: 400 });
}
const [deleted] = await db
.delete(ajustements)
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.returning();
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
};
+12
View File
@@ -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" },
});
},
};

Some files were not shown because too many files have changed in this diff Show More