Compare commits

..

1 Commits

Author SHA1 Message Date
djalim 6c38cd0019 feat: cascade deletes, student notes, import popups, module reorganization
- Cascade delete on all entities (student, module, UE, user, role, promotion)
- Fix Response body reuse bug (factory functions instead of constants)
- Student note viewing via CAS uid (strip non-digit prefix)
- Fix middleware page visibility for students in LOCAL mode
- Import result popup component (shared across all import pages)
- Fix student import to use numEtud from Excel
- Bulk student selection with promo change and delete
- Move UE/UE-Module API and pages from notes to admin module
- Move promotions page from students to admin module
- Multi-year maquette import with per-year promo selection
- Inline promo creation in maquette import
- Static Excel templates (students, notes, maquette)
- Fix XLSX export using blob download instead of writeFile
- Allow students to read modules list (GET /modules)
2026-04-30 13:47:16 +02:00
136 changed files with 6764 additions and 8016 deletions
-8
View File
@@ -1,8 +0,0 @@
#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
-4
View File
@@ -56,10 +56,6 @@ jobs:
run: |
sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
sed 's/--> statement-breakpoint/;/g' databases/migrations/0003_add_session2_and_malus.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
sed 's/--> statement-breakpoint/;/g' databases/migrations/0004_add_stages_and_mobilites.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
- name: Install dependencies
run: npm install --ignore-scripts && deno install
+79
View File
@@ -0,0 +1,79 @@
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
- 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
+354
View File
@@ -0,0 +1,354 @@
# PolyMPR - Claude Code Context
## 📋 Project Overview
**PolyMPR** (Poly Management Platform for Resources) is a modular HR management
system built with **Deno + Fresh** framework. It's designed to help
organizations manage HR, student records, notes, mobility programs, and
role-based administration.
### Stack
- **Runtime**: Deno
- **Framework**: Fresh (edge-ready web framework)
- **Database**: PostgreSQL with Drizzle ORM
- **Frontend**: Preact with signals
- **Authentication**: JWT-based via cookies
- **Testing**: Deno test framework with HappyDOM for DOM testing
### Current Status
🚧 **In Progress** - API layer largely complete, UI pages not yet built. The
schema below is the **final/definitive schema** that guides all development.
---
## 🏗️ Architecture
### Module Structure
The application uses a **modulith architecture** with the following modules:
```
routes/(apps)/
├── students/ - Student management & promotions
├── notes/ - Grade management & academic records
├── mobility/ - Mobility programs & exchanges
└── admin/ - Role & permission management
```
### Key Directories
- `/routes` - Fresh routes and components
- `/databases` - Database connection, schema, and migrations
- `/defaults` - Interfaces and shared types
- `/tests` - Unit, integration, and E2E tests
- `/static` - Public assets
### Authentication Flow
1. User authenticates via CAS (Polytech)
2. JWT token stored in `sessionToken` cookie
3. Middleware validates token on each request
4. Public routes: `/`, `/login`, `/logout`, `/about`, `/contact`
5. All other routes require authentication
---
## 📊 Database Schema (Final/Definitive)
```mermaid
erDiagram
USER {
string id PK
string nom
string prenom
int idRole FK
}
ROLE {
int id PK
string nom
}
PERMISSION {
int id PK
string nom
}
ROLE_PERMISSION {
int idRole PK,FK
int idPermission PK,FK
}
STUDENT {
int numEtud PK
string nom
string prenom
string idPromo FK
}
PROMOTION {
string idPromo PK
string annee
}
MODULE {
string id PK
string nom
}
ENSEIGNEMENT {
string idProf PK,FK
string idModule PK,FK
string idPromo PK,FK
}
UE {
int id PK
string nom
}
UE_MODULE {
string idModule PK,FK
int idUE PK,FK
string idPromo PK,FK
float coeff
}
NOTE {
int numEtud PK,FK
string idModule PK,FK
float note
}
AJUSTEMENT {
int numEtud PK,FK
int idUE PK,FK
float valeur
}
USER }o--|| ROLE : "a"
ROLE_PERMISSION }o--|| ROLE : "accorde"
ROLE_PERMISSION }o--|| PERMISSION : "inclut"
ENSEIGNEMENT }o--|| USER : "réalisé par"
ENSEIGNEMENT }o--|| MODULE : "porte sur"
ENSEIGNEMENT }o--|| PROMOTION : "concerne"
STUDENT }o--|| PROMOTION : "appartient à"
UE_MODULE }o--|| MODULE : "associe"
UE_MODULE }o--|| UE : "appartient à"
UE_MODULE }o--|| PROMOTION : "pour"
NOTE }o--|| STUDENT : "reçoit"
NOTE }o--|| MODULE : "dans"
AJUSTEMENT }o--|| STUDENT : "concerne"
AJUSTEMENT }o--|| UE : "dans"
```
### Current Schema
The Drizzle ORM schema in `/databases/schema.ts` implements all tables: `roles`,
`permissions`, `rolePermissions`, `users`, `promotions`, `students`, `modules`,
`enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`.
---
## 🎯 Open Issues (69 total)
### UI Pages
**Catalog**
- 📋 UI - Page Catalogue d'applications (#71)
**Components**
- 🎨 UI (composant) - Popup Résultats d'import (#75)
**Students**
- 📋 UI - Admin Liste des élèves (#79)
- 📋 UI - Admin Gestion des promotions (#80)
- 📋 UI - Admin Import xlsx élèves (#81)
- 📋 UI - Admin Édition d'un élève (#82)
**Notes**
- 📋 UI - Page Élève Mes Notes (#72)
- 📋 UI - Admin Consulter les notes (#73)
- 📋 UI - Admin Importer des notes (.xlsx) (#74)
- 📋 UI - Admin Édition notes d'un élève (#76)
- 📋 UI - Admin Récap notes élève / semestre (#77)
- 📋 UI - Admin Gestion des UEs (#78)
**Administration**
- 📋 UI - Gestion des utilisateurs (#83)
- 📋 UI - Gestion des rôles (#84)
- 📋 UI - Permissions d'un rôle (#85)
- 📋 UI - Vue des permissions (#86)
- 📋 UI - Gestion des modules (#87)
- 📋 UI - Enseignements (Assignations) (#88)
---
### API Endpoints
Legend: ✅ implemented & tested | 📋 not yet implemented
**Students API**
- ✅ GET `/students` (#7)
- ✅ POST `/students` (#8)
- ✅ POST `/students/import-csv` (#9)
- ✅ GET `/students/{numEtud}` (#10)
- ✅ PUT `/students/{numEtud}` (#11)
- ✅ DELETE `/students/{numEtud}` (#12)
- ✅ GET `/promotions` (#13)
- ✅ POST `/promotions` (#14)
- ✅ GET `/promotions/{idPromo}` (#15)
- ✅ PUT `/promotions/{idPromo}` (#16)
- ✅ DELETE `/promotions/{idPromo}` (#17)
**Administration API - Modules & Enseignements**
- ✅ GET `/modules` (#23)
- ✅ POST `/modules` (#24)
- ✅ GET `/modules/{idModule}` (#25)
- ✅ PUT `/modules/{idModule}` (#26)
- ✅ DELETE `/modules/{idModule}` (#27)
- ✅ POST `/enseignements` (#29)
- ✅ GET `/enseignements/{idProf}/{idModule}/{idPromo}` (#30)
- ✅ DELETE `/enseignements/{idProf}/{idModule}/{idPromo}` (#31)
**Notes API - UEs & UE-Modules**
- ✅ GET `/ues` (#32)
- ✅ POST `/ues` (#33)
- ✅ GET `/ues/{idUE}` (#34)
- ✅ PUT `/ues/{idUE}` (#35)
- ✅ DELETE `/ues/{idUE}` (#36)
- ✅ GET `/ue-modules` (#37)
- ✅ POST `/ue-modules` (#38)
- ✅ GET `/ue-modules/{idModule}/{idUE}/{idPromo}` (#39)
- ✅ PUT `/ue-modules/{idModule}/{idUE}/{idPromo}` (#40)
- ✅ DELETE `/ue-modules/{idModule}/{idUE}/{idPromo}` (#41)
**Notes API - Notes & Ajustements**
- ✅ GET `/notes` (#42)
- ✅ POST `/notes` (#43)
- 📋 POST `/notes/import-xlsx` (#44)
- ✅ GET `/notes/{numEtud}/{idModule}` (#45)
- ✅ PUT `/notes/{numEtud}/{idModule}` (#46)
- ✅ DELETE `/notes/{numEtud}/{idModule}` (#47)
- ✅ GET `/ajustements` (#48)
- ✅ POST `/ajustements` (#49)
- ✅ GET `/ajustements/{numEtud}/{idUE}` (#50)
- ✅ PUT `/ajustements/{numEtud}/{idUE}` (#51)
- ✅ DELETE `/ajustements/{numEtud}/{idUE}` (#52)
**Administration API - Users, Roles & Permissions**
- ✅ GET `/users` (#60)
- ✅ POST `/users` (#61)
- ✅ GET `/users/{id}` (#62)
- ✅ PUT `/users/{id}` (#63)
- ✅ DELETE `/users/{id}` (#64)
- ✅ GET `/roles` (#65)
- ✅ POST `/roles` (#66)
- ✅ GET `/roles/{idRole}` (#67)
- ✅ PUT `/roles/{idRole}` (#68)
- ✅ DELETE `/roles/{idRole}` (#69)
- ✅ GET `/permissions` (#70)
---
## 🎨 Design Reference
**Figma Prototype**:
https://www.figma.com/design/La79bsUsWnJCtMsrrt2zGd/Prototype?node-id=0-1
This is the **final design specification** for the UI. All UI implementations
should follow this design.
---
## 🚀 Development Guidelines
### Getting Started
```bash
# Run tests
deno task test
# Start development server
deno task start
# Build for production
deno task build
# Format & lint
deno task check
```
### Git Workflow
1. Create branch: `git checkout -b PMPR-{ISSUE_ID}`
2. Implement changes
3. Run tests and linting
4. Submit PR
### Code Style
- Format: Follow Deno defaults (enforced via `deno fmt`)
- Linting: Fresh recommended rules
- TypeScript strict mode enabled
- Use Drizzle ORM for database operations
### Testing
3-level architecture — all 149 tests pass:
- **Unit** (`tests/unit/`) — pure logic with mock DB + mock API, no real DB
- **Integration** (`tests/integration/`) — Drizzle ORM direct on real DB
- **E2E** (`tests/e2e/`) — Fresh handler + real DB (handler-level, not browser)
Helpers in `tests/helpers/`:
- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`,
`makeJsonRequest`…)
- `db_integration.ts` — seed functions + `truncateAll()` for test isolation
- `db_mock.ts` / `api_mock.ts` — in-memory mocks for unit tests
```bash
deno task test # run all tests
deno task test:coverage # coverage report (terminal)
deno task test:coverage:html # coverage report (HTML → coverage/html/index.html)
nix run nixpkgs#act -- -j unit --no-cache-server # unit tests via GitHub Actions
nix run nixpkgs#act -- -j integration --no-cache-server # integration + e2e via GitHub Actions
```
---
## 📦 Key Dependencies
- **fresh@1.7.3** - Web framework
- **drizzle-orm@0.45.2** - ORM
- **pg@8.20.0** - PostgreSQL driver
- **@popov/jwt@1.0.1** - JWT utilities
- **preact@10.22.0** - UI library
- **happy-dom@16.0.0** - DOM testing
---
## 🔗 Related Resources
- **Repository**: https://git.polytech.djalim.fr/djalim/PolyMPR
- **Issue Tracker**: Gitea (via `tea` CLI)
- **Wiki**: Check CONTRIBUTING.md for dev setup
- **Database**: PostgreSQL (configured in `.env`)
---
## 💡 Important Notes
1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints
are implemented.
2. **Next priority**: UI pages (none built yet) — follow the Figma prototype.
3. **Module Pattern**: Each module should follow the same pattern: routes, API
endpoints, components, and tests.
4. **Permissions**: All admin operations should respect the ROLE_PERMISSION
system.
5. **Fresh Conventions**: Routes use Fresh's file-based routing convention
(e.g., `routes/path/index.tsx`).
6. **Drizzle `.where()` pitfall**: Always wrap multiple conditions with `and()`.
`.where(eq(a), eq(b))` silently ignores the second argument.
-3
View File
@@ -30,12 +30,9 @@ services:
ports:
- "4430:443"
env_file: .env
volumes:
- contracts:/app/uploads/contracts
depends_on:
migrate:
condition: service_completed_successfully
volumes:
db_data:
contracts:
+24
View File
@@ -0,0 +1,24 @@
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]
@@ -1,28 +0,0 @@
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;
-7
View File
@@ -29,13 +29,6 @@
"when": 1777155028711,
"tag": "0003_add_session2_and_malus",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1777155028712,
"tag": "0004_add_stages_and_mobilites",
"breakpoints": true
}
]
}
+10 -26
View File
@@ -1,7 +1,7 @@
import {
date,
doublePrecision,
integer,
pgEnum,
pgTable,
primaryKey,
serial,
@@ -89,29 +89,13 @@ export const ajustements = pgTable("ajustements", {
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),
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"),
});
+358
View File
@@ -0,0 +1,358 @@
-- ============================================================
-- PolyMPR — Test seed data
-- Source: JIN7SAA Semestre 7 Informatique FISE
-- ============================================================
-- ------------------------------------------------------------
-- 1. Promotion
-- ------------------------------------------------------------
INSERT INTO promotions ("idPromo", "annee") VALUES
('4AFISE', '2024')
ON CONFLICT ("idPromo") DO NOTHING;
-- ------------------------------------------------------------
-- 2. Students
-- ------------------------------------------------------------
INSERT INTO students ("numEtud", nom, prenom, "idPromo") VALUES
(24029625, 'MARTIN', 'Sophie', '4AFISE'),
(22005810, 'DUPONT', 'Lucas', '4AFISE'),
(24026283, 'BERNARD', 'Emma', '4AFISE'),
(24024101, 'THOMAS', 'Hugo', '4AFISE'),
(24021136, 'PETIT', 'Léa', '4AFISE'),
(24027947, 'ROBERT', 'Nathan', '4AFISE'),
(22001491, 'RICHARD', 'Alice', '4AFISE'),
(22023423, 'SIMON', 'Théo', '4AFISE'),
(21217292, 'LAURENT', 'Camille', '4AFISE'),
(24024550, 'LEFEBVRE', 'Maxime', '4AFISE'),
(22019957, 'MICHEL', 'Inès', '4AFISE'),
(22008222, 'GARCIA', 'Baptiste', '4AFISE'),
(22006557, 'DAVID', 'Manon', '4AFISE'),
(22019337, 'BERTRAND', 'Julien', '4AFISE'),
(22017498, 'ROUX', 'Chloé', '4AFISE'),
(22019070, 'VINCENT', 'Tom', '4AFISE'),
(21213966, 'FOURNIER', 'Jade', '4AFISE'),
(25027910, 'MOREL', 'Enzo', '4AFISE'),
(24025920, 'GIRARD', 'Anaïs', '4AFISE'),
(21212006, 'ANDRÉ', 'Romain', '4AFISE'),
(21230594, 'LEFÈVRE', 'Pauline', '4AFISE'),
(22010753, 'MERCIER', 'Axel', '4AFISE'),
(24031005, 'DUPUIS', 'Clara', '4AFISE'),
(24029523, 'LAMBERT', 'Florian', '4AFISE'),
(24025433, 'BONNET', 'Mathilde', '4AFISE'),
(23024748, 'FRANCOIS', 'Louis', '4AFISE'),
(24016406, 'MARTINEZ', 'Zoé', '4AFISE'),
(24028169, 'LEBLANC', 'Kévin', '4AFISE'),
(22017365, 'GARNIER', 'Julie', '4AFISE'),
(23026015, 'CHEVALIER', 'Antoine', '4AFISE')
ON CONFLICT ("numEtud") DO NOTHING;
-- ------------------------------------------------------------
-- 3. Role: Professeur
-- ------------------------------------------------------------
INSERT INTO roles (nom) VALUES ('Professeur')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 4. Permissions du rôle Professeur
-- ------------------------------------------------------------
INSERT INTO role_permissions ("idRole", "idPermission")
SELECT r.id, p.id
FROM roles r, permissions p
WHERE r.nom = 'Professeur'
AND p.id IN ('note_read', 'note_write', 'student_read', 'module_read')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 5. Professor users
-- ------------------------------------------------------------
INSERT INTO users (id, nom, prenom, "idRole")
SELECT prof.id, prof.nom, prof.prenom, r.id
FROM roles r,
(VALUES
('prof.jin701b', 'DUBOIS', 'Pierre'),
('prof.jin701c', 'MOREAU', 'Claire'),
('prof.jin710a', 'DURAND', 'François'),
('prof.jin702a', 'LEROY', 'Anne'),
('prof.jin702b', 'GIRARD', 'Michel'),
('prof.jin702c', 'MOREL', 'Patricia'),
('prof.jin703a', 'SIMON', 'Jacques'),
('prof.jin703b', 'LAURENT', 'Sylvie'),
('prof.jin703c', 'LEFEBVRE', 'René'),
('prof.jin712a', 'ROUSSEAU', 'Hélène'),
('prof.jin712b', 'BLANC', 'Philippe'),
('prof.jin712c', 'GUERIN', 'Stéphane'),
('prof.jtr701a', 'THOMAS', 'Sarah'),
('prof.jtr701d', 'ROBIN', 'Christine'),
('prof.jtr701e', 'DAVID', 'Bernard'),
('prof.jtr701f', 'FAURE', 'Daniel')
) AS prof(id, nom, prenom)
WHERE r.nom = 'Professeur'
ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------
-- 6. Modules
-- ------------------------------------------------------------
INSERT INTO modules (id, nom) VALUES
('JIN701B', 'Programmation distribuée'),
('JIN701C', 'Projet développement 1'),
('JIN710A', 'Virtualisation et Conteneurisation'),
('JIN702A', 'Probabilités'),
('JIN702B', 'Statistiques'),
('JIN702C', 'Optimisation'),
('JIN703A', 'Programmation graphique'),
('JIN703B', 'Analyse d''images'),
('JIN703C', 'Modélisation géométrique'),
('JIN712A', 'Apprentissage automatique'),
('JIN712B', 'Fouilles de données'),
('JIN712C', 'Base de données avancées'),
('JTR701A', 'Anglais'),
('JTR701D', 'Gestion commerciale et marketing'),
('JTR701E', 'Management de la qualité'),
('JTR701F', 'Management de Projet')
ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------
-- 7. UEs
-- ------------------------------------------------------------
INSERT INTO ues (nom) VALUES
('Conception et développement Avancés 1'),
('Mathématiques pour l''Informatique 2'),
('Introduction à la réalité mixte'),
('IA et Science des Données Avancées'),
('Langues et SHEJS 3')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 8. UE-Module associations (coeff 1 for all)
-- ------------------------------------------------------------
INSERT INTO ue_modules ("idModule", "idUE", "idPromo", coeff)
SELECT m, u.id, '4AFISE', 1
FROM ues u,
(VALUES
('JIN701B', 'Conception et développement Avancés 1'),
('JIN701C', 'Conception et développement Avancés 1'),
('JIN710A', 'Conception et développement Avancés 1'),
('JIN702A', 'Mathématiques pour l''Informatique 2'),
('JIN702B', 'Mathématiques pour l''Informatique 2'),
('JIN702C', 'Mathématiques pour l''Informatique 2'),
('JIN703A', 'Introduction à la réalité mixte'),
('JIN703B', 'Introduction à la réalité mixte'),
('JIN703C', 'Introduction à la réalité mixte'),
('JIN712A', 'IA et Science des Données Avancées'),
('JIN712B', 'IA et Science des Données Avancées'),
('JIN712C', 'IA et Science des Données Avancées'),
('JTR701A', 'Langues et SHEJS 3'),
('JTR701D', 'Langues et SHEJS 3'),
('JTR701E', 'Langues et SHEJS 3'),
('JTR701F', 'Langues et SHEJS 3')
) AS mapping(m, ue_nom)
WHERE u.nom = mapping.ue_nom
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 9. Enseignements
-- ------------------------------------------------------------
INSERT INTO enseignements ("idProf", "idModule", "idPromo") VALUES
('prof.jin701b', 'JIN701B', '4AFISE'),
('prof.jin701c', 'JIN701C', '4AFISE'),
('prof.jin710a', 'JIN710A', '4AFISE'),
('prof.jin702a', 'JIN702A', '4AFISE'),
('prof.jin702b', 'JIN702B', '4AFISE'),
('prof.jin702c', 'JIN702C', '4AFISE'),
('prof.jin703a', 'JIN703A', '4AFISE'),
('prof.jin703b', 'JIN703B', '4AFISE'),
('prof.jin703c', 'JIN703C', '4AFISE'),
('prof.jin712a', 'JIN712A', '4AFISE'),
('prof.jin712b', 'JIN712B', '4AFISE'),
('prof.jin712c', 'JIN712C', '4AFISE'),
('prof.jtr701a', 'JTR701A', '4AFISE'),
('prof.jtr701d', 'JTR701D', '4AFISE'),
('prof.jtr701e', 'JTR701E', '4AFISE'),
('prof.jtr701f', 'JTR701F', '4AFISE')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 10. Notes (ABI / #VALEUR! entries are skipped)
-- ------------------------------------------------------------
INSERT INTO notes ("numEtud", "idModule", note) VALUES
-- 24029625 MARTIN Sophie
(24029625,'JIN701B',14.00),(24029625,'JIN701C',13.50),(24029625,'JIN710A',15.00),
(24029625,'JIN702A',15.50),(24029625,'JIN702B',17.00),(24029625,'JIN702C',13.00),
(24029625,'JIN703A',15.00),(24029625,'JIN703B',14.00),(24029625,'JIN703C',8.44),
(24029625,'JIN712A',15.50),(24029625,'JIN712B',13.80),(24029625,'JIN712C',13.10),
(24029625,'JTR701A',16.10),(24029625,'JTR701D',11.00),(24029625,'JTR701E',14.21),(24029625,'JTR701F',15.48),
-- 22005810 DUPONT Lucas
(22005810,'JIN701B',10.50),(22005810,'JIN701C',14.00),(22005810,'JIN710A',14.00),
(22005810,'JIN702A',20.00),(22005810,'JIN702B',16.00),(22005810,'JIN702C',16.00),
(22005810,'JIN703A',15.00),(22005810,'JIN703B',13.00),(22005810,'JIN703C',7.50),
(22005810,'JIN712A',10.85),(22005810,'JIN712B',11.50),(22005810,'JIN712C',10.80),
(22005810,'JTR701A',12.10),(22005810,'JTR701D',10.00),(22005810,'JTR701E',11.85),(22005810,'JTR701F',12.47),
-- 24026283 BERNARD Emma
(24026283,'JIN701B',11.50),(24026283,'JIN701C',16.50),(24026283,'JIN710A',13.00),
(24026283,'JIN702A',17.00),(24026283,'JIN702B',15.00),(24026283,'JIN702C',14.00),
(24026283,'JIN703A',14.00),(24026283,'JIN703B',12.00),(24026283,'JIN703C',11.25),
(24026283,'JIN712A',13.25),(24026283,'JIN712B',13.00),(24026283,'JIN712C',15.70),
(24026283,'JTR701A',16.10),(24026283,'JTR701D',14.00),(24026283,'JTR701E',15.50),(24026283,'JTR701F',13.91),
-- 24024101 THOMAS Hugo
(24024101,'JIN701B',10.50),(24024101,'JIN701C',13.50),(24024101,'JIN710A',12.00),
(24024101,'JIN702A',13.00),(24024101,'JIN702B',11.00),(24024101,'JIN702C',14.00),
(24024101,'JIN703A',17.00),(24024101,'JIN703B',12.00),(24024101,'JIN703C',11.88),
(24024101,'JIN712A',6.50),(24024101,'JIN712B',18.30),(24024101,'JIN712C',13.90),
(24024101,'JTR701A',17.50),(24024101,'JTR701D',17.50),(24024101,'JTR701E',16.09),(24024101,'JTR701F',15.04),
-- 24021136 PETIT Léa
(24021136,'JIN701B',15.00),(24021136,'JIN701C',15.00),(24021136,'JIN710A',12.00),
(24021136,'JIN702A',16.50),(24021136,'JIN702B',17.00),(24021136,'JIN702C',10.00),
(24021136,'JIN703A',14.00),(24021136,'JIN703B',13.00),(24021136,'JIN703C',9.38),
(24021136,'JIN712A',8.50),(24021136,'JIN712B',13.90),(24021136,'JIN712C',15.20),
(24021136,'JTR701A',18.20),(24021136,'JTR701D',15.00),(24021136,'JTR701E',12.83),(24021136,'JTR701F',14.06),
-- 24027947 ROBERT Nathan
(24027947,'JIN701B',7.50),(24027947,'JIN701C',15.00),(24027947,'JIN710A',13.50),
(24027947,'JIN702A',20.00),(24027947,'JIN702B',17.00),(24027947,'JIN702C',14.00),
(24027947,'JIN703A',13.00),(24027947,'JIN703B',10.00),(24027947,'JIN703C',8.75),
(24027947,'JIN712A',7.50),(24027947,'JIN712B',13.90),(24027947,'JIN712C',10.00),
(24027947,'JTR701A',17.30),(24027947,'JTR701D',16.00),(24027947,'JTR701E',14.71),(24027947,'JTR701F',11.61),
-- 22001491 RICHARD Alice
(22001491,'JIN701B',17.50),(22001491,'JIN701C',13.50),(22001491,'JIN710A',15.00),
(22001491,'JIN702A',20.00),(22001491,'JIN702B',15.00),(22001491,'JIN702C',14.00),
(22001491,'JIN703A',15.00),(22001491,'JIN703B',15.00),(22001491,'JIN703C',15.94),
(22001491,'JIN712A',5.50),(22001491,'JIN712B',18.10),(22001491,'JIN712C',15.75),
(22001491,'JTR701A',17.70),(22001491,'JTR701D',11.50),(22001491,'JTR701E',14.35),(22001491,'JTR701F',15.61),
-- 22023423 SIMON Théo
(22023423,'JIN701B',13.00),(22023423,'JIN701C',16.00),(22023423,'JIN710A',13.00),
(22023423,'JIN702A',20.00),(22023423,'JIN702B',18.00),(22023423,'JIN702C',14.50),
(22023423,'JIN703A',13.00),(22023423,'JIN703B',11.00),(22023423,'JIN703C',14.69),
(22023423,'JIN712A',7.00),(22023423,'JIN712B',13.70),(22023423,'JIN712C',14.95),
(22023423,'JTR701A',18.10),(22023423,'JTR701D',18.00),(22023423,'JTR701E',14.82),(22023423,'JTR701F',14.36),
-- 21217292 LAURENT Camille
(21217292,'JIN701B',16.00),(21217292,'JIN701C',13.50),(21217292,'JIN710A',15.00),
(21217292,'JIN702A',5.00),(21217292,'JIN702B',8.00),(21217292,'JIN702C',19.00),
(21217292,'JIN703A',14.00),(21217292,'JIN703B',14.00),(21217292,'JIN703C',11.88),
(21217292,'JIN712A',15.00),(21217292,'JIN712B',16.10),(21217292,'JIN712C',12.95),
(21217292,'JTR701A',18.10),(21217292,'JTR701D',14.50),(21217292,'JTR701E',12.57),(21217292,'JTR701F',13.61),
-- 24024550 LEFEBVRE Maxime
(24024550,'JIN701B',16.00),(24024550,'JIN701C',13.50),(24024550,'JIN710A',13.00),
(24024550,'JIN702A',20.00),(24024550,'JIN702B',9.00),(24024550,'JIN702C',15.00),
(24024550,'JIN703A',17.00),(24024550,'JIN703B',12.00),(24024550,'JIN703C',10.31),
(24024550,'JIN712A',16.50),(24024550,'JIN712B',12.40),(24024550,'JIN712C',16.60),
(24024550,'JTR701A',16.80),(24024550,'JTR701D',18.00),(24024550,'JTR701E',14.70),(24024550,'JTR701F',13.41),
-- 22019957 MICHEL Inès (JIN703C=ABI skipped)
(22019957,'JIN701B',10.50),(22019957,'JIN701C',14.00),(22019957,'JIN710A',12.00),
(22019957,'JIN702A',14.00),(22019957,'JIN702B',18.00),(22019957,'JIN702C',11.50),
(22019957,'JIN703A',15.00),(22019957,'JIN703B',10.00),
(22019957,'JIN712A',4.00),(22019957,'JIN712B',15.90),(22019957,'JIN712C',10.00),
(22019957,'JTR701A',15.80),(22019957,'JTR701D',18.00),(22019957,'JTR701E',14.77),(22019957,'JTR701F',14.11),
-- 22008222 GARCIA Baptiste
(22008222,'JIN701B',16.00),(22008222,'JIN701C',14.00),(22008222,'JIN710A',18.00),
(22008222,'JIN702A',18.00),(22008222,'JIN702B',17.00),(22008222,'JIN702C',14.50),
(22008222,'JIN703A',15.00),(22008222,'JIN703B',13.00),(22008222,'JIN703C',15.31),
(22008222,'JIN712A',15.50),(22008222,'JIN712B',17.00),(22008222,'JIN712C',16.40),
(22008222,'JTR701A',17.90),(22008222,'JTR701D',16.00),(22008222,'JTR701E',13.62),(22008222,'JTR701F',13.04),
-- 22006557 DAVID Manon
(22006557,'JIN701B',13.50),(22006557,'JIN701C',17.50),(22006557,'JIN710A',14.00),
(22006557,'JIN702A',20.00),(22006557,'JIN702B',18.00),(22006557,'JIN702C',10.50),
(22006557,'JIN703A',15.00),(22006557,'JIN703B',15.00),(22006557,'JIN703C',9.38),
(22006557,'JIN712A',17.00),(22006557,'JIN712B',14.40),(22006557,'JIN712C',16.50),
(22006557,'JTR701A',13.80),(22006557,'JTR701D',17.00),(22006557,'JTR701E',15.77),(22006557,'JTR701F',14.75),
-- 22019337 BERTRAND Julien
(22019337,'JIN701B',11.00),(22019337,'JIN701C',14.00),(22019337,'JIN710A',13.50),
(22019337,'JIN702A',15.00),(22019337,'JIN702B',17.00),(22019337,'JIN702C',11.50),
(22019337,'JIN703A',15.00),(22019337,'JIN703B',13.00),(22019337,'JIN703C',10.00),
(22019337,'JIN712A',2.00),(22019337,'JIN712B',12.70),(22019337,'JIN712C',14.05),
(22019337,'JTR701A',14.20),(22019337,'JTR701D',10.00),(22019337,'JTR701E',10.84),(22019337,'JTR701F',14.23),
-- 22017498 ROUX Chloé
(22017498,'JIN701B',8.00),(22017498,'JIN701C',16.00),(22017498,'JIN710A',12.00),
(22017498,'JIN702A',20.00),(22017498,'JIN702B',18.00),(22017498,'JIN702C',10.00),
(22017498,'JIN703A',15.00),(22017498,'JIN703B',14.00),(22017498,'JIN703C',9.06),
(22017498,'JIN712A',16.00),(22017498,'JIN712B',10.60),(22017498,'JIN712C',16.00),
(22017498,'JTR701A',15.80),(22017498,'JTR701D',13.00),(22017498,'JTR701E',14.19),(22017498,'JTR701F',13.61),
-- 22019070 VINCENT Tom
(22019070,'JIN701B',18.00),(22019070,'JIN701C',15.00),(22019070,'JIN710A',13.50),
(22019070,'JIN702A',14.00),(22019070,'JIN702B',13.00),(22019070,'JIN702C',17.50),
(22019070,'JIN703A',16.00),(22019070,'JIN703B',14.00),(22019070,'JIN703C',16.88),
(22019070,'JIN712A',18.50),(22019070,'JIN712B',16.20),(22019070,'JIN712C',12.65),
(22019070,'JTR701A',18.80),(22019070,'JTR701D',16.50),(22019070,'JTR701E',11.63),(22019070,'JTR701F',15.11),
-- 21213966 FOURNIER Jade (JTR701D=ABI skipped)
(21213966,'JIN701B',7.50),(21213966,'JIN701C',14.00),(21213966,'JIN710A',14.00),
(21213966,'JIN702A',17.00),(21213966,'JIN702B',17.00),(21213966,'JIN702C',12.50),
(21213966,'JIN703A',15.00),(21213966,'JIN703B',13.00),(21213966,'JIN703C',3.75),
(21213966,'JIN712A',4.50),(21213966,'JIN712B',12.50),(21213966,'JIN712C',13.75),
(21213966,'JTR701A',11.00),(21213966,'JTR701E',15.99),(21213966,'JTR701F',11.61),
-- 25027910 MOREL Enzo
(25027910,'JIN701B',7.50),(25027910,'JIN701C',15.00),(25027910,'JIN710A',13.00),
(25027910,'JIN702A',3.00),(25027910,'JIN702B',4.00),(25027910,'JIN702C',10.50),
(25027910,'JIN703A',12.00),(25027910,'JIN703B',12.00),(25027910,'JIN703C',6.25),
(25027910,'JIN712A',7.85),(25027910,'JIN712B',10.20),(25027910,'JIN712C',13.00),
(25027910,'JTR701A',14.30),(25027910,'JTR701D',12.00),(25027910,'JTR701E',13.36),(25027910,'JTR701F',14.86),
-- 24025920 GIRARD Anaïs (JIN712C=ABI, JTR701D=ABI skipped)
(24025920,'JIN701B',10.50),(24025920,'JIN701C',12.00),(24025920,'JIN710A',13.00),
(24025920,'JIN702A',19.00),(24025920,'JIN702B',19.00),(24025920,'JIN702C',7.50),
(24025920,'JIN703A',11.00),(24025920,'JIN703B',10.00),(24025920,'JIN703C',7.19),
(24025920,'JIN712A',8.25),(24025920,'JIN712B',10.20),
(24025920,'JTR701A',10.60),(24025920,'JTR701E',4.29),(24025920,'JTR701F',8.73),
-- 21212006 ANDRÉ Romain
(21212006,'JIN701B',12.50),(21212006,'JIN701C',17.50),(21212006,'JIN710A',17.00),
(21212006,'JIN702A',12.00),(21212006,'JIN702B',9.00),(21212006,'JIN702C',10.00),
(21212006,'JIN703A',15.00),(21212006,'JIN703B',14.00),(21212006,'JIN703C',13.44),
(21212006,'JIN712A',16.50),(21212006,'JIN712B',12.00),(21212006,'JIN712C',16.40),
(21212006,'JTR701A',18.30),(21212006,'JTR701D',16.00),(21212006,'JTR701E',10.85),(21212006,'JTR701F',13.29),
-- 21230594 LEFÈVRE Pauline
(21230594,'JIN701B',18.00),(21230594,'JIN701C',16.50),(21230594,'JIN710A',11.00),
(21230594,'JIN702A',5.00),(21230594,'JIN702B',13.00),(21230594,'JIN702C',17.50),
(21230594,'JIN703A',11.00),(21230594,'JIN703B',12.00),(21230594,'JIN703C',18.44),
(21230594,'JIN712A',16.00),(21230594,'JIN712B',15.90),(21230594,'JIN712C',11.60),
(21230594,'JTR701A',18.80),(21230594,'JTR701D',16.00),(21230594,'JTR701E',14.61),(21230594,'JTR701F',14.61),
-- 22010753 MERCIER Axel
(22010753,'JIN701B',9.00),(22010753,'JIN701C',14.00),(22010753,'JIN710A',14.00),
(22010753,'JIN702A',17.00),(22010753,'JIN702B',15.00),(22010753,'JIN702C',13.00),
(22010753,'JIN703A',13.00),(22010753,'JIN703B',13.00),(22010753,'JIN703C',7.50),
(22010753,'JIN712A',5.50),(22010753,'JIN712B',15.00),(22010753,'JIN712C',15.15),
(22010753,'JTR701A',13.40),(22010753,'JTR701D',15.50),(22010753,'JTR701E',15.80),(22010753,'JTR701F',13.11),
-- 24031005 DUPUIS Clara
(24031005,'JIN701B',8.00),(24031005,'JIN701C',15.00),(24031005,'JIN710A',13.00),
(24031005,'JIN702A',16.00),(24031005,'JIN702B',19.00),(24031005,'JIN702C',9.50),
(24031005,'JIN703A',15.00),(24031005,'JIN703B',13.00),(24031005,'JIN703C',9.06),
(24031005,'JIN712A',5.00),(24031005,'JIN712B',10.90),(24031005,'JIN712C',14.35),
(24031005,'JTR701A',15.00),(24031005,'JTR701D',12.00),(24031005,'JTR701E',10.78),(24031005,'JTR701F',13.36),
-- 24029523 LAMBERT Florian
(24029523,'JIN701B',13.00),(24029523,'JIN701C',16.50),(24029523,'JIN710A',12.50),
(24029523,'JIN702A',14.00),(24029523,'JIN702B',6.00),(24029523,'JIN702C',10.50),
(24029523,'JIN703A',14.00),(24029523,'JIN703B',11.00),(24029523,'JIN703C',14.38),
(24029523,'JIN712A',11.50),(24029523,'JIN712B',9.50),(24029523,'JIN712C',15.60),
(24029523,'JTR701A',16.30),(24029523,'JTR701D',18.00),(24029523,'JTR701E',10.99),(24029523,'JTR701F',12.23),
-- 24025433 BONNET Mathilde
(24025433,'JIN701B',9.50),(24025433,'JIN701C',10.00),(24025433,'JIN710A',11.00),
(24025433,'JIN702A',8.00),(24025433,'JIN702B',6.00),(24025433,'JIN702C',20.00),
(24025433,'JIN703A',2.50),(24025433,'JIN703B',12.00),(24025433,'JIN703C',18.44),
(24025433,'JIN712A',13.50),(24025433,'JIN712B',14.20),(24025433,'JIN712C',5.25),
(24025433,'JTR701A',16.00),(24025433,'JTR701D',17.50),(24025433,'JTR701E',11.38),(24025433,'JTR701F',13.11),
-- 23024748 FRANCOIS Louis
(23024748,'JIN701B',11.50),(23024748,'JIN701C',11.00),(23024748,'JIN710A',15.00),
(23024748,'JIN702A',18.00),(23024748,'JIN702B',15.00),(23024748,'JIN702C',10.00),
(23024748,'JIN703A',15.00),(23024748,'JIN703B',13.00),(23024748,'JIN703C',7.50),
(23024748,'JIN712A',4.00),(23024748,'JIN712B',10.90),(23024748,'JIN712C',10.00),
(23024748,'JTR701A',14.30),(23024748,'JTR701D',12.00),(23024748,'JTR701E',14.42),(23024748,'JTR701F',12.61),
-- 24016406 MARTINEZ Zoé
(24016406,'JIN701B',11.50),(24016406,'JIN701C',16.50),(24016406,'JIN710A',12.00),
(24016406,'JIN702A',19.00),(24016406,'JIN702B',15.00),(24016406,'JIN702C',7.00),
(24016406,'JIN703A',15.00),(24016406,'JIN703B',14.00),(24016406,'JIN703C',11.56),
(24016406,'JIN712A',14.00),(24016406,'JIN712B',12.20),(24016406,'JIN712C',16.20),
(24016406,'JTR701A',18.50),(24016406,'JTR701D',13.00),(24016406,'JTR701E',15.01),(24016406,'JTR701F',12.81),
-- 24028169 LEBLANC Kévin
(24028169,'JIN701B',9.00),(24028169,'JIN701C',11.00),(24028169,'JIN710A',17.00),
(24028169,'JIN702A',15.50),(24028169,'JIN702B',13.00),(24028169,'JIN702C',14.50),
(24028169,'JIN703A',14.00),(24028169,'JIN703B',14.00),(24028169,'JIN703C',9.06),
(24028169,'JIN712A',7.00),(24028169,'JIN712B',15.10),(24028169,'JIN712C',13.30),
(24028169,'JTR701A',14.80),(24028169,'JTR701D',17.50),(24028169,'JTR701E',10.99),(24028169,'JTR701F',9.23),
-- 22017365 GARNIER Julie
(22017365,'JIN701B',15.50),(22017365,'JIN701C',17.50),(22017365,'JIN710A',15.00),
(22017365,'JIN702A',13.50),(22017365,'JIN702B',17.00),(22017365,'JIN702C',19.00),
(22017365,'JIN703A',15.00),(22017365,'JIN703B',14.00),(22017365,'JIN703C',17.50),
(22017365,'JIN712A',17.50),(22017365,'JIN712B',14.40),(22017365,'JIN712C',16.70),
(22017365,'JTR701A',17.50),(22017365,'JTR701D',15.00),(22017365,'JTR701E',15.35),(22017365,'JTR701F',15.61),
-- 23026015 CHEVALIER Antoine
(23026015,'JIN701B',9.00),(23026015,'JIN701C',11.00),(23026015,'JIN710A',15.00),
(23026015,'JIN702A',20.00),(23026015,'JIN702B',18.00),(23026015,'JIN702C',13.50),
(23026015,'JIN703A',14.00),(23026015,'JIN703B',12.00),(23026015,'JIN703C',11.56),
(23026015,'JIN712A',7.00),(23026015,'JIN712B',16.50),(23026015,'JIN712C',11.40),
(23026015,'JTR701A',12.30),(23026015,'JTR701D',11.00),(23026015,'JTR701E',10.67),(23026015,'JTR701F',12.40)
ON CONFLICT DO NOTHING;
-1
View File
@@ -20,7 +20,6 @@ export interface AppProperties {
pages: Record<string, string>;
adminOnly: string[];
studentOnly?: string[];
employeeOnly?: boolean;
hint: string;
}
-62
View File
@@ -1,62 +0,0 @@
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);
};
}
-1
View File
@@ -12,7 +12,6 @@
"update": "deno run -A -r https://fresh.deno.dev/update .",
"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/",
+3 -1
View File
@@ -6,6 +6,8 @@ await load({ envPath: "./.env", export: true });
await ensureDatabases();
export default defineConfig({
server: {
port: 80,
cert: await Deno.readTextFile("certs/cert.pem"),
key: await Deno.readTextFile("certs/key.pem"),
port: 443,
},
});
+15 -48
View File
@@ -4,7 +4,6 @@
import * as $_apps_layout from "./routes/(apps)/_layout.tsx";
import * as $_apps_middleware from "./routes/(apps)/_middleware.ts";
import * as $_apps_admin_slug_ from "./routes/(apps)/admin/[slug].tsx";
import * as $_apps_admin_api_enseignements from "./routes/(apps)/admin/api/enseignements.ts";
import * as $_apps_admin_api_enseignements_idProf_idModule_idPromo_ from "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts";
import * as $_apps_admin_api_example from "./routes/(apps)/admin/api/example.ts";
@@ -31,23 +30,16 @@ import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/rol
import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx";
import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx";
import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx";
import * as $_apps_mobility_slug_ from "./routes/(apps)/mobility/[...slug].tsx";
import * as $_apps_mobility_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_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts";
import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx";
import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx";
import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx";
import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx";
import * as $_apps_mobility_partials_overview_numEtud_ from "./routes/(apps)/mobility/partials/overview/[numEtud].tsx";
import * as $_apps_notes_slug_ from "./routes/(apps)/notes/[slug].tsx";
import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustements.ts";
import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts";
import * as $_apps_notes_api_modules from "./routes/(apps)/notes/api/modules.ts";
import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts";
import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts";
import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts";
import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts";
import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts";
import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx";
import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx";
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx";
@@ -55,14 +47,6 @@ import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/parti
import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx";
import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx";
import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx";
import * as $_apps_stages_slug_ from "./routes/(apps)/stages/[...slug].tsx";
import * as $_apps_stages_api_stages from "./routes/(apps)/stages/api/stages.ts";
import * as $_apps_stages_api_stages_idStage_ from "./routes/(apps)/stages/api/stages/[idStage].ts";
import * as $_apps_stages_index from "./routes/(apps)/stages/index.tsx";
import * as $_apps_stages_partials_index from "./routes/(apps)/stages/partials/index.tsx";
import * as $_apps_stages_partials_overview from "./routes/(apps)/stages/partials/overview.tsx";
import * as $_apps_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";
@@ -95,12 +79,13 @@ import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_island
import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx";
import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx";
import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx";
import * as $_apps_mobility_islands_MobilityOverview from "./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx";
import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx";
import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx";
import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx";
import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx";
import * as $_apps_notes_islands_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.tsx";
import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx";
import * as $_apps_stages_islands_StagesOverview from "./routes/(apps)/stages/(_islands)/StagesOverview.tsx";
import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx";
import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx";
import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx";
@@ -110,7 +95,6 @@ const manifest = {
routes: {
"./routes/(apps)/_layout.tsx": $_apps_layout,
"./routes/(apps)/_middleware.ts": $_apps_middleware,
"./routes/(apps)/admin/[slug].tsx": $_apps_admin_slug_,
"./routes/(apps)/admin/api/enseignements.ts":
$_apps_admin_api_enseignements,
"./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts":
@@ -147,31 +131,23 @@ const manifest = {
"./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues,
"./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users,
"./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_,
"./routes/(apps)/mobility/[...slug].tsx": $_apps_mobility_slug_,
"./routes/(apps)/mobility/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/api/insert_mobility.ts":
$_apps_mobility_api_insert_mobility,
"./routes/(apps)/mobility/index.tsx": $_apps_mobility_index,
"./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx":
$_apps_mobility_partials_admin_edit_mobility,
"./routes/(apps)/mobility/partials/index.tsx":
$_apps_mobility_partials_index,
"./routes/(apps)/mobility/partials/overview.tsx":
$_apps_mobility_partials_overview,
"./routes/(apps)/mobility/partials/overview/[numEtud].tsx":
$_apps_mobility_partials_overview_numEtud_,
"./routes/(apps)/notes/[slug].tsx": $_apps_notes_slug_,
"./routes/(apps)/notes/api/ajustements.ts": $_apps_notes_api_ajustements,
"./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts":
$_apps_notes_api_ajustements_numEtud_idUE_,
"./routes/(apps)/notes/api/modules.ts": $_apps_notes_api_modules,
"./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes,
"./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts":
$_apps_notes_api_notes_numEtud_idModule_,
"./routes/(apps)/notes/api/notes/import-xlsx.ts":
$_apps_notes_api_notes_import_xlsx,
"./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules,
"./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues,
"./routes/(apps)/notes/edition/[numEtud].tsx":
$_apps_notes_edition_numEtud_,
"./routes/(apps)/notes/index.tsx": $_apps_notes_index,
@@ -182,17 +158,6 @@ const manifest = {
"./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index,
"./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes,
"./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_,
"./routes/(apps)/stages/[...slug].tsx": $_apps_stages_slug_,
"./routes/(apps)/stages/api/stages.ts": $_apps_stages_api_stages,
"./routes/(apps)/stages/api/stages/[idStage].ts":
$_apps_stages_api_stages_idStage_,
"./routes/(apps)/stages/index.tsx": $_apps_stages_index,
"./routes/(apps)/stages/partials/index.tsx": $_apps_stages_partials_index,
"./routes/(apps)/stages/partials/overview.tsx":
$_apps_stages_partials_overview,
"./routes/(apps)/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":
@@ -245,8 +210,12 @@ const manifest = {
$_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)/mobility/(_islands)/ConsultMobility.tsx":
$_apps_mobility_islands_ConsultMobility,
"./routes/(apps)/mobility/(_islands)/EditMobility.tsx":
$_apps_mobility_islands_EditMobility,
"./routes/(apps)/mobility/(_islands)/ImportFile.tsx":
$_apps_mobility_islands_ImportFile,
"./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx":
$_apps_notes_islands_AdminConsultNotes,
"./routes/(apps)/notes/(_islands)/ImportNotes.tsx":
@@ -255,8 +224,6 @@ const manifest = {
$_apps_notes_islands_NoteRecap,
"./routes/(apps)/notes/(_islands)/NotesView.tsx":
$_apps_notes_islands_NotesView,
"./routes/(apps)/stages/(_islands)/StagesOverview.tsx":
$_apps_stages_islands_StagesOverview,
"./routes/(apps)/students/(_islands)/ConsultStudents.tsx":
$_apps_students_islands_ConsultStudents,
"./routes/(apps)/students/(_islands)/EditStudents.tsx":
-85
View File
@@ -1,85 +0,0 @@
/**
* 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
@@ -1,90 +0,0 @@
// @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
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Footer(_props: FooterProps) {
return (
<footer>
<p>
&copy; 2026 PolyMPR - <a href="/about" f-client-nav={false}>About</a>
&copy; 2025 PolyMPR - <a href="/about" f-client-nav={false}>About</a>
</p>
</footer>
);
-8
View File
@@ -11,14 +11,6 @@ export default function Header(props: HeaderProps) {
<nav>
<a href="/apps" f-client-nav={false}>Catalog</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>
</header>
);
+1 -7
View File
@@ -21,17 +21,11 @@ export const handler: MiddlewareHandler<AuthenticatedState>[] = [
`./${currentApp}/(_props)/props.ts`
)).default;
context.state.availablePages = { ...properties.pages };
const isStudent =
context.state.session.eduPersonPrimaryAffiliation === "student";
const isLocal = Deno.env.get("LOCAL") === "true";
// Block students from accessing employeeOnly modules entirely
if (isStudent && properties.employeeOnly) {
return new Response(null, { status: 403 });
}
context.state.availablePages = { ...properties.pages };
if (isStudent) {
// Students only see studentOnly pages (+ non-restricted pages)
properties.adminOnly.forEach((page) =>
@@ -115,7 +115,7 @@ export default function AdminEnseignements() {
return (
<div class="page-content">
<h2 class="page-title">Assignations Enseignant ECUE / Promo</h2>
<h2 class="page-title">Assignations Enseignant Module / Promo</h2>
{error && <p class="state-error">{error}</p>}
@@ -135,7 +135,7 @@ export default function AdminEnseignements() {
onChange={(e) =>
setFilterModule((e.target as HTMLSelectElement).value)}
>
<option value="">ECUE </option>
<option value="">Module </option>
{modules.map((m) => (
<option key={m.id} value={m.id}>{m.id} {m.nom}</option>
))}
@@ -194,7 +194,7 @@ export default function AdminEnseignements() {
</select>
</div>
<div class="form-field">
<label>ECUE</label>
<label>Module</label>
<select
class="filter-select"
value={addModule}
@@ -202,7 +202,7 @@ export default function AdminEnseignements() {
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%"
>
<option value="">ECUE...</option>
<option value="">Module...</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} -- {m.nom}
@@ -251,7 +251,7 @@ export default function AdminEnseignements() {
<thead>
<tr>
<th>Promo</th>
<th>ECUE</th>
<th>Module</th>
<th>Enseignant (User.id)</th>
<th>Actions</th>
</tr>
@@ -319,7 +319,7 @@ export default function AdminEnseignements() {
<div class="info-note">
<p>
Un même ECUE peut être enseigné par plusieurs utilisateurs sur une
Un même module peut être enseigné par plusieurs utilisateurs sur une
même promo.
</p>
<p class="info-note-dim">
@@ -22,7 +22,7 @@ export default function AdminModules() {
fetch("/admin/api/enseignements"),
fetch("/admin/api/users"),
]);
if (!mRes.ok) throw new Error("Impossible de charger les ECUEs");
if (!mRes.ok) throw new Error("Impossible de charger les modules");
setModules(await mRes.json());
if (eRes.ok) setEnseignements(await eRes.json());
if (uRes.ok) setUsers(await uRes.json());
@@ -61,7 +61,7 @@ export default function AdminModules() {
}
async function deleteModule(id: string) {
if (!confirm(`Supprimer l'ECUE ${id} ?`)) return;
if (!confirm(`Supprimer le module ${id} ?`)) return;
try {
const res = await fetch(
`/admin/api/modules/${encodeURIComponent(id)}`,
@@ -102,7 +102,7 @@ export default function AdminModules() {
return (
<div class="page-content">
<h2 class="page-title">Gestion des ECUEs</h2>
<h2 class="page-title">Gestion des Modules</h2>
{error && <p class="state-error">{error}</p>}
@@ -122,7 +122,7 @@ export default function AdminModules() {
}}
style="margin-left: auto"
>
+ Ajouter ECUE
+ Ajouter module
</button>
</div>
@@ -134,7 +134,7 @@ export default function AdminModules() {
<thead>
<tr>
<th>id (code)</th>
<th>Nom de l'ECUE</th>
<th>Nom du module</th>
<th>Enseignants assignes</th>
<th>Actions</th>
</tr>
@@ -144,7 +144,7 @@ export default function AdminModules() {
? (
<tr>
<td colspan={4} class="state-empty">
Aucun ECUE enregistré
Aucun module enregistré
</td>
</tr>
)
@@ -218,13 +218,13 @@ export default function AdminModules() {
</div>
)}
{/* Nouvel ECUE */}
{/* Nouveau module */}
<div
id="new-module-section"
class="edit-section"
style="margin-top: 1.5rem"
>
<p class="edit-section-title">Nouvel ECUE</p>
<p class="edit-section-title">Nouveau module</p>
<div class="form-row">
<input
class="form-input"
@@ -235,7 +235,7 @@ export default function AdminModules() {
/>
<input
class="form-input"
placeholder="Nom de l'ECUE"
placeholder="Nom du module"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/>
+9 -9
View File
@@ -104,7 +104,7 @@ export default function AdminUEs() {
idUE: number,
idPromo: string,
) {
if (!confirm("Supprimer cet ECUE de la UE ?")) return;
if (!confirm("Supprimer ce module de la UE ?")) return;
try {
const res = await fetch(
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
@@ -121,7 +121,7 @@ export default function AdminUEs() {
async function addUeModule() {
if (!selectedUe || !addModuleId || !addPromoId) {
setAddError("ECUE et Promo sont requis");
setAddError("Module et Promo sont requis");
return;
}
const coeff = parseFloat(addCoeff);
@@ -203,7 +203,7 @@ export default function AdminUEs() {
class="col-dim"
style="font-size: 0.78rem; margin: -0.5rem 0 1rem"
>
UE = Unité d'Enseignement regroupant plusieurs ECUEs
UE = Unité d'Enseignement regroupant plusieurs modules
</p>
{error && <p class="state-error">{error}</p>}
@@ -314,13 +314,13 @@ export default function AdminUEs() {
<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)
Modules assignés (UE_Module)
</p>
<div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table">
<thead>
<tr>
<th>ECUE</th>
<th>Module</th>
<th>Promo</th>
<th>Coeff</th>
<th>Actions</th>
@@ -331,7 +331,7 @@ export default function AdminUEs() {
? (
<tr>
<td colspan={4} class="state-empty">
Aucun ECUE assigné
Aucun module assigné
</td>
</tr>
)
@@ -441,7 +441,7 @@ export default function AdminUEs() {
</div>
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un ECUE à cette UE
Ajouter un module à cette UE
</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
@@ -458,7 +458,7 @@ export default function AdminUEs() {
)}
style="min-width: 12rem"
>
<option value="">ECUE </option>
<option value="">Module </option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} {m.nom}
@@ -504,7 +504,7 @@ export default function AdminUEs() {
: (
<div class="panel-box">
<p class="state-empty" style="padding: 2rem 0">
Sélectionnez une UE pour voir ses ECUEs
Sélectionnez une UE pour voir ses modules
</p>
</div>
)}
@@ -33,7 +33,7 @@ export default function EditModule({ moduleId }: Props) {
fetch("/admin/api/users"),
fetch("/students/api/promotions"),
]);
if (!mRes.ok) throw new Error("ECUE introuvable");
if (!mRes.ok) throw new Error("Module introuvable");
const m: Module = await mRes.json();
setMod(m);
setNom(m.nom);
@@ -70,7 +70,7 @@ export default function EditModule({ moduleId }: Props) {
if (!res.ok) throw new Error("Modification échouée");
const updated: Module = await res.json();
setMod(updated);
setSaveMsg("ECUE enregistré.");
setSaveMsg("Module enregistré.");
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
@@ -79,7 +79,7 @@ export default function EditModule({ moduleId }: Props) {
}
async function deleteModule() {
if (!confirm(`Supprimer définitivement l'ECUE ${moduleId} ?`)) return;
if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return;
try {
const res = await fetch(
`/admin/api/modules/${encodeURIComponent(moduleId)}`,
@@ -173,7 +173,7 @@ export default function EditModule({ moduleId }: Props) {
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
ECUE -- {mod.id}
Module -- {mod.id}
</h2>
<div class="info-bar">
@@ -202,7 +202,7 @@ export default function EditModule({ moduleId }: Props) {
/>
</div>
<div class="form-field">
<label>Nom de l'ECUE</label>
<label>Nom du module</label>
<input
class="form-input"
value={nom}
@@ -224,7 +224,7 @@ export default function EditModule({ moduleId }: Props) {
class="btn btn-danger"
onClick={deleteModule}
>
Supprimer l'ECUE
Supprimer le module
</button>
</div>
</div>
+4 -4
View File
@@ -106,7 +106,7 @@ export default function EditUser({ userId }: Props) {
async function addEnseignement() {
if (!addModule || !addPromo) {
setAddError("ECUE et Promo sont requis");
setAddError("Module et Promo sont requis");
return;
}
setAdding(true);
@@ -276,7 +276,7 @@ export default function EditUser({ userId }: Props) {
class="col-dim"
style="font-size: 0.75rem; margin: 0 0 0.75rem"
>
ECUEs enseignes par cet utilisateur
Modules enseignes par cet utilisateur
</p>
{enseignements.length > 0
@@ -285,7 +285,7 @@ export default function EditUser({ userId }: Props) {
<table class="data-table">
<thead>
<tr>
<th>ECUE</th>
<th>Module</th>
<th>Promo</th>
<th>Actions</th>
</tr>
@@ -360,7 +360,7 @@ export default function EditUser({ userId }: Props) {
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 12rem"
>
<option value="">ECUE</option>
<option value="">Module</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} -- {m.nom}
+101 -48
View File
@@ -2,19 +2,98 @@
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 ParsedUE = {
code: string | null;
name: string;
ects: number | null;
modules: ParsedModule[];
};
type ParsedModule = {
code: string;
name: string;
coeff: number;
};
type ParsedYear = {
label: string;
ues: ParsedUE[];
};
type Promo = { id: string; annee: string | null };
function parseMaquette(workbook: XLSX.WorkBook): ParsedYear[] {
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
header: 1,
});
const years: ParsedYear[] = [];
let currentYear: ParsedYear | null = null;
let currentUE: ParsedUE | null = null;
let moduleIndex = 0;
for (const row of rows) {
if (!row || row.length === 0) continue;
const col0 = row[0] != null ? String(row[0]).trim() : "";
// Detect year row: INFO3A, INFO4A, INFO5A, INFOAPP3A, INFOAPP4A, etc.
if (/^(INFO|INFOAPP)\s*\d+A$/i.test(col0)) {
currentYear = { label: col0, ues: [] };
years.push(currentYear);
currentUE = null;
continue;
}
// Detect UE row: col[0] === "UE" or starts with "UE" (e.g., "UE51")
if (col0 === "UE" || (col0.startsWith("UE") && /^UE\d/.test(col0))) {
const ueCode = row[1] != null ? String(row[1]).trim() : null;
const ueName = row[2] != null ? String(row[2]).trim() : "UE sans nom";
const ects = typeof row[4] === "number" ? row[4] : null;
currentUE = { code: ueCode, name: ueName, ects, modules: [] };
if (currentYear) {
currentYear.ues.push(currentUE);
} else {
// No year detected yet — create a default one
currentYear = { label: "Maquette", ues: [currentUE] };
years.push(currentYear);
}
moduleIndex = 0;
continue;
}
// Detect semester header rows — just skip, don't reset UE
if (/^SEM\s*\d/i.test(col0)) {
currentUE = null;
continue;
}
// Detect module row: inside a UE, col[3] has a name, col[5] is a number (coeff)
if (currentUE && row[3] != null && typeof row[5] === "number") {
const modName = String(row[3]).trim();
if (!modName) continue;
let modCode = row[1] != null ? String(row[1]).trim() : "";
if (!modCode) {
const uePrefix = (currentUE.code || "MOD").replace(/[^A-Z0-9]/gi, "");
modCode = `${uePrefix}_${String(moduleIndex).padStart(2, "0")}`;
}
currentUE.modules.push({ code: modCode, name: modName, coeff: row[5] });
moduleIndex++;
}
}
return years;
}
export default function ImportMaquette() {
const file = useSignal<File | null>(null);
const dragging = useSignal(false);
@@ -72,10 +151,7 @@ export default function ImportMaquette() {
});
if (res.ok) {
const created = await res.json();
promos.value = [...promos.value, {
id: created.id,
annee: created.annee,
}];
promos.value = [...promos.value, { id: created.id, annee: created.annee }];
newPromoId.value = "";
newPromoAnnee.value = "";
} else {
@@ -150,13 +226,13 @@ export default function ImportMaquette() {
added++;
details.push({
type: "change",
message: `ECUE ${mod.code} "${mod.name}" cree`,
message: `Module ${mod.code} "${mod.name}" cree`,
});
} else if (modRes.status !== 409) {
errCount++;
details.push({
type: "error",
message: `ECUE "${mod.code}" : creation echouee`,
message: `Module "${mod.code}" : creation echouee`,
});
continue;
}
@@ -202,7 +278,7 @@ export default function ImportMaquette() {
globalThis.open("/templates/modele_maquette.xlsx", "_blank");
}
function _downloadExport() {
function downloadExport() {
Promise.all([
fetch("/admin/api/ues").then((r) => r.json()),
fetch("/admin/api/ue-modules").then((r) => r.json()),
@@ -213,14 +289,7 @@ export default function ImportMaquette() {
);
const data: (string | number | null)[][] = [
[
"Annee\nSemestres",
"Codes APOGEE",
null,
null,
"Credits\nECTS",
"Coeff.",
],
["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\nECTS", "Coeff."],
];
for (const ue of uesData) {
@@ -234,14 +303,7 @@ export default function ImportMaquette() {
data.push(["UE", null, ue.nom, null, totalCoeff]);
for (const um of mods) {
const mod = modMap[um.idModule];
data.push([
null,
um.idModule,
null,
mod ? mod.nom : um.idModule,
null,
um.coeff,
]);
data.push([null, um.idModule, null, mod ? mod.nom : um.idModule, null, um.coeff]);
}
data.push([]);
}
@@ -250,10 +312,7 @@ export default function ImportMaquette() {
const ws = XLSX.utils.aoa_to_sheet(data);
XLSX.utils.book_append_sheet(wb, ws, "Maquette");
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([buf], {
type:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -325,9 +384,8 @@ export default function ImportMaquette() {
class="filter-select"
placeholder="ID (ex: 3AFISE24-25)"
value={newPromoId.value}
onInput={(
e,
) => (newPromoId.value = (e.target as HTMLInputElement).value)}
onInput={(e) =>
(newPromoId.value = (e.target as HTMLInputElement).value)}
style="min-width: 10rem"
/>
<input
@@ -335,9 +393,8 @@ export default function ImportMaquette() {
class="filter-select"
placeholder="Annee (ex: 2024-2025)"
value={newPromoAnnee.value}
onInput={(
e,
) => (newPromoAnnee.value = (e.target as HTMLInputElement).value)}
onInput={(e) =>
(newPromoAnnee.value = (e.target as HTMLInputElement).value)}
style="min-width: 8rem"
/>
<button
@@ -367,7 +424,7 @@ export default function ImportMaquette() {
<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
{" "} {year.ues.length} UE, {totalMods} modules
</span>
</p>
<select
@@ -394,7 +451,7 @@ export default function ImportMaquette() {
<thead>
<tr>
<th>UE</th>
<th>ECUE</th>
<th>Module</th>
<th>Code</th>
<th>Coeff</th>
</tr>
@@ -406,7 +463,7 @@ export default function ImportMaquette() {
<tr key={`ue-${i}`}>
<td style="font-weight: 600">{ue.name}</td>
<td class="col-dim" colspan={3}>
Aucun ECUE
Aucun module
</td>
</tr>
)
@@ -420,7 +477,7 @@ export default function ImportMaquette() {
{ue.name}
{ue.ects != null && (
<span class="col-dim">
({ue.ects} ECTS)
{" "}({ue.ects} ECTS)
</span>
)}
</td>
@@ -456,8 +513,6 @@ export default function ImportMaquette() {
>
Telecharger Modele
</button>
{
/* TODO: fix blob download in Fresh
<button
type="button"
class="btn btn-secondary"
@@ -465,13 +520,11 @@ export default function ImportMaquette() {
>
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)
{" "}et <strong>modules</strong> (colonnes code, nom, coefficient)
</p>
</div>
);
+3 -13
View File
@@ -8,24 +8,14 @@ const properties: AppProperties = {
users: "Utilisateurs",
roles: "Rôles",
permissions: "Permissions",
modules: "ECUEs",
modules: "Modules",
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",
adminOnly: ["users", "roles", "permissions", "modules", "enseignements", "promotions", "ues", "import-maquette"],
hint: "PolyMPR module",
};
export default properties;
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
+1 -1
View File
@@ -44,7 +44,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
if (existing) {
return new Response(
JSON.stringify({ error: "Un ECUE avec cet identifiant existe déjà" }),
JSON.stringify({ error: "Un module avec cet identifiant existe déjà" }),
{ status: 409, headers: { "content-type": "application/json" } },
);
}
+2 -2
View File
@@ -65,8 +65,8 @@ export const handler: Handlers = {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error creating UE-ECUE:", error);
return new Response("Failed to create UE-ECUE", { status: 500 });
console.error("Error creating UE-module:", error);
return new Response("Failed to create UE-module", { status: 500 });
}
},
};
@@ -6,7 +6,7 @@ import { and, eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Association UE-ECUE introuvable" }),
JSON.stringify({ error: "Association UE-Module introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
@@ -14,6 +14,5 @@ async function Enseignements(
return <AdminEnseignements />;
}
export { Enseignements as Page };
export const config = getPartialsConfig();
export default makePartials(Enseignements);
@@ -19,6 +19,5 @@ async function ImportMaquettePage(
);
}
export { ImportMaquettePage as Page };
export const config = getPartialsConfig();
export default makePartials(ImportMaquettePage);
-1
View File
@@ -14,6 +14,5 @@ async function Modules(
return <AdminModules />;
}
export { Modules as Page };
export const config = getPartialsConfig();
export default makePartials(Modules);
@@ -14,6 +14,5 @@ async function Permissions(
return <AdminPermissions />;
}
export { Permissions as Page };
export const config = getPartialsConfig();
export default makePartials(Permissions);
@@ -14,6 +14,5 @@ async function Promotions(
return <AdminPromotions />;
}
export { Promotions as Page };
export const config = getPartialsConfig();
export default makePartials(Promotions);
-1
View File
@@ -14,6 +14,5 @@ async function Roles(
return <AdminRoles />;
}
export { Roles as Page };
export const config = getPartialsConfig();
export default makePartials(Roles);
-1
View File
@@ -14,6 +14,5 @@ async function UEs(
return <AdminUEs />;
}
export { UEs as Page };
export const config = getPartialsConfig();
export default makePartials(UEs);
-1
View File
@@ -14,6 +14,5 @@ async function Users(
return <AdminUsers />;
}
export { Users as Page };
export const config = getPartialsConfig();
export default makePartials(Users);
@@ -0,0 +1,115 @@
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>
);
}
@@ -0,0 +1,75 @@
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>
);
}
@@ -0,0 +1,248 @@
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>
);
}
@@ -1,997 +0,0 @@
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>
);
}
+6 -7
View File
@@ -3,15 +3,14 @@ import { AppProperties } from "$root/defaults/interfaces.ts";
const properties: AppProperties = {
name: "PolyMobility",
icon: "flight_takeoff",
hint: "Suivi des mobilités internationales",
hint: "Student mobility management",
pages: {
index: "Accueil",
overview: "Suivi des mobilités",
// "my-mobility": "Ma mobilité", // TODO Fix ma mobilité page, so it renders correctly for students
index: "Homepage",
overview: "Mobility overview",
edit_mobility: "Mobility management",
consult_students_test: "Test consult students",
},
adminOnly: ["overview"],
studentOnly: ["my-mobility"],
employeeOnly: true, // TODO Fix ma mobilité page, so it renders correctly for students
adminOnly: ["edit_mobility", "consult_students_test"],
};
export default properties;
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
@@ -0,0 +1,122 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobility, promotions, students } from "$root/databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers = {
async GET() {
try {
const studentRows = await db
.select({
id: students.userId,
firstName: students.firstName,
lastName: students.lastName,
promotionId: students.promotionId,
endyear: promotions.endyear,
current: promotions.current,
})
.from(students)
.leftJoin(promotions, eq(students.promotionId, promotions.id));
const mobilityRows = await db.select().from(mobility);
const promotionRows = await db
.select({
id: promotions.id,
endyear: promotions.endyear,
current: promotions.current,
})
.from(promotions);
return new Response(
JSON.stringify({
mobilities: mobilityRows,
students: studentRows,
promotions: promotionRows,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error fetching mobility data:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
async POST(request) {
try {
const body = await request.json();
const { data } = body;
if (!Array.isArray(data)) {
throw new Error("Invalid request body");
}
for (const entry of data) {
const {
id,
studentId,
startDate,
endDate,
weeksCount,
destinationCountry,
destinationName,
mobilityStatus = "N/A",
} = entry;
const studentExists = await db
.select({ userId: students.userId })
.from(students)
.where(eq(students.userId, studentId))
.limit(1)
.then((rows) => rows.length > 0);
if (!studentExists) {
console.warn(`Skipping mobility for unknown studentId: ${studentId}`);
continue;
}
let calculatedWeeksCount = weeksCount;
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
calculatedWeeksCount = start <= end
? Math.ceil(
(end.getTime() - start.getTime()) / (7 * 24 * 60 * 60 * 1000),
)
: null;
}
await db
.insert(mobility)
.values({
id,
studentId,
startDate,
endDate,
weeksCount: calculatedWeeksCount,
destinationCountry,
destinationName,
mobilityStatus,
})
.onConflictDoUpdate({
target: mobility.id,
set: {
startDate,
endDate,
weeksCount: calculatedWeeksCount,
destinationCountry,
destinationName,
mobilityStatus,
},
});
}
return new Response("Data inserted/updated successfully", {
status: 200,
});
} catch (error) {
console.error("Error inserting mobility data:", error);
return new Response("Failed to insert/update data", { status: 500 });
}
},
};
-116
View File
@@ -1,116 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const VALID_STATUSES = [
"contracts_received",
"under_revision",
"done",
"validated",
"canceled",
] as const;
export const handler: Handlers<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 isEmployee =
context.state.session.eduPersonPrimaryAffiliation === "employee";
try {
const body = await request.json();
const { numEtud, duree, ecole, pays, status, idStage } = body;
// Students can only create mobilites for themselves
if (!isEmployee && numEtud !== undefined) {
// Students cannot set idStage or status
if (idStage || (status && status !== "contracts_received")) {
return new Response(null, { status: 403 });
}
}
if (!numEtud || duree === undefined) {
return new Response(
JSON.stringify({ error: "Champs requis: numEtud, duree" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (!Number.isInteger(duree) || duree < 1) {
return new Response(
JSON.stringify({ error: "duree doit être un entier >= 1" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (
status !== undefined &&
!VALID_STATUSES.includes(status)
) {
return new Response(
JSON.stringify({
error: `status invalide, valeurs: ${VALID_STATUSES.join(", ")}`,
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
// Stage-linked mobilities are always validated
const effectiveStatus = idStage
? "validated"
: (status ?? "contracts_received");
const [created] = await db
.insert(mobilites)
.values({
numEtud,
duree,
ecole: ecole ?? null,
pays: pays ?? null,
status: effectiveStatus,
idStage: idStage ?? null,
})
.returning();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
} catch (error) {
console.error("Error creating mobilite:", error);
return new Response("Failed to create mobilite", { status: 500 });
}
},
};
@@ -1,149 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const VALID_STATUSES = [
"contracts_received",
"under_revision",
"done",
"validated",
"canceled",
] as const;
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Mobilité introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<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 (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN();
}
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
const body = await request.json();
const { duree, ecole, pays, status, idStage } = body;
if (
status !== undefined &&
!VALID_STATUSES.includes(status)
) {
return new Response(
JSON.stringify({
error: `status invalide, valeurs: ${VALID_STATUSES.join(", ")}`,
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) {
return new Response(
JSON.stringify({ error: "duree doit être un entier >= 1" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const set: Record<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 (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN();
}
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
// Delete contract file if exists
const row = await db
.select({ contratMob: mobilites.contratMob })
.from(mobilites)
.where(eq(mobilites.id, idMob))
.then((rows) => rows[0] ?? null);
if (row?.contratMob) {
try {
await Deno.remove(`uploads/contracts/${row.contratMob}`);
} catch { /* file may not exist */ }
}
const [deleted] = await db
.delete(mobilites)
.where(eq(mobilites.id, idMob))
.returning();
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
};
@@ -1,156 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const CONTRACTS_DIR = "uploads/contracts";
export const handler: Handlers<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 (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return new Response(null, { status: 403 });
}
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
const row = await db
.select({ contratMob: mobilites.contratMob })
.from(mobilites)
.where(eq(mobilites.id, idMob))
.then((rows) => rows[0] ?? null);
if (!row) {
return new Response(
JSON.stringify({ error: "Mobilité introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
if (row.contratMob) {
try {
await Deno.remove(`${CONTRACTS_DIR}/${row.contratMob}`);
} catch { /* file may not exist */ }
}
await db
.update(mobilites)
.set({ contratMob: null })
.where(eq(mobilites.id, idMob));
return new Response(null, { status: 204 });
},
};
@@ -0,0 +1,21 @@
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);
@@ -0,0 +1,20 @@
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);
+3 -19
View File
@@ -3,27 +3,11 @@ import {
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import { State } from "$root/routes/_middleware.ts";
// deno-lint-ignore require-await
export async function Index(
_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 async function Index(_request: Request, context: FreshContext<State>) {
return <h2>Welcome to {context.state.session?.displayName}.</h2>;
}
export const config = getPartialsConfig();
+10 -9
View File
@@ -1,19 +1,20 @@
import ConsultMobility from "$root/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import MobilityOverview from "../(_islands)/MobilityOverview.tsx";
import { State } from "$root/routes/_middleware.ts";
// deno-lint-ignore require-await
async function Overview(
_request: Request,
_context: FreshContext<State>,
) {
return <MobilityOverview />;
async function Mobility(_request: Request, _context: FreshContext<State>) {
return (
<>
<h1>Edit mobility</h1>
<ConsultMobility />
</>
);
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
export default makePartials(Mobility);
@@ -1,20 +0,0 @@
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);
+30 -41
View File
@@ -2,11 +2,6 @@
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,
@@ -273,14 +268,14 @@ export default function ImportNotes() {
globalThis.open("/templates/modele_notes.xlsx", "_blank");
}
function _downloadExport() {
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()),
fetch("/admin/api/modules").then((r) => r.json()),
fetch("/admin/api/ue-modules").then((r) => r.json()),
fetch("/admin/api/ues").then((r) => r.json()),
]).then(
([
studentsData,
@@ -398,19 +393,19 @@ export default function ImportNotes() {
const ueMods = orderedCols.filter(
(c) => c.type === "module" && c.ueId === col.ueId,
);
const notesRecord: Record<string, { note: number; noteSession2: number | null }> = {};
ueMods.forEach(um => {
let total = 0, coeffSum = 0;
for (const um of ueMods) {
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
if (n && um.coeff) {
total += n.note * um.coeff;
coeffSum += um.coeff;
}
}
row.push(
coeffSum > 0
? Math.round((total / coeffSum) * 100) / 100
: null,
);
row.push(avg !== null ? roundGrade(avg) : null);
}
}
s1Rows.push(row);
@@ -430,19 +425,20 @@ export default function ImportNotes() {
const ueMods = orderedCols.filter(
(c) => c.type === "module" && c.ueId === col.ueId,
);
const notesRecord: Record<string, { note: number; noteSession2: number | null }> = {};
ueMods.forEach(um => {
let total = 0, coeffSum = 0;
for (const um of ueMods) {
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
if (n && um.coeff) {
const noteVal = n.noteSession2 ?? n.note;
total += noteVal * um.coeff;
coeffSum += um.coeff;
}
}
row.push(
coeffSum > 0
? Math.round((total / coeffSum) * 100) / 100
: null,
);
row.push(avg !== null ? roundGrade(avg) : null);
}
}
s2Rows.push(row);
@@ -454,10 +450,7 @@ export default function ImportNotes() {
const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]);
XLSX.utils.book_append_sheet(wb, ws2, "Session 2");
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([buf], {
type:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -585,7 +578,7 @@ export default function ImportNotes() {
))}
</div>
<p class="col-dim" style="font-size: 0.72rem; margin-top: 0.35rem">
M = ECUE (importe) | UE = moyenne UE (ignore) | X = malus
M = module (importe) | UE = moyenne UE (ignore) | X = malus
</p>
</div>
)}
@@ -607,8 +600,6 @@ export default function ImportNotes() {
>
Telecharger Modele
</button>
{
/* TODO: fix blob download in Fresh
<button
type="button"
class="btn btn-secondary"
@@ -616,13 +607,11 @@ export default function ImportNotes() {
>
Exporter Notes
</button>
*/
}
</div>
<p class="upload-format">
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
<strong>CODE - ECUE</strong> (colonnes notes){" "}
<strong>CODE - Module</strong> (colonnes notes){" "}
les colonnes UE et MALUS sont auto-detectees
</p>
</div>
+36 -17
View File
@@ -1,12 +1,6 @@
import { useEffect, useState } from "preact/hooks";
import {
applyAjustement,
calculateWeightedAverage,
getEffectiveNote,
} from "$root/logic/grades.ts";
type Student = {
// ...
numEtud: number;
nom: string;
prenom: string;
@@ -43,6 +37,11 @@ function noteClass(n: number): string {
return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail";
}
/** Returns the effective note (session 2 if exists, otherwise session 1). */
function effectiveNote(n: Note): number {
return n.noteSession2 ?? n.note;
}
export default function NoteRecap({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null);
const [ueList, setUeList] = useState<UE[]>([]);
@@ -67,11 +66,11 @@ export default function NoteRecap({ numEtud }: Props) {
setStudent(s);
const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([
fetch("/notes/api/ues"),
fetch("/admin/api/ues"),
fetch(
`/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
`/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
),
fetch("/notes/api/modules"),
fetch("/admin/api/modules"),
fetch(`/notes/api/notes?numEtud=${numEtud}`),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]);
@@ -109,6 +108,19 @@ export default function NoteRecap({ numEtud }: Props) {
load();
}, [numEtud]);
function calcAvg(ueMods: UEModule[]): number | null {
let total = 0,
coeff = 0;
for (const um of ueMods) {
const n = noteMap.get(um.idModule);
if (n === undefined) return null;
const val = effectiveNote(n);
total += val * um.coeff;
coeff += um.coeff;
}
return coeff > 0 ? total / coeff : null;
}
async function saveNote(
idModule: string,
field: "note" | "noteSession2",
@@ -268,11 +280,18 @@ export default function NoteRecap({ numEtud }: Props) {
</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);
const ueMods = ueModules.filter((um) => um.idUE === ue.id);
const avg = calcAvg(ueMods);
const ajust = ajustements.find((a) => a.idUE === ue.id);
// Final displayed average: if ajust.valeur exists it replaces avg, then subtract malus
let finalAvg = avg;
if (ajust) {
finalAvg = ajust.valeur;
if (ajust.malus > 0) {
finalAvg = (finalAvg ?? 0) - ajust.malus;
}
}
return (
<div key={ue.id} class="edit-section">
@@ -305,14 +324,14 @@ export default function NoteRecap({ numEtud }: Props) {
)}
</div>
{/* ECUE rows */}
{/* Module 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.
Aucun module associe a cette UE pour cette promotion.
</p>
)
: (
@@ -322,7 +341,7 @@ export default function NoteRecap({ numEtud }: Props) {
const noteVal = noteObj?.note;
const noteS2 = noteObj?.noteSession2;
const effective = noteObj
? getEffectiveNote(noteObj)
? effectiveNote(noteObj)
: undefined;
const nomMod = moduleMap.get(um.idModule) ?? um.idModule;
+32 -13
View File
@@ -1,9 +1,4 @@
import { useEffect, useState } from "preact/hooks";
import {
applyAjustement,
calculateWeightedAverage,
getEffectiveNote,
} from "$root/logic/grades.ts";
type Note = {
numEtud: number;
@@ -11,7 +6,6 @@ type Note = {
note: number;
noteSession2: number | null;
};
// ... rest of types unchanged ...
type UE = { id: number; nom: string };
type UEModule = {
idModule: string;
@@ -42,6 +36,11 @@ function avgClass(avg: number | null): string {
return avg >= 10 ? "avg-good" : "avg-warn";
}
/** Returns the effective note (session 2 if exists, otherwise session 1). */
function effectiveNote(n: Note): number {
return n.noteSession2 ?? n.note;
}
export default function NotesView({ numEtud, prenom }: Props) {
const [notes, setNotes] = useState<Note[]>([]);
const [ues, setUes] = useState<UE[]>([]);
@@ -63,9 +62,9 @@ export default function NotesView({ numEtud, prenom }: Props) {
try {
const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([
fetch(`/notes/api/notes?numEtud=${numEtud}`),
fetch("/notes/api/ues"),
fetch("/notes/api/ue-modules"),
fetch("/notes/api/modules"),
fetch("/admin/api/ues"),
fetch("/admin/api/ue-modules"),
fetch("/admin/api/modules"),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]);
@@ -176,9 +175,29 @@ export default function NotesView({ numEtud, prenom }: Props) {
if (!ue) return null;
const ueModsForUE = filteredUeModules.filter((um) => um.idUE === ueId);
const avg = calculateWeightedAverage(ueModsForUE, noteMap);
let weightedSum = 0;
let coveredCoeff = 0;
ueModsForUE.forEach((um) => {
const noteObj = noteMap[um.idModule];
if (noteObj) {
const val = effectiveNote(noteObj);
weightedSum += val * um.coeff;
coveredCoeff += um.coeff;
}
});
const avg = coveredCoeff > 0 ? weightedSum / coveredCoeff : null;
const ajust = ajMap[ueId] ?? null;
const finalAvg = applyAjustement(avg, ajust);
// If ajust.valeur exists, it replaces the calculated average
// Then malus is subtracted
let finalAvg: number | null = avg;
if (ajust) {
finalAvg = ajust.valeur;
if (ajust.malus > 0) {
finalAvg = (finalAvg ?? 0) - ajust.malus;
}
}
return (
<div key={ueId} class="ue-card">
@@ -199,14 +218,14 @@ export default function NotesView({ numEtud, prenom }: Props) {
{ueModsForUE.map((um) => {
const mod = moduleMap[um.idModule];
const noteObj = noteMap[um.idModule] ?? null;
const effective = noteObj ? getEffectiveNote(noteObj) : null;
const effective = noteObj ? effectiveNote(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})
{mod ? mod.nom : "Module inconnu"} (coef {um.coeff})
</span>
<span class={`score-chip ${scoreClass(effective)}`}>
{effective !== null ? `${effective}/20` : "—"}
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
-12
View File
@@ -1,12 +0,0 @@
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" },
});
},
};
-28
View File
@@ -1,28 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { ueModules } from "$root/databases/schema.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers = {
async GET(request) {
const url = new URL(request.url);
const idPromo = url.searchParams.get("idPromo");
const idUEParam = url.searchParams.get("idUE");
const idUE = idUEParam ? parseInt(idUEParam) : null;
if (idUEParam && isNaN(idUE!)) {
return new Response("Paramètre idUE invalide", { status: 400 });
}
const rows = await db.select().from(ueModules).where(
and(
idPromo ? eq(ueModules.idPromo, idPromo) : undefined,
idUE ? eq(ueModules.idUE, idUE) : undefined,
),
);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
});
},
};
-12
View File
@@ -1,12 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { ues } from "$root/databases/schema.ts";
export const handler: Handlers = {
async GET() {
const rows = await db.select().from(ues);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
});
},
};
@@ -11,6 +11,5 @@ async function Courses(_request: Request, _context: FreshContext<State>) {
return <AdminConsultNotes />;
}
export { Courses as Page };
export const config = getPartialsConfig();
export default makePartials(Courses);
@@ -19,6 +19,5 @@ async function ImportNotesPage(
);
}
export { ImportNotesPage as Page };
export const config = getPartialsConfig();
export default makePartials(ImportNotesPage);
-1
View File
@@ -54,6 +54,5 @@ async function Notes(
);
}
export { Notes as Page };
export const config = getPartialsConfig();
export default makePartials(Notes);
@@ -1,558 +0,0 @@
import { useEffect, useState } from "preact/hooks";
type Student = {
numEtud: number;
nom: string;
prenom: string;
idPromo: string;
};
type Promotion = { id: string; annee: string };
type Stage = {
id: number;
numEtud: number;
duree: number;
nomEntreprise: string;
mission: string | null;
};
const REQUIRED_WEEKS = 40;
export default function StagesOverview(
{ initialNumEtud }: { initialNumEtud?: number } = {},
) {
const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]);
const [stagesList, setStagesList] = useState<Stage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterPromo, setFilterPromo] = useState("");
const [filterNom, setFilterNom] = useState("");
// Detail view state
const [detailStudent, setDetailStudent] = useState<Student | null>(null);
const [editingStage, setEditingStage] = useState<Stage | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
async function load() {
try {
const [sRes, pRes, stRes] = await Promise.all([
fetch("/students/api/students"),
fetch("/students/api/promotions"),
fetch("/stages/api/stages"),
]);
if (!sRes.ok) throw new Error("Impossible de charger les données");
const [sData, pData, stData] = await Promise.all([
sRes.json(),
pRes.ok ? pRes.json() : [],
stRes.ok ? stRes.json() : [],
]);
setStudents(sData);
setPromos(pData);
setStagesList(stData);
if (initialNumEtud) {
const found = (sData as Student[]).find((s) =>
s.numEtud === initialNumEtud
);
if (found) setDetailStudent(found);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
function openStudent(s: Student) {
setDetailStudent(s);
history.pushState(null, "", `/stages/overview/${s.numEtud}`);
}
function closeStudent() {
setDetailStudent(null);
setEditingStage(null);
setShowAddForm(false);
history.pushState(null, "", "/stages/overview");
}
if (detailStudent) {
return (
<DetailView
student={detailStudent}
stages={stagesList.filter((s) => s.numEtud === detailStudent.numEtud)}
allStages={stagesList}
editingStage={editingStage}
setEditingStage={setEditingStage}
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 stagesByStudent = (numEtud: number) =>
stagesList.filter((s) => s.numEtud === numEtud);
return (
<div class="page-content">
<h2 class="page-title">Suivi des stages</h2>
<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>
<ListView
students={filtered}
stagesByStudent={stagesByStudent}
onConsult={(s) => openStudent(s)}
/>
</div>
);
}
// ─── Liste View ─────────────────────────────────────────────
function ListView(
{ students, stagesByStudent, onConsult }: {
students: Student[];
stagesByStudent: (n: number) => Stage[];
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 stages = stagesByStudent(s.numEtud);
const weeks = stages.reduce((sum, st) => sum + st.duree, 0);
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>
);
}
// ─── Detail View ────────────────────────────────────────────
function DetailView(
{
student,
stages,
allStages,
editingStage,
setEditingStage,
showAddForm,
setShowAddForm,
onBack,
onReload,
}: {
student: Student;
stages: Stage[];
allStages: Stage[];
editingStage: Stage | null;
setEditingStage: (s: Stage | null) => void;
showAddForm: boolean;
setShowAddForm: (v: boolean) => void;
onBack: () => void;
onReload: () => Promise<void>;
},
) {
const weeks = stages.reduce((sum, s) => sum + s.duree, 0);
const entreprises = [
...new Set(allStages.map((s) => s.nomEntreprise).filter(Boolean)),
];
async function deleteStage(id: number) {
if (!confirm("Supprimer ce stage ?")) return;
await fetch(`/stages/api/stages/${id}`, { method: "DELETE" });
await onReload();
}
return (
<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
</span>
</h2>
{stages.length === 0 && (
<p class="state-empty">
Aucun stage enregistré.
</p>
)}
{stages.map((stage, i) => {
const isEditing = editingStage?.id === stage.id;
if (isEditing) {
return (
<StageEditForm
key={stage.id}
stage={stage}
entreprises={entreprises}
onCancel={() => setEditingStage(null)}
onSave={async () => {
setEditingStage(null);
await onReload();
}}
/>
);
}
return (
<div key={stage.id} class="ue-card" style={{ marginBottom: "1rem" }}>
<div class="ue-card-header">
<p class="ue-card-title">
Stage {i + 1}
</p>
<p class="ue-card-avg">
Durée : {stage.duree} semaine(s)
</p>
</div>
<div style={{ padding: "0.6rem 1.1rem" }}>
<p style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
Entreprise : <strong>{stage.nomEntreprise}</strong>
{stage.mission && <span> {stage.mission}</span>}
</p>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginTop: "0.5rem",
}}
>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() =>
setEditingStage(stage)}
>
Modifier
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
deleteStage(stage.id)}
>
Supprimer
</button>
</div>
</div>
</div>
);
})}
{showAddForm
? (
<StageAddForm
numEtud={student.numEtud}
entreprises={entreprises}
onCancel={() => setShowAddForm(false)}
onSave={async () => {
setShowAddForm(false);
await onReload();
}}
/>
)
: (
<button
type="button"
class="btn btn-primary"
onClick={() => setShowAddForm(true)}
>
+ Nouveau stage
</button>
)}
</div>
);
}
// ─── Inline forms ───────────────────────────────────────────
function StageEditForm(
{ stage, entreprises, onCancel, onSave }: {
stage: Stage;
entreprises: string[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
const [duree, setDuree] = useState(String(stage.duree));
const [nomEntreprise, setNomEntreprise] = useState(stage.nomEntreprise);
const [mission, setMission] = useState(stage.mission ?? "");
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
try {
const res = await fetch(`/stages/api/stages/${stage.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
duree: parseInt(duree),
nomEntreprise,
mission: mission || null,
}),
});
if (!res.ok) throw new Error("Erreur");
await onSave();
} catch {
alert("Erreur lors de la modification");
} finally {
setBusy(false);
}
}
return (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Modifier le stage #{stage.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>
<div class="form-field">
<label>Entreprise</label>
<input
class="form-input"
list="edit-entreprises"
value={nomEntreprise}
onInput={(e) =>
setNomEntreprise((e.target as HTMLInputElement).value)}
/>
<datalist id="edit-entreprises">
{entreprises.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Mission</label>
<input
class="form-input"
value={mission}
onInput={(e) => setMission((e.target as HTMLInputElement).value)}
/>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy || !nomEntreprise}
onClick={submit}
>
Enregistrer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
function StageAddForm(
{ numEtud, entreprises, onCancel, onSave }: {
numEtud: number;
entreprises: string[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
const [duree, setDuree] = useState("4");
const [nomEntreprise, setNomEntreprise] = useState("");
const [mission, setMission] = useState("");
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
try {
const res = await fetch("/stages/api/stages", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
numEtud,
duree: parseInt(duree),
nomEntreprise,
mission: mission || null,
}),
});
if (!res.ok) throw new Error("Erreur");
await onSave();
} catch {
alert("Erreur lors de la création");
} finally {
setBusy(false);
}
}
return (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Nouveau stage</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>
<div class="form-field">
<label>Entreprise</label>
<input
class="form-input"
list="add-entreprises"
value={nomEntreprise}
onInput={(e) =>
setNomEntreprise((e.target as HTMLInputElement).value)}
/>
<datalist id="add-entreprises">
{entreprises.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Mission</label>
<input
class="form-input"
value={mission}
onInput={(e) => setMission((e.target as HTMLInputElement).value)}
/>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy || !nomEntreprise || !duree}
onClick={submit}
>
Créer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
import { AppProperties } from "$root/defaults/interfaces.ts";
const properties: AppProperties = {
name: "Stages",
icon: "work",
pages: {
index: "Accueil",
overview: "Suivi des stages",
},
adminOnly: ["overview"],
employeeOnly: true,
hint: "Suivi des stages et semaines",
};
export default properties;
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
-84
View File
@@ -1,84 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { stages } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers<null, AuthenticatedState> = {
// GET /stages — list all, optional ?numEtud filter
async GET(request) {
try {
const url = new URL(request.url);
const numEtudParam = url.searchParams.get("numEtud");
let query = db.select().from(stages).$dynamic();
if (numEtudParam) {
const numEtud = parseInt(numEtudParam);
if (isNaN(numEtud)) {
return new Response("Paramètre numEtud invalide", { status: 400 });
}
query = query.where(eq(stages.numEtud, numEtud));
}
const result = await query;
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching stages:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
// POST /stages — create stage (employee only)
async POST(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return new Response(null, { status: 403 });
}
try {
const body = await request.json();
const { numEtud, duree, nomEntreprise, mission } = body;
if (!numEtud || duree === undefined || !nomEntreprise) {
return new Response(
JSON.stringify({
error: "Champs requis: numEtud, duree, nomEntreprise",
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (!Number.isInteger(duree) || duree < 1) {
return new Response(
JSON.stringify({ error: "duree doit être un entier >= 1" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [created] = await db
.insert(stages)
.values({
numEtud,
duree,
nomEntreprise,
mission: mission ?? null,
})
.returning();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
} catch (error) {
console.error("Error creating stage:", error);
return new Response("Failed to create stage", { status: 500 });
}
},
};
@@ -1,122 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites, stages } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Stage introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// GET /stages/:idStage
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const idStage = Number(context.params.idStage);
if (isNaN(idStage)) {
return new Response("Paramètre idStage invalide", { status: 400 });
}
const row = await db
.select()
.from(stages)
.where(eq(stages.id, idStage))
.then((rows) => rows[0] ?? null);
if (!row) return NOT_FOUND();
return new Response(JSON.stringify(row), {
headers: { "content-type": "application/json" },
});
},
// PUT /stages/:idStage (employee only)
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN();
}
const idStage = Number(context.params.idStage);
if (isNaN(idStage)) {
return new Response("Paramètre idStage invalide", { status: 400 });
}
const body = await request.json();
const { duree, nomEntreprise, mission } = body;
if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) {
return new Response(
JSON.stringify({ error: "duree doit être un entier >= 1" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const set: Record<string, unknown> = {};
if (duree !== undefined) set.duree = duree;
if (nomEntreprise !== undefined) set.nomEntreprise = nomEntreprise;
if (mission !== undefined) set.mission = mission;
if (Object.keys(set).length === 0) {
return new Response(
JSON.stringify({ error: "Au moins un champ à modifier requis" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [updated] = await db
.update(stages)
.set(set)
.where(eq(stages.id, idStage))
.returning();
if (!updated) return NOT_FOUND();
// If duration changed and this stage is linked as a mobility, update the mobility too
if (duree !== undefined) {
await db
.update(mobilites)
.set({ duree })
.where(eq(mobilites.idStage, idStage));
}
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
});
},
// DELETE /stages/:idStage (employee only)
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN();
}
const idStage = Number(context.params.idStage);
if (isNaN(idStage)) {
return new Response("Paramètre idStage invalide", { status: 400 });
}
// Remove linked mobilites first (FK constraint)
await db.delete(mobilites).where(eq(mobilites.idStage, idStage));
const [deleted] = await db
.delete(stages)
.where(eq(stages.id, idStage))
.returning();
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
};
-2
View File
@@ -1,2 +0,0 @@
import makeIndex from "$root/defaults/makeIndex.ts";
export default makeIndex(import.meta.dirname!);
-30
View File
@@ -1,30 +0,0 @@
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">Stages</h2>
<p>
Bienvenue{" "}
<strong>
{(context.state as unknown as { session: Record<string, string> })
.session.displayName}
</strong>
.
</p>
<p>Suivi des stages : 40 semaines requises par élève.</p>
</div>
);
}
export const config = getPartialsConfig();
export default makePartials(Index);
@@ -1,19 +0,0 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import StagesOverview from "../(_islands)/StagesOverview.tsx";
// deno-lint-ignore require-await
async function Overview(
_request: Request,
_context: FreshContext<State>,
) {
return <StagesOverview />;
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
@@ -1,20 +0,0 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import StagesOverview from "../../(_islands)/StagesOverview.tsx";
// deno-lint-ignore require-await
async function Overview(
_request: Request,
context: FreshContext<State>,
) {
const numEtud = Number(context.params.numEtud);
return <StagesOverview initialNumEtud={numEtud} />;
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
@@ -8,8 +8,6 @@ type Student = {
};
type Promo = { id: string; annee: string };
type Module = { id: string; nom: string };
type Mobilite = { id: number; duree: number; status: string };
type Stage = { id: number; duree: number };
type Props = { numEtud: number };
@@ -27,8 +25,6 @@ export default function EditStudents({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null);
const [promos, setPromos] = useState<Promo[]>([]);
const [_modules, setModules] = useState<Module[]>([]);
const [mobWeeks, setMobWeeks] = useState(0);
const [stageWeeks, setStageWeeks] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
@@ -42,12 +38,10 @@ export default function EditStudents({ numEtud }: Props) {
useEffect(() => {
async function load() {
try {
const [sRes, pRes, mRes, mobRes, stRes] = await Promise.all([
const [sRes, pRes, mRes] = await Promise.all([
fetch(`/students/api/students/${numEtud}`),
fetch("/students/api/promotions"),
fetch("/notes/api/modules"),
fetch(`/mobility/api/mobilites?numEtud=${numEtud}`),
fetch(`/stages/api/stages?numEtud=${numEtud}`),
fetch("/admin/api/modules"),
]);
if (!sRes.ok) throw new Error("Élève introuvable");
const s: Student = await sRes.json();
@@ -57,19 +51,6 @@ export default function EditStudents({ numEtud }: Props) {
setIdPromo(s.idPromo);
if (pRes.ok) setPromos(await pRes.json());
if (mRes.ok) setModules(await mRes.json());
if (mobRes.ok) {
const mobs: Mobilite[] = await mobRes.json();
setMobWeeks(
mobs.filter((m) => m.status === "validated").reduce(
(s, m) => s + m.duree,
0,
),
);
}
if (stRes.ok) {
const stages: Stage[] = await stRes.json();
setStageWeeks(stages.reduce((s, st) => s + st.duree, 0));
}
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
@@ -226,69 +207,30 @@ export default function EditStudents({ numEtud }: Props) {
</div>
</div>
{/* Section 2: Notes */}
{/* Section 2: Spécialisations */}
<div class="edit-section">
<p class="edit-section-title">Notes</p>
<p class="edit-section-title">Spécialisations</p>
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
>
Fonctionnalité non disponible (endpoint non implémenté).
</p>
</div>
{/* Section 3: Notes lecture seule */}
<div class="edit-section">
<p class="edit-section-title">Notes (lecture seule)</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span class="col-dim" style="font-size: 0.82rem">
Récap complet des notes et moyennes
Voir le récap complet des notes et moyennes de cet étudiant
</span>
<a
class="btn btn-secondary"
href={`/notes/recap/${numEtud}`}
f-client-nav={false}
>
Voir les notes
</a>
</div>
</div>
{/* Section 3: Mobilités */}
<div class="edit-section">
<p class="edit-section-title">Mobilités</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap">
<span
style={{
fontFamily: "monospace",
fontWeight: "var(--font-weight-bold)",
color: mobWeeks >= 12 ? "#22c55e" : "#dc2626",
}}
>
{mobWeeks}/12 semaines validées
</span>
</span>
<a
class="btn btn-secondary"
href={`/mobility/overview/${numEtud}`}
f-client-nav={false}
>
Consulter
</a>
</div>
</div>
{/* Section 4: Stages */}
<div class="edit-section">
<p class="edit-section-title">Stages</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap">
<span
style={{
fontFamily: "monospace",
fontWeight: "var(--font-weight-bold)",
color: stageWeeks >= 40 ? "#22c55e" : "#dc2626",
}}
>
{stageWeeks}/40 semaines
</span>
</span>
<a
class="btn btn-secondary"
href={`/stages/overview/${numEtud}`}
f-client-nav={false}
>
Consulter
Récap notes
</a>
</div>
</div>
-1
View File
@@ -9,7 +9,6 @@ const properties: AppProperties = {
upload: "Import xlsx",
},
adminOnly: ["consult", "upload"],
employeeOnly: true,
hint: "Create students promotion and see informations",
};
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
@@ -2,9 +2,8 @@ import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import {
ajustements,
mobilites,
mobility,
notes,
stages,
students,
} from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
@@ -81,7 +80,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
},
// #12 DELETE /students/{numEtud}
// Cascade: deletes notes, ajustements, mobilites, stages for this student.
// Cascade: deletes notes, ajustements, mobility for this student.
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
@@ -103,8 +102,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
await db.transaction(async (tx) => {
await tx.delete(notes).where(eq(notes.numEtud, numEtud));
await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud));
await tx.delete(mobilites).where(eq(mobilites.numEtud, numEtud));
await tx.delete(stages).where(eq(stages.numEtud, numEtud));
await tx.delete(mobility).where(eq(mobility.studentId, numEtud));
await tx.delete(students).where(eq(students.numEtud, numEtud));
});
@@ -11,6 +11,5 @@ async function Students(_request: Request, _context: FreshContext<State>) {
return <ConsultStudents />;
}
export { Students as Page };
export const config = getPartialsConfig();
export default makePartials(Students);
@@ -16,6 +16,5 @@ async function Students(_request: Request, _context: FreshContext<State>) {
);
}
export { Students as Page };
export const config = getPartialsConfig();
export default makePartials(Students);
-1
View File
@@ -29,7 +29,6 @@ export default async function App(
<link rel="stylesheet" href="/styles/app-cards.css" />
<link rel="stylesheet" href="/styles/students.css" />
<link rel="stylesheet" href="/styles/ui.css" />
<script src="/theme.js"></script>
</head>
<body f-client-nav>
<Header link={link} />
-22
View File
@@ -43,28 +43,6 @@ export function getKey(user: string): string {
}
export const handler: MiddlewareHandler<State>[] = [
async function logRequest(
request: Request,
context: FreshContext<State>,
): Promise<Response> {
const url = new URL(request.url);
const start = performance.now();
console.log(`--> ${request.method} ${url.pathname}${url.search}`);
try {
const response = await context.next();
const duration = (performance.now() - start).toFixed(1);
console.log(`<-- ${request.method} ${url.pathname} ${response.status} (${duration}ms)`);
return response;
} catch (error) {
const duration = (performance.now() - start).toFixed(1);
console.error(`<-- ${request.method} ${url.pathname} ERROR (${duration}ms)`);
console.error(error);
throw error;
}
},
/**
* Check if user is authenticated and add session to context accordingly.
* @param request The HTTP incomming request.
+1 -12
View File
@@ -44,20 +44,9 @@ export default async function Apps(
_request: Request,
context: FreshContext<State, Record<string, AppProperties>>,
) {
let visibleApps = context.data;
if (
context.state.isAuthenticated &&
context.state.session.eduPersonPrimaryAffiliation === "student"
) {
visibleApps = Object.fromEntries(
Object.entries(context.data).filter(([_, app]) => !app.employeeOnly),
);
}
return (
<>
<AppNavigator apps={visibleApps} />
<AppNavigator apps={context.data} />
</>
);
}
+3 -18
View File
@@ -1,28 +1,13 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import { FreshContext } from "$fresh/server.ts";
export const handler: Handlers<unknown, State> = {
GET(_request: Request, context: FreshContext<State>) {
if (context.state.isAuthenticated) {
return new Response(null, {
status: 302,
headers: { Location: "/apps" },
});
}
return context.render();
},
};
export default function Home() {
// deno-lint-ignore require-await
export default async function Home(_request: Request, _context: FreshContext) {
return (
<>
<h2>PolyMPR</h2>
<h3>
The <em>ultimate</em> HR platform
</h3>
<p>
<a href="/login">Se connecter</a>
</p>
</>
);
}
+2
View File
@@ -45,6 +45,8 @@ function createUserJWT(casResponse: CasResponse): Promise<string> {
}
});
console.log(fullUserInfos);
const now = Math.floor(Date.now() / 1000);
const payload: LoginJWT = {
iss: "PolyMPR",
+3 -26
View File
@@ -5,12 +5,7 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
{
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([
[
null,
null,
null,
"Promotion peut etre vide mais doit prealablement Exister",
],
[null, null, null, "Promotion peut etre vide mais doit prealablement Exister"],
["Nom", "Prenom", "Numero-etudiant", "Promotion"],
["NOM", "PRENOM", 12345678, "3AFISE24-25"],
]);
@@ -43,26 +38,8 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
{
const data = [
["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."],
[
"Description des UE du diplome",
null,
null,
null,
null,
null,
"Nombre d'heures",
],
[
"Annee\nSemestres",
"Codes APOGEE",
null,
null,
"Credits\n ECTS",
"Coeff.",
"CM",
"TD",
"TP",
],
["Description des UE du diplome", null, null, null, null, null, "Nombre d'heures"],
["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\n ECTS", "Coeff.", "CM", "TD", "TP"],
["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"],
["SEM 5", null, null, null, 30],
["UE", "CODE_UE1", "Nom de l'UE 1", null, 6],
+2 -6
View File
@@ -9,9 +9,7 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) {
for (const sheetName of wb.SheetNames) {
console.log(`\n--- Sheet: ${sheetName} ---`);
const sheet = wb.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
header: 1,
});
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { header: 1 });
// Print first 5 cols of each row, mark rows that look like year/semester headers
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
@@ -19,9 +17,7 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) {
const col0 = row[0] != null ? String(row[0]).trim() : "";
// Show rows that are structural (year, semester, UE headers)
if (col0 || (row[1] != null && String(row[1]).trim())) {
const preview = row.slice(0, 6).map((c) =>
c != null ? String(c).substring(0, 25) : ""
).join(" | ");
const preview = row.slice(0, 6).map(c => c != null ? String(c).substring(0, 25) : "").join(" | ");
console.log(` [${i}] ${preview}`);
}
}
+2 -10
View File
@@ -40,12 +40,6 @@
font-size: 0.8rem;
font-family: inherit;
min-width: 8rem;
box-sizing: border-box;
}
.form-field .filter-select {
width: 100%;
min-width: 0;
}
.filter-input:focus,
@@ -374,9 +368,7 @@
color: light-dark(var(--light-foreground), var(--dark-foreground));
font-size: 0.82rem;
font-family: inherit;
min-width: 0;
width: 100%;
box-sizing: border-box;
min-width: 12rem;
}
.form-input:focus {
@@ -807,7 +799,7 @@
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
-29
View File
@@ -1,29 +0,0 @@
(function () {
const t = localStorage.getItem("theme");
if (t) document.documentElement.style.colorScheme = t;
document.addEventListener("click", function (e) {
const btn = e.target.closest("#theme-toggle");
if (!btn) return;
const cs = getComputedStyle(document.documentElement).colorScheme;
const isDark = cs === "dark" ||
(!cs || cs === "light dark") &&
matchMedia("(prefers-color-scheme:dark)").matches;
const next = isDark ? "light" : "dark";
document.documentElement.style.colorScheme = next;
localStorage.setItem("theme", next);
btn.querySelector("span").textContent = next === "dark"
? "light_mode"
: "dark_mode";
});
document.addEventListener("DOMContentLoaded", function () {
const btn = document.getElementById("theme-toggle");
if (!btn) return;
const cs = getComputedStyle(document.documentElement).colorScheme;
const isDark = cs === "dark" ||
(!cs || cs === "light dark") &&
matchMedia("(prefers-color-scheme:dark)").matches;
btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode";
});
})();
-160
View File
@@ -1,160 +0,0 @@
// Integration tests for /ajustements — Drizzle ORM direct on real DB
import { assertEquals, assertExists, assertRejects } from "@std/assert";
import {
seedAjustements,
seedPromotions,
seedStudents,
seedUes,
testDb,
truncateAll,
} from "../helpers/db_integration.ts";
import { ajustements } from "$root/databases/schema.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration ajustements: list all ajustements",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
const [s] = await seedStudents([{
nom: "Dupont",
prenom: "Jean",
idPromo: "P1",
}]);
const [ue] = await seedUes([{ nom: "UE Info" }]);
await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 13.0 }]);
const rows = await testDb.select().from(ajustements);
assertEquals(rows.length, 1);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ajustements: create and retrieve by composite key",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
const [s] = await seedStudents([{
nom: "Martin",
prenom: "Alice",
idPromo: "P1",
}]);
const [ue] = await seedUes([{ nom: "UE Maths" }]);
const [created] = await testDb
.insert(ajustements)
.values({ numEtud: s.numEtud, idUE: ue.id, valeur: 15.5 })
.returning();
assertExists(created);
assertEquals(created.valeur, 15.5);
const row = await testDb
.select()
.from(ajustements)
.where(
and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)),
)
.then((r) => r[0] ?? null);
assertExists(row);
assertEquals(row.valeur, 15.5);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name:
"integration ajustements: get by composite key returns null when not found",
async fn() {
await truncateAll();
const row = await testDb
.select()
.from(ajustements)
.where(and(eq(ajustements.numEtud, 99999), eq(ajustements.idUE, 99)))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ajustements: duplicate composite key insert fails",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
const [s] = await seedStudents([{
nom: "Durand",
prenom: "Claire",
idPromo: "P1",
}]);
const [ue] = await seedUes([{ nom: "UE Info" }]);
await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 12.0 }]);
await assertRejects(() =>
testDb.insert(ajustements).values({
numEtud: s.numEtud,
idUE: ue.id,
valeur: 13.0,
})
);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ajustements: update valeur",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
const [s] = await seedStudents([{
nom: "Bernard",
prenom: "Lucie",
idPromo: "P1",
}]);
const [ue] = await seedUes([{ nom: "UE Physique" }]);
await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 10.0 }]);
const [updated] = await testDb
.update(ajustements)
.set({ valeur: 18.0 })
.where(
and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)),
)
.returning();
assertEquals(updated.valeur, 18.0);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ajustements: delete removes the ajustement",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
const [s] = await seedStudents([{
nom: "Thomas",
prenom: "Eva",
idPromo: "P1",
}]);
const [ue] = await seedUes([{ nom: "UE Chimie" }]);
await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 11.0 }]);
await testDb.delete(ajustements).where(
and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)),
);
const row = await testDb
.select()
.from(ajustements)
.where(
and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id)),
)
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
-148
View File
@@ -1,148 +0,0 @@
// Integration tests for /enseignements — Drizzle ORM direct on real DB
import { assertEquals, assertExists, assertRejects } from "@std/assert";
import {
seedEnseignements,
seedModules,
seedPromotions,
seedUsers,
testDb,
truncateAll,
} from "../helpers/db_integration.ts";
import { enseignements } from "$root/databases/schema.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration enseignements: list all enseignements",
async fn() {
await truncateAll();
await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]);
await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]);
await seedPromotions([{ id: "P1" }]);
await seedEnseignements([
{ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" },
{ idProf: "prof.dupont", idModule: "M2", idPromo: "P1" },
]);
const rows = await testDb.select().from(enseignements);
assertEquals(rows.length, 2);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration enseignements: create and retrieve by composite key",
async fn() {
await truncateAll();
await seedUsers([{ id: "prof.moreau", nom: "Moreau", prenom: "Sophie" }]);
await seedModules([{ id: "M1", nom: "Mod A" }]);
await seedPromotions([{ id: "P1" }]);
const [created] = await testDb
.insert(enseignements)
.values({ idProf: "prof.moreau", idModule: "M1", idPromo: "P1" })
.returning();
assertExists(created);
assertEquals(created.idProf, "prof.moreau");
const row = await testDb
.select()
.from(enseignements)
.where(
and(
eq(enseignements.idProf, "prof.moreau"),
eq(enseignements.idModule, "M1"),
eq(enseignements.idPromo, "P1"),
),
)
.then((r) => r[0] ?? null);
assertExists(row);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name:
"integration enseignements: get by composite key returns null when not found",
async fn() {
await truncateAll();
const row = await testDb
.select()
.from(enseignements)
.where(
and(
eq(enseignements.idProf, "ghost"),
eq(enseignements.idModule, "GHOST"),
eq(enseignements.idPromo, "GHOST"),
),
)
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration enseignements: duplicate composite key insert fails",
async fn() {
await truncateAll();
await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]);
await seedModules([{ id: "M1", nom: "Mod A" }]);
await seedPromotions([{ id: "P1" }]);
await seedEnseignements([{
idProf: "prof.dupont",
idModule: "M1",
idPromo: "P1",
}]);
await assertRejects(() =>
testDb.insert(enseignements).values({
idProf: "prof.dupont",
idModule: "M1",
idPromo: "P1",
})
);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration enseignements: delete removes the enseignement",
async fn() {
await truncateAll();
await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]);
await seedModules([{ id: "M1", nom: "Mod A" }]);
await seedPromotions([{ id: "P1" }]);
await seedEnseignements([{
idProf: "prof.dupont",
idModule: "M1",
idPromo: "P1",
}]);
await testDb
.delete(enseignements)
.where(
and(
eq(enseignements.idProf, "prof.dupont"),
eq(enseignements.idModule, "M1"),
eq(enseignements.idPromo, "P1"),
),
);
const row = await testDb
.select()
.from(enseignements)
.where(
and(
eq(enseignements.idProf, "prof.dupont"),
eq(enseignements.idModule, "M1"),
eq(enseignements.idPromo, "P1"),
),
)
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
-104
View File
@@ -1,104 +0,0 @@
// #113 - Integration tests for /modules endpoints
import { assertEquals, assertExists, assertRejects } from "@std/assert";
import { seedModules, testDb, truncateAll } from "../helpers/db_integration.ts";
import { modules } from "$root/databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration modules: list all modules",
async fn() {
await truncateAll();
await seedModules([{ id: "MATH101", nom: "Mathématiques" }, {
id: "INFO101",
nom: "Informatique",
}]);
const rows = await testDb.select().from(modules);
assertEquals(rows.length, 2);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration modules: create and retrieve by id",
async fn() {
await truncateAll();
const [created] = await testDb.insert(modules).values({
id: "PHYS101",
nom: "Physique",
}).returning();
assertExists(created);
assertEquals(created.id, "PHYS101");
const row = await testDb
.select()
.from(modules)
.where(eq(modules.id, "PHYS101"))
.then((r) => r[0] ?? null);
assertExists(row);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration modules: get by id returns null when not found",
async fn() {
await truncateAll();
const row = await testDb
.select()
.from(modules)
.where(eq(modules.id, "NONEXISTENT"))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration modules: duplicate id insert fails",
async fn() {
await truncateAll();
await seedModules([{ id: "MATH101", nom: "Mathématiques" }]);
await assertRejects(() =>
testDb.insert(modules).values({ id: "MATH101", nom: "Doublon" })
);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration modules: update nom",
async fn() {
await truncateAll();
await seedModules([{ id: "ELEC201", nom: "Électronique" }]);
const [updated] = await testDb
.update(modules)
.set({ nom: "Électronique numérique" })
.where(eq(modules.id, "ELEC201"))
.returning();
assertEquals(updated.nom, "Électronique numérique");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration modules: delete removes the module",
async fn() {
await truncateAll();
await seedModules([{ id: "BIO101", nom: "Biologie" }]);
await testDb.delete(modules).where(eq(modules.id, "BIO101"));
const row = await testDb
.select()
.from(modules)
.where(eq(modules.id, "BIO101"))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
-154
View File
@@ -1,154 +0,0 @@
// Integration tests for /notes — Drizzle ORM direct on real DB
import { assertEquals, assertExists, assertRejects } from "@std/assert";
import {
seedModules,
seedNotes,
seedPromotions,
seedStudents,
testDb,
truncateAll,
} from "../helpers/db_integration.ts";
import { notes } from "$root/databases/schema.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration notes: list all notes",
async fn() {
await truncateAll();
await seedPromotions([{ id: "PROMO-2024" }]);
const [s] = await seedStudents([{
nom: "Dupont",
prenom: "Jean",
idPromo: "PROMO-2024",
}]);
await seedModules([{ id: "MOD101", nom: "Module A" }]);
await seedNotes([{ numEtud: s.numEtud, idModule: "MOD101", note: 15.5 }]);
const rows = await testDb.select().from(notes);
assertEquals(rows.length, 1);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration notes: create and retrieve by composite key",
async fn() {
await truncateAll();
await seedPromotions([{ id: "PROMO-2024" }]);
const [s] = await seedStudents([{
nom: "Martin",
prenom: "Alice",
idPromo: "PROMO-2024",
}]);
await seedModules([{ id: "MOD102", nom: "Module B" }]);
const [created] = await testDb.insert(notes).values({
numEtud: s.numEtud,
idModule: "MOD102",
note: 12.0,
}).returning();
assertExists(created);
assertEquals(created.note, 12.0);
const row = await testDb
.select()
.from(notes)
.where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD102")))
.then((r) => r[0] ?? null);
assertExists(row);
assertEquals(row.note, 12.0);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration notes: get by composite key returns null when not found",
async fn() {
await truncateAll();
const row = await testDb
.select()
.from(notes)
.where(and(eq(notes.numEtud, 99999), eq(notes.idModule, "GHOST")))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration notes: duplicate composite key insert fails",
async fn() {
await truncateAll();
await seedPromotions([{ id: "PROMO-2024" }]);
const [s] = await seedStudents([{
nom: "Durand",
prenom: "Claire",
idPromo: "PROMO-2024",
}]);
await seedModules([{ id: "MOD103", nom: "Module C" }]);
await seedNotes([{ numEtud: s.numEtud, idModule: "MOD103", note: 10.0 }]);
await assertRejects(() =>
testDb.insert(notes).values({
numEtud: s.numEtud,
idModule: "MOD103",
note: 11.0,
})
);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration notes: update note value",
async fn() {
await truncateAll();
await seedPromotions([{ id: "PROMO-2024" }]);
const [s] = await seedStudents([{
nom: "Bernard",
prenom: "Lucie",
idPromo: "PROMO-2024",
}]);
await seedModules([{ id: "MOD104", nom: "Module D" }]);
await seedNotes([{ numEtud: s.numEtud, idModule: "MOD104", note: 8.0 }]);
const [updated] = await testDb
.update(notes)
.set({ note: 16.0 })
.where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD104")))
.returning();
assertEquals(updated.note, 16.0);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration notes: delete removes the note",
async fn() {
await truncateAll();
await seedPromotions([{ id: "PROMO-2024" }]);
const [s] = await seedStudents([{
nom: "Thomas",
prenom: "Eva",
idPromo: "PROMO-2024",
}]);
await seedModules([{ id: "MOD105", nom: "Module E" }]);
await seedNotes([{ numEtud: s.numEtud, idModule: "MOD105", note: 14.0 }]);
await testDb.delete(notes).where(
and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD105")),
);
const row = await testDb
.select()
.from(notes)
.where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD105")))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
-112
View File
@@ -1,112 +0,0 @@
// #110 - Integration tests for /promotions endpoints
import { assertEquals, assertExists } from "@std/assert";
import {
seedPromotions,
testDb,
truncateAll,
} from "../helpers/db_integration.ts";
import { promotions } from "$root/databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration promotions: list all",
async fn() {
await truncateAll();
await seedPromotions([
{ id: "PEIP1-2024", annee: "2024" },
{ id: "PEIP2-2024", annee: "2024" },
]);
const rows = await testDb.select().from(promotions);
assertEquals(rows.length, 2);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration promotions: create and retrieve by id",
async fn() {
await truncateAll();
const [created] = await testDb
.insert(promotions)
.values({ id: "INFO3-2025", annee: "2025" })
.returning();
assertExists(created);
assertEquals(created.id, "INFO3-2025");
assertEquals(created.annee, "2025");
const row = await testDb
.select()
.from(promotions)
.where(eq(promotions.id, "INFO3-2025"))
.then((r) => r[0] ?? null);
assertExists(row);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration promotions: get by id returns null when not found",
async fn() {
await truncateAll();
const row = await testDb
.select()
.from(promotions)
.where(eq(promotions.id, "NONEXISTENT"))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration promotions: update annee",
async fn() {
await truncateAll();
await seedPromotions([{ id: "INFO3-2023", annee: "2023" }]);
const [updated] = await testDb
.update(promotions)
.set({ annee: "2024" })
.where(eq(promotions.id, "INFO3-2023"))
.returning();
assertExists(updated);
assertEquals(updated.annee, "2024");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration promotions: delete removes the row",
async fn() {
await truncateAll();
await seedPromotions([{ id: "INFO3-2022", annee: "2022" }]);
await testDb.delete(promotions).where(eq(promotions.id, "INFO3-2022"));
const row = await testDb
.select()
.from(promotions)
.where(eq(promotions.id, "INFO3-2022"))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration promotions: update non-existent returns empty",
async fn() {
await truncateAll();
const result = await testDb
.update(promotions)
.set({ annee: "2099" })
.where(eq(promotions.id, "GHOST"))
.returning();
assertEquals(result.length, 0);
},
sanitizeResources: false,
sanitizeOps: false,
});
-123
View File
@@ -1,123 +0,0 @@
// #112 - Integration tests for /roles endpoints
import { assertEquals, assertExists } from "@std/assert";
import { seedRoles, testDb, truncateAll } from "../helpers/db_integration.ts";
import { permissions, rolePermissions, roles } from "$root/databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration roles: list all roles",
async fn() {
await truncateAll();
await seedRoles([{ nom: "admin" }, { nom: "employee" }]);
const rows = await testDb.select().from(roles);
assertEquals(rows.length, 2);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration roles: create and retrieve by id",
async fn() {
await truncateAll();
const [created] = await testDb.insert(roles).values({ nom: "viewer" })
.returning();
assertExists(created.id);
assertEquals(created.nom, "viewer");
const row = await testDb
.select()
.from(roles)
.where(eq(roles.id, created.id))
.then((r) => r[0] ?? null);
assertExists(row);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration roles: assign and retrieve permissions",
async fn() {
await truncateAll();
const [role] = await seedRoles([{ nom: "admin" }]);
await testDb.insert(permissions).values([
{ id: "student_read", nom: "Consulter les élèves" },
{ id: "student_write", nom: "Gérer les élèves" },
]);
await testDb.insert(rolePermissions).values([
{ idRole: role.id, idPermission: "student_read" },
{ idRole: role.id, idPermission: "student_write" },
]);
const perms = await testDb
.select()
.from(rolePermissions)
.where(eq(rolePermissions.idRole, role.id));
assertEquals(perms.length, 2);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration roles: update role nom",
async fn() {
await truncateAll();
const [role] = await seedRoles([{ nom: "employee" }]);
const [updated] = await testDb
.update(roles)
.set({ nom: "teacher" })
.where(eq(roles.id, role.id))
.returning();
assertEquals(updated.nom, "teacher");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration roles: reset permissions on update",
async fn() {
await truncateAll();
const [role] = await seedRoles([{ nom: "admin" }]);
await testDb.insert(permissions).values([
{ id: "note_read", nom: "Consulter les notes" },
{ id: "note_write", nom: "Gérer les notes" },
]);
await testDb.insert(rolePermissions).values([
{ idRole: role.id, idPermission: "note_read" },
]);
// reset
await testDb.delete(rolePermissions).where(
eq(rolePermissions.idRole, role.id),
);
await testDb.insert(rolePermissions).values([
{ idRole: role.id, idPermission: "note_write" },
]);
const perms = await testDb
.select()
.from(rolePermissions)
.where(eq(rolePermissions.idRole, role.id));
assertEquals(perms.length, 1);
assertEquals(perms[0].idPermission, "note_write");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration roles: delete role removes it",
async fn() {
await truncateAll();
const [role] = await seedRoles([{ nom: "moderator" }]);
await testDb.delete(roles).where(eq(roles.id, role.id));
const row = await testDb
.select()
.from(roles)
.where(eq(roles.id, role.id))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
-173
View File
@@ -1,173 +0,0 @@
// #109 - Integration tests for /students endpoints
// Teste les opérations DB directement avec une vraie base de données
import { assertEquals, assertExists } from "@std/assert";
import {
seedPromotions,
seedStudents,
testDb,
truncateAll,
} from "../helpers/db_integration.ts";
import { students } from "$root/databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration students: list all students",
async fn() {
await truncateAll();
await seedPromotions([{ id: "PEIP1-2024" }]);
await seedStudents([
{ nom: "Dupont", prenom: "Jean", idPromo: "PEIP1-2024" },
{ nom: "Martin", prenom: "Alice", idPromo: "PEIP1-2024" },
]);
const rows = await testDb.select().from(students);
assertEquals(rows.length, 2);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration students: filter by idPromo",
async fn() {
await truncateAll();
await seedPromotions([{ id: "PEIP1-2024" }, { id: "PEIP2-2024" }]);
await seedStudents([
{ nom: "Dupont", prenom: "Jean", idPromo: "PEIP1-2024" },
{ nom: "Martin", prenom: "Alice", idPromo: "PEIP1-2024" },
{ nom: "Durand", prenom: "Claire", idPromo: "PEIP2-2024" },
]);
const rows = await testDb
.select()
.from(students)
.where(eq(students.idPromo, "PEIP1-2024"));
assertEquals(rows.length, 2);
assertEquals(rows.every((s) => s.idPromo === "PEIP1-2024"), true);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration students: create and retrieve by numEtud",
async fn() {
await truncateAll();
await seedPromotions([{ id: "INFO3-2024" }]);
const [created] = await testDb
.insert(students)
.values({ nom: "Leroy", prenom: "Paul", idPromo: "INFO3-2024" })
.returning();
assertExists(created.numEtud);
const row = await testDb
.select()
.from(students)
.where(eq(students.numEtud, created.numEtud))
.then((r) => r[0] ?? null);
assertExists(row);
assertEquals(row.nom, "Leroy");
assertEquals(row.idPromo, "INFO3-2024");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration students: get by numEtud returns null when not found",
async fn() {
await truncateAll();
const row = await testDb
.select()
.from(students)
.where(eq(students.numEtud, 999999))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration students: update student fields",
async fn() {
await truncateAll();
await seedPromotions([{ id: "INFO3-2024" }, { id: "INFO4-2024" }]);
const [s] = await seedStudents([
{ nom: "Petit", prenom: "Hugo", idPromo: "INFO3-2024" },
]);
const [updated] = await testDb
.update(students)
.set({ nom: "Grand", idPromo: "INFO4-2024" })
.where(eq(students.numEtud, s.numEtud))
.returning();
assertEquals(updated.nom, "Grand");
assertEquals(updated.idPromo, "INFO4-2024");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration students: delete student",
async fn() {
await truncateAll();
await seedPromotions([{ id: "INFO3-2024" }]);
const [s] = await seedStudents([
{ nom: "Thomas", prenom: "Eva", idPromo: "INFO3-2024" },
]);
await testDb.delete(students).where(eq(students.numEtud, s.numEtud));
const row = await testDb
.select()
.from(students)
.where(eq(students.numEtud, s.numEtud))
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration students: update non-existent student returns empty",
async fn() {
await truncateAll();
const result = await testDb
.update(students)
.set({ nom: "Ghost" })
.where(eq(students.numEtud, 999999))
.returning();
assertEquals(result.length, 0);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration students: delete non-existent student returns empty",
async fn() {
await truncateAll();
const result = await testDb
.delete(students)
.where(eq(students.numEtud, 999999))
.returning();
assertEquals(result.length, 0);
},
sanitizeResources: false,
sanitizeOps: false,
});
-183
View File
@@ -1,183 +0,0 @@
// Integration tests for /ue-modules — Drizzle ORM direct on real DB
import { assertEquals, assertExists, assertRejects } from "@std/assert";
import {
seedModules,
seedPromotions,
seedUeModules,
seedUes,
testDb,
truncateAll,
} from "../helpers/db_integration.ts";
import { ueModules } from "$root/databases/schema.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration ue_modules: list all associations",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]);
const [ue] = await seedUes([{ nom: "UE Info" }]);
await seedUeModules([
{ idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 },
{ idModule: "M2", idUE: ue.id, idPromo: "P1", coeff: 3.0 },
]);
const rows = await testDb.select().from(ueModules);
assertEquals(rows.length, 2);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ue_modules: create and retrieve by composite key",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
await seedModules([{ id: "M1", nom: "Mod A" }]);
const [ue] = await seedUes([{ nom: "UE Maths" }]);
const [created] = await testDb
.insert(ueModules)
.values({ idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 4.0 })
.returning();
assertExists(created);
assertEquals(created.coeff, 4.0);
const row = await testDb
.select()
.from(ueModules)
.where(
and(
eq(ueModules.idModule, "M1"),
eq(ueModules.idUE, ue.id),
eq(ueModules.idPromo, "P1"),
),
)
.then((r) => r[0] ?? null);
assertExists(row);
assertEquals(row.coeff, 4.0);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name:
"integration ue_modules: get by composite key returns null when not found",
async fn() {
await truncateAll();
const row = await testDb
.select()
.from(ueModules)
.where(
and(
eq(ueModules.idModule, "GHOST"),
eq(ueModules.idUE, 99),
eq(ueModules.idPromo, "GHOST"),
),
)
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ue_modules: duplicate composite key insert fails",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
await seedModules([{ id: "M1", nom: "Mod A" }]);
const [ue] = await seedUes([{ nom: "UE Info" }]);
await seedUeModules([{
idModule: "M1",
idUE: ue.id,
idPromo: "P1",
coeff: 2.0,
}]);
await assertRejects(() =>
testDb.insert(ueModules).values({
idModule: "M1",
idUE: ue.id,
idPromo: "P1",
coeff: 5.0,
})
);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ue_modules: update coeff",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
await seedModules([{ id: "M1", nom: "Mod A" }]);
const [ue] = await seedUes([{ nom: "UE Info" }]);
await seedUeModules([{
idModule: "M1",
idUE: ue.id,
idPromo: "P1",
coeff: 2.0,
}]);
const [updated] = await testDb
.update(ueModules)
.set({ coeff: 6.0 })
.where(
and(
eq(ueModules.idModule, "M1"),
eq(ueModules.idUE, ue.id),
eq(ueModules.idPromo, "P1"),
),
)
.returning();
assertEquals(updated.coeff, 6.0);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ue_modules: delete removes the association",
async fn() {
await truncateAll();
await seedPromotions([{ id: "P1" }]);
await seedModules([{ id: "M1", nom: "Mod A" }]);
const [ue] = await seedUes([{ nom: "UE Info" }]);
await seedUeModules([{
idModule: "M1",
idUE: ue.id,
idPromo: "P1",
coeff: 2.0,
}]);
await testDb
.delete(ueModules)
.where(
and(
eq(ueModules.idModule, "M1"),
eq(ueModules.idUE, ue.id),
eq(ueModules.idPromo, "P1"),
),
);
const row = await testDb
.select()
.from(ueModules)
.where(
and(
eq(ueModules.idModule, "M1"),
eq(ueModules.idUE, ue.id),
eq(ueModules.idPromo, "P1"),
),
)
.then((r) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
-90
View File
@@ -1,90 +0,0 @@
// Integration tests for /ues — Drizzle ORM direct on real DB
import { assertEquals, assertExists, assertRejects } from "@std/assert";
import { seedUes, testDb, truncateAll } from "../helpers/db_integration.ts";
import { ues } from "$root/databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
Deno.test({
name: "integration ues: list all UEs",
async fn() {
await truncateAll();
await seedUes([{ nom: "UE Informatique" }, { nom: "UE Mathématiques" }]);
const rows = await testDb.select().from(ues);
assertEquals(rows.length, 2);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ues: create and retrieve by id",
async fn() {
await truncateAll();
const [created] = await testDb.insert(ues).values({ nom: "UE Physique" })
.returning();
assertExists(created);
assertExists(created.id);
assertEquals(created.nom, "UE Physique");
const row = await testDb.select().from(ues).where(eq(ues.id, created.id))
.then((r) => r[0] ?? null);
assertExists(row);
assertEquals(row.nom, "UE Physique");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ues: get by id returns null when not found",
async fn() {
await truncateAll();
const row = await testDb.select().from(ues).where(eq(ues.id, 99999)).then((
r,
) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ues: update nom",
async fn() {
await truncateAll();
const [ue] = await seedUes([{ nom: "UE Chimie" }]);
const [updated] = await testDb.update(ues).set({
nom: "UE Chimie organique",
}).where(eq(ues.id, ue.id)).returning();
assertEquals(updated.nom, "UE Chimie organique");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ues: delete removes the UE",
async fn() {
await truncateAll();
const [ue] = await seedUes([{ nom: "UE à supprimer" }]);
await testDb.delete(ues).where(eq(ues.id, ue.id));
const row = await testDb.select().from(ues).where(eq(ues.id, ue.id)).then((
r,
) => r[0] ?? null);
assertEquals(row, null);
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration ues: nom is required (not null)",
async fn() {
await truncateAll();
// deno-lint-ignore no-explicit-any
await assertRejects(() => testDb.insert(ues).values({ nom: null as any }));
},
sanitizeResources: false,
sanitizeOps: false,
});
-58
View File
@@ -1,58 +0,0 @@
import { assertEquals, assertExists } from "@std/assert";
import {
closeTestPool,
seedRoles,
seedUsers,
testDb,
truncateAll,
} from "../helpers/db_integration.ts";
import { users } from "$root/databases/schema.ts";
Deno.test({
name: "integration: GET /users - DB round trip",
async fn() {
await truncateAll();
const [role] = await seedRoles([{ nom: "employee" }]);
await seedUsers([
{ id: "dupont.jean", nom: "Dupont", prenom: "Jean", idRole: role.id },
{ id: "martin.alice", nom: "Martin", prenom: "Alice", idRole: role.id },
]);
const rows = await testDb.select().from(users);
assertEquals(rows.length, 2);
assertExists(rows.find((u) => u.id === "dupont.jean"));
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration: INSERT user and retrieve by id",
async fn() {
await truncateAll();
const [role] = await seedRoles([{ nom: "admin" }]);
const [created] = await testDb.insert(users).values({
id: "durand.claire",
nom: "Durand",
prenom: "Claire",
idRole: role.id,
}).returning();
assertExists(created);
assertEquals(created.id, "durand.claire");
assertEquals(created.nom, "Durand");
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "integration: cleanup - close pool",
async fn() {
await closeTestPool();
},
sanitizeResources: false,
sanitizeOps: false,
});

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