Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df3957741d | |||
| 04be659d6b | |||
| f71128a7f3 | |||
| 720a380be8 | |||
| 6c602cb10a | |||
| bb09c1cce5 | |||
| f162fcaadc | |||
| 2c5e4ebf11 | |||
| 757e364af0 | |||
| 378cbb0c06 | |||
| d3de5c29e7 | |||
| 733259e317 | |||
| 56019ad372 | |||
| fcc9547a30 | |||
| 5ba8b8cb68 | |||
| 34b7ac0231 | |||
| 714486f43c | |||
| b0930b8da2 | |||
| 2f4d8db1bf | |||
| a3b55d0a1b | |||
| 86080b8042 | |||
| e3a7e20993 | |||
| c5d02a2890 | |||
| c86d20ca81 | |||
| f038e4020b | |||
| e75098083a | |||
| e3eefd945c | |||
| d25c353018 | |||
| b3eb1b60a5 | |||
| 222c3237f0 | |||
| e2f5bf7b95 | |||
| cd5c524ff0 | |||
| e5c6c389ea | |||
| daa7f4951f | |||
| a95818e3bf | |||
| 26eedcc4f2 | |||
| ce4782580d | |||
| 91248370da | |||
| 6b8b5e6aa3 | |||
| d1c3b93755 | |||
| f42df29f06 | |||
| c8b808f509 | |||
| fdfdd74894 | |||
| 60dde4675c | |||
| fef9457795 | |||
| 6db04045f4 | |||
| cdd9c0bf06 | |||
| 980efcfbc3 | |||
| 66183c2ad8 | |||
| 9976b9e2b4 | |||
| 457b008ba3 | |||
| 22750ba07e | |||
| 49876339bf | |||
| eeb087ea76 | |||
| 7ad70c4525 | |||
| 79669d60cf | |||
| d3f1f433e1 | |||
| 022994e5a7 | |||
| 33d023986c | |||
| bbc9ea58e2 | |||
| 96b7edf77f | |||
| a19a1e6c13 | |||
| 2739a01ab5 | |||
| f3c1f10999 | |||
| 92182b952f |
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.git
|
||||
coverage
|
||||
.env
|
||||
@@ -6,9 +6,26 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
check-code:
|
||||
name: "Check Deno code"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Check formatting
|
||||
run: deno fmt --check
|
||||
|
||||
- name: Check linting
|
||||
run: deno lint
|
||||
|
||||
deploy:
|
||||
name: "Build Docker image"
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-code
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -24,6 +28,3 @@ jobs:
|
||||
|
||||
- name: Check linting
|
||||
run: deno lint
|
||||
|
||||
- name: Run tests
|
||||
run: deno test -A --no-check tests/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,7 +1,12 @@
|
||||
FROM denoland/deno:alpine
|
||||
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
RUN deno cache main.ts --allow-import
|
||||
RUN deno task build
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# Bug Report — PolyMPR
|
||||
|
||||
> Généré le 2026-04-23
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critique
|
||||
|
||||
### #1 — Schema mismatch : module mobility entièrement cassé
|
||||
|
||||
**Fichier** : `routes/(apps)/mobility/api/insert_mobility.ts`
|
||||
|
||||
Références à des colonnes inexistantes dans le schéma Drizzle :
|
||||
|
||||
| Utilisé dans le code | Colonne réelle |
|
||||
| ---------------------- | ------------------ |
|
||||
| `students.userId` | `students.numEtud` |
|
||||
| `students.firstName` | `students.nom` |
|
||||
| `students.lastName` | `students.prenom` |
|
||||
| `students.promotionId` | `students.idPromo` |
|
||||
| `promotions.endyear` | `promotions.annee` |
|
||||
| `promotions.current` | _(n'existe pas)_ |
|
||||
|
||||
Le module crashe à l'exécution. À corriger en alignant les noms de colonnes avec
|
||||
le schéma.
|
||||
|
||||
---
|
||||
|
||||
### #2 — Auth manquante sur de nombreux endpoints
|
||||
|
||||
Les endpoints suivants n'ont aucune vérification `eduPersonPrimaryAffiliation` :
|
||||
|
||||
- `routes/(apps)/notes/api/notes.ts` (GET, POST)
|
||||
- `routes/(apps)/notes/api/ue-modules.ts` (GET, POST)
|
||||
- `routes/(apps)/notes/api/ues.ts` (GET, POST)
|
||||
- `routes/(apps)/notes/api/ues/[idUE].ts` (GET, PUT, DELETE)
|
||||
- `routes/(apps)/admin/api/users.ts` (GET, POST)
|
||||
- `routes/(apps)/admin/api/users/[id].ts` (GET, PUT, DELETE)
|
||||
- `routes/(apps)/admin/api/modules/[idModule].ts` (GET, PUT, DELETE)
|
||||
- `routes/(apps)/admin/api/roles.ts` (GET, POST)
|
||||
- `routes/(apps)/admin/api/roles/[idRole].ts` (GET, PUT, DELETE)
|
||||
- `routes/(apps)/admin/api/permissions.ts` (GET)
|
||||
- `routes/(apps)/mobility/api/insert_mobility.ts`
|
||||
|
||||
Tous ces endpoints exposent des données sensibles sans vérifier les permissions.
|
||||
|
||||
---
|
||||
|
||||
## 🟠 Haut
|
||||
|
||||
### #3 — Bug Drizzle ORM : `.where()` avec plusieurs `eq()` sans `and()`
|
||||
|
||||
**Fichier** : `routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts` — lignes
|
||||
34, 72, 100
|
||||
|
||||
`.where()` n'accepte qu'un seul argument. Passer plusieurs `eq()` séparés par
|
||||
des virgules ne génère pas le SQL attendu (seule la première condition est prise
|
||||
en compte).
|
||||
|
||||
```ts
|
||||
// ❌ Incorrect
|
||||
.where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))
|
||||
|
||||
// ✅ Correct
|
||||
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### #4 — Bug Drizzle ORM : `.where()` à 3 conditions sans `and()`
|
||||
|
||||
**Fichier** :
|
||||
`routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts` — handler
|
||||
GET (~ligne 41)
|
||||
|
||||
Même problème que #3, mais avec 3 conditions. Les handlers PUT et DELETE ont
|
||||
déjà `and()`, seul le GET est affecté.
|
||||
|
||||
```ts
|
||||
// ❌ Incorrect
|
||||
.where(
|
||||
eq(ueModules.idModule, idModule),
|
||||
eq(ueModules.idUE, idUE),
|
||||
eq(ueModules.idPromo, idPromo),
|
||||
)
|
||||
|
||||
// ✅ Correct
|
||||
.where(
|
||||
and(
|
||||
eq(ueModules.idModule, idModule),
|
||||
eq(ueModules.idUE, idUE),
|
||||
eq(ueModules.idPromo, idPromo),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Moyen
|
||||
|
||||
### #5 — `and()` passé avec des valeurs `undefined`
|
||||
|
||||
**Fichier** : `routes/(apps)/notes/api/ue-modules.ts`
|
||||
|
||||
```ts
|
||||
and(
|
||||
idPromo ? eq(ueModules.idPromo, idPromo) : undefined,
|
||||
idUE ? eq(ueModules.idUE, idUE) : undefined,
|
||||
);
|
||||
```
|
||||
|
||||
Drizzle tolère les `undefined` dans `and()` dans certaines versions, mais ce
|
||||
n'est pas garanti. Mieux vaut construire les conditions dynamiquement avant de
|
||||
les passer.
|
||||
|
||||
---
|
||||
|
||||
### #6 — Validation `!numEtud` rejette faussement `0`
|
||||
|
||||
**Fichier** : `routes/(apps)/notes/api/notes.ts` — handler POST
|
||||
|
||||
```ts
|
||||
// ❌ Rejette numEtud = 0
|
||||
if (note === undefined || !numEtud || !idModule)
|
||||
|
||||
// ✅ Correct
|
||||
if (note === undefined || numEtud === undefined || numEtud === null || !idModule)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### #7 — `Number(idRole)` sans vérification `isNaN`
|
||||
|
||||
**Fichier** : `routes/(apps)/admin/api/users.ts`
|
||||
|
||||
Si `idRole` est une chaîne non numérique, `Number()` retourne `NaN` ce qui
|
||||
provoque une erreur SQL.
|
||||
|
||||
```ts
|
||||
// ❌ Pas de vérification
|
||||
const rows = idRole
|
||||
? await db.select().from(users).where(eq(users.idRole, Number(idRole)))
|
||||
: await db.select().from(users);
|
||||
|
||||
// ✅ Valider avant usage
|
||||
const role = Number(idRole);
|
||||
if (isNaN(role)) return new Response(..., { status: 400 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### #8 — Réponses d'erreur en texte brut au lieu de JSON
|
||||
|
||||
**Fichier** : `routes/(apps)/notes/api/notes.ts`
|
||||
|
||||
Certaines réponses d'erreur retournent une string sans
|
||||
`content-type: application/json`, incohérent avec le reste de l'API qui retourne
|
||||
`{ error: "..." }`.
|
||||
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASS}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-polympr}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
migrate:
|
||||
image: registry.docker.polytech.djalim.fr/polympr:latest
|
||||
working_dir: /app
|
||||
restart: "no"
|
||||
command: ["node", "node_modules/.bin/drizzle-kit", "migrate"]
|
||||
env_file: .env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
app:
|
||||
image: registry.docker.polytech.djalim.fr/polympr:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4430:443"
|
||||
env_file: .env
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
@@ -0,0 +1,56 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: polympr_test
|
||||
volumes:
|
||||
- db_data_test:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
migrate:
|
||||
image: node:alpine
|
||||
working_dir: /app
|
||||
restart: "no"
|
||||
volumes:
|
||||
- .:/app
|
||||
command: node_modules/.bin/drizzle-kit migrate
|
||||
environment:
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASS: testpass
|
||||
POSTGRES_DB: polympr_test
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
app:
|
||||
image: denoland/deno:alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- deno_cache:/deno-dir
|
||||
command: run -A --unstable-ffi main.ts
|
||||
ports:
|
||||
- "4430:443"
|
||||
environment:
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASS: testpass
|
||||
POSTGRES_DB: polympr_test
|
||||
LOCAL: "true"
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
volumes:
|
||||
db_data_test:
|
||||
deno_cache:
|
||||
+1
-3
@@ -16,11 +16,9 @@ services:
|
||||
image: postgres
|
||||
restart: always
|
||||
shm_size: 128mb
|
||||
environment:
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASS}
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints: [node.role == manager]
|
||||
|
||||
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Applied by postgres on first container startup via /docker-entrypoint-initdb.d.
|
||||
# drizzle-kit migration files use "--> statement-breakpoint" markers which are
|
||||
# not valid SQL — strip them before applying.
|
||||
set -e
|
||||
for f in /migrations/*.sql; do
|
||||
echo "Applying $f..."
|
||||
sed '/^-->/d' "$f" | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"
|
||||
done
|
||||
echo "All migrations applied."
|
||||
@@ -0,0 +1,100 @@
|
||||
CREATE TABLE "ajustements" (
|
||||
"numEtud" integer NOT NULL,
|
||||
"idUE" integer NOT NULL,
|
||||
"valeur" double precision NOT NULL,
|
||||
CONSTRAINT "ajustements_numEtud_idUE_pk" PRIMARY KEY("numEtud","idUE")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "enseignements" (
|
||||
"idProf" text NOT NULL,
|
||||
"idModule" text NOT NULL,
|
||||
"idPromo" text NOT NULL,
|
||||
CONSTRAINT "enseignements_idProf_idModule_idPromo_pk" PRIMARY KEY("idProf","idModule","idPromo")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "mobility" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"studentId" integer,
|
||||
"startDate" date,
|
||||
"endDate" date,
|
||||
"weeksCount" integer,
|
||||
"destinationCountry" text,
|
||||
"destinationName" text,
|
||||
"mobilityStatus" text DEFAULT 'N/A'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "modules" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"nom" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notes" (
|
||||
"numEtud" integer NOT NULL,
|
||||
"idModule" text NOT NULL,
|
||||
"note" double precision NOT NULL,
|
||||
CONSTRAINT "notes_numEtud_idModule_pk" PRIMARY KEY("numEtud","idModule")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "permissions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"nom" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "promotions" (
|
||||
"idPromo" text PRIMARY KEY NOT NULL,
|
||||
"annee" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "role_permissions" (
|
||||
"idRole" integer NOT NULL,
|
||||
"idPermission" text NOT NULL,
|
||||
CONSTRAINT "role_permissions_idRole_idPermission_pk" PRIMARY KEY("idRole","idPermission")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "roles" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"nom" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "students" (
|
||||
"numEtud" serial PRIMARY KEY NOT NULL,
|
||||
"nom" text NOT NULL,
|
||||
"prenom" text NOT NULL,
|
||||
"idPromo" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ue_modules" (
|
||||
"idModule" text NOT NULL,
|
||||
"idUE" integer NOT NULL,
|
||||
"idPromo" text NOT NULL,
|
||||
"coeff" double precision NOT NULL,
|
||||
CONSTRAINT "ue_modules_idModule_idUE_idPromo_pk" PRIMARY KEY("idModule","idUE","idPromo")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ues" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"nom" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"nom" text NOT NULL,
|
||||
"prenom" text NOT NULL,
|
||||
"idRole" integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ajustements" ADD CONSTRAINT "ajustements_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ajustements" ADD CONSTRAINT "ajustements_idUE_ues_id_fk" FOREIGN KEY ("idUE") REFERENCES "public"."ues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idProf_users_id_fk" FOREIGN KEY ("idProf") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "mobility" ADD CONSTRAINT "mobility_studentId_students_numEtud_fk" FOREIGN KEY ("studentId") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_idRole_roles_id_fk" FOREIGN KEY ("idRole") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_idPermission_permissions_id_fk" FOREIGN KEY ("idPermission") REFERENCES "public"."permissions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "students" ADD CONSTRAINT "students_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idUE_ues_id_fk" FOREIGN KEY ("idUE") REFERENCES "public"."ues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_idRole_roles_id_fk" FOREIGN KEY ("idRole") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1,11 @@
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "permissions" ("id", "nom") VALUES
|
||||
('note_read', 'Consulter les notes des étudiants'),
|
||||
('note_write', 'Saisir et modifier les notes'),
|
||||
('student_read', 'Consulter la liste des étudiants'),
|
||||
('student_write','Gérer les étudiants (ajout, modification, suppression)'),
|
||||
('module_read', 'Consulter les modules et enseignements'),
|
||||
('module_write', 'Gérer les modules et enseignements'),
|
||||
('user_read', 'Consulter les utilisateurs et leurs rôles'),
|
||||
('user_write', 'Gérer les utilisateurs et leurs rôles'),
|
||||
('role_write', 'Gérer les rôles et leurs permissions');
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Update permission names to French
|
||||
-- This migration inserts or updates the permission labels used by the API.
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "permissions" ("id", "nom") VALUES
|
||||
('note_read', 'Consulter les notes des étudiants'),
|
||||
('note_write', 'Saisir et modifier les notes'),
|
||||
('student_read', 'Consulter la liste des étudiants'),
|
||||
('student_write','Gérer les étudiants (ajout, modification, suppression)'),
|
||||
('module_read', 'Consulter les modules et enseignements'),
|
||||
('module_write', 'Gérer les modules et enseignements'),
|
||||
('user_read', 'Consulter les utilisateurs et leurs rôles'),
|
||||
('user_write', 'Gérer les utilisateurs et leurs rôles'),
|
||||
('role_write', 'Gérer les rôles et leurs permissions')
|
||||
ON CONFLICT ("id") DO UPDATE SET "nom" = EXCLUDED."nom";
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "notes" ADD COLUMN "noteSession2" double precision;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ajustements" ADD COLUMN "malus" integer NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,680 @@
|
||||
{
|
||||
"id": "bd317b68-1c46-4e83-b4d3-a14f68751afb",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ajustements": {
|
||||
"name": "ajustements",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"numEtud": {
|
||||
"name": "numEtud",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idUE": {
|
||||
"name": "idUE",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"valeur": {
|
||||
"name": "valeur",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ajustements_numEtud_students_numEtud_fk": {
|
||||
"name": "ajustements_numEtud_students_numEtud_fk",
|
||||
"tableFrom": "ajustements",
|
||||
"tableTo": "students",
|
||||
"columnsFrom": [
|
||||
"numEtud"
|
||||
],
|
||||
"columnsTo": [
|
||||
"numEtud"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ajustements_idUE_ues_id_fk": {
|
||||
"name": "ajustements_idUE_ues_id_fk",
|
||||
"tableFrom": "ajustements",
|
||||
"tableTo": "ues",
|
||||
"columnsFrom": [
|
||||
"idUE"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"ajustements_numEtud_idUE_pk": {
|
||||
"name": "ajustements_numEtud_idUE_pk",
|
||||
"columns": [
|
||||
"numEtud",
|
||||
"idUE"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.enseignements": {
|
||||
"name": "enseignements",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"idProf": {
|
||||
"name": "idProf",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idModule": {
|
||||
"name": "idModule",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idPromo": {
|
||||
"name": "idPromo",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"enseignements_idProf_users_id_fk": {
|
||||
"name": "enseignements_idProf_users_id_fk",
|
||||
"tableFrom": "enseignements",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"idProf"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"enseignements_idModule_modules_id_fk": {
|
||||
"name": "enseignements_idModule_modules_id_fk",
|
||||
"tableFrom": "enseignements",
|
||||
"tableTo": "modules",
|
||||
"columnsFrom": [
|
||||
"idModule"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"enseignements_idPromo_promotions_idPromo_fk": {
|
||||
"name": "enseignements_idPromo_promotions_idPromo_fk",
|
||||
"tableFrom": "enseignements",
|
||||
"tableTo": "promotions",
|
||||
"columnsFrom": [
|
||||
"idPromo"
|
||||
],
|
||||
"columnsTo": [
|
||||
"idPromo"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"enseignements_idProf_idModule_idPromo_pk": {
|
||||
"name": "enseignements_idProf_idModule_idPromo_pk",
|
||||
"columns": [
|
||||
"idProf",
|
||||
"idModule",
|
||||
"idPromo"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mobility": {
|
||||
"name": "mobility",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"studentId": {
|
||||
"name": "studentId",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"startDate": {
|
||||
"name": "startDate",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"endDate": {
|
||||
"name": "endDate",
|
||||
"type": "date",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"weeksCount": {
|
||||
"name": "weeksCount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"destinationCountry": {
|
||||
"name": "destinationCountry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"destinationName": {
|
||||
"name": "destinationName",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"mobilityStatus": {
|
||||
"name": "mobilityStatus",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'N/A'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"mobility_studentId_students_numEtud_fk": {
|
||||
"name": "mobility_studentId_students_numEtud_fk",
|
||||
"tableFrom": "mobility",
|
||||
"tableTo": "students",
|
||||
"columnsFrom": [
|
||||
"studentId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"numEtud"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.modules": {
|
||||
"name": "modules",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"nom": {
|
||||
"name": "nom",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notes": {
|
||||
"name": "notes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"numEtud": {
|
||||
"name": "numEtud",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idModule": {
|
||||
"name": "idModule",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notes_numEtud_students_numEtud_fk": {
|
||||
"name": "notes_numEtud_students_numEtud_fk",
|
||||
"tableFrom": "notes",
|
||||
"tableTo": "students",
|
||||
"columnsFrom": [
|
||||
"numEtud"
|
||||
],
|
||||
"columnsTo": [
|
||||
"numEtud"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"notes_idModule_modules_id_fk": {
|
||||
"name": "notes_idModule_modules_id_fk",
|
||||
"tableFrom": "notes",
|
||||
"tableTo": "modules",
|
||||
"columnsFrom": [
|
||||
"idModule"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"notes_numEtud_idModule_pk": {
|
||||
"name": "notes_numEtud_idModule_pk",
|
||||
"columns": [
|
||||
"numEtud",
|
||||
"idModule"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.permissions": {
|
||||
"name": "permissions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"nom": {
|
||||
"name": "nom",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.promotions": {
|
||||
"name": "promotions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"idPromo": {
|
||||
"name": "idPromo",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"annee": {
|
||||
"name": "annee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.role_permissions": {
|
||||
"name": "role_permissions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"idRole": {
|
||||
"name": "idRole",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idPermission": {
|
||||
"name": "idPermission",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"role_permissions_idRole_roles_id_fk": {
|
||||
"name": "role_permissions_idRole_roles_id_fk",
|
||||
"tableFrom": "role_permissions",
|
||||
"tableTo": "roles",
|
||||
"columnsFrom": [
|
||||
"idRole"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"role_permissions_idPermission_permissions_id_fk": {
|
||||
"name": "role_permissions_idPermission_permissions_id_fk",
|
||||
"tableFrom": "role_permissions",
|
||||
"tableTo": "permissions",
|
||||
"columnsFrom": [
|
||||
"idPermission"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"role_permissions_idRole_idPermission_pk": {
|
||||
"name": "role_permissions_idRole_idPermission_pk",
|
||||
"columns": [
|
||||
"idRole",
|
||||
"idPermission"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.roles": {
|
||||
"name": "roles",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"nom": {
|
||||
"name": "nom",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.students": {
|
||||
"name": "students",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"numEtud": {
|
||||
"name": "numEtud",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"nom": {
|
||||
"name": "nom",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"prenom": {
|
||||
"name": "prenom",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idPromo": {
|
||||
"name": "idPromo",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"students_idPromo_promotions_idPromo_fk": {
|
||||
"name": "students_idPromo_promotions_idPromo_fk",
|
||||
"tableFrom": "students",
|
||||
"tableTo": "promotions",
|
||||
"columnsFrom": [
|
||||
"idPromo"
|
||||
],
|
||||
"columnsTo": [
|
||||
"idPromo"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ue_modules": {
|
||||
"name": "ue_modules",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"idModule": {
|
||||
"name": "idModule",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idUE": {
|
||||
"name": "idUE",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idPromo": {
|
||||
"name": "idPromo",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"coeff": {
|
||||
"name": "coeff",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ue_modules_idModule_modules_id_fk": {
|
||||
"name": "ue_modules_idModule_modules_id_fk",
|
||||
"tableFrom": "ue_modules",
|
||||
"tableTo": "modules",
|
||||
"columnsFrom": [
|
||||
"idModule"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ue_modules_idUE_ues_id_fk": {
|
||||
"name": "ue_modules_idUE_ues_id_fk",
|
||||
"tableFrom": "ue_modules",
|
||||
"tableTo": "ues",
|
||||
"columnsFrom": [
|
||||
"idUE"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"ue_modules_idPromo_promotions_idPromo_fk": {
|
||||
"name": "ue_modules_idPromo_promotions_idPromo_fk",
|
||||
"tableFrom": "ue_modules",
|
||||
"tableTo": "promotions",
|
||||
"columnsFrom": [
|
||||
"idPromo"
|
||||
],
|
||||
"columnsTo": [
|
||||
"idPromo"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"ue_modules_idModule_idUE_idPromo_pk": {
|
||||
"name": "ue_modules_idModule_idUE_idPromo_pk",
|
||||
"columns": [
|
||||
"idModule",
|
||||
"idUE",
|
||||
"idPromo"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ues": {
|
||||
"name": "ues",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"nom": {
|
||||
"name": "nom",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"nom": {
|
||||
"name": "nom",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"prenom": {
|
||||
"name": "prenom",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"idRole": {
|
||||
"name": "idRole",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"users_idRole_roles_id_fk": {
|
||||
"name": "users_idRole_roles_id_fk",
|
||||
"tableFrom": "users",
|
||||
"tableTo": "roles",
|
||||
"columnsFrom": [
|
||||
"idRole"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1777155028708,
|
||||
"tag": "0000_square_jetstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1777155028709,
|
||||
"tag": "0001_seed_permissions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1777155028710,
|
||||
"tag": "0002_update_permission_names",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1777155028711,
|
||||
"tag": "0003_add_session2_and_malus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
date,
|
||||
doublePrecision,
|
||||
integer,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
serial,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const roles = pgTable("roles", {
|
||||
id: serial("id").primaryKey(),
|
||||
nom: text("nom").notNull(),
|
||||
});
|
||||
|
||||
export const permissions = pgTable("permissions", {
|
||||
id: text("id").primaryKey(),
|
||||
nom: text("nom").notNull(),
|
||||
});
|
||||
|
||||
export const rolePermissions = pgTable("role_permissions", {
|
||||
idRole: integer("idRole").notNull().references(() => roles.id),
|
||||
idPermission: text("idPermission").notNull().references(() => permissions.id),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.idRole, t.idPermission] }),
|
||||
}));
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
nom: text("nom").notNull(),
|
||||
prenom: text("prenom").notNull(),
|
||||
idRole: integer("idRole").references(() => roles.id),
|
||||
});
|
||||
|
||||
export const promotions = pgTable("promotions", {
|
||||
id: text("idPromo").primaryKey(),
|
||||
annee: text("annee"),
|
||||
});
|
||||
|
||||
export const students = pgTable("students", {
|
||||
numEtud: serial("numEtud").primaryKey(),
|
||||
nom: text("nom").notNull(),
|
||||
prenom: text("prenom").notNull(),
|
||||
idPromo: text("idPromo").references(() => promotions.id),
|
||||
});
|
||||
|
||||
export const modules = pgTable("modules", {
|
||||
id: text("id").primaryKey(),
|
||||
nom: text("nom").notNull(),
|
||||
});
|
||||
|
||||
export const enseignements = pgTable("enseignements", {
|
||||
idProf: text("idProf").notNull().references(() => users.id),
|
||||
idModule: text("idModule").notNull().references(() => modules.id),
|
||||
idPromo: text("idPromo").notNull().references(() => promotions.id),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.idProf, t.idModule, t.idPromo] }),
|
||||
}));
|
||||
|
||||
export const ues = pgTable("ues", {
|
||||
id: serial("id").primaryKey(),
|
||||
nom: text("nom").notNull(),
|
||||
});
|
||||
|
||||
export const ueModules = pgTable("ue_modules", {
|
||||
idModule: text("idModule").notNull().references(() => modules.id),
|
||||
idUE: integer("idUE").notNull().references(() => ues.id),
|
||||
idPromo: text("idPromo").notNull().references(() => promotions.id),
|
||||
coeff: doublePrecision("coeff").notNull(),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.idModule, t.idUE, t.idPromo] }),
|
||||
}));
|
||||
|
||||
export const notes = pgTable("notes", {
|
||||
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
|
||||
idModule: text("idModule").notNull().references(() => modules.id),
|
||||
note: doublePrecision("note").notNull(),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.numEtud, t.idModule] }),
|
||||
}));
|
||||
|
||||
export const ajustements = pgTable("ajustements", {
|
||||
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
|
||||
idUE: integer("idUE").notNull().references(() => ues.id),
|
||||
valeur: doublePrecision("valeur").notNull(),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.numEtud, t.idUE] }),
|
||||
}));
|
||||
|
||||
export const mobility = pgTable("mobility", {
|
||||
id: serial("id").primaryKey(),
|
||||
studentId: integer("studentId").references(() => students.numEtud),
|
||||
startDate: date("startDate"),
|
||||
endDate: date("endDate"),
|
||||
weeksCount: integer("weeksCount"),
|
||||
destinationCountry: text("destinationCountry"),
|
||||
destinationName: text("destinationName"),
|
||||
mobilityStatus: text("mobilityStatus").default("N/A"),
|
||||
});
|
||||
@@ -75,6 +75,7 @@ export const notes = pgTable("notes", {
|
||||
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
|
||||
idModule: text("idModule").notNull().references(() => modules.id),
|
||||
note: doublePrecision("note").notNull(),
|
||||
noteSession2: doublePrecision("noteSession2"),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.numEtud, t.idModule] }),
|
||||
}));
|
||||
@@ -83,6 +84,7 @@ export const ajustements = pgTable("ajustements", {
|
||||
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
|
||||
idUE: integer("idUE").notNull().references(() => ues.id),
|
||||
valeur: doublePrecision("valeur").notNull(),
|
||||
malus: integer("malus").notNull().default(0),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.numEtud, t.idUE] }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
export type ImportResult = {
|
||||
added: number;
|
||||
modified: number;
|
||||
ignored: number;
|
||||
errors: number;
|
||||
details: ImportDetail[];
|
||||
};
|
||||
|
||||
export type ImportDetail = {
|
||||
type: "change" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
result: ImportResult;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ImportResultPopup({ result, onClose }: Props) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const hasErrors = result.errors > 0;
|
||||
const changes = result.details.filter((d) => d.type === "change");
|
||||
const errors = result.details.filter((d) => d.type === "error");
|
||||
|
||||
return (
|
||||
<div class="import-popup-overlay" onClick={onClose}>
|
||||
<div class="import-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="import-popup-header">
|
||||
<h3 class="import-popup-title">Resultats de l'import</h3>
|
||||
<span
|
||||
class={`import-popup-badge ${
|
||||
hasErrors ? "badge-error" : "badge-success"
|
||||
}`}
|
||||
>
|
||||
{hasErrors ? "Erreur" : "Succes"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="import-popup-stats">
|
||||
<div class="import-stat-row">
|
||||
<span class="import-stat-label">Ajoutes</span>
|
||||
<span class="import-stat-value stat-added">
|
||||
{result.added} note{result.added !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div class="import-stat-row">
|
||||
<span class="import-stat-label">Modifies</span>
|
||||
<span class="import-stat-value stat-modified">
|
||||
{result.modified} note{result.modified !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div class="import-stat-row">
|
||||
<span class="import-stat-label">Ignores</span>
|
||||
<span class="import-stat-value stat-ignored">
|
||||
{result.ignored} note{result.ignored !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div class="import-stat-row">
|
||||
<span class="import-stat-label">Erreurs</span>
|
||||
<span class="import-stat-value stat-errors">
|
||||
{result.errors} note{result.errors !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-popup-actions">
|
||||
{result.details.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
Details {showDetails ? "\u25B3" : "\u25BD"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Ok
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDetails && result.details.length > 0 && (
|
||||
<div class="import-popup-details">
|
||||
{changes.length > 0 &&
|
||||
changes.map((d, i) => (
|
||||
<p key={`c-${i}`} class="import-detail-change">{d.message}</p>
|
||||
))}
|
||||
{errors.length > 0 &&
|
||||
errors.map((d, i) => (
|
||||
<p key={`e-${i}`} class="import-detail-error">{d.message}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface AppProperties {
|
||||
icon: string;
|
||||
pages: Record<string, string>;
|
||||
adminOnly: string[];
|
||||
studentOnly?: string[];
|
||||
hint: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
"build": "deno run -A --unstable-ffi dev.ts build",
|
||||
"preview": "deno run -A --unstable-ffi main.ts",
|
||||
"update": "deno run -A -r https://fresh.deno.dev/update .",
|
||||
"test": "deno test -A --no-check tests/"
|
||||
"test": "deno test -A --no-check tests/",
|
||||
"test:unit": "deno test -A --no-check tests/unit/",
|
||||
"test:integration": "deno test -A --no-check tests/integration/",
|
||||
"test:e2e": "deno test -A --no-check tests/e2e/",
|
||||
"test:coverage": "deno test -A --no-check --coverage=coverage tests/ && deno coverage coverage --exclude=tests/",
|
||||
"test:coverage:html": "deno test -A --no-check --coverage=coverage tests/ && deno coverage coverage --exclude=tests/ --html",
|
||||
"migrate": "node_modules/.bin/drizzle-kit migrate"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
|
||||
+8
-6
@@ -1,15 +1,17 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
import process from "node:process";
|
||||
|
||||
const url = process.env.DATABASE_URL ??
|
||||
`postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASS}@${
|
||||
process.env.POSTGRES_HOST ?? "localhost"
|
||||
}:${process.env.POSTGRES_PORT ?? 5432}/${process.env.POSTGRES_DB}`;
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./databases/schema.ts",
|
||||
schema: "./databases/schema.kit.ts",
|
||||
out: "./databases/migrations",
|
||||
dbCredentials: {
|
||||
host: process.env.POSTGRES_HOST!,
|
||||
port: Number(process.env.POSTGRES_PORT ?? 5432),
|
||||
user: process.env.POSTGRES_USER!,
|
||||
password: process.env.POSTGRES_PASS!,
|
||||
database: process.env.POSTGRES_DB!,
|
||||
url,
|
||||
ssl: false,
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776548001,
|
||||
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
description = "PolyMPR CLI - A tool for managing PolyMPR modules";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
packages.pmpr = pkgs.stdenv.mkDerivation {
|
||||
pname = "pmpr";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.deno
|
||||
pkgs.autoPatchelfHook
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
pkgs.stdenv.cc.cc.lib
|
||||
];
|
||||
|
||||
buildPhase = ''
|
||||
export HOME=$TMPDIR
|
||||
deno cache toolbox/cli.ts
|
||||
deno compile -A --output pmpr toolbox/cli.ts
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin
|
||||
cp pmpr $out/bin/pmpr
|
||||
'';
|
||||
};
|
||||
|
||||
packages.default = self.packages.${system}.pmpr;
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
nativeBuildInputs = [
|
||||
pkgs.deno
|
||||
pkgs.patchelf
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
pkgs.stdenv.cc.cc.lib
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH"
|
||||
export NIX_LD_INTERPRETER=$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)
|
||||
echo "Welcome to PolyMPR development shell!"
|
||||
echo "Use 'deno task compile' to build the CLI."
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
+142
@@ -4,16 +4,55 @@
|
||||
|
||||
import * as $_apps_layout from "./routes/(apps)/_layout.tsx";
|
||||
import * as $_apps_middleware from "./routes/(apps)/_middleware.ts";
|
||||
import * as $_apps_admin_api_enseignements from "./routes/(apps)/admin/api/enseignements.ts";
|
||||
import * as $_apps_admin_api_enseignements_idProf_idModule_idPromo_ from "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts";
|
||||
import * as $_apps_admin_api_example from "./routes/(apps)/admin/api/example.ts";
|
||||
import * as $_apps_admin_api_modules from "./routes/(apps)/admin/api/modules.ts";
|
||||
import * as $_apps_admin_api_modules_idModule_ from "./routes/(apps)/admin/api/modules/[idModule].ts";
|
||||
import * as $_apps_admin_api_permissions from "./routes/(apps)/admin/api/permissions.ts";
|
||||
import * as $_apps_admin_api_roles from "./routes/(apps)/admin/api/roles.ts";
|
||||
import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles/[idRole].ts";
|
||||
import * as $_apps_admin_api_ue_modules from "./routes/(apps)/admin/api/ue-modules.ts";
|
||||
import * as $_apps_admin_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
|
||||
import * as $_apps_admin_api_ues from "./routes/(apps)/admin/api/ues.ts";
|
||||
import * as $_apps_admin_api_ues_idUE_ from "./routes/(apps)/admin/api/ues/[idUE].ts";
|
||||
import * as $_apps_admin_api_users from "./routes/(apps)/admin/api/users.ts";
|
||||
import * as $_apps_admin_api_users_id_ from "./routes/(apps)/admin/api/users/[id].ts";
|
||||
import * as $_apps_admin_index from "./routes/(apps)/admin/index.tsx";
|
||||
import * as $_apps_admin_modules_idModule_ from "./routes/(apps)/admin/modules/[idModule].tsx";
|
||||
import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx";
|
||||
import * as $_apps_admin_partials_import_maquette from "./routes/(apps)/admin/partials/import-maquette.tsx";
|
||||
import * as $_apps_admin_partials_index from "./routes/(apps)/admin/partials/index.tsx";
|
||||
import * as $_apps_admin_partials_modules from "./routes/(apps)/admin/partials/modules.tsx";
|
||||
import * as $_apps_admin_partials_permissions from "./routes/(apps)/admin/partials/permissions.tsx";
|
||||
import * as $_apps_admin_partials_promotions from "./routes/(apps)/admin/partials/promotions.tsx";
|
||||
import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx";
|
||||
import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx";
|
||||
import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx";
|
||||
import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx";
|
||||
import * as $_apps_mobility_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_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_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_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx";
|
||||
import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx";
|
||||
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx";
|
||||
import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx";
|
||||
import * as $_apps_notes_partials_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_students_api_promotions from "./routes/(apps)/students/api/promotions.ts";
|
||||
import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts";
|
||||
import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts";
|
||||
import * as $_apps_students_api_students_numEtud_ from "./routes/(apps)/students/api/students/[numEtud].ts";
|
||||
import * as $_apps_students_api_students_import_csv from "./routes/(apps)/students/api/students/import-csv.ts";
|
||||
import * as $_apps_students_edit_numEtud_ from "./routes/(apps)/students/edit/[numEtud].tsx";
|
||||
import * as $_apps_students_index from "./routes/(apps)/students/index.tsx";
|
||||
import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx";
|
||||
import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx";
|
||||
@@ -24,14 +63,29 @@ import * as $_app from "./routes/_app.tsx";
|
||||
import * as $_middleware from "./routes/_middleware.ts";
|
||||
import * as $about from "./routes/about.tsx";
|
||||
import * as $apps from "./routes/apps.tsx";
|
||||
import * as $dev_login from "./routes/dev-login.ts";
|
||||
import * as $index from "./routes/index.tsx";
|
||||
import * as $login from "./routes/login.tsx";
|
||||
import * as $logout from "./routes/logout.tsx";
|
||||
import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx";
|
||||
import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx";
|
||||
import * as $_apps_admin_islands_AdminEnseignements from "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx";
|
||||
import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_islands)/AdminModules.tsx";
|
||||
import * as $_apps_admin_islands_AdminPermissions from "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx";
|
||||
import * as $_apps_admin_islands_AdminPromotions from "./routes/(apps)/admin/(_islands)/AdminPromotions.tsx";
|
||||
import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx";
|
||||
import * as $_apps_admin_islands_AdminUEs from "./routes/(apps)/admin/(_islands)/AdminUEs.tsx";
|
||||
import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx";
|
||||
import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx";
|
||||
import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx";
|
||||
import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx";
|
||||
import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
|
||||
import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx";
|
||||
import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx";
|
||||
import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx";
|
||||
import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx";
|
||||
import * as $_apps_notes_islands_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.tsx";
|
||||
import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx";
|
||||
import * as $_apps_students_islands_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";
|
||||
@@ -41,6 +95,42 @@ const manifest = {
|
||||
routes: {
|
||||
"./routes/(apps)/_layout.tsx": $_apps_layout,
|
||||
"./routes/(apps)/_middleware.ts": $_apps_middleware,
|
||||
"./routes/(apps)/admin/api/enseignements.ts":
|
||||
$_apps_admin_api_enseignements,
|
||||
"./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts":
|
||||
$_apps_admin_api_enseignements_idProf_idModule_idPromo_,
|
||||
"./routes/(apps)/admin/api/example.ts": $_apps_admin_api_example,
|
||||
"./routes/(apps)/admin/api/modules.ts": $_apps_admin_api_modules,
|
||||
"./routes/(apps)/admin/api/modules/[idModule].ts":
|
||||
$_apps_admin_api_modules_idModule_,
|
||||
"./routes/(apps)/admin/api/permissions.ts": $_apps_admin_api_permissions,
|
||||
"./routes/(apps)/admin/api/roles.ts": $_apps_admin_api_roles,
|
||||
"./routes/(apps)/admin/api/roles/[idRole].ts":
|
||||
$_apps_admin_api_roles_idRole_,
|
||||
"./routes/(apps)/admin/api/ue-modules.ts": $_apps_admin_api_ue_modules,
|
||||
"./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts":
|
||||
$_apps_admin_api_ue_modules_idModule_idUE_idPromo_,
|
||||
"./routes/(apps)/admin/api/ues.ts": $_apps_admin_api_ues,
|
||||
"./routes/(apps)/admin/api/ues/[idUE].ts": $_apps_admin_api_ues_idUE_,
|
||||
"./routes/(apps)/admin/api/users.ts": $_apps_admin_api_users,
|
||||
"./routes/(apps)/admin/api/users/[id].ts": $_apps_admin_api_users_id_,
|
||||
"./routes/(apps)/admin/index.tsx": $_apps_admin_index,
|
||||
"./routes/(apps)/admin/modules/[idModule].tsx":
|
||||
$_apps_admin_modules_idModule_,
|
||||
"./routes/(apps)/admin/partials/enseignements.tsx":
|
||||
$_apps_admin_partials_enseignements,
|
||||
"./routes/(apps)/admin/partials/import-maquette.tsx":
|
||||
$_apps_admin_partials_import_maquette,
|
||||
"./routes/(apps)/admin/partials/index.tsx": $_apps_admin_partials_index,
|
||||
"./routes/(apps)/admin/partials/modules.tsx": $_apps_admin_partials_modules,
|
||||
"./routes/(apps)/admin/partials/permissions.tsx":
|
||||
$_apps_admin_partials_permissions,
|
||||
"./routes/(apps)/admin/partials/promotions.tsx":
|
||||
$_apps_admin_partials_promotions,
|
||||
"./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles,
|
||||
"./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues,
|
||||
"./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users,
|
||||
"./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_,
|
||||
"./routes/(apps)/mobility/api/insert_mobility.ts":
|
||||
$_apps_mobility_api_insert_mobility,
|
||||
"./routes/(apps)/mobility/index.tsx": $_apps_mobility_index,
|
||||
@@ -50,12 +140,35 @@ const manifest = {
|
||||
$_apps_mobility_partials_index,
|
||||
"./routes/(apps)/mobility/partials/overview.tsx":
|
||||
$_apps_mobility_partials_overview,
|
||||
"./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/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/edition/[numEtud].tsx":
|
||||
$_apps_notes_edition_numEtud_,
|
||||
"./routes/(apps)/notes/index.tsx": $_apps_notes_index,
|
||||
"./routes/(apps)/notes/partials/(admin)/courses.tsx":
|
||||
$_apps_notes_partials_admin_courses,
|
||||
"./routes/(apps)/notes/partials/(admin)/import.tsx":
|
||||
$_apps_notes_partials_admin_import,
|
||||
"./routes/(apps)/notes/partials/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)/students/api/promotions.ts":
|
||||
$_apps_students_api_promotions,
|
||||
"./routes/(apps)/students/api/promotions/[idPromo].ts":
|
||||
$_apps_students_api_promotions_idPromo_,
|
||||
"./routes/(apps)/students/api/students.ts": $_apps_students_api_students,
|
||||
"./routes/(apps)/students/api/students/[numEtud].ts":
|
||||
$_apps_students_api_students_numEtud_,
|
||||
"./routes/(apps)/students/api/students/import-csv.ts":
|
||||
$_apps_students_api_students_import_csv,
|
||||
"./routes/(apps)/students/edit/[numEtud].tsx":
|
||||
$_apps_students_edit_numEtud_,
|
||||
"./routes/(apps)/students/index.tsx": $_apps_students_index,
|
||||
"./routes/(apps)/students/partials/(admin)/consult.tsx":
|
||||
$_apps_students_partials_admin_consult,
|
||||
@@ -69,6 +182,7 @@ const manifest = {
|
||||
"./routes/_middleware.ts": $_middleware,
|
||||
"./routes/about.tsx": $about,
|
||||
"./routes/apps.tsx": $apps,
|
||||
"./routes/dev-login.ts": $dev_login,
|
||||
"./routes/index.tsx": $index,
|
||||
"./routes/login.tsx": $login,
|
||||
"./routes/logout.tsx": $logout,
|
||||
@@ -76,12 +190,40 @@ const manifest = {
|
||||
islands: {
|
||||
"./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator,
|
||||
"./routes/(_islands)/Navbar.tsx": $_islands_Navbar,
|
||||
"./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx":
|
||||
$_apps_admin_islands_AdminEnseignements,
|
||||
"./routes/(apps)/admin/(_islands)/AdminModules.tsx":
|
||||
$_apps_admin_islands_AdminModules,
|
||||
"./routes/(apps)/admin/(_islands)/AdminPermissions.tsx":
|
||||
$_apps_admin_islands_AdminPermissions,
|
||||
"./routes/(apps)/admin/(_islands)/AdminPromotions.tsx":
|
||||
$_apps_admin_islands_AdminPromotions,
|
||||
"./routes/(apps)/admin/(_islands)/AdminRoles.tsx":
|
||||
$_apps_admin_islands_AdminRoles,
|
||||
"./routes/(apps)/admin/(_islands)/AdminUEs.tsx":
|
||||
$_apps_admin_islands_AdminUEs,
|
||||
"./routes/(apps)/admin/(_islands)/AdminUsers.tsx":
|
||||
$_apps_admin_islands_AdminUsers,
|
||||
"./routes/(apps)/admin/(_islands)/EditModule.tsx":
|
||||
$_apps_admin_islands_EditModule,
|
||||
"./routes/(apps)/admin/(_islands)/EditUser.tsx":
|
||||
$_apps_admin_islands_EditUser,
|
||||
"./routes/(apps)/admin/(_islands)/ImportMaquette.tsx":
|
||||
$_apps_admin_islands_ImportMaquette,
|
||||
"./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx":
|
||||
$_apps_mobility_islands_ConsultMobility,
|
||||
"./routes/(apps)/mobility/(_islands)/EditMobility.tsx":
|
||||
$_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":
|
||||
$_apps_notes_islands_ImportNotes,
|
||||
"./routes/(apps)/notes/(_islands)/NoteRecap.tsx":
|
||||
$_apps_notes_islands_NoteRecap,
|
||||
"./routes/(apps)/notes/(_islands)/NotesView.tsx":
|
||||
$_apps_notes_islands_NotesView,
|
||||
"./routes/(apps)/students/(_islands)/ConsultStudents.tsx":
|
||||
$_apps_students_islands_ConsultStudents,
|
||||
"./routes/(apps)/students/(_islands)/EditStudents.tsx":
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"pg": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.20.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,23 @@ export const handler: MiddlewareHandler<AuthenticatedState>[] = [
|
||||
`./${currentApp}/(_props)/props.ts`
|
||||
)).default;
|
||||
|
||||
context.state.availablePages = properties.pages;
|
||||
if (
|
||||
context.state.session.eduPersonPrimaryAffiliation == "student" &&
|
||||
Deno.env.get("LOCAL") != "true"
|
||||
) {
|
||||
context.state.availablePages = { ...properties.pages };
|
||||
const isStudent =
|
||||
context.state.session.eduPersonPrimaryAffiliation === "student";
|
||||
const isLocal = Deno.env.get("LOCAL") === "true";
|
||||
|
||||
if (isStudent) {
|
||||
// Students only see studentOnly pages (+ non-restricted pages)
|
||||
properties.adminOnly.forEach((page) =>
|
||||
delete context.state.availablePages[page]
|
||||
);
|
||||
} else if (isLocal) {
|
||||
// In local mode, employees see all pages (admin + student)
|
||||
} else {
|
||||
// In prod, employees don't see studentOnly pages
|
||||
properties.studentOnly?.forEach((page) =>
|
||||
delete context.state.availablePages[page]
|
||||
);
|
||||
}
|
||||
|
||||
return await context.next();
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Enseignement = { idProf: string; idModule: string; idPromo: string };
|
||||
type Module = { id: string; nom: string };
|
||||
type Promo = { id: string; annee: string };
|
||||
|
||||
export default function AdminEnseignements() {
|
||||
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [promos, setPromos] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [filterPromo, setFilterPromo] = useState("");
|
||||
const [filterModule, setFilterModule] = useState("");
|
||||
const [filterEnseignant, setFilterEnseignant] = useState("");
|
||||
|
||||
// Add form
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addPromo, setAddPromo] = useState("");
|
||||
const [addModule, setAddModule] = useState("");
|
||||
const [addProf, setAddProf] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [eRes, mRes, pRes] = await Promise.all([
|
||||
fetch("/admin/api/enseignements"),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!eRes.ok) throw new Error("Impossible de charger les enseignements");
|
||||
setEnseignements(await eRes.json());
|
||||
if (mRes.ok) setModules(await mRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function deleteEnseignement(
|
||||
idProf: string,
|
||||
idModule: string,
|
||||
idPromo: string,
|
||||
) {
|
||||
if (
|
||||
!confirm(
|
||||
`Supprimer l'assignation ${idProf} → ${idModule} / ${idPromo} ?`,
|
||||
)
|
||||
) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/enseignements/${encodeURIComponent(idProf)}/${
|
||||
encodeURIComponent(idModule)
|
||||
}/${encodeURIComponent(idPromo)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function addEnseignement() {
|
||||
if (!addProf.trim() || !addModule || !addPromo) {
|
||||
setAddError("Tous les champs sont requis");
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
const res = await fetch("/admin/api/enseignements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idProf: addProf.trim(),
|
||||
idModule: addModule,
|
||||
idPromo: addPromo,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAddProf("");
|
||||
setAddModule("");
|
||||
setAddPromo("");
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
|
||||
|
||||
const filtered = enseignements.filter((e) => {
|
||||
const matchPromo = !filterPromo || e.idPromo === filterPromo;
|
||||
const matchModule = !filterModule || e.idModule === filterModule;
|
||||
const matchEns = !filterEnseignant ||
|
||||
e.idProf.toLowerCase().includes(filterEnseignant.toLowerCase());
|
||||
return matchPromo && matchModule && matchEns;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Assignations Enseignant → Module / Promo</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="filters">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={filterPromo}
|
||||
onChange={(e) =>
|
||||
setFilterPromo((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Promo ▾</option>
|
||||
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={filterModule}
|
||||
onChange={(e) =>
|
||||
setFilterModule((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Module ▾</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.id} – {m.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
class="filter-input"
|
||||
placeholder="Enseignant ▾"
|
||||
value={filterEnseignant}
|
||||
onInput={(e) =>
|
||||
setFilterEnseignant((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setFilterPromo("");
|
||||
setFilterModule("");
|
||||
setFilterEnseignant("");
|
||||
}}
|
||||
>
|
||||
Filtrer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={() => setShowAdd((v) => !v)}
|
||||
style="margin-left: auto"
|
||||
>
|
||||
+ Assigner
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div class="modal-overlay" onClick={() => setShowAdd(false)}>
|
||||
<div class="modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<p class="modal-title">Assigner un enseignement</p>
|
||||
{addError && (
|
||||
<p class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
<div class="modal-form">
|
||||
<div class="form-field">
|
||||
<label>Promo</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addPromo}
|
||||
onChange={(e) =>
|
||||
setAddPromo((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
>
|
||||
<option value="">Promo...</option>
|
||||
{promos.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Module</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addModule}
|
||||
onChange={(e) =>
|
||||
setAddModule((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
>
|
||||
<option value="">Module...</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id} -- {m.nom}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>User ID enseignant</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="User ID enseignant..."
|
||||
value={addProf}
|
||||
onInput={(e) =>
|
||||
setAddProf((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={() => setShowAdd(false)}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addEnseignement}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "..." : "+ Assigner"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Promo</th>
|
||||
<th>Module</th>
|
||||
<th>Enseignant (User.id)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={4} class="state-empty">
|
||||
Aucun enseignement trouvé
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filtered.map((e) => {
|
||||
const mod = moduleMap[e.idModule];
|
||||
return (
|
||||
<tr key={`${e.idProf}-${e.idModule}-${e.idPromo}`}>
|
||||
<td>
|
||||
<span class="promo-chip">{e.idPromo}</span>
|
||||
</td>
|
||||
<td class="col-promo">
|
||||
{mod ? `${mod.id} – ${mod.nom}` : e.idModule}
|
||||
</td>
|
||||
<td>{e.idProf}</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
deleteEnseignement(
|
||||
e.idProf,
|
||||
e.idModule,
|
||||
e.idPromo,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="14"
|
||||
height="16"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="info-note">
|
||||
<p>
|
||||
Un même module peut être enseigné par plusieurs utilisateurs sur une
|
||||
même promo.
|
||||
</p>
|
||||
<p class="info-note-dim">
|
||||
Clé composite = idProf (User.Id) + idModule + idPromo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Module = { id: string; nom: string };
|
||||
type Enseignement = { idProf: string; idModule: string; idPromo: string };
|
||||
type User = { id: string; nom: string; prenom: string };
|
||||
|
||||
export default function AdminModules() {
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newId, setNewId] = useState("");
|
||||
const [newNom, setNewNom] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [filterNom, setFilterNom] = useState("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [mRes, eRes, uRes] = await Promise.all([
|
||||
fetch("/admin/api/modules"),
|
||||
fetch("/admin/api/enseignements"),
|
||||
fetch("/admin/api/users"),
|
||||
]);
|
||||
if (!mRes.ok) throw new Error("Impossible de charger les modules");
|
||||
setModules(await mRes.json());
|
||||
if (eRes.ok) setEnseignements(await eRes.json());
|
||||
if (uRes.ok) setUsers(await uRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createModule() {
|
||||
if (!newId.trim() || !newNom.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/admin/api/modules", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: newId.trim(), nom: newNom.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setNewId("");
|
||||
setNewNom("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteModule(id: string) {
|
||||
if (!confirm(`Supprimer le module ${id} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/modules/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
const userMap = Object.fromEntries(
|
||||
users.map((u) => [u.id, u]),
|
||||
);
|
||||
|
||||
function enseignantsForModule(moduleId: string): string {
|
||||
const profs = [
|
||||
...new Set(
|
||||
enseignements
|
||||
.filter((e) => e.idModule === moduleId)
|
||||
.map((e) => e.idProf),
|
||||
),
|
||||
];
|
||||
if (profs.length === 0) return "";
|
||||
return profs
|
||||
.map((id) => {
|
||||
const u = userMap[id];
|
||||
return u ? `${u.nom} ${u.prenom.charAt(0)}.` : id;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
const filtered = modules.filter((m) =>
|
||||
!filterNom ||
|
||||
`${m.id} ${m.nom}`.toLowerCase().includes(filterNom.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Modules</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
class="filter-input"
|
||||
placeholder="Rechercher..."
|
||||
value={filterNom}
|
||||
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={() => {
|
||||
const el = document.getElementById("new-module-section");
|
||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||
}}
|
||||
style="margin-left: auto"
|
||||
>
|
||||
+ Ajouter module
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement...</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id (code)</th>
|
||||
<th>Nom du module</th>
|
||||
<th>Enseignants assignes</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={4} class="state-empty">
|
||||
Aucun module enregistré
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filtered.map((m) => {
|
||||
const profs = enseignantsForModule(m.id);
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td class="col-dim">{m.id}</td>
|
||||
<td>{m.nom}</td>
|
||||
<td>
|
||||
{profs
|
||||
? (
|
||||
<span style="font-size: 0.78rem">
|
||||
{profs}
|
||||
</span>
|
||||
)
|
||||
: <span class="col-dim">--</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<a
|
||||
class="btn btn-sm btn-secondary"
|
||||
href={`/admin/modules/${
|
||||
encodeURIComponent(m.id)
|
||||
}`}
|
||||
f-client-nav={false}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>{" "}
|
||||
edit
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteModule(m.id)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="14"
|
||||
height="16"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nouveau module */}
|
||||
<div
|
||||
id="new-module-section"
|
||||
class="edit-section"
|
||||
style="margin-top: 1.5rem"
|
||||
>
|
||||
<p class="edit-section-title">Nouveau module</p>
|
||||
<div class="form-row">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Code"
|
||||
value={newId}
|
||||
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 8rem; max-width: 10rem"
|
||||
/>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom du module"
|
||||
value={newNom}
|
||||
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createModule}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? "..." : "+ Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Perm = { id: string; nom: string };
|
||||
type Role = { id: number; nom: string; permissions: string[] };
|
||||
|
||||
const ROLE_COLORS = [
|
||||
"#22c55e",
|
||||
"#d4a017",
|
||||
"#e07020",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#ec4899",
|
||||
];
|
||||
|
||||
function roleColor(roleId: number): string {
|
||||
return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length];
|
||||
}
|
||||
|
||||
export default function AdminPermissions() {
|
||||
const [permissions, setPermissions] = useState<Perm[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [pRes, rRes] = await Promise.all([
|
||||
fetch("/admin/api/permissions"),
|
||||
fetch("/admin/api/roles"),
|
||||
]);
|
||||
if (!pRes.ok) throw new Error("Impossible de charger les permissions");
|
||||
setPermissions(await pRes.json());
|
||||
if (rRes.ok) setRoles(await rRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
function rolesForPerm(permId: string): Role[] {
|
||||
return roles.filter((r) => r.permissions.includes(permId));
|
||||
}
|
||||
|
||||
const MAX_ROLE_CHIPS = 2;
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Permissions</h2>
|
||||
|
||||
<div class="info-note" style="margin-top: 0; margin-bottom: 1.25rem">
|
||||
<p>
|
||||
Les permissions sont définies statiquement par le serveur.
|
||||
</p>
|
||||
<p class="info-note-dim">
|
||||
Elles ne peuvent pas être créées ou supprimées via l'API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>idPermission</th>
|
||||
<th>nomPermission</th>
|
||||
<th>Rôles associés</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => {
|
||||
const associated = rolesForPerm(p.id);
|
||||
const shown = associated.slice(0, MAX_ROLE_CHIPS);
|
||||
const overflow = associated.length - MAX_ROLE_CHIPS;
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td>
|
||||
<span
|
||||
class="col-promo"
|
||||
style="font-family: monospace"
|
||||
>
|
||||
{p.id}
|
||||
</span>
|
||||
</td>
|
||||
<td>{p.nom}</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
|
||||
{shown.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
class="role-chip"
|
||||
style={`border-color: ${
|
||||
roleColor(r.id)
|
||||
}; color: ${roleColor(r.id)}`}
|
||||
>
|
||||
{r.nom}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
class="col-dim"
|
||||
style="font-size: 0.72rem; margin-left: 0.2rem"
|
||||
>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
{associated.length === 0 && (
|
||||
<span class="col-dim">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Promotion = { id: string; annee: string | null };
|
||||
type Student = { numEtud: number; idPromo: string };
|
||||
|
||||
function parsePromo(id: string) {
|
||||
const m = id.match(/^(\d+A)(FISE|FISA)(.+)$/);
|
||||
if (!m) return { annee: id, filiere: "?", anneeSco: "?" };
|
||||
return { annee: m[1], filiere: m[2], anneeSco: m[3] };
|
||||
}
|
||||
|
||||
const ANNEES = ["3A", "4A", "5A"];
|
||||
const FILIERES = ["FISE", "FISA"];
|
||||
|
||||
export default function AdminPromotions() {
|
||||
const [promos, setPromos] = useState<Promotion[]>([]);
|
||||
const [students, setStudents] = useState<Student[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// PromoBuilder state
|
||||
const [selectedAnnee, setSelectedAnnee] = useState("4A");
|
||||
const [selectedFiliere, setSelectedFiliere] = useState("FISE");
|
||||
const [anneeSco, setAnneeSco] = useState("");
|
||||
|
||||
const generatedId = anneeSco.trim()
|
||||
? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}`
|
||||
: "";
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [pRes, sRes] = await Promise.all([
|
||||
fetch("/students/api/promotions"),
|
||||
fetch("/students/api/students"),
|
||||
]);
|
||||
if (!pRes.ok) throw new Error("Impossible de charger les promotions");
|
||||
setPromos(await pRes.json());
|
||||
if (sRes.ok) setStudents(await sRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createPromo() {
|
||||
if (!generatedId) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/students/api/promotions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idPromo: generatedId,
|
||||
annee: selectedAnnee,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAnneeSco("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePromo(id: string) {
|
||||
if (studentCount(id) > 0) {
|
||||
setError(
|
||||
`Impossible de supprimer ${id} : des étudiants y sont encore assignés. Réassignez-les d'abord.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(`Supprimer la promotion ${id} et toutes ses données liées ?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/students/api/promotions/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Suppression échouée");
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
function studentCount(idPromo: string) {
|
||||
return students.filter((s) => s.idPromo === idPromo).length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Promotions</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
{/* PromoBuilder */}
|
||||
<div class="promo-builder">
|
||||
<p class="promo-builder-title">Créer une promotion</p>
|
||||
<p class="promo-builder-subtitle">
|
||||
idPromo est généré automatiquement
|
||||
</p>
|
||||
|
||||
<div class="promo-builder-row">
|
||||
<div class="promo-builder-field">
|
||||
<label>Année</label>
|
||||
<div class="pill-group">
|
||||
{ANNEES.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
class={`pill-btn${selectedAnnee === a ? " active" : ""}`}
|
||||
onClick={() => setSelectedAnnee(a)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="promo-builder-field">
|
||||
<label>Filière</label>
|
||||
<div class="pill-group">
|
||||
{FILIERES.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
class={`pill-btn${selectedFiliere === f ? " active" : ""}`}
|
||||
onClick={() => setSelectedFiliere(f)}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="promo-builder-field">
|
||||
<label>Année scolaire</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="ex: 25-26, 24-27…"
|
||||
value={anneeSco}
|
||||
onInput={(e) => setAnneeSco((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 9rem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem">
|
||||
<span style="font-size: 0.78rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
|
||||
idPromo généré :
|
||||
</span>
|
||||
<span class="promo-id-preview">
|
||||
{generatedId || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createPromo}
|
||||
disabled={creating || !generatedId}
|
||||
>
|
||||
{creating ? "…" : "+ Créer la promo"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing promotions table */}
|
||||
<p style="font-size: 0.82rem; font-weight: var(--font-weight-bold); margin-bottom: 0.5rem">
|
||||
Promotions existantes
|
||||
</p>
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>idPromo</th>
|
||||
<th>Année</th>
|
||||
<th>Filière</th>
|
||||
<th>Année sco.</th>
|
||||
<th>Nb étudiants</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{promos.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={6} class="state-empty">
|
||||
Aucune promotion enregistrée
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: promos.map((p) => {
|
||||
const parsed = parsePromo(p.id);
|
||||
const count = studentCount(p.id);
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td>
|
||||
<span class="promo-chip">{p.id}</span>
|
||||
</td>
|
||||
<td>{parsed.annee}</td>
|
||||
<td>
|
||||
<span class="filiere-chip">{parsed.filiere}</span>
|
||||
</td>
|
||||
<td>{parsed.anneeSco}</td>
|
||||
<td class="col-dim">
|
||||
{count} étudiant{count !== 1 ? "s" : ""}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
disabled={count > 0}
|
||||
title={count > 0
|
||||
? "Réassignez les étudiants avant de supprimer"
|
||||
: "Supprimer la promotion"}
|
||||
onClick={() => deletePromo(p.id)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect x="5" y="6" width="14" height="16" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Role = { id: number; nom: string; permissions: string[] };
|
||||
type Perm = { id: string; nom: string };
|
||||
|
||||
const MAX_CHIPS = 3;
|
||||
|
||||
export default function AdminRoles() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [permissions, setPermissions] = useState<Perm[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newNom, setNewNom] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Manage-perms sub-view
|
||||
const [managingRole, setManagingRole] = useState<Role | null>(null);
|
||||
const [editPerms, setEditPerms] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [rRes, pRes] = await Promise.all([
|
||||
fetch("/admin/api/roles"),
|
||||
fetch("/admin/api/permissions"),
|
||||
]);
|
||||
if (!rRes.ok) throw new Error("Impossible de charger les rôles");
|
||||
setRoles(await rRes.json());
|
||||
if (pRes.ok) setPermissions(await pRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createRole() {
|
||||
if (!newNom.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/admin/api/roles", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nom: newNom.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setNewNom("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRole(id: number) {
|
||||
if (!confirm("Supprimer ce rôle ?")) return;
|
||||
try {
|
||||
const res = await fetch(`/admin/api/roles/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
function openManage(role: Role) {
|
||||
setManagingRole(role);
|
||||
setEditPerms(new Set(role.permissions));
|
||||
setSaveError(null);
|
||||
}
|
||||
|
||||
function togglePerm(permId: string) {
|
||||
setEditPerms((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(permId)) next.delete(permId);
|
||||
else next.add(permId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function savePerms() {
|
||||
if (!managingRole) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const res = await fetch(`/admin/api/roles/${managingRole.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
nom: managingRole.nom,
|
||||
permissions: [...editPerms],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Enregistrement échoué");
|
||||
await load();
|
||||
setManagingRole(null);
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Manage-perms view ----
|
||||
if (managingRole) {
|
||||
const activeCount = editPerms.size;
|
||||
return (
|
||||
<div class="page-content">
|
||||
<a
|
||||
class="back-link"
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setManagingRole(null);
|
||||
}}
|
||||
>
|
||||
← Retour à la liste des rôles
|
||||
</a>
|
||||
<h2 class="page-title">
|
||||
Permissions du rôle – {managingRole.nom}
|
||||
</h2>
|
||||
|
||||
{saveError && <p class="state-error">{saveError}</p>}
|
||||
|
||||
<div class="perm-header-bar">
|
||||
<div style="display: flex; align-items: center; gap: 0.6rem">
|
||||
<span class="numEtud-chip">idRole : {managingRole.id}</span>
|
||||
<span style="font-weight: var(--font-weight-bold); font-size: 0.9rem">
|
||||
{managingRole.nom}
|
||||
</span>
|
||||
<span style="font-size: 0.8rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color))">
|
||||
{activeCount} permission{activeCount !== 1 ? "s" : ""} active
|
||||
{activeCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={savePerms}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "..." : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 0.5rem; display: flex; justify-content: space-between">
|
||||
<span style="font-size: 0.78rem; font-weight: var(--font-weight-bold)">
|
||||
Permissions disponibles
|
||||
</span>
|
||||
<span style="font-size: 0.72rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
|
||||
Activer = inclure dans le rôle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="perm-toggle-grid">
|
||||
{permissions.map((p) => {
|
||||
const active = editPerms.has(p.id);
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
class={`perm-toggle-card${active ? " active" : ""}`}
|
||||
>
|
||||
<div class="perm-toggle-label">
|
||||
<span class="perm-toggle-id">{p.id}</span>
|
||||
<span class="perm-toggle-nom">{p.nom}</span>
|
||||
</div>
|
||||
<span class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={() => togglePerm(p.id)}
|
||||
/>
|
||||
<span class="toggle-slider" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const permMap = Object.fromEntries(permissions.map((p) => [p.id, p.nom]));
|
||||
|
||||
// ---- Main list view ----
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Rôles</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="toolbar">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom du rôle..."
|
||||
value={newNom}
|
||||
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createRole()}
|
||||
style="min-width: 14rem"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createRole}
|
||||
disabled={creating}
|
||||
>
|
||||
+ Créer rôle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement...</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>idRole</th>
|
||||
<th>Nom du rôle</th>
|
||||
<th>Permissions</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={4} class="state-empty">
|
||||
Aucun rôle enregistré
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: roles.map((r) => {
|
||||
const shown = r.permissions.slice(0, MAX_CHIPS);
|
||||
const overflow = r.permissions.length - MAX_CHIPS;
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td class="col-dim">{r.id}</td>
|
||||
<td style="font-weight: var(--font-weight-bold)">
|
||||
{r.nom}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
|
||||
{shown.map((p) => (
|
||||
<span key={p} class="perm-chip">
|
||||
{permMap[p] ?? p}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
class="col-dim"
|
||||
style="font-size: 0.72rem; margin-left: 0.2rem"
|
||||
>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
onClick={() => openManage(r)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
||||
</svg>{" "}
|
||||
Gérer perms
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteRole(r.id)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="14"
|
||||
height="16"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type UE = { id: number; nom: string };
|
||||
type UEModule = {
|
||||
idModule: string;
|
||||
idUE: number;
|
||||
idPromo: string;
|
||||
coeff: number;
|
||||
};
|
||||
type Module = { id: string; nom: string };
|
||||
type Promo = { id: string; annee: string };
|
||||
|
||||
export default function AdminUEs() {
|
||||
const [ues, setUes] = useState<UE[]>([]);
|
||||
const [ueModules, setUeModules] = useState<UEModule[]>([]);
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [promos, setPromos] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedUe, setSelectedUe] = useState<UE | null>(null);
|
||||
const [filterPromo, setFilterPromo] = useState("");
|
||||
|
||||
// New UE form
|
||||
const [newUeNom, setNewUeNom] = useState("");
|
||||
const [creatingUe, setCreatingUe] = useState(false);
|
||||
|
||||
// Add UE-module form
|
||||
const [addModuleId, setAddModuleId] = useState("");
|
||||
const [addPromoId, setAddPromoId] = useState("");
|
||||
const [addCoeff, setAddCoeff] = useState("1");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
// Inline coeff editing
|
||||
const [editingCoeff, setEditingCoeff] = useState<string | null>(null);
|
||||
const [editCoeffValue, setEditCoeffValue] = useState("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [uRes, umRes, mRes, pRes] = await Promise.all([
|
||||
fetch("/admin/api/ues"),
|
||||
fetch("/admin/api/ue-modules"),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!uRes.ok) throw new Error("Impossible de charger les UEs");
|
||||
const uesData: UE[] = await uRes.json();
|
||||
setUes(uesData);
|
||||
if (umRes.ok) setUeModules(await umRes.json());
|
||||
if (mRes.ok) setModules(await mRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
// Keep selection in sync
|
||||
setSelectedUe((prev) =>
|
||||
prev ? uesData.find((u) => u.id === prev.id) ?? null : null
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createUE() {
|
||||
if (!newUeNom.trim()) return;
|
||||
setCreatingUe(true);
|
||||
try {
|
||||
const res = await fetch("/admin/api/ues", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nom: newUeNom.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Création échouée");
|
||||
setNewUeNom("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setCreatingUe(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUE(ue: UE) {
|
||||
if (!confirm(`Supprimer la UE "${ue.nom}" et tous ses liens ?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/admin/api/ues/${ue.id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Suppression échouée");
|
||||
}
|
||||
if (selectedUe?.id === ue.id) setSelectedUe(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUeModule(
|
||||
idModule: string,
|
||||
idUE: number,
|
||||
idPromo: string,
|
||||
) {
|
||||
if (!confirm("Supprimer ce module de la UE ?")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
|
||||
encodeURIComponent(idPromo)
|
||||
}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function addUeModule() {
|
||||
if (!selectedUe || !addModuleId || !addPromoId) {
|
||||
setAddError("Module et Promo sont requis");
|
||||
return;
|
||||
}
|
||||
const coeff = parseFloat(addCoeff);
|
||||
if (isNaN(coeff) || coeff <= 0) {
|
||||
setAddError("Coefficient invalide");
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
const res = await fetch("/admin/api/ue-modules", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idModule: addModuleId,
|
||||
idUE: selectedUe.id,
|
||||
idPromo: addPromoId,
|
||||
coeff,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAddModuleId("");
|
||||
setAddPromoId("");
|
||||
setAddCoeff("1");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCoeff(
|
||||
idModule: string,
|
||||
idUE: number,
|
||||
idPromo: string,
|
||||
coeff: number,
|
||||
) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
|
||||
encodeURIComponent(idPromo)
|
||||
}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ coeff }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Modification échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setEditingCoeff(null);
|
||||
}
|
||||
}
|
||||
|
||||
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
|
||||
|
||||
// Filter UEs by promo: keep UEs that have at least one ue_module for that promo
|
||||
const filteredUes = filterPromo
|
||||
? ues.filter((ue) =>
|
||||
ueModules.some((um) => um.idUE === ue.id && um.idPromo === filterPromo)
|
||||
)
|
||||
: ues;
|
||||
|
||||
const selectedUeModules = selectedUe
|
||||
? ueModules.filter((um) => um.idUE === selectedUe.id)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des UEs</h2>
|
||||
<p
|
||||
class="col-dim"
|
||||
style="font-size: 0.78rem; margin: -0.5rem 0 1rem"
|
||||
>
|
||||
UE = Unité d'Enseignement regroupant plusieurs modules
|
||||
</p>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="ue-split">
|
||||
{/* Left panel – UE list */}
|
||||
<div class="ue-panel-left">
|
||||
<div class="panel-box">
|
||||
<p class="panel-box-title">UEs existantes</p>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={filterPromo}
|
||||
onChange={(e) =>
|
||||
setFilterPromo(
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
style="width: 100%; margin-bottom: 0.5rem"
|
||||
>
|
||||
<option value="">Toutes les promos</option>
|
||||
{promos.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<div class="form-row" style="margin-bottom: 0.75rem">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom de la nouvelle UE…"
|
||||
value={newUeNom}
|
||||
onInput={(e) =>
|
||||
setNewUeNom((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createUE()}
|
||||
style="min-width: 0; flex: 1"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createUE}
|
||||
disabled={creatingUe}
|
||||
style="width: 100%; justify-content: center; margin-bottom: 0.5rem"
|
||||
>
|
||||
+ Nouvelle UE
|
||||
</button>
|
||||
<div>
|
||||
{filteredUes.map((ue) => (
|
||||
<div
|
||||
key={ue.id}
|
||||
class={`ue-list-item${
|
||||
selectedUe?.id === ue.id ? " active" : ""
|
||||
}`}
|
||||
style="display: flex; align-items: center; justify-content: space-between"
|
||||
>
|
||||
<span
|
||||
style="flex: 1; cursor: pointer"
|
||||
onClick={() => {
|
||||
setSelectedUe(ue);
|
||||
setAddError(null);
|
||||
}}
|
||||
>
|
||||
{ue.nom}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteUE(ue);
|
||||
}}
|
||||
title="Supprimer cette UE"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="14"
|
||||
height="16"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{filteredUes.length === 0 && (
|
||||
<p class="state-empty" style="padding: 1rem 0">
|
||||
{filterPromo ? "Aucune UE pour cette promo" : "Aucune UE"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel – UE detail */}
|
||||
<div class="ue-panel-right">
|
||||
{selectedUe
|
||||
? (
|
||||
<div class="panel-box">
|
||||
<p class="panel-box-title">{selectedUe.nom}</p>
|
||||
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
|
||||
Modules assignés (UE_Module)
|
||||
</p>
|
||||
<div class="data-table-wrap" style="margin-bottom: 1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
<th>Promo</th>
|
||||
<th>Coeff</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedUeModules.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={4} class="state-empty">
|
||||
Aucun module assigné
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: selectedUeModules.map((um) => {
|
||||
const mod = moduleMap[um.idModule];
|
||||
return (
|
||||
<tr
|
||||
key={`${um.idModule}-${um.idPromo}`}
|
||||
>
|
||||
<td class="col-promo">
|
||||
{mod
|
||||
? `${mod.id} – ${mod.nom}`
|
||||
: um.idModule}
|
||||
</td>
|
||||
<td>
|
||||
<span class="promo-chip">{um.idPromo}</span>
|
||||
</td>
|
||||
<td
|
||||
onClick={() => {
|
||||
const key =
|
||||
`${um.idModule}-${um.idUE}-${um.idPromo}`;
|
||||
setEditingCoeff(key);
|
||||
setEditCoeffValue(String(um.coeff));
|
||||
}}
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{editingCoeff ===
|
||||
`${um.idModule}-${um.idUE}-${um.idPromo}`
|
||||
? (
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
value={editCoeffValue}
|
||||
min="0.1"
|
||||
step="0.5"
|
||||
style="width: 5rem; padding: 0.2rem 0.4rem; font-size: 0.82rem"
|
||||
autoFocus
|
||||
onInput={(e) =>
|
||||
setEditCoeffValue(
|
||||
(e.target as HTMLInputElement)
|
||||
.value,
|
||||
)}
|
||||
onBlur={() => {
|
||||
const v = parseFloat(
|
||||
editCoeffValue,
|
||||
);
|
||||
if (!isNaN(v) && v > 0) {
|
||||
updateCoeff(
|
||||
um.idModule,
|
||||
um.idUE,
|
||||
um.idPromo,
|
||||
v,
|
||||
);
|
||||
} else {
|
||||
setEditingCoeff(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
(e.target as HTMLInputElement)
|
||||
.blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingCoeff(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: um.coeff}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
deleteUeModule(
|
||||
um.idModule,
|
||||
um.idUE,
|
||||
um.idPromo,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="14"
|
||||
height="16"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
|
||||
Ajouter un module à cette UE
|
||||
</p>
|
||||
{addError && (
|
||||
<p class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
<div class="form-row">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addModuleId}
|
||||
onChange={(e) =>
|
||||
setAddModuleId(
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
style="min-width: 12rem"
|
||||
>
|
||||
<option value="">Module ▾</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id} – {m.nom}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addPromoId}
|
||||
onChange={(e) =>
|
||||
setAddPromoId(
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
style="min-width: 9rem"
|
||||
>
|
||||
<option value="">Promo ▾</option>
|
||||
{promos.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="Coeff"
|
||||
value={addCoeff}
|
||||
min="0.1"
|
||||
step="0.5"
|
||||
onInput={(e) =>
|
||||
setAddCoeff((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 5rem; max-width: 6rem"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addUeModule}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "…" : "+ Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div class="panel-box">
|
||||
<p class="state-empty" style="padding: 2rem 0">
|
||||
Sélectionnez une UE pour voir ses modules
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type User = { id: string; nom: string; prenom: string; idRole: number | null };
|
||||
type Role = { id: number; nom: string };
|
||||
|
||||
const ROLE_COLORS = [
|
||||
"#22c55e",
|
||||
"#d4a017",
|
||||
"#e07020",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#ec4899",
|
||||
];
|
||||
|
||||
function roleColor(roleId: number): string {
|
||||
return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length];
|
||||
}
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newId, setNewId] = useState("");
|
||||
const [newNom, setNewNom] = useState("");
|
||||
const [newPrenom, setNewPrenom] = useState("");
|
||||
const [newIdRole, setNewIdRole] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const [filterNom, setFilterNom] = useState("");
|
||||
const [filterRole, setFilterRole] = useState("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [uRes, rRes] = await Promise.all([
|
||||
fetch("/admin/api/users"),
|
||||
fetch("/admin/api/roles"),
|
||||
]);
|
||||
if (!uRes.ok) throw new Error("Impossible de charger les utilisateurs");
|
||||
setUsers(await uRes.json());
|
||||
if (rRes.ok) setRoles(await rRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createUser() {
|
||||
if (!newId.trim() || !newNom.trim() || !newPrenom.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/admin/api/users", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: newId.trim(),
|
||||
nom: newNom.trim(),
|
||||
prenom: newPrenom.trim(),
|
||||
idRole: newIdRole ? Number(newIdRole) : null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setNewId("");
|
||||
setNewNom("");
|
||||
setNewPrenom("");
|
||||
setNewIdRole("");
|
||||
setShowCreate(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: string) {
|
||||
if (!confirm(`Supprimer l'utilisateur ${id} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/admin/api/users/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom]));
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
const matchNom = !filterNom ||
|
||||
`${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes(
|
||||
filterNom.toLowerCase(),
|
||||
);
|
||||
const matchRole = !filterRole || String(u.idRole) === filterRole;
|
||||
return matchNom && matchRole;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Utilisateurs</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
class="filter-input"
|
||||
placeholder="Rechercher..."
|
||||
value={filterNom}
|
||||
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={filterRole}
|
||||
onChange={(e) => setFilterRole((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Role</option>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={() => setShowCreate(true)}
|
||||
style="margin-left: auto"
|
||||
>
|
||||
+ Créer utilisateur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Creation modal */}
|
||||
{showCreate && (
|
||||
<div
|
||||
class="modal-overlay"
|
||||
onClick={() => setShowCreate(false)}
|
||||
>
|
||||
<div class="modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<p class="modal-title">Créer un utilisateur</p>
|
||||
<div class="modal-form">
|
||||
<div class="form-field">
|
||||
<label>Login (uid)</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Login (uid)"
|
||||
value={newId}
|
||||
onInput={(e) =>
|
||||
setNewId((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Nom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom"
|
||||
value={newNom}
|
||||
onInput={(e) =>
|
||||
setNewNom((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Prénom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Prénom"
|
||||
value={newPrenom}
|
||||
onInput={(e) =>
|
||||
setNewPrenom((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Rôle</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={newIdRole}
|
||||
onChange={(e) =>
|
||||
setNewIdRole((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
>
|
||||
<option value="">Aucun rôle</option>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={() => setShowCreate(false)}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createUser}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? "..." : "+ Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement...</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id (login)</th>
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
<th>Rôle(s)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={5} class="state-empty">
|
||||
Aucun utilisateur trouvé
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filtered.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td class="col-dim">{u.id}</td>
|
||||
<td>{u.nom}</td>
|
||||
<td>{u.prenom}</td>
|
||||
<td>
|
||||
{u.idRole
|
||||
? (
|
||||
<span
|
||||
class="role-chip"
|
||||
style={`border-color: ${
|
||||
roleColor(u.idRole)
|
||||
}; color: ${roleColor(u.idRole)}`}
|
||||
>
|
||||
{roleMap[u.idRole] ?? `#${u.idRole}`}
|
||||
</span>
|
||||
)
|
||||
: <span class="col-dim">--</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<a
|
||||
class="btn btn-sm btn-secondary"
|
||||
href={`/admin/users/${encodeURIComponent(u.id)}`}
|
||||
f-client-nav={false}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>{" "}
|
||||
edit
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteUser(u.id)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect x="5" y="6" width="14" height="16" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Module = { id: string; nom: string };
|
||||
type Enseignement = { idProf: string; idModule: string; idPromo: string };
|
||||
type User = { id: string; nom: string; prenom: string };
|
||||
type Promo = { id: string; annee: string };
|
||||
|
||||
type Props = { moduleId: string };
|
||||
|
||||
export default function EditModule({ moduleId }: Props) {
|
||||
const [mod, setMod] = useState<Module | null>(null);
|
||||
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [promos, setPromos] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [nom, setNom] = useState("");
|
||||
|
||||
// Add enseignement
|
||||
const [addProf, setAddProf] = useState("");
|
||||
const [addPromo, setAddPromo] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [mRes, eRes, uRes, pRes] = await Promise.all([
|
||||
fetch(`/admin/api/modules/${encodeURIComponent(moduleId)}`),
|
||||
fetch("/admin/api/enseignements"),
|
||||
fetch("/admin/api/users"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!mRes.ok) throw new Error("Module introuvable");
|
||||
const m: Module = await mRes.json();
|
||||
setMod(m);
|
||||
setNom(m.nom);
|
||||
if (eRes.ok) {
|
||||
const all: Enseignement[] = await eRes.json();
|
||||
setEnseignements(all.filter((e) => e.idModule === moduleId));
|
||||
}
|
||||
if (uRes.ok) setUsers(await uRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [moduleId]);
|
||||
|
||||
async function saveInfos() {
|
||||
if (!mod) return;
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/modules/${encodeURIComponent(moduleId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nom: nom.trim() }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Modification échouée");
|
||||
const updated: Module = await res.json();
|
||||
setMod(updated);
|
||||
setSaveMsg("Module enregistré.");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteModule() {
|
||||
if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/modules/${encodeURIComponent(moduleId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
globalThis.location.href = "/admin/modules";
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function addEnseignement() {
|
||||
if (!addProf || !addPromo) {
|
||||
setAddError("Enseignant et Promo sont requis");
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
const res = await fetch("/admin/api/enseignements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idProf: addProf,
|
||||
idModule: moduleId,
|
||||
idPromo: addPromo,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAddProf("");
|
||||
setAddPromo("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEnseignement(idProf: string, idPromo: string) {
|
||||
if (!confirm("Retirer cet enseignement ?")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/enseignements/${encodeURIComponent(idProf)}/${
|
||||
encodeURIComponent(moduleId)
|
||||
}/${encodeURIComponent(idPromo)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !mod) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-error">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mod) return null;
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<a
|
||||
class="back-link"
|
||||
href="/admin/modules"
|
||||
f-partial="/admin/partials/modules"
|
||||
>
|
||||
← Retour a la liste
|
||||
</a>
|
||||
|
||||
<h2
|
||||
class="page-title"
|
||||
style="border-bottom: none; margin-bottom: 0.5rem"
|
||||
>
|
||||
Module -- {mod.id}
|
||||
</h2>
|
||||
|
||||
<div class="info-bar">
|
||||
<span class="module-chip">{mod.id}</span>
|
||||
<span>{mod.nom}</span>
|
||||
</div>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
{saveMsg && (
|
||||
<p style="font-size: 0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.5rem">
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Section 1: Infos */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Informations</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>Code</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={mod.id}
|
||||
disabled
|
||||
style="opacity: 0.6"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Nom du module</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={nom}
|
||||
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: space-between; flex-wrap: wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={saveInfos}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "..." : "Enregistrer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onClick={deleteModule}
|
||||
>
|
||||
Supprimer le module
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Enseignements */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Enseignants assignes</p>
|
||||
|
||||
{enseignements.length > 0
|
||||
? (
|
||||
<div class="data-table-wrap" style="margin-bottom: 1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enseignant</th>
|
||||
<th>Promo</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enseignements.map((e) => {
|
||||
const u = userMap[e.idProf];
|
||||
return (
|
||||
<tr key={`${e.idProf}-${e.idPromo}`}>
|
||||
<td>
|
||||
{u ? `${u.nom} ${u.prenom.charAt(0)}.` : e.idProf}
|
||||
</td>
|
||||
<td>
|
||||
<span class="promo-chip">{e.idPromo}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
removeEnseignement(e.idProf, e.idPromo)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="14"
|
||||
height="16"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
class="state-empty"
|
||||
style="padding: 1rem 0; text-align: left"
|
||||
>
|
||||
Aucun enseignant assigne.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
|
||||
Ajouter un enseignant
|
||||
</p>
|
||||
{addError && (
|
||||
<p class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
<div class="form-row">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addProf}
|
||||
onChange={(e) => setAddProf((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 12rem"
|
||||
>
|
||||
<option value="">Enseignant</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.nom} {u.prenom} ({u.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addPromo}
|
||||
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 9rem"
|
||||
>
|
||||
<option value="">Promo</option>
|
||||
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addEnseignement}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "..." : "+ Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type User = { id: string; nom: string; prenom: string; idRole: number | null };
|
||||
type Role = { id: number; nom: string };
|
||||
type Enseignement = { idProf: string; idModule: string; idPromo: string };
|
||||
type Module = { id: string; nom: string };
|
||||
type Promo = { id: string; annee: string };
|
||||
|
||||
type Props = { userId: string };
|
||||
|
||||
export default function EditUser({ userId }: Props) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [promos, setPromos] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [nom, setNom] = useState("");
|
||||
const [prenom, setPrenom] = useState("");
|
||||
const [idRole, setIdRole] = useState("");
|
||||
|
||||
// Add enseignement form
|
||||
const [addModule, setAddModule] = useState("");
|
||||
const [addPromo, setAddPromo] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [uRes, rRes, eRes, mRes, pRes] = await Promise.all([
|
||||
fetch(`/admin/api/users/${encodeURIComponent(userId)}`),
|
||||
fetch("/admin/api/roles"),
|
||||
fetch("/admin/api/enseignements"),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!uRes.ok) throw new Error("Utilisateur introuvable");
|
||||
const u: User = await uRes.json();
|
||||
setUser(u);
|
||||
setNom(u.nom);
|
||||
setPrenom(u.prenom);
|
||||
setIdRole(u.idRole !== null ? String(u.idRole) : "");
|
||||
if (rRes.ok) setRoles(await rRes.json());
|
||||
if (eRes.ok) {
|
||||
const allEns: Enseignement[] = await eRes.json();
|
||||
setEnseignements(allEns.filter((e) => e.idProf === userId));
|
||||
}
|
||||
if (mRes.ok) setModules(await mRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [userId]);
|
||||
|
||||
async function saveInfos() {
|
||||
if (!user) return;
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/users/${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
nom: nom.trim(),
|
||||
prenom: prenom.trim(),
|
||||
idRole: idRole ? Number(idRole) : null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Modification échouée");
|
||||
const updated: User = await res.json();
|
||||
setUser(updated);
|
||||
setSaveMsg("Informations enregistrées.");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
if (!confirm(`Supprimer définitivement l'utilisateur ${userId} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/users/${encodeURIComponent(userId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
globalThis.location.href = "/admin/users";
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function addEnseignement() {
|
||||
if (!addModule || !addPromo) {
|
||||
setAddError("Module et Promo sont requis");
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
const res = await fetch("/admin/api/enseignements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idProf: userId,
|
||||
idModule: addModule,
|
||||
idPromo: addPromo,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAddModule("");
|
||||
setAddPromo("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEnseignement(idModule: string, idPromo: string) {
|
||||
if (!confirm("Retirer cet enseignement ?")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/enseignements/${encodeURIComponent(userId)}/${
|
||||
encodeURIComponent(idModule)
|
||||
}/${encodeURIComponent(idPromo)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
|
||||
const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom]));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !user) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-error">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<a
|
||||
class="back-link"
|
||||
href="/admin/users"
|
||||
f-partial="/admin/partials/users"
|
||||
>
|
||||
← Retour a la liste
|
||||
</a>
|
||||
|
||||
<h2
|
||||
class="page-title"
|
||||
style="border-bottom: none; margin-bottom: 0.5rem"
|
||||
>
|
||||
Edition -- {user.prenom} {user.nom}
|
||||
</h2>
|
||||
|
||||
<div class="info-bar">
|
||||
<span class="numEtud-chip">{user.id}</span>
|
||||
<span>
|
||||
{user.idRole
|
||||
? (roleMap[user.idRole] ?? `Role #${user.idRole}`)
|
||||
: "Aucun role"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
{saveMsg && (
|
||||
<p style="font-size: 0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.5rem">
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Section 1: Informations generales */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Informations generales</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>Nom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={nom}
|
||||
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Prenom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={prenom}
|
||||
onInput={(e) => setPrenom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Login</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={user.id}
|
||||
disabled
|
||||
style="opacity: 0.6"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Role</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={idRole}
|
||||
onChange={(e) => setIdRole((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0"
|
||||
>
|
||||
<option value="">Aucun role</option>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}
|
||||
</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: space-between; flex-wrap: wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={saveInfos}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "..." : "Enregistrer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onClick={deleteUser}
|
||||
>
|
||||
Supprimer l'utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Enseignements */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Enseignements</p>
|
||||
<p
|
||||
class="col-dim"
|
||||
style="font-size: 0.75rem; margin: 0 0 0.75rem"
|
||||
>
|
||||
Modules enseignes par cet utilisateur
|
||||
</p>
|
||||
|
||||
{enseignements.length > 0
|
||||
? (
|
||||
<div class="data-table-wrap" style="margin-bottom: 1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
<th>Promo</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enseignements.map((e) => {
|
||||
const mod = moduleMap[e.idModule];
|
||||
return (
|
||||
<tr key={`${e.idModule}-${e.idPromo}`}>
|
||||
<td class="col-promo">
|
||||
{mod ? `${mod.id} -- ${mod.nom}` : e.idModule}
|
||||
</td>
|
||||
<td>
|
||||
<span class="promo-chip">{e.idPromo}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
removeEnseignement(e.idModule, e.idPromo)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="14"
|
||||
height="16"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
class="state-empty"
|
||||
style="padding: 1rem 0; text-align: left"
|
||||
>
|
||||
Aucun enseignement assigne.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
|
||||
Ajouter un enseignement
|
||||
</p>
|
||||
{addError && (
|
||||
<p class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
<div class="form-row">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addModule}
|
||||
onChange={(e) =>
|
||||
setAddModule((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 12rem"
|
||||
>
|
||||
<option value="">Module</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id} -- {m.nom}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addPromo}
|
||||
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 9rem"
|
||||
>
|
||||
<option value="">Promo</option>
|
||||
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addEnseignement}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "..." : "+ Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
|
||||
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { useSignal } from "@preact/signals";
|
||||
import ImportResultPopup, {
|
||||
type ImportDetail,
|
||||
type ImportResult,
|
||||
} from "$root/defaults/ImportResultPopup.tsx";
|
||||
|
||||
type ParsedUE = {
|
||||
code: string | null;
|
||||
name: string;
|
||||
ects: number | null;
|
||||
modules: ParsedModule[];
|
||||
};
|
||||
|
||||
type ParsedModule = {
|
||||
code: string;
|
||||
name: string;
|
||||
coeff: number;
|
||||
};
|
||||
|
||||
type ParsedYear = {
|
||||
label: string;
|
||||
ues: ParsedUE[];
|
||||
};
|
||||
|
||||
type Promo = { id: string; annee: string | null };
|
||||
|
||||
function parseMaquette(workbook: XLSX.WorkBook): ParsedYear[] {
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
|
||||
header: 1,
|
||||
});
|
||||
|
||||
const years: ParsedYear[] = [];
|
||||
let currentYear: ParsedYear | null = null;
|
||||
let currentUE: ParsedUE | null = null;
|
||||
let moduleIndex = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row || row.length === 0) continue;
|
||||
|
||||
const col0 = row[0] != null ? String(row[0]).trim() : "";
|
||||
|
||||
// Detect year row: INFO3A, INFO4A, INFO5A, INFOAPP3A, INFOAPP4A, etc.
|
||||
if (/^(INFO|INFOAPP)\s*\d+A$/i.test(col0)) {
|
||||
currentYear = { label: col0, ues: [] };
|
||||
years.push(currentYear);
|
||||
currentUE = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect UE row: col[0] === "UE" or starts with "UE" (e.g., "UE51")
|
||||
if (col0 === "UE" || (col0.startsWith("UE") && /^UE\d/.test(col0))) {
|
||||
const ueCode = row[1] != null ? String(row[1]).trim() : null;
|
||||
const ueName = row[2] != null ? String(row[2]).trim() : "UE sans nom";
|
||||
const ects = typeof row[4] === "number" ? row[4] : null;
|
||||
|
||||
currentUE = { code: ueCode, name: ueName, ects, modules: [] };
|
||||
if (currentYear) {
|
||||
currentYear.ues.push(currentUE);
|
||||
} else {
|
||||
// No year detected yet — create a default one
|
||||
currentYear = { label: "Maquette", ues: [currentUE] };
|
||||
years.push(currentYear);
|
||||
}
|
||||
moduleIndex = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect semester header rows — just skip, don't reset UE
|
||||
if (/^SEM\s*\d/i.test(col0)) {
|
||||
currentUE = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect module row: inside a UE, col[3] has a name, col[5] is a number (coeff)
|
||||
if (currentUE && row[3] != null && typeof row[5] === "number") {
|
||||
const modName = String(row[3]).trim();
|
||||
if (!modName) continue;
|
||||
|
||||
let modCode = row[1] != null ? String(row[1]).trim() : "";
|
||||
if (!modCode) {
|
||||
const uePrefix = (currentUE.code || "MOD").replace(/[^A-Z0-9]/gi, "");
|
||||
modCode = `${uePrefix}_${String(moduleIndex).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
currentUE.modules.push({ code: modCode, name: modName, coeff: row[5] });
|
||||
moduleIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return years;
|
||||
}
|
||||
|
||||
export default function ImportMaquette() {
|
||||
const file = useSignal<File | null>(null);
|
||||
const dragging = useSignal(false);
|
||||
const uploading = useSignal(false);
|
||||
const error = useSignal<string | null>(null);
|
||||
const importResult = useSignal<ImportResult | null>(null);
|
||||
const preview = useSignal<ParsedYear[] | null>(null);
|
||||
const promos = useSignal<Promo[]>([]);
|
||||
// Map: year label -> selected promo id
|
||||
const yearPromos = useSignal<Record<string, string>>({});
|
||||
// Inline promo creation
|
||||
const newPromoId = useSignal("");
|
||||
const newPromoAnnee = useSignal("");
|
||||
const creatingPromo = useSignal(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/students/api/promotions")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => (promos.value = data));
|
||||
}, []);
|
||||
|
||||
function pickFile(f: File) {
|
||||
if (!f.name.match(/\.xlsx?$/i)) {
|
||||
error.value = "Fichier invalide — format attendu : .xlsx";
|
||||
return;
|
||||
}
|
||||
file.value = f;
|
||||
error.value = null;
|
||||
importResult.value = null;
|
||||
preview.value = null;
|
||||
yearPromos.value = {};
|
||||
|
||||
f.arrayBuffer().then((buf) => {
|
||||
try {
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
preview.value = parseMaquette(wb);
|
||||
} catch {
|
||||
error.value = "Impossible de lire le fichier.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createPromo() {
|
||||
if (!newPromoId.value.trim() || !newPromoAnnee.value.trim()) return;
|
||||
creatingPromo.value = true;
|
||||
try {
|
||||
const res = await fetch("/students/api/promotions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idPromo: newPromoId.value.trim(),
|
||||
annee: newPromoAnnee.value.trim(),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
promos.value = [...promos.value, { id: created.id, annee: created.annee }];
|
||||
newPromoId.value = "";
|
||||
newPromoAnnee.value = "";
|
||||
} else {
|
||||
error.value = "Erreur lors de la creation de la promotion.";
|
||||
}
|
||||
} finally {
|
||||
creatingPromo.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setYearPromo(yearLabel: string, promoId: string) {
|
||||
yearPromos.value = { ...yearPromos.value, [yearLabel]: promoId };
|
||||
}
|
||||
|
||||
// Check that at least one year has a promo assigned
|
||||
function canImport(): boolean {
|
||||
if (!preview.value || uploading.value) return false;
|
||||
return preview.value.some((y) => yearPromos.value[y.label]);
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!preview.value) return;
|
||||
uploading.value = true;
|
||||
error.value = null;
|
||||
importResult.value = null;
|
||||
|
||||
let added = 0;
|
||||
let ignored = 0;
|
||||
let errCount = 0;
|
||||
const details: ImportDetail[] = [];
|
||||
|
||||
try {
|
||||
for (const year of preview.value) {
|
||||
const promoId = yearPromos.value[year.label];
|
||||
if (!promoId) {
|
||||
ignored += year.ues.reduce((s, ue) => s + ue.modules.length + 1, 0);
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `${year.label} : ignoree (pas de promo selectionnee)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const ue of year.ues) {
|
||||
const ueRes = await fetch("/admin/api/ues", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nom: ue.name }),
|
||||
});
|
||||
if (!ueRes.ok) {
|
||||
errCount++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `UE "${ue.name}" : creation echouee`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const createdUE = await ueRes.json();
|
||||
added++;
|
||||
details.push({
|
||||
type: "change",
|
||||
message: `UE "${ue.name}" creee (id: ${createdUE.id})`,
|
||||
});
|
||||
|
||||
for (const mod of ue.modules) {
|
||||
const modRes = await fetch("/admin/api/modules", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: mod.code, nom: mod.name }),
|
||||
});
|
||||
if (modRes.ok) {
|
||||
added++;
|
||||
details.push({
|
||||
type: "change",
|
||||
message: `Module ${mod.code} "${mod.name}" cree`,
|
||||
});
|
||||
} else if (modRes.status !== 409) {
|
||||
errCount++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `Module "${mod.code}" : creation echouee`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkRes = await fetch("/admin/api/ue-modules", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idModule: mod.code,
|
||||
idUE: createdUE.id,
|
||||
idPromo: promoId,
|
||||
coeff: mod.coeff,
|
||||
}),
|
||||
});
|
||||
if (linkRes.ok) {
|
||||
added++;
|
||||
} else {
|
||||
errCount++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `Lien ${mod.code} -> UE ${ue.name} : echoue`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
importResult.value = {
|
||||
added,
|
||||
modified: 0,
|
||||
ignored,
|
||||
errors: errCount,
|
||||
details,
|
||||
};
|
||||
} catch {
|
||||
error.value = "Erreur lors de l'import.";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
globalThis.open("/templates/modele_maquette.xlsx", "_blank");
|
||||
}
|
||||
|
||||
function downloadExport() {
|
||||
Promise.all([
|
||||
fetch("/admin/api/ues").then((r) => r.json()),
|
||||
fetch("/admin/api/ue-modules").then((r) => r.json()),
|
||||
fetch("/admin/api/modules").then((r) => r.json()),
|
||||
]).then(([uesData, ueModulesData, modulesData]) => {
|
||||
const modMap = Object.fromEntries(
|
||||
modulesData.map((m: { id: string; nom: string }) => [m.id, m]),
|
||||
);
|
||||
|
||||
const data: (string | number | null)[][] = [
|
||||
["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\nECTS", "Coeff."],
|
||||
];
|
||||
|
||||
for (const ue of uesData) {
|
||||
const mods = ueModulesData.filter(
|
||||
(um: { idUE: number }) => um.idUE === ue.id,
|
||||
);
|
||||
const totalCoeff = mods.reduce(
|
||||
(s: number, um: { coeff: number }) => s + um.coeff,
|
||||
0,
|
||||
);
|
||||
data.push(["UE", null, ue.nom, null, totalCoeff]);
|
||||
for (const um of mods) {
|
||||
const mod = modMap[um.idModule];
|
||||
data.push([null, um.idModule, null, mod ? mod.nom : um.idModule, null, um.coeff]);
|
||||
}
|
||||
data.push([]);
|
||||
}
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Maquette");
|
||||
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
|
||||
const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "export_maquette.xlsx";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style="display:none"
|
||||
onChange={(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class={`drop-zone${dragging.value ? " dragging" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
dragging.value = true;
|
||||
}}
|
||||
onDragLeave={() => (dragging.value = false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragging.value = false;
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<span class="drop-zone-icon">⬇</span>
|
||||
{file.value ? <span class="drop-zone-file">{file.value.name}</span> : (
|
||||
<>
|
||||
<span class="drop-zone-text">
|
||||
Glisser le fichier maquette .xlsx ici
|
||||
</span>
|
||||
<span class="drop-zone-hint">ou cliquer pour parcourir</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error.value && <p class="state-error">{error.value}</p>}
|
||||
|
||||
{importResult.value && (
|
||||
<ImportResultPopup
|
||||
result={importResult.value}
|
||||
onClose={() => (importResult.value = null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create promo inline */}
|
||||
<div class="create-promo-inline">
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
|
||||
Creer une promotion
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="filter-select"
|
||||
placeholder="ID (ex: 3AFISE24-25)"
|
||||
value={newPromoId.value}
|
||||
onInput={(e) =>
|
||||
(newPromoId.value = (e.target as HTMLInputElement).value)}
|
||||
style="min-width: 10rem"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="filter-select"
|
||||
placeholder="Annee (ex: 2024-2025)"
|
||||
value={newPromoAnnee.value}
|
||||
onInput={(e) =>
|
||||
(newPromoAnnee.value = (e.target as HTMLInputElement).value)}
|
||||
style="min-width: 8rem"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={createPromo}
|
||||
disabled={creatingPromo.value || !newPromoId.value.trim() ||
|
||||
!newPromoAnnee.value.trim()}
|
||||
style="white-space: nowrap"
|
||||
>
|
||||
{creatingPromo.value ? "..." : "+ Creer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview grouped by year */}
|
||||
{preview.value && preview.value.length > 0 && (
|
||||
<div style="margin-bottom: 1rem">
|
||||
{preview.value.map((year) => {
|
||||
const totalMods = year.ues.reduce(
|
||||
(s, ue) => s + ue.modules.length,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<div key={year.label} style="margin-bottom: 1.25rem">
|
||||
<div style="display: flex; gap: 1rem; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap">
|
||||
<p style="font-size: 0.85rem; font-weight: 700; margin: 0">
|
||||
{year.label}
|
||||
<span class="col-dim" style="font-weight: 400">
|
||||
{" "}— {year.ues.length} UE, {totalMods} modules
|
||||
</span>
|
||||
</p>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={yearPromos.value[year.label] || ""}
|
||||
onChange={(e) =>
|
||||
setYearPromo(
|
||||
year.label,
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
>
|
||||
<option value="">— Ignorer —</option>
|
||||
{promos.value.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="data-table-wrap"
|
||||
style="max-height: 15rem; overflow-y: auto"
|
||||
>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>UE</th>
|
||||
<th>Module</th>
|
||||
<th>Code</th>
|
||||
<th>Coeff</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{year.ues.map((ue, i) =>
|
||||
ue.modules.length === 0
|
||||
? (
|
||||
<tr key={`ue-${i}`}>
|
||||
<td style="font-weight: 600">{ue.name}</td>
|
||||
<td class="col-dim" colspan={3}>
|
||||
Aucun module
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: ue.modules.map((mod, j) => (
|
||||
<tr key={`${i}-${j}`}>
|
||||
{j === 0 && (
|
||||
<td
|
||||
rowSpan={ue.modules.length}
|
||||
style="font-weight: 600; vertical-align: top"
|
||||
>
|
||||
{ue.name}
|
||||
{ue.ects != null && (
|
||||
<span class="col-dim">
|
||||
{" "}({ue.ects} ECTS)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>{mod.name}</td>
|
||||
<td class="col-dim">{mod.code}</td>
|
||||
<td>{mod.coeff}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="upload-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={doImport}
|
||||
disabled={!canImport()}
|
||||
>
|
||||
{uploading.value ? "..." : "+ Importer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={downloadTemplate}
|
||||
>
|
||||
Telecharger Modele
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={downloadExport}
|
||||
>
|
||||
Exporter Maquette
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="upload-format">
|
||||
Format : fichier maquette FISE / FISA avec lignes <strong>UE</strong>
|
||||
{" "}et <strong>modules</strong> (colonnes code, nom, coefficient)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,17 @@ const properties: AppProperties = {
|
||||
name: "Admin",
|
||||
icon: "school",
|
||||
pages: {
|
||||
index: "Homepage",
|
||||
index: "Accueil",
|
||||
users: "Utilisateurs",
|
||||
roles: "Rôles",
|
||||
permissions: "Permissions",
|
||||
modules: "Modules",
|
||||
enseignements: "Enseignements",
|
||||
promotions: "Promotions",
|
||||
ues: "UEs",
|
||||
"import-maquette": "Import Maquette",
|
||||
},
|
||||
adminOnly: [],
|
||||
adminOnly: ["users", "roles", "permissions", "modules", "enseignements", "promotions", "ues", "import-maquette"],
|
||||
hint: "PolyMPR module",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { enseignements } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { and, eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const _NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
const FORBIDDEN = () => new Response(null, { status: 403 });
|
||||
|
||||
const CONFLICT = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Cet enseignement existe déjà." }),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// GET /enseignements
|
||||
async GET(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return new Response(JSON.stringify([]), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const rows = await db.select().from(enseignements);
|
||||
return new Response(JSON.stringify(rows), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
// #29 POST /enseignements
|
||||
async POST(
|
||||
request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
let body: { idProf: string; idModule: string; idPromo: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
|
||||
if (!body.idProf || !body.idModule || !body.idPromo) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if enseignement already exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(enseignements)
|
||||
.where(
|
||||
and(
|
||||
eq(enseignements.idProf, body.idProf),
|
||||
eq(enseignements.idModule, body.idModule),
|
||||
eq(enseignements.idPromo, body.idPromo),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
return CONFLICT();
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(enseignements)
|
||||
.values({
|
||||
idProf: body.idProf,
|
||||
idModule: body.idModule,
|
||||
idPromo: body.idPromo,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return new Response(JSON.stringify(created), {
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { enseignements } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { and, eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
const FORBIDDEN = () => new Response(null, { status: 403 });
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #30 GET /enseignements/{idProf}/{idModule}/{idPromo}
|
||||
async GET(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const idProf = context.params.idProf;
|
||||
const idModule = context.params.idModule;
|
||||
const idPromo = context.params.idPromo;
|
||||
|
||||
const enseignement = await db
|
||||
.select()
|
||||
.from(enseignements)
|
||||
.where(
|
||||
and(
|
||||
eq(enseignements.idProf, idProf),
|
||||
eq(enseignements.idModule, idModule),
|
||||
eq(enseignements.idPromo, idPromo),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!enseignement) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(enseignement), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
// #31 DELETE /enseignements/{idProf}/{idModule}/{idPromo}
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const idProf = context.params.idProf;
|
||||
const idModule = context.params.idModule;
|
||||
const idPromo = context.params.idPromo;
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(enseignements)
|
||||
.where(
|
||||
and(
|
||||
eq(enseignements.idProf, idProf),
|
||||
eq(enseignements.idModule, idModule),
|
||||
eq(enseignements.idPromo, idPromo),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!deleted) return NOT_FOUND();
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
};
|
||||
@@ -2,20 +2,14 @@ import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { modules } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #23 GET /modules
|
||||
async GET(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
_context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return new Response(JSON.stringify([]), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await db.select().from(modules);
|
||||
return new Response(JSON.stringify(rows), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -31,9 +25,14 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
const body: { id: string; nom: string } = await request.json();
|
||||
let body: { id: string; nom: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
|
||||
if (!body.id || !body.nom) {
|
||||
if (!body.id || !body.id.trim() || !body.nom || !body.nom.trim()) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import {
|
||||
enseignements,
|
||||
modules,
|
||||
notes,
|
||||
ueModules,
|
||||
} from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #25 GET /modules/{idModule}
|
||||
async GET(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
const module = await db
|
||||
.select()
|
||||
.from(modules)
|
||||
.where(eq(modules.id, context.params.idModule))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!module) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(module), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
// #26 PUT /modules/{idModule}
|
||||
async PUT(
|
||||
request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
let body: { nom: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
|
||||
if (typeof body.nom !== "string") {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(modules)
|
||||
.set({ nom: body.nom })
|
||||
.where(eq(modules.id, context.params.idModule))
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
// #27 DELETE /modules/{idModule}
|
||||
// Cascade: deletes notes, ue_modules, enseignements for this module.
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
const idModule = context.params.idModule;
|
||||
|
||||
const mod = await db
|
||||
.select()
|
||||
.from(modules)
|
||||
.where(eq(modules.id, idModule))
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (!mod) return NOT_FOUND();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(notes).where(eq(notes.idModule, idModule));
|
||||
await tx.delete(ueModules).where(eq(ueModules.idModule, idModule));
|
||||
await tx.delete(enseignements).where(
|
||||
eq(enseignements.idModule, idModule),
|
||||
);
|
||||
await tx.delete(modules).where(eq(modules.id, idModule));
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
};
|
||||
@@ -1,21 +1,15 @@
|
||||
import { Handlers } from "$fresh/server.ts";
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { permissions } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
|
||||
const PERMISSIONS = [
|
||||
{ id: "student_read", nom: "Consulter les élèves" },
|
||||
{ id: "student_write", nom: "Gérer les élèves" },
|
||||
{ id: "note_read", nom: "Consulter les notes" },
|
||||
{ id: "note_write", nom: "Gérer les notes" },
|
||||
{ id: "module_read", nom: "Consulter les modules" },
|
||||
{ id: "module_write", nom: "Gérer les modules" },
|
||||
{ id: "user_read", nom: "Consulter les utilisateurs" },
|
||||
{ id: "user_write", nom: "Gérer les utilisateurs" },
|
||||
{ id: "role_write", nom: "Gérer les rôles" },
|
||||
] as const;
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
GET(_request, _context): Response {
|
||||
return new Response(JSON.stringify(PERMISSIONS), {
|
||||
async GET(
|
||||
_request: Request,
|
||||
_context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
const result = await db.select().from(permissions);
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { rolePermissions, roles } from "$root/databases/schema.ts";
|
||||
import { rolePermissions, roles, users } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
async function getRoleWithPermissions(
|
||||
id: number,
|
||||
@@ -41,7 +42,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
const id = Number(context.params.idRole);
|
||||
const role = await getRoleWithPermissions(id);
|
||||
|
||||
if (!role) return NOT_FOUND;
|
||||
if (!role) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(role), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -62,7 +63,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(roles.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND;
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
// Reset permissions
|
||||
await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id));
|
||||
@@ -80,21 +81,29 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
},
|
||||
|
||||
// #69 DELETE /roles/{idRole}
|
||||
// Cascade: deletes role_permissions, detaches users (idRole set to null).
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
const id = Number(context.params.idRole);
|
||||
|
||||
// Cascade delete role_permissions first
|
||||
await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id));
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(roles)
|
||||
const role = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.id, id))
|
||||
.returning();
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (!deleted) return NOT_FOUND;
|
||||
if (!role) return NOT_FOUND();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(rolePermissions).where(eq(rolePermissions.idRole, id));
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ idRole: null })
|
||||
.where(eq(users.idRole, id));
|
||||
await tx.delete(roles).where(eq(roles.id, id));
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Handlers } from "$fresh/server.ts";
|
||||
import { db } from "../../../../databases/db.ts";
|
||||
import { ueModules } from "../../../../databases/schema.ts";
|
||||
import { and, eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
export const handler: Handlers = {
|
||||
// #37 GET /ue-modules
|
||||
async GET(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const idPromo = url.searchParams.get("idPromo");
|
||||
const idUEParam = url.searchParams.get("idUE");
|
||||
|
||||
const idUE = idUEParam ? parseInt(idUEParam) : null;
|
||||
|
||||
if (idUEParam && isNaN(idUE!)) {
|
||||
return new Response("Paramètre idUE invalide", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await db.select().from(ueModules).where(
|
||||
and(
|
||||
idPromo ? eq(ueModules.idPromo, idPromo) : undefined,
|
||||
idUE ? eq(ueModules.idUE, idUE) : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching UE-modules:", error);
|
||||
return new Response("Failed to fetch data", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
// #38 POST /ue-modules
|
||||
async POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { idModule, idUE, idPromo, coeff } = body;
|
||||
|
||||
if (!idModule || !idUE || !idPromo || coeff === undefined) {
|
||||
return new Response(
|
||||
"Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis",
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof coeff !== "number" || coeff < 0) {
|
||||
return new Response("Champ 'coeff' doit être un nombre >= 0", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db.insert(ueModules).values({
|
||||
idModule,
|
||||
idUE,
|
||||
idPromo,
|
||||
coeff,
|
||||
}).returning();
|
||||
|
||||
return new Response(JSON.stringify(result[0]), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating UE-module:", error);
|
||||
return new Response("Failed to create UE-module", { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { ueModules } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { and, eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Association UE-Module introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
const FORBIDDEN = () => new Response(null, { status: 403 });
|
||||
|
||||
const BAD_REQUEST = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Paramètres invalides" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #39 GET /ue-modules/{idModule}/{idUE}/{idPromo}
|
||||
async GET(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const idModule = context.params.idModule;
|
||||
const idUE = Number(context.params.idUE);
|
||||
const idPromo = context.params.idPromo;
|
||||
|
||||
if (isNaN(idUE)) {
|
||||
return BAD_REQUEST();
|
||||
}
|
||||
|
||||
const ueModuleAssociation = await db
|
||||
.select()
|
||||
.from(ueModules)
|
||||
.where(
|
||||
eq(ueModules.idModule, idModule),
|
||||
eq(ueModules.idUE, idUE),
|
||||
eq(ueModules.idPromo, idPromo),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!ueModuleAssociation) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(ueModuleAssociation), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
// #40 PUT /ue-modules/{idModule}/{idUE}/{idPromo}
|
||||
async PUT(
|
||||
request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const idModule = context.params.idModule;
|
||||
const idUE = Number(context.params.idUE);
|
||||
const idPromo = context.params.idPromo;
|
||||
|
||||
if (isNaN(idUE)) {
|
||||
return BAD_REQUEST();
|
||||
}
|
||||
|
||||
const body: { coeff: number } = await request.json();
|
||||
|
||||
if (typeof body.coeff !== "number") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Le champ 'coeff' doit être un nombre" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(ueModules)
|
||||
.set({ coeff: body.coeff })
|
||||
.where(
|
||||
and(
|
||||
eq(ueModules.idModule, idModule),
|
||||
eq(ueModules.idUE, idUE),
|
||||
eq(ueModules.idPromo, idPromo),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
idModule: updated.idModule,
|
||||
idUE: updated.idUE,
|
||||
idPromo: updated.idPromo,
|
||||
coeff: updated.coeff,
|
||||
}),
|
||||
{
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
// #41 DELETE /ue-modules/{idModule}/{idUE}/{idPromo}
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const idModule = context.params.idModule;
|
||||
const idUE = Number(context.params.idUE);
|
||||
const idPromo = context.params.idPromo;
|
||||
|
||||
if (isNaN(idUE)) {
|
||||
return BAD_REQUEST();
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(ueModules)
|
||||
.where(
|
||||
and(
|
||||
eq(ueModules.idModule, idModule),
|
||||
eq(ueModules.idUE, idUE),
|
||||
eq(ueModules.idPromo, idPromo),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!deleted) return NOT_FOUND();
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const handler: Handlers = {
|
||||
const body = await request.json();
|
||||
const { nom } = body;
|
||||
|
||||
if (!nom) {
|
||||
if (!nom || !nom.trim()) {
|
||||
return new Response("Champ 'nom' manquant", { status: 400 });
|
||||
}
|
||||
|
||||
@@ -39,4 +39,4 @@ export const handler: Handlers = {
|
||||
return new Response("Failed to create UE", { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Handlers } from "$fresh/server.ts";
|
||||
import { db } from "../../../../../databases/db.ts";
|
||||
import {
|
||||
ajustements,
|
||||
ueModules,
|
||||
ues,
|
||||
} from "../../../../../databases/schema.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
export const handler: Handlers = {
|
||||
// # 34 GET /ues/:idUE
|
||||
async GET(_request, context) {
|
||||
try {
|
||||
const idUE = parseInt(context.params.idUE);
|
||||
|
||||
if (isNaN(idUE)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Paramètre idUE invalide" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.select().from(ues).where(eq(ues.id, idUE));
|
||||
|
||||
if (result.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result[0]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching UE:", error);
|
||||
return new Response("Failed to fetch data", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
// #35 PUT /ues/:idUE
|
||||
async PUT(request, context) {
|
||||
try {
|
||||
const idUE = parseInt(context.params.idUE);
|
||||
|
||||
if (isNaN(idUE)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Paramètre idUE invalide" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { nom } = body;
|
||||
|
||||
if (!nom) {
|
||||
return new Response("Champ 'nom' manquant", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await db.update(ues).set({ nom }).where(eq(ues.id, idUE))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result[0]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating UE:", error);
|
||||
return new Response("Failed to update UE", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
// #36 DELETE /ues/:idUE
|
||||
// Cascade: deletes ajustements, ue_modules for this UE.
|
||||
async DELETE(_request, context) {
|
||||
try {
|
||||
const idUE = parseInt(context.params.idUE);
|
||||
|
||||
if (isNaN(idUE)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Paramètre idUE invalide" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.select().from(ues).where(eq(ues.id, idUE));
|
||||
|
||||
if (existing.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(ajustements).where(eq(ajustements.idUE, idUE));
|
||||
await tx.delete(ueModules).where(eq(ueModules.idUE, idUE));
|
||||
await tx.delete(ues).where(eq(ues.id, idUE));
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error("Error deleting UE:", error);
|
||||
return new Response("Failed to delete UE", { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -27,10 +27,17 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
request: Request,
|
||||
_context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
const body: { id: string; nom: string; prenom: string; idRole: number } =
|
||||
await request.json();
|
||||
let body: { id: string; nom: string; prenom: string; idRole: number };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
|
||||
if (!body.id || !body.nom || !body.prenom) {
|
||||
if (
|
||||
!body.id || !body.id.trim() || !body.nom || !body.nom.trim() ||
|
||||
!body.prenom || !body.prenom.trim()
|
||||
) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { users } from "$root/databases/schema.ts";
|
||||
import { enseignements, users } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #62 GET /users/{id}
|
||||
@@ -21,7 +22,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(users.id, context.params.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!user) return NOT_FOUND;
|
||||
if (!user) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(user), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -42,7 +43,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(users.id, context.params.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND;
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -50,16 +51,25 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
},
|
||||
|
||||
// #64 DELETE /users/{id}
|
||||
// Cascade: deletes enseignements for this user.
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
const [deleted] = await db
|
||||
.delete(users)
|
||||
.where(eq(users.id, context.params.id))
|
||||
.returning();
|
||||
const id = context.params.id;
|
||||
|
||||
if (!deleted) return NOT_FOUND;
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (!user) return NOT_FOUND();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(enseignements).where(eq(enseignements.idProf, id));
|
||||
await tx.delete(users).where(eq(users.id, id));
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import EditModule from "../(_islands)/EditModule.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export default async function EditModulePage(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
) {
|
||||
return <EditModule moduleId={context.params.idModule} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminEnseignements from "../(_islands)/AdminEnseignements.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Enseignements(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminEnseignements />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Enseignements);
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import ImportMaquette from "../(_islands)/ImportMaquette.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function ImportMaquettePage(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Importer une Maquette (UE & Modules)</h2>
|
||||
<ImportMaquette />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(ImportMaquettePage);
|
||||
@@ -3,10 +3,41 @@ import {
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/routes/_middleware.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
|
||||
export function Index(_request: Request, _context: FreshContext<State>) {
|
||||
return <h2>Welcome to Admin.</h2>;
|
||||
// deno-lint-ignore require-await
|
||||
export async function Index(
|
||||
_request: Request,
|
||||
context: FreshContext<State>,
|
||||
) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Administration</h2>
|
||||
<p>
|
||||
Bienvenue{" "}
|
||||
<strong>
|
||||
{(context.state as unknown as { session: Record<string, string> })
|
||||
.session.displayName}
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
Gérez les{" "}
|
||||
<a href="/admin/modules" f-partial="/admin/partials/modules">
|
||||
modules
|
||||
</a>
|
||||
,{" "}
|
||||
<a href="/admin/users" f-partial="/admin/partials/users">
|
||||
utilisateurs
|
||||
</a>
|
||||
,{" "}
|
||||
<a href="/admin/roles" f-partial="/admin/partials/roles">
|
||||
rôles
|
||||
</a>{" "}
|
||||
depuis la barre de navigation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminModules from "../(_islands)/AdminModules.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Modules(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminModules />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Modules);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminPermissions from "../(_islands)/AdminPermissions.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Permissions(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminPermissions />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Permissions);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminPromotions from "../(_islands)/AdminPromotions.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Promotions(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminPromotions />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Promotions);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminRoles from "../(_islands)/AdminRoles.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Roles(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminRoles />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Roles);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminUEs from "../(_islands)/AdminUEs.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function UEs(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminUEs />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(UEs);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminUsers from "../(_islands)/AdminUsers.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Users(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminUsers />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Users);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import EditUser from "../(_islands)/EditUser.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export default async function EditUserPage(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
) {
|
||||
return <EditUser userId={context.params.id} />;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Student = {
|
||||
numEtud: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
idPromo: string;
|
||||
};
|
||||
type Promotion = { id: string; annee: string | null };
|
||||
|
||||
export default function AdminConsultNotes() {
|
||||
const [students, setStudents] = useState<Student[]>([]);
|
||||
const [promos, setPromos] = useState<Promotion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [filterPromo, setFilterPromo] = useState("");
|
||||
const [filterNom, setFilterNom] = useState("");
|
||||
const [filterPrenom, setFilterPrenom] = useState("");
|
||||
const [applied, setApplied] = useState({
|
||||
promo: "",
|
||||
nom: "",
|
||||
prenom: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [sRes, pRes] = await Promise.all([
|
||||
fetch("/students/api/students"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!sRes.ok) throw new Error("Impossible de charger les étudiants");
|
||||
setStudents(await sRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const filtered = students.filter((s) => {
|
||||
if (applied.promo && s.idPromo !== applied.promo) return false;
|
||||
if (
|
||||
applied.nom &&
|
||||
!s.nom.toLowerCase().includes(applied.nom.toLowerCase())
|
||||
) return false;
|
||||
if (
|
||||
applied.prenom &&
|
||||
!s.prenom.toLowerCase().includes(applied.prenom.toLowerCase())
|
||||
) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
setApplied({ promo: filterPromo, nom: filterNom, prenom: filterPrenom });
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<div class="toolbar">
|
||||
<h2 class="page-title">Consulter les Notes</h2>
|
||||
</div>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="filters">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={filterPromo}
|
||||
onChange={(e) =>
|
||||
setFilterPromo((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Toutes les promos</option>
|
||||
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
|
||||
</select>
|
||||
<input
|
||||
class="filter-input"
|
||||
placeholder="Nom"
|
||||
value={filterNom}
|
||||
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<input
|
||||
class="filter-input"
|
||||
placeholder="Prénom"
|
||||
value={filterPrenom}
|
||||
onInput={(e) => setFilterPrenom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button type="button" class="btn btn-primary" onClick={applyFilters}>
|
||||
Filtrer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Promo</th>
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
<th>N° Étudiant</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={5} class="state-empty">
|
||||
Aucun étudiant trouvé
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filtered.map((s) => (
|
||||
<tr key={s.numEtud}>
|
||||
<td class="col-promo">{s.idPromo}</td>
|
||||
<td>{s.nom}</td>
|
||||
<td>{s.prenom}</td>
|
||||
<td class="col-dim">{s.numEtud}</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<a
|
||||
class="btn btn-sm btn-secondary"
|
||||
href={`/notes/edition/${s.numEtud}`}
|
||||
f-client-nav={false}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>{" "}
|
||||
édit
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
|
||||
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { useSignal } from "@preact/signals";
|
||||
import ImportResultPopup, {
|
||||
type ImportDetail,
|
||||
type ImportResult,
|
||||
} from "$root/defaults/ImportResultPopup.tsx";
|
||||
|
||||
type Student = { numEtud: number; nom: string; prenom: string };
|
||||
type ColumnInfo = {
|
||||
index: number;
|
||||
code: string;
|
||||
name: string;
|
||||
coeff: number | null;
|
||||
type: "module" | "malus" | "ue" | "semester" | "unknown";
|
||||
};
|
||||
|
||||
function parseHeader(header: string): { code: string; name: string } {
|
||||
const parts = header.split(" - ");
|
||||
if (parts.length >= 2) {
|
||||
return { code: parts[0].trim(), name: parts.slice(1).join(" - ").trim() };
|
||||
}
|
||||
return { code: header.trim(), name: header.trim() };
|
||||
}
|
||||
|
||||
function detectColumnType(
|
||||
header: string,
|
||||
_coeff: number | null,
|
||||
): ColumnInfo["type"] {
|
||||
const h = header.trim();
|
||||
if (/^MALUS/i.test(h)) return "malus";
|
||||
if (/^S\d+$/i.test(h)) return "semester";
|
||||
// UE codes typically contain "U" followed by digits (e.g., JIN5U05, JTR5U01)
|
||||
const { code } = parseHeader(h);
|
||||
if (/U\d/i.test(code) && !/^MALUS/i.test(code)) return "ue";
|
||||
return "module";
|
||||
}
|
||||
|
||||
export default function ImportNotes() {
|
||||
const file = useSignal<File | null>(null);
|
||||
const dragging = useSignal(false);
|
||||
const uploading = useSignal(false);
|
||||
const error = useSignal<string | null>(null);
|
||||
const importResult = useSignal<ImportResult | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const students = useSignal<Student[]>([]);
|
||||
const columns = useSignal<ColumnInfo[]>([]);
|
||||
const sheetNames = useSignal<string[]>([]);
|
||||
const selectedSheet = useSignal("");
|
||||
const session = useSignal<"1" | "2">("1");
|
||||
const workbookRef = useRef<XLSX.WorkBook | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/students/api/students")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => (students.value = data));
|
||||
}, []);
|
||||
|
||||
function pickFile(f: File) {
|
||||
if (!f.name.match(/\.xlsx?$/i)) {
|
||||
error.value = "Fichier invalide — format attendu : .xlsx";
|
||||
return;
|
||||
}
|
||||
file.value = f;
|
||||
error.value = null;
|
||||
importResult.value = null;
|
||||
columns.value = [];
|
||||
|
||||
f.arrayBuffer().then((buf) => {
|
||||
try {
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
workbookRef.current = wb;
|
||||
sheetNames.value = wb.SheetNames;
|
||||
if (wb.SheetNames.length > 0) {
|
||||
selectedSheet.value = wb.SheetNames[0];
|
||||
parseSheet(wb, wb.SheetNames[0]);
|
||||
}
|
||||
} catch {
|
||||
error.value = "Impossible de lire le fichier.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseSheet(wb: XLSX.WorkBook, sheetName: string) {
|
||||
const sheet = wb.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
|
||||
header: 1,
|
||||
});
|
||||
if (rows.length < 2) {
|
||||
columns.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRow = rows[0];
|
||||
const coeffRow = rows[1];
|
||||
|
||||
const cols: ColumnInfo[] = [];
|
||||
// First 2 columns are nom/prenom, skip them
|
||||
for (let i = 2; i < headerRow.length; i++) {
|
||||
const h = headerRow[i];
|
||||
if (h == null || String(h).trim() === "") continue;
|
||||
const header = String(h).trim();
|
||||
const coeff = typeof coeffRow[i] === "number" ? coeffRow[i] : null;
|
||||
const { code, name } = parseHeader(header);
|
||||
const type = detectColumnType(header, coeff as number | null);
|
||||
cols.push({ index: i, code, name, coeff: coeff as number | null, type });
|
||||
}
|
||||
columns.value = cols;
|
||||
}
|
||||
|
||||
function onSheetChange(name: string) {
|
||||
selectedSheet.value = name;
|
||||
if (workbookRef.current) {
|
||||
parseSheet(workbookRef.current, name);
|
||||
}
|
||||
}
|
||||
|
||||
function findStudent(
|
||||
nom: string,
|
||||
prenom: string,
|
||||
): Student | undefined {
|
||||
const normNom = nom.toUpperCase().trim();
|
||||
const normPrenom = prenom.toUpperCase().trim();
|
||||
return students.value.find(
|
||||
(s) =>
|
||||
s.nom.toUpperCase().trim() === normNom &&
|
||||
s.prenom.toUpperCase().trim() === normPrenom,
|
||||
);
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!workbookRef.current || !selectedSheet.value) return;
|
||||
uploading.value = true;
|
||||
error.value = null;
|
||||
importResult.value = null;
|
||||
|
||||
try {
|
||||
const sheet = workbookRef.current.Sheets[selectedSheet.value];
|
||||
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
|
||||
header: 1,
|
||||
});
|
||||
|
||||
const moduleCols = columns.value.filter((c) => c.type === "module");
|
||||
|
||||
let added = 0;
|
||||
let modified = 0;
|
||||
let ignored = 0;
|
||||
let errors = 0;
|
||||
const details: ImportDetail[] = [];
|
||||
|
||||
// Process data rows (skip header + coeff rows)
|
||||
for (let r = 2; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (!row || row.length < 3) continue;
|
||||
|
||||
const nom = row[0] != null ? String(row[0]).trim() : "";
|
||||
const prenom = row[1] != null ? String(row[1]).trim() : "";
|
||||
if (!nom || !prenom) continue;
|
||||
|
||||
const student = findStudent(nom, prenom);
|
||||
if (!student) {
|
||||
ignored++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `${nom} ${prenom} : Etudiant non trouve`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Import module notes
|
||||
for (const col of moduleCols) {
|
||||
const val = row[col.index];
|
||||
if (val == null || typeof val !== "number") {
|
||||
if (val != null && typeof val !== "number") {
|
||||
errors++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message:
|
||||
`${student.numEtud} : ${col.code} : Note "${val}" invalide`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (val < 0 || val > 20) {
|
||||
errors++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message:
|
||||
`${student.numEtud} : ${col.code} : Note ${val} hors limites`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const noteField = session.value === "2" ? "noteSession2" : "note";
|
||||
|
||||
// Try PUT first (update), then POST (create)
|
||||
const putRes = await fetch(
|
||||
`/notes/api/notes/${student.numEtud}/${
|
||||
encodeURIComponent(col.code)
|
||||
}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ [noteField]: val }),
|
||||
},
|
||||
);
|
||||
|
||||
if (putRes.ok) {
|
||||
const prev = await putRes.json();
|
||||
const oldVal = session.value === "2"
|
||||
? prev.noteSession2
|
||||
: prev.note;
|
||||
modified++;
|
||||
details.push({
|
||||
type: "change",
|
||||
message: `${student.numEtud} : ${col.code} : ${
|
||||
oldVal ?? "null"
|
||||
} -> ${val}`,
|
||||
});
|
||||
} else if (putRes.status === 404) {
|
||||
// Note doesn't exist yet, create it
|
||||
const body: Record<string, unknown> = {
|
||||
numEtud: student.numEtud,
|
||||
idModule: col.code,
|
||||
note: session.value === "1" ? val : 0,
|
||||
};
|
||||
if (session.value === "2") body.noteSession2 = val;
|
||||
|
||||
const postRes = await fetch("/notes/api/notes", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (postRes.ok) {
|
||||
added++;
|
||||
details.push({
|
||||
type: "change",
|
||||
message: `${student.numEtud} : ${col.code} : null -> ${val}`,
|
||||
});
|
||||
} else {
|
||||
errors++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message:
|
||||
`${student.numEtud} : ${col.code} : Matiere non trouvee`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `${student.numEtud} : ${col.code} : Erreur serveur`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
importResult.value = { added, modified, ignored, errors, details };
|
||||
} catch {
|
||||
error.value = "Erreur lors de l'import.";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
globalThis.open("/templates/modele_notes.xlsx", "_blank");
|
||||
}
|
||||
|
||||
function downloadExport() {
|
||||
// Export notes from the API in the same format
|
||||
Promise.all([
|
||||
fetch("/students/api/students").then((r) => r.json()),
|
||||
fetch("/notes/api/notes").then((r) => r.json()),
|
||||
fetch("/admin/api/modules").then((r) => r.json()),
|
||||
fetch("/admin/api/ue-modules").then((r) => r.json()),
|
||||
fetch("/admin/api/ues").then((r) => r.json()),
|
||||
]).then(
|
||||
([
|
||||
studentsData,
|
||||
notesData,
|
||||
modulesData,
|
||||
ueModulesData,
|
||||
uesData,
|
||||
]) => {
|
||||
// Build module map
|
||||
const modMap = new Map<string, string>(
|
||||
modulesData.map((m: { id: string; nom: string }) => [m.id, m.nom]),
|
||||
);
|
||||
|
||||
// Get unique module IDs from notes
|
||||
const moduleIds = [
|
||||
...new Set(
|
||||
notesData.map((n: { idModule: string }) => n.idModule),
|
||||
),
|
||||
] as string[];
|
||||
|
||||
// Group ue-modules by UE
|
||||
const ueMap = new Map<number, string>(
|
||||
uesData.map((u: { id: number; nom: string }) => [u.id, u.nom]),
|
||||
);
|
||||
const umByUE = new Map<number, typeof ueModulesData>();
|
||||
for (const um of ueModulesData) {
|
||||
if (!umByUE.has(um.idUE)) umByUE.set(um.idUE, []);
|
||||
umByUE.get(um.idUE)!.push(um);
|
||||
}
|
||||
|
||||
// Build column order: group modules by UE, add UE avg columns
|
||||
const orderedCols: {
|
||||
id: string;
|
||||
header: string;
|
||||
coeff: number | null;
|
||||
type: "module" | "ue";
|
||||
ueId?: number;
|
||||
}[] = [];
|
||||
|
||||
const usedModules = new Set<string>();
|
||||
for (const [ueId, ums] of umByUE) {
|
||||
for (const um of ums) {
|
||||
if (!moduleIds.includes(um.idModule)) continue;
|
||||
orderedCols.push({
|
||||
id: um.idModule,
|
||||
header: `${um.idModule} - ${
|
||||
modMap.get(um.idModule) || um.idModule
|
||||
}`,
|
||||
coeff: um.coeff,
|
||||
type: "module",
|
||||
ueId,
|
||||
});
|
||||
usedModules.add(um.idModule);
|
||||
}
|
||||
const ueName = ueMap.get(ueId) || `UE ${ueId}`;
|
||||
orderedCols.push({
|
||||
id: `ue_${ueId}`,
|
||||
header: ueName,
|
||||
coeff: ums.reduce(
|
||||
(s: number, um: { coeff: number }) => s + um.coeff,
|
||||
0,
|
||||
),
|
||||
type: "ue",
|
||||
ueId,
|
||||
});
|
||||
}
|
||||
// Add modules not linked to any UE
|
||||
for (const mId of moduleIds) {
|
||||
if (usedModules.has(mId)) continue;
|
||||
orderedCols.push({
|
||||
id: mId,
|
||||
header: `${mId} - ${modMap.get(mId) || mId}`,
|
||||
coeff: null,
|
||||
type: "module",
|
||||
});
|
||||
}
|
||||
|
||||
// Build note lookup: numEtud -> idModule -> note
|
||||
const noteLookup = new Map<
|
||||
number,
|
||||
Map<string, { note: number; noteSession2: number | null }>
|
||||
>();
|
||||
for (const n of notesData) {
|
||||
if (!noteLookup.has(n.numEtud)) noteLookup.set(n.numEtud, new Map());
|
||||
noteLookup.get(n.numEtud)!.set(n.idModule, {
|
||||
note: n.note,
|
||||
noteSession2: n.noteSession2,
|
||||
});
|
||||
}
|
||||
|
||||
// Get students who have notes
|
||||
const studentsWithNotes = studentsData.filter(
|
||||
(s: Student) => noteLookup.has(s.numEtud),
|
||||
);
|
||||
|
||||
// Build header rows
|
||||
const headerRow: (string | null)[] = [null, null];
|
||||
const coeffRow: (number | null)[] = [null, null];
|
||||
for (const col of orderedCols) {
|
||||
headerRow.push(col.header);
|
||||
coeffRow.push(col.coeff);
|
||||
}
|
||||
|
||||
// Build session 1 data rows
|
||||
const s1Rows: (string | number | null)[][] = [];
|
||||
for (const s of studentsWithNotes) {
|
||||
const row: (string | number | null)[] = [s.nom, s.prenom];
|
||||
const sNotes = noteLookup.get(s.numEtud) || new Map();
|
||||
for (const col of orderedCols) {
|
||||
if (col.type === "module") {
|
||||
const n = sNotes.get(col.id);
|
||||
row.push(n ? n.note : null);
|
||||
} else {
|
||||
// UE average - calculate
|
||||
const ueMods = orderedCols.filter(
|
||||
(c) => c.type === "module" && c.ueId === col.ueId,
|
||||
);
|
||||
let total = 0, coeffSum = 0;
|
||||
for (const um of ueMods) {
|
||||
const n = sNotes.get(um.id);
|
||||
if (n && um.coeff) {
|
||||
total += n.note * um.coeff;
|
||||
coeffSum += um.coeff;
|
||||
}
|
||||
}
|
||||
row.push(
|
||||
coeffSum > 0
|
||||
? Math.round((total / coeffSum) * 100) / 100
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
s1Rows.push(row);
|
||||
}
|
||||
|
||||
// Build session 2 data rows
|
||||
const s2Rows: (string | number | null)[][] = [];
|
||||
for (const s of studentsWithNotes) {
|
||||
const row: (string | number | null)[] = [s.nom, s.prenom];
|
||||
const sNotes = noteLookup.get(s.numEtud) || new Map();
|
||||
for (const col of orderedCols) {
|
||||
if (col.type === "module") {
|
||||
const n = sNotes.get(col.id);
|
||||
// Use session 2 note if available, else session 1
|
||||
row.push(n ? (n.noteSession2 ?? n.note) : null);
|
||||
} else {
|
||||
const ueMods = orderedCols.filter(
|
||||
(c) => c.type === "module" && c.ueId === col.ueId,
|
||||
);
|
||||
let total = 0, coeffSum = 0;
|
||||
for (const um of ueMods) {
|
||||
const n = sNotes.get(um.id);
|
||||
if (n && um.coeff) {
|
||||
const noteVal = n.noteSession2 ?? n.note;
|
||||
total += noteVal * um.coeff;
|
||||
coeffSum += um.coeff;
|
||||
}
|
||||
}
|
||||
row.push(
|
||||
coeffSum > 0
|
||||
? Math.round((total / coeffSum) * 100) / 100
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
s2Rows.push(row);
|
||||
}
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws1 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s1Rows]);
|
||||
XLSX.utils.book_append_sheet(wb, ws1, "Session 1");
|
||||
const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]);
|
||||
XLSX.utils.book_append_sheet(wb, ws2, "Session 2");
|
||||
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
|
||||
const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "export_notes.xlsx";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style="display:none"
|
||||
onChange={(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class={`drop-zone${dragging.value ? " dragging" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
dragging.value = true;
|
||||
}}
|
||||
onDragLeave={() => (dragging.value = false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragging.value = false;
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<span class="drop-zone-icon">⬇</span>
|
||||
{file.value ? <span class="drop-zone-file">{file.value.name}</span> : (
|
||||
<>
|
||||
<span class="drop-zone-text">Glisser le fichier .xlsx ici</span>
|
||||
<span class="drop-zone-hint">ou cliquer pour parcourir</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error.value && <p class="state-error">{error.value}</p>}
|
||||
|
||||
{importResult.value && (
|
||||
<ImportResultPopup
|
||||
result={importResult.value}
|
||||
onClose={() => (importResult.value = null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sheet + session selector */}
|
||||
{sheetNames.value.length > 0 && (
|
||||
<div style="display: flex; gap: 1rem; margin-bottom: 0.75rem; flex-wrap: wrap">
|
||||
<div>
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
|
||||
Feuille
|
||||
</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedSheet.value}
|
||||
onChange={(e) =>
|
||||
onSheetChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{sheetNames.value.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
|
||||
Importer en tant que
|
||||
</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={session.value}
|
||||
onChange={(e) => (session.value = (e.target as HTMLSelectElement)
|
||||
.value as "1" | "2")}
|
||||
>
|
||||
<option value="1">Session 1 (note)</option>
|
||||
<option value="2">Session 2 (noteSession2)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column preview */}
|
||||
{columns.value.length > 0 && (
|
||||
<div style="margin-bottom: 1rem">
|
||||
<p style="font-size: 0.82rem; font-weight: 600; margin-bottom: 0.5rem">
|
||||
Colonnes detectees :
|
||||
</p>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.35rem">
|
||||
{columns.value.map((col) => (
|
||||
<span
|
||||
key={col.index}
|
||||
class={`numEtud-chip${
|
||||
col.type === "module"
|
||||
? ""
|
||||
: col.type === "malus"
|
||||
? " note-chip--fail"
|
||||
: " note-chip--promo"
|
||||
}`}
|
||||
style="font-size: 0.72rem"
|
||||
title={`${col.type} — ${col.name}${
|
||||
col.coeff != null ? ` (coef ${col.coeff})` : ""
|
||||
}`}
|
||||
>
|
||||
{col.type === "module"
|
||||
? "M"
|
||||
: col.type === "ue"
|
||||
? "UE"
|
||||
: col.type === "malus"
|
||||
? "X"
|
||||
: "?"} {col.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p class="col-dim" style="font-size: 0.72rem; margin-top: 0.35rem">
|
||||
M = module (importe) | UE = moyenne UE (ignore) | X = malus
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="upload-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={doImport}
|
||||
disabled={!file.value || uploading.value ||
|
||||
columns.value.filter((c) => c.type === "module").length === 0}
|
||||
>
|
||||
{uploading.value ? "..." : "+ Importer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={downloadTemplate}
|
||||
>
|
||||
Telecharger Modele
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={downloadExport}
|
||||
>
|
||||
Exporter Notes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="upload-format">
|
||||
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
|
||||
<strong>CODE - Module</strong> (colonnes notes){" "}
|
||||
— les colonnes UE et MALUS sont auto-detectees
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Student = {
|
||||
numEtud: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
idPromo: string;
|
||||
};
|
||||
type UE = { id: number; nom: string };
|
||||
type UEModule = {
|
||||
idModule: string;
|
||||
idUE: number;
|
||||
idPromo: string;
|
||||
coeff: number;
|
||||
};
|
||||
type Module = { id: string; nom: string };
|
||||
type Note = {
|
||||
numEtud: number;
|
||||
idModule: string;
|
||||
note: number;
|
||||
noteSession2: number | null;
|
||||
};
|
||||
type Ajustement = {
|
||||
numEtud: number;
|
||||
idUE: number;
|
||||
valeur: number;
|
||||
malus: number;
|
||||
};
|
||||
|
||||
type Props = { numEtud: number };
|
||||
|
||||
function fmt(n: number): string {
|
||||
return `${Math.round(n * 10) / 10}/20`;
|
||||
}
|
||||
|
||||
function noteClass(n: number): string {
|
||||
return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail";
|
||||
}
|
||||
|
||||
/** 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[]>([]);
|
||||
const [ueModules, setUeModules] = useState<UEModule[]>([]);
|
||||
const [moduleMap, setModuleMap] = useState<Map<string, string>>(new Map());
|
||||
const [noteMap, setNoteMap] = useState<Map<string, Note>>(new Map());
|
||||
const [ajustements, setAjustements] = useState<Ajustement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingNote, setEditingNote] = useState<
|
||||
{ idModule: string; field: "note" | "noteSession2"; value: string } | null
|
||||
>(null);
|
||||
const [ajustInputs, setAjustInputs] = useState<
|
||||
Record<number, { valeur: string; malus: string }>
|
||||
>({});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const sRes = await fetch(`/students/api/students/${numEtud}`);
|
||||
if (!sRes.ok) throw new Error("Eleve introuvable");
|
||||
const s: Student = await sRes.json();
|
||||
setStudent(s);
|
||||
|
||||
const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([
|
||||
fetch("/admin/api/ues"),
|
||||
fetch(
|
||||
`/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
|
||||
),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch(`/notes/api/notes?numEtud=${numEtud}`),
|
||||
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
|
||||
]);
|
||||
|
||||
if (uesRes.ok) setUeList(await uesRes.json());
|
||||
if (umRes.ok) setUeModules(await umRes.json());
|
||||
if (mRes.ok) {
|
||||
const mods: Module[] = await mRes.json();
|
||||
setModuleMap(new Map(mods.map((m) => [m.id, m.nom])));
|
||||
}
|
||||
if (notesRes.ok) {
|
||||
const ns: Note[] = await notesRes.json();
|
||||
setNoteMap(new Map(ns.map((n) => [n.idModule, n])));
|
||||
}
|
||||
if (ajustRes.ok) {
|
||||
const aj: Ajustement[] = await ajustRes.json();
|
||||
setAjustements(aj);
|
||||
const inputs: Record<number, { valeur: string; malus: string }> = {};
|
||||
for (const a of aj) {
|
||||
inputs[a.idUE] = {
|
||||
valeur: String(a.valeur),
|
||||
malus: String(a.malus),
|
||||
};
|
||||
}
|
||||
setAjustInputs(inputs);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [numEtud]);
|
||||
|
||||
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",
|
||||
value: string,
|
||||
) {
|
||||
if (value.trim() === "" && field === "noteSession2") {
|
||||
// Clear session 2 note
|
||||
const res = await fetch(
|
||||
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ noteSession2: null }),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
const updated: Note = await res.json();
|
||||
setNoteMap((prev) => new Map(prev).set(idModule, updated));
|
||||
}
|
||||
setEditingNote(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const note = parseFloat(value.replace(",", "."));
|
||||
if (isNaN(note) || note < 0 || note > 20) {
|
||||
setEditingNote(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = noteMap.get(idModule);
|
||||
|
||||
if (existing) {
|
||||
// Update
|
||||
const res = await fetch(
|
||||
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ [field]: note }),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
const updated: Note = await res.json();
|
||||
setNoteMap((prev) => new Map(prev).set(idModule, updated));
|
||||
}
|
||||
} else {
|
||||
// Create
|
||||
const body: Record<string, unknown> = {
|
||||
numEtud,
|
||||
idModule,
|
||||
note: field === "note" ? note : 0,
|
||||
};
|
||||
if (field === "noteSession2") body.noteSession2 = note;
|
||||
const res = await fetch("/notes/api/notes", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
const created: Note = await res.json();
|
||||
setNoteMap((prev) => new Map(prev).set(idModule, created));
|
||||
}
|
||||
}
|
||||
setEditingNote(null);
|
||||
}
|
||||
|
||||
async function applyAjust(idUE: number) {
|
||||
const inputs = ajustInputs[idUE];
|
||||
const val = parseFloat((inputs?.valeur ?? "").replace(",", "."));
|
||||
const malus = parseInt(inputs?.malus ?? "0");
|
||||
if (isNaN(val) || val < 0 || val > 20) return;
|
||||
if (isNaN(malus) || malus < 0) return;
|
||||
|
||||
const existing = ajustements.find((a) => a.idUE === idUE);
|
||||
const res = existing
|
||||
? await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ valeur: val, malus }),
|
||||
})
|
||||
: await fetch("/notes/api/ajustements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ numEtud, idUE, valeur: val, malus }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated: Ajustement = await res.json();
|
||||
setAjustements((prev) =>
|
||||
existing
|
||||
? prev.map((a) => (a.idUE === idUE ? updated : a))
|
||||
: [...prev, updated]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAjust(idUE: number) {
|
||||
const res = await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
setAjustements((prev) => prev.filter((a) => a.idUE !== idUE));
|
||||
setAjustInputs((prev) => {
|
||||
const c = { ...prev };
|
||||
delete c[idUE];
|
||||
return c;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error && !student) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-error">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!student) return null;
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<a
|
||||
class="back-link"
|
||||
href="/notes/courses"
|
||||
f-partial="/notes/partials/courses"
|
||||
>
|
||||
← Retour a la liste
|
||||
</a>
|
||||
|
||||
<h2
|
||||
class="page-title"
|
||||
style="border-bottom: none; margin-bottom: 0.5rem"
|
||||
>
|
||||
Recap notes – {student.prenom} {student.nom}
|
||||
</h2>
|
||||
|
||||
<div class="info-bar" style="margin-bottom: 1.25rem">
|
||||
<span class="numEtud-chip">{student.numEtud}</span>
|
||||
<span style="font-weight: 600">
|
||||
{student.prenom} {student.nom}
|
||||
</span>
|
||||
<span class="note-chip note-chip--promo">{student.idPromo}</span>
|
||||
</div>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
{ueList.length === 0
|
||||
? (
|
||||
<p class="state-empty">
|
||||
Aucune UE configuree pour cette promotion.
|
||||
</p>
|
||||
)
|
||||
: ueList.map((ue) => {
|
||||
const ueMods = 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">
|
||||
{/* UE header */}
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap">
|
||||
<p class="edit-section-title" style="margin: 0">{ue.nom}</p>
|
||||
{avg !== null && (
|
||||
<span
|
||||
class={noteClass(avg)}
|
||||
style="font-size: 0.78rem"
|
||||
>
|
||||
Moy. calculee : {fmt(avg)}
|
||||
</span>
|
||||
)}
|
||||
{ajust && (
|
||||
<span
|
||||
class="note-chip note-chip--ajust"
|
||||
style="font-size: 0.78rem"
|
||||
>
|
||||
Ajust. actif : {fmt(ajust.valeur)}
|
||||
</span>
|
||||
)}
|
||||
{ajust && ajust.malus > 0 && (
|
||||
<span
|
||||
class="note-chip note-chip--fail"
|
||||
style="font-size: 0.78rem"
|
||||
>
|
||||
Malus : -{ajust.malus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Module rows */}
|
||||
{ueMods.length === 0
|
||||
? (
|
||||
<p
|
||||
class="col-dim"
|
||||
style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem"
|
||||
>
|
||||
Aucun module associe a cette UE pour cette promotion.
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<div style="margin-bottom: 0.75rem">
|
||||
{ueMods.map((um) => {
|
||||
const noteObj = noteMap.get(um.idModule);
|
||||
const noteVal = noteObj?.note;
|
||||
const noteS2 = noteObj?.noteSession2;
|
||||
const effective = noteObj
|
||||
? effectiveNote(noteObj)
|
||||
: undefined;
|
||||
const nomMod = moduleMap.get(um.idModule) ?? um.idModule;
|
||||
|
||||
return (
|
||||
<div key={um.idModule} class="note-row">
|
||||
<span class="note-row-label">
|
||||
<span class="numEtud-chip note-row-chip">
|
||||
{um.idModule}
|
||||
</span>
|
||||
{nomMod}
|
||||
</span>
|
||||
<span class="col-dim note-row-coef">
|
||||
coef {um.coeff}
|
||||
</span>
|
||||
|
||||
{/* Session 1 note */}
|
||||
{editingNote?.idModule === um.idModule &&
|
||||
editingNote.field === "note"
|
||||
? (
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem">
|
||||
<input
|
||||
class="form-input"
|
||||
style="width: 5rem; text-align: center; font-size: 0.85rem"
|
||||
value={editingNote.value}
|
||||
autoFocus
|
||||
onInput={(e) =>
|
||||
setEditingNote({
|
||||
...editingNote,
|
||||
value:
|
||||
(e.target as HTMLInputElement).value,
|
||||
})}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
saveNote(
|
||||
um.idModule,
|
||||
"note",
|
||||
editingNote.value,
|
||||
);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingNote(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() =>
|
||||
saveNote(
|
||||
um.idModule,
|
||||
"note",
|
||||
editingNote.value,
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
class="col-dim"
|
||||
style="font-size: 0.75rem"
|
||||
>
|
||||
/20
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
class={noteVal !== undefined
|
||||
? noteClass(noteVal)
|
||||
: "note-chip note-chip--none"}
|
||||
style="font-size: 0.78rem; cursor: pointer"
|
||||
title="S1 — Cliquer pour modifier"
|
||||
onClick={() =>
|
||||
setEditingNote({
|
||||
idModule: um.idModule,
|
||||
field: "note",
|
||||
value: noteVal !== undefined
|
||||
? String(noteVal)
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
S1:{" "}
|
||||
{noteVal !== undefined ? fmt(noteVal) : "—/20"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Session 2 note */}
|
||||
{editingNote?.idModule === um.idModule &&
|
||||
editingNote.field === "noteSession2"
|
||||
? (
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem">
|
||||
<input
|
||||
class="form-input"
|
||||
style="width: 5rem; text-align: center; font-size: 0.85rem"
|
||||
value={editingNote.value}
|
||||
autoFocus
|
||||
placeholder="vide = suppr"
|
||||
onInput={(e) =>
|
||||
setEditingNote({
|
||||
...editingNote,
|
||||
value:
|
||||
(e.target as HTMLInputElement).value,
|
||||
})}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
saveNote(
|
||||
um.idModule,
|
||||
"noteSession2",
|
||||
editingNote.value,
|
||||
);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingNote(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() =>
|
||||
saveNote(
|
||||
um.idModule,
|
||||
"noteSession2",
|
||||
editingNote.value,
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
class="col-dim"
|
||||
style="font-size: 0.75rem"
|
||||
>
|
||||
/20
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
class={noteS2 != null
|
||||
? noteClass(noteS2)
|
||||
: "note-chip note-chip--none"}
|
||||
style="font-size: 0.78rem; cursor: pointer"
|
||||
title="S2 — Cliquer pour modifier (vide = pas de session 2)"
|
||||
onClick={() =>
|
||||
setEditingNote({
|
||||
idModule: um.idModule,
|
||||
field: "noteSession2",
|
||||
value: noteS2 != null ? String(noteS2) : "",
|
||||
})}
|
||||
>
|
||||
S2: {noteS2 != null ? fmt(noteS2) : "—"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Effective note indicator */}
|
||||
{noteS2 != null && (
|
||||
<span
|
||||
class="col-dim"
|
||||
style="font-size: 0.72rem; font-style: italic"
|
||||
>
|
||||
→ {fmt(effective!)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ajustement + Malus */}
|
||||
<div class="ajust-section">
|
||||
<p class="ajust-title">Ajustement de la moyenne UE</p>
|
||||
<p class="ajust-hint">
|
||||
La valeur remplace la moyenne calculee. Le malus est
|
||||
soustrait.
|
||||
</p>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap">
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem">
|
||||
<span class="col-dim" style="font-size: 0.8rem">
|
||||
Val:
|
||||
</span>
|
||||
<input
|
||||
class="form-input"
|
||||
style="width: 4.5rem; text-align: center"
|
||||
placeholder="—"
|
||||
value={ajustInputs[ue.id]?.valeur ?? ""}
|
||||
onInput={(e) =>
|
||||
setAjustInputs((prev) => ({
|
||||
...prev,
|
||||
[ue.id]: {
|
||||
valeur: (e.target as HTMLInputElement).value,
|
||||
malus: prev[ue.id]?.malus ?? "0",
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
<span class="col-dim" style="font-size: 0.8rem">/20</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem">
|
||||
<span class="col-dim" style="font-size: 0.8rem">
|
||||
Malus:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
style="width: 4rem; text-align: center"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
value={ajustInputs[ue.id]?.malus ?? ""}
|
||||
onInput={(e) =>
|
||||
setAjustInputs((prev) => ({
|
||||
...prev,
|
||||
[ue.id]: {
|
||||
valeur: prev[ue.id]?.valeur ?? "",
|
||||
malus: (e.target as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
onClick={() => applyAjust(ue.id)}
|
||||
>
|
||||
Appliquer
|
||||
</button>
|
||||
{ajust && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
onClick={() => resetAjust(ue.id)}
|
||||
>
|
||||
Reinitialiser
|
||||
</button>
|
||||
<span
|
||||
class="col-dim"
|
||||
style="font-size: 0.75rem; font-family: monospace"
|
||||
>
|
||||
Affiche : {fmt(ajust.valeur)}
|
||||
{ajust.malus > 0
|
||||
? ` - ${ajust.malus} = ${
|
||||
fmt(ajust.valeur - ajust.malus)
|
||||
}`
|
||||
: ""}
|
||||
{avg !== null ? ` (calculee : ${fmt(avg)})` : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Note = {
|
||||
numEtud: number;
|
||||
idModule: string;
|
||||
note: number;
|
||||
noteSession2: number | null;
|
||||
};
|
||||
type UE = { id: number; nom: string };
|
||||
type UEModule = {
|
||||
idModule: string;
|
||||
idUE: number;
|
||||
idPromo: string;
|
||||
coeff: number;
|
||||
};
|
||||
type Module = { id: string; nom: string };
|
||||
type Ajustement = {
|
||||
numEtud: number;
|
||||
idUE: number;
|
||||
valeur: number;
|
||||
malus: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
numEtud: number | null;
|
||||
prenom: string;
|
||||
};
|
||||
|
||||
function scoreClass(score: number | null): string {
|
||||
if (score === null) return "score-none";
|
||||
return score >= 10 ? "score-good" : "score-warn";
|
||||
}
|
||||
|
||||
function avgClass(avg: number | null): string {
|
||||
if (avg === null) return "";
|
||||
return avg >= 10 ? "avg-good" : "avg-warn";
|
||||
}
|
||||
|
||||
/** 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[]>([]);
|
||||
const [ueModules, setUeModules] = useState<UEModule[]>([]);
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [ajustements, setAjustements] = useState<Ajustement[]>([]);
|
||||
const [promos, setPromos] = useState<string[]>([]);
|
||||
const [activePromo, setActivePromo] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (numEtud === null) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([
|
||||
fetch(`/notes/api/notes?numEtud=${numEtud}`),
|
||||
fetch("/admin/api/ues"),
|
||||
fetch("/admin/api/ue-modules"),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
|
||||
]);
|
||||
|
||||
if (!notesRes.ok || !uesRes.ok || !ueModRes.ok) {
|
||||
throw new Error("Erreur lors du chargement");
|
||||
}
|
||||
|
||||
const [notesData, uesData, ueModData, modData, ajData] = await Promise
|
||||
.all([
|
||||
notesRes.json(),
|
||||
uesRes.json(),
|
||||
ueModRes.json(),
|
||||
modRes.ok ? modRes.json() : [],
|
||||
ajRes.ok ? ajRes.json() : [],
|
||||
]);
|
||||
|
||||
setNotes(notesData);
|
||||
setUes(uesData);
|
||||
setUeModules(ueModData);
|
||||
setModules(modData);
|
||||
setAjustements(ajData);
|
||||
|
||||
const noteModuleIds = new Set(notesData.map((n: Note) => n.idModule));
|
||||
const relevantPromos = [
|
||||
...new Set(
|
||||
ueModData
|
||||
.filter((um: UEModule) => noteModuleIds.has(um.idModule))
|
||||
.map((um: UEModule) => um.idPromo),
|
||||
),
|
||||
] as string[];
|
||||
|
||||
setPromos(relevantPromos);
|
||||
if (relevantPromos.length > 0) setActivePromo(relevantPromos[0]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur inconnue");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}, [numEtud]);
|
||||
|
||||
if (numEtud === null) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-empty">
|
||||
Bonjour {prenom}{" "}
|
||||
— aucun dossier etudiant n'est associe a votre compte.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-error">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredUeModules = activePromo
|
||||
? ueModules.filter((um) => um.idPromo === activePromo)
|
||||
: ueModules;
|
||||
|
||||
const ueIds = [...new Set(filteredUeModules.map((um) => um.idUE))];
|
||||
|
||||
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
|
||||
const noteMap = Object.fromEntries(
|
||||
notes.map((n) => [n.idModule, n]),
|
||||
);
|
||||
const ajMap = Object.fromEntries(
|
||||
ajustements.map((a) => [a.idUE, a]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
{promos.length > 1 && (
|
||||
<div class="tabs">
|
||||
{promos.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p}
|
||||
class={`tab-btn${activePromo === p ? " active" : ""}`}
|
||||
onClick={() => setActivePromo(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ueIds.length === 0 && (
|
||||
<p class="state-empty">Aucune note disponible pour cette periode.</p>
|
||||
)}
|
||||
|
||||
{ueIds.map((ueId) => {
|
||||
const ue = ues.find((u) => u.id === ueId);
|
||||
if (!ue) return null;
|
||||
|
||||
const ueModsForUE = filteredUeModules.filter((um) => um.idUE === ueId);
|
||||
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;
|
||||
|
||||
// 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">
|
||||
<div class="ue-card-header">
|
||||
<p class="ue-card-title">UE : {ue.nom}</p>
|
||||
{finalAvg !== null
|
||||
? (
|
||||
<p class={`ue-card-avg ${avgClass(finalAvg)}`}>
|
||||
Moyenne : {finalAvg.toFixed(2)}/20
|
||||
{ajust && ajust.malus > 0 && (
|
||||
<span>(malus : -{ajust.malus})</span>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
: <p class="ue-card-avg avg-warn">Notes non disponibles</p>}
|
||||
</div>
|
||||
|
||||
{ueModsForUE.map((um) => {
|
||||
const mod = moduleMap[um.idModule];
|
||||
const noteObj = noteMap[um.idModule] ?? null;
|
||||
const effective = noteObj ? 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 : "Module inconnu"} (coef {um.coeff})
|
||||
</span>
|
||||
<span class={`score-chip ${scoreClass(effective)}`}>
|
||||
{effective !== null ? `${effective}/20` : "—"}
|
||||
{hasS2 && (
|
||||
<span
|
||||
style="font-size: 0.7rem; opacity: 0.7; margin-left: 0.35rem"
|
||||
title={`Session 1 : ${noteObj!.note}/20`}
|
||||
>
|
||||
(S1: {noteObj!.note})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,13 @@ const properties: AppProperties = {
|
||||
name: "PolyNotes",
|
||||
icon: "school",
|
||||
pages: {
|
||||
index: "Homepage",
|
||||
notes: "Notes",
|
||||
courses: "Courses management",
|
||||
index: "Accueil",
|
||||
notes: "Mes notes",
|
||||
courses: "Consulter",
|
||||
import: "Import Notes",
|
||||
},
|
||||
adminOnly: ["courses", "students"],
|
||||
adminOnly: ["courses", "import"],
|
||||
studentOnly: ["notes"],
|
||||
hint: "Student grading management",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { ajustements } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #48 GET /ajustements
|
||||
async GET(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const numEtudParam = url.searchParams.get("numEtud");
|
||||
const idUEParam = url.searchParams.get("idUE");
|
||||
|
||||
let query = db.select().from(ajustements).$dynamic();
|
||||
|
||||
if (numEtudParam) {
|
||||
const numEtud = parseInt(numEtudParam);
|
||||
if (isNaN(numEtud)) {
|
||||
return new Response("Paramètre numEtud invalide", { status: 400 });
|
||||
}
|
||||
query = query.where(eq(ajustements.numEtud, numEtud));
|
||||
}
|
||||
|
||||
if (idUEParam) {
|
||||
const idUE = parseInt(idUEParam);
|
||||
if (isNaN(idUE)) {
|
||||
return new Response("Paramètre idUE invalide", { status: 400 });
|
||||
}
|
||||
query = query.where(eq(ajustements.idUE, idUE));
|
||||
}
|
||||
|
||||
const result = await query;
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching ajustements:", error);
|
||||
return new Response("Failed to fetch data", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
// #49 POST /ajustements
|
||||
async POST(
|
||||
request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body: {
|
||||
numEtud: number;
|
||||
idUE: number;
|
||||
valeur: number;
|
||||
malus?: number;
|
||||
} = await request.json();
|
||||
|
||||
if (!body.numEtud || !body.idUE || body.valeur === undefined) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Champs requis: numEtud, idUE, valeur" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
body.malus !== undefined &&
|
||||
(!Number.isInteger(body.malus) || body.malus < 0)
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "malus doit être un entier >= 0" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(ajustements)
|
||||
.values({
|
||||
numEtud: body.numEtud,
|
||||
idUE: body.idUE,
|
||||
valeur: body.valeur,
|
||||
malus: body.malus ?? 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return new Response(JSON.stringify(created), {
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating ajustement:", error);
|
||||
return new Response("Failed to create ajustement", { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { ajustements } from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { and, eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ajustement introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
const FORBIDDEN = () => new Response(null, { status: 403 });
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #50 GET /ajustements/{numEtud}/{idUE}
|
||||
async GET(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
const idUE = Number(context.params.idUE);
|
||||
|
||||
if (isNaN(numEtud) || isNaN(idUE)) {
|
||||
return new Response("Paramètres invalides", { status: 400 });
|
||||
}
|
||||
|
||||
const ajustement = await db
|
||||
.select()
|
||||
.from(ajustements)
|
||||
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!ajustement) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(ajustement), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
// #51 PUT /ajustements/{numEtud}/{idUE}
|
||||
async PUT(
|
||||
request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
const idUE = Number(context.params.idUE);
|
||||
|
||||
if (isNaN(numEtud) || isNaN(idUE)) {
|
||||
return new Response("Paramètres invalides", { status: 400 });
|
||||
}
|
||||
|
||||
const body: { valeur: number; malus?: number } = await request.json();
|
||||
|
||||
if (body.valeur === undefined) {
|
||||
return new Response(JSON.stringify({ error: "Champ requis: valeur" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
body.malus !== undefined &&
|
||||
(!Number.isInteger(body.malus) || body.malus < 0)
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "malus doit être un entier >= 0" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const set: { valeur: number; malus?: number } = { valeur: body.valeur };
|
||||
if (body.malus !== undefined) {
|
||||
set.malus = body.malus;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(ajustements)
|
||||
.set(set)
|
||||
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
// #52 DELETE /ajustements/{numEtud}/{idUE}
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
const idUE = Number(context.params.idUE);
|
||||
|
||||
if (isNaN(numEtud) || isNaN(idUE)) {
|
||||
return new Response("Paramètres invalides", { status: 400 });
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(ajustements)
|
||||
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
|
||||
.returning();
|
||||
|
||||
if (!deleted) return NOT_FOUND();
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Handlers } from "$fresh/server.ts";
|
||||
import { db } from "../../../../databases/db.ts";
|
||||
import { notes } from "../../../../databases/schema.ts";
|
||||
import { eq } from "npm:drizzle-orm";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
export const handler: Handlers = {
|
||||
// #42 GET /notes
|
||||
@@ -36,4 +36,60 @@ export const handler: Handlers = {
|
||||
return new Response("Failed to fetch data", { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// #43 POST /notes
|
||||
async POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { note, numEtud, idModule, noteSession2 } = body;
|
||||
|
||||
if (note === undefined || !numEtud || !idModule) {
|
||||
return new Response("Champs 'note', 'numEtud' et 'idModule' requis", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof note !== "number" || note < 0 || note > 20) {
|
||||
return new Response("Champ 'note' doit être un nombre entre 0 et 20", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
noteSession2 !== undefined && noteSession2 !== null &&
|
||||
(typeof noteSession2 !== "number" || noteSession2 < 0 ||
|
||||
noteSession2 > 20)
|
||||
) {
|
||||
return new Response(
|
||||
"Champ 'noteSession2' doit être un nombre entre 0 et 20",
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const values: {
|
||||
note: number;
|
||||
numEtud: number;
|
||||
idModule: string;
|
||||
noteSession2?: number | null;
|
||||
} = {
|
||||
note,
|
||||
numEtud,
|
||||
idModule,
|
||||
};
|
||||
if (noteSession2 !== undefined) {
|
||||
values.noteSession2 = noteSession2;
|
||||
}
|
||||
|
||||
const result = await db.insert(notes).values(values)
|
||||
.returning();
|
||||
|
||||
return new Response(JSON.stringify(result[0]), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating note:", error);
|
||||
return new Response("Failed to create note", { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Handlers } from "$fresh/server.ts";
|
||||
import { db } from "../../../../../../databases/db.ts";
|
||||
import { notes } from "../../../../../../databases/schema.ts";
|
||||
import { and, eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
export const handler: Handlers = {
|
||||
// #45 GET /notes/:numEtud/:idModule
|
||||
async GET(_request, context) {
|
||||
try {
|
||||
const numEtud = parseInt(context.params.numEtud);
|
||||
const { idModule } = context.params;
|
||||
|
||||
if (isNaN(numEtud)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Paramètre numEtud invalide" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.select().from(notes).where(
|
||||
and(
|
||||
eq(notes.numEtud, numEtud),
|
||||
eq(notes.idModule, idModule),
|
||||
),
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result[0]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching note:", error);
|
||||
return new Response("Failed to fetch data", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
// #46 PUT /notes/:numEtud/:idModule
|
||||
async PUT(request, context) {
|
||||
try {
|
||||
const numEtud = parseInt(context.params.numEtud);
|
||||
const { idModule } = context.params;
|
||||
|
||||
if (isNaN(numEtud)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Paramètre numEtud invalide" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { note, noteSession2 } = body;
|
||||
|
||||
if (note === undefined && noteSession2 === undefined) {
|
||||
return new Response("Au moins 'note' ou 'noteSession2' requis", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
note !== undefined &&
|
||||
(typeof note !== "number" || note < 0 || note > 20)
|
||||
) {
|
||||
return new Response("Champ 'note' doit être un nombre entre 0 et 20", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
noteSession2 !== undefined && noteSession2 !== null &&
|
||||
(typeof noteSession2 !== "number" || noteSession2 < 0 ||
|
||||
noteSession2 > 20)
|
||||
) {
|
||||
return new Response(
|
||||
"Champ 'noteSession2' doit être un nombre entre 0 et 20",
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const set: { note?: number; noteSession2?: number | null } = {};
|
||||
if (note !== undefined) set.note = note;
|
||||
if (noteSession2 !== undefined) set.noteSession2 = noteSession2;
|
||||
|
||||
const result = await db.update(notes).set(set).where(
|
||||
and(
|
||||
eq(notes.numEtud, numEtud),
|
||||
eq(notes.idModule, idModule),
|
||||
),
|
||||
).returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result[0]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating note:", error);
|
||||
return new Response("Failed to update note", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
// #47 DELETE /notes/:numEtud/:idModule
|
||||
async DELETE(_request, context) {
|
||||
try {
|
||||
const numEtud = parseInt(context.params.numEtud);
|
||||
const { idModule } = context.params;
|
||||
|
||||
if (isNaN(numEtud)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Paramètre numEtud invalide" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.delete(notes).where(
|
||||
and(
|
||||
eq(notes.numEtud, numEtud),
|
||||
eq(notes.idModule, idModule),
|
||||
),
|
||||
).returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error("Error deleting note:", error);
|
||||
return new Response("Failed to delete note", { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
|
||||
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
|
||||
import { Handlers } from "$fresh/server.ts";
|
||||
import { db } from "../../../../../databases/db.ts";
|
||||
import { notes } from "../../../../../databases/schema.ts";
|
||||
|
||||
export const handler: Handlers = {
|
||||
//# 44 POST /notes/import-xlsx
|
||||
async POST(request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const idModule = formData.get("idModule");
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return new Response("Champ 'file' manquant", { status: 400 });
|
||||
}
|
||||
|
||||
if (!idModule || typeof idModule !== "string") {
|
||||
return new Response("Champ 'idModule' manquant", { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer);
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet) as {
|
||||
numEtud: number;
|
||||
note: number;
|
||||
noteSession2?: number;
|
||||
}[];
|
||||
|
||||
for (const row of rows) {
|
||||
const { numEtud, note, noteSession2 } = row;
|
||||
|
||||
if (!numEtud || note === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const values: {
|
||||
numEtud: number;
|
||||
idModule: string;
|
||||
note: number;
|
||||
noteSession2?: number | null;
|
||||
} = {
|
||||
numEtud,
|
||||
idModule,
|
||||
note,
|
||||
};
|
||||
const set: { note: number; noteSession2?: number | null } = { note };
|
||||
|
||||
if (noteSession2 !== undefined) {
|
||||
values.noteSession2 = noteSession2;
|
||||
set.noteSession2 = noteSession2;
|
||||
}
|
||||
|
||||
await db.insert(notes)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [notes.numEtud, notes.idModule],
|
||||
set,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error("Error importing notes:", error);
|
||||
return new Response("Failed to import notes", { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import NoteRecap from "../(_islands)/NoteRecap.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export default async function EditionPage(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
) {
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
return <NoteRecap numEtud={numEtud} />;
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/routes/_middleware.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminConsultNotes from "../../(_islands)/AdminConsultNotes.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Courses(_request: Request, context: FreshContext<State>) {
|
||||
return <h2>Welcome to {context.state.session?.displayName}.</h2>;
|
||||
async function Courses(_request: Request, _context: FreshContext<State>) {
|
||||
return <AdminConsultNotes />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import ImportNotes from "../../(_islands)/ImportNotes.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function ImportNotesPage(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Importer des Notes</h2>
|
||||
<ImportNotes />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(ImportNotesPage);
|
||||
@@ -3,11 +3,53 @@ import {
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/routes/_middleware.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export async function Index(_request: Request, context: FreshContext<State>) {
|
||||
return <h2>Welcome to {context.state.session?.displayName}.</h2>;
|
||||
export async function Index(
|
||||
_request: Request,
|
||||
context: FreshContext<State>,
|
||||
) {
|
||||
const isEmployee =
|
||||
(context.state as unknown as { session: Record<string, string> }).session
|
||||
.eduPersonPrimaryAffiliation === "employee";
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">PolyNotes</h2>
|
||||
<p>
|
||||
Bienvenue{" "}
|
||||
<strong>
|
||||
{(context.state as unknown as { session: Record<string, string> })
|
||||
.session.displayName}
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
{isEmployee
|
||||
? (
|
||||
<p>
|
||||
Consultez les{" "}
|
||||
<a href="/notes/courses" f-partial="/notes/partials/courses">
|
||||
notes des élèves
|
||||
</a>{" "}
|
||||
ou gérez les{" "}
|
||||
<a href="/notes/ues" f-partial="/notes/partials/ues">
|
||||
UEs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p>
|
||||
Consultez vos{" "}
|
||||
<a href="/notes/notes" f-partial="/notes/partials/notes">
|
||||
notes
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
|
||||
@@ -1,13 +1,57 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { students } from "$root/databases/schema.ts";
|
||||
import { and, eq } from "npm:drizzle-orm@0.45.2";
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/routes/_middleware.ts";
|
||||
import { CasContent, State } from "$root/defaults/interfaces.ts";
|
||||
import NotesView from "../(_islands)/NotesView.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Notes(_request: Request, context: FreshContext<State>) {
|
||||
return <h2>Welcome to {context.state.session?.displayName}.</h2>;
|
||||
async function Notes(
|
||||
_request: Request,
|
||||
context: FreshContext<State>,
|
||||
) {
|
||||
const session = (context.state as unknown as { session: CasContent }).session;
|
||||
|
||||
let numEtud: number | null = null;
|
||||
try {
|
||||
if (session.eduPersonPrimaryAffiliation === "student") {
|
||||
// Students: uid is "<letter>21212006" in AMU CAS — strip non-digit prefix
|
||||
const etudId = parseInt(session.uid.replace(/^\D+/, ""), 10);
|
||||
if (!isNaN(etudId)) {
|
||||
const student = await db
|
||||
.select()
|
||||
.from(students)
|
||||
.where(eq(students.numEtud, etudId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
numEtud = student?.numEtud ?? null;
|
||||
}
|
||||
} else {
|
||||
// Employees: look up by nom/prenom
|
||||
const student = await db
|
||||
.select()
|
||||
.from(students)
|
||||
.where(
|
||||
and(
|
||||
eq(students.nom, session.sn),
|
||||
eq(students.prenom, session.givenName),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
numEtud = student?.numEtud ?? null;
|
||||
}
|
||||
} catch {
|
||||
// DB lookup failed — island will show fallback message
|
||||
}
|
||||
|
||||
return (
|
||||
<NotesView
|
||||
numEtud={numEtud}
|
||||
prenom={session.givenName || session.displayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import NoteRecap from "../(_islands)/NoteRecap.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export default async function RecapPage(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
) {
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
return <NoteRecap numEtud={numEtud} />;
|
||||
}
|
||||
@@ -1,45 +1,313 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import Promotion from "$root/routes/(apps)/students/(_components)/Promotion.tsx";
|
||||
|
||||
type SingleUserResponse = { promo: Promotion; student: Student };
|
||||
type ManyUsersResponse = { promos: Promotion[]; students: Student[] };
|
||||
|
||||
type APIResponse = SingleUserResponse | ManyUsersResponse;
|
||||
type Student = {
|
||||
numEtud: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
idPromo: string;
|
||||
};
|
||||
type Promotion = { id: string; annee: string };
|
||||
|
||||
export default function ConsultStudents() {
|
||||
const [data, setData] = useState<APIResponse | null>(null);
|
||||
const [students, setStudents] = useState<Student[]>([]);
|
||||
const [promos, setPromos] = useState<Promotion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterPromo, setFilterPromo] = useState("");
|
||||
const [filterNom, setFilterNom] = useState("");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkPromo, setBulkPromo] = useState("");
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [sRes, pRes] = await Promise.all([
|
||||
fetch("/students/api/students"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!sRes.ok) throw new Error("Impossible de charger les élèves");
|
||||
setStudents(await sRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const response = await fetch("/students/api/students");
|
||||
if (!response.ok) {
|
||||
setError("Failed to load data. Please try again later.");
|
||||
}
|
||||
|
||||
const result: APIResponse = await response.json();
|
||||
setData(result);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function deleteStudent(numEtud: number) {
|
||||
if (!confirm(`Supprimer l'élève #${numEtud} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/students/api/students/${numEtud}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(numEtud);
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
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 filteredIds = new Set(filtered.map((s) => s.numEtud));
|
||||
const selectedInView = [...selected].filter((id) => filteredIds.has(id));
|
||||
const allFilteredSelected = filtered.length > 0 &&
|
||||
filtered.every((s) => selected.has(s.numEtud));
|
||||
|
||||
function toggleOne(numEtud: number) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(numEtud)) next.delete(numEtud);
|
||||
else next.add(numEtud);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allFilteredSelected) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const s of filtered) next.delete(s.numEtud);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const s of filtered) next.add(s.numEtud);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
const count = selectedInView.length;
|
||||
if (count === 0) return;
|
||||
if (
|
||||
!confirm(`Supprimer définitivement ${count} élève(s) sélectionné(s) ?`)
|
||||
) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
selectedInView.map((id) =>
|
||||
fetch(`/students/api/students/${id}`, { method: "DELETE" })
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
if (failed > 0) setError(`${failed} suppression(s) échouée(s)`);
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkChangePromo() {
|
||||
if (!bulkPromo || selectedInView.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
selectedInView.map((id) =>
|
||||
fetch(`/students/api/students/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ idPromo: bulkPromo }),
|
||||
})
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
if (failed > 0) setError(`${failed} modification(s) échouée(s)`);
|
||||
setSelected(new Set());
|
||||
setBulkPromo("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{data && ((Object.hasOwn(data, "student"))
|
||||
? (
|
||||
<Promotion
|
||||
students={[(data as SingleUserResponse).student]}
|
||||
promo={(data as SingleUserResponse).promo}
|
||||
/>
|
||||
)
|
||||
: (data as ManyUsersResponse).promos.map((promo) => (
|
||||
<Promotion
|
||||
students={(data as ManyUsersResponse).students}
|
||||
promo={promo}
|
||||
/>
|
||||
)))}
|
||||
</>
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Élèves</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="toolbar">
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
href="/students/upload"
|
||||
f-partial="/students/partials/upload"
|
||||
style="margin-left: auto"
|
||||
>
|
||||
Importer xlsx
|
||||
</a>
|
||||
</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} — {p.annee}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
class="filter-input"
|
||||
placeholder="Rechercher par nom…"
|
||||
value={filterNom}
|
||||
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedInView.length > 0 && (
|
||||
<div class="bulk-bar">
|
||||
<span class="bulk-count">
|
||||
{selectedInView.length} sélectionné(s)
|
||||
</span>
|
||||
<div class="bulk-actions">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={bulkPromo}
|
||||
onChange={(e) =>
|
||||
setBulkPromo((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Changer de promo…</option>
|
||||
{promos.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
disabled={!bulkPromo || bulkBusy}
|
||||
onClick={bulkChangePromo}
|
||||
>
|
||||
Appliquer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
disabled={bulkBusy}
|
||||
onClick={bulkDelete}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2.5rem">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allFilteredSelected && filtered.length > 0}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<th>N° étud.</th>
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
<th>Promo</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={6} class="state-empty">
|
||||
Aucun élève trouvé
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filtered.map((s) => (
|
||||
<tr
|
||||
key={s.numEtud}
|
||||
class={selected.has(s.numEtud) ? "row-selected" : ""}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(s.numEtud)}
|
||||
onChange={() => toggleOne(s.numEtud)}
|
||||
/>
|
||||
</td>
|
||||
<td class="col-dim">{s.numEtud}</td>
|
||||
<td>{s.nom}</td>
|
||||
<td>{s.prenom}</td>
|
||||
<td>{s.idPromo}</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<a
|
||||
class="btn btn-sm btn-secondary"
|
||||
href={`/students/edit/${s.numEtud}`}
|
||||
f-client-nav={false}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteStudent(s.numEtud)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect x="5" y="6" width="14" height="16" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Student = {
|
||||
numEtud: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
idPromo: string;
|
||||
};
|
||||
type Promo = { id: string; annee: string };
|
||||
type Module = { id: string; nom: string };
|
||||
|
||||
type Props = { numEtud: number };
|
||||
|
||||
function anneeLabel(idPromo: string): string {
|
||||
const m = idPromo.match(/^(\d+)A/);
|
||||
if (!m) return "";
|
||||
const n = m[1];
|
||||
if (n === "3") return "3ème année";
|
||||
if (n === "4") return "4ème année";
|
||||
if (n === "5") return "5ème année";
|
||||
return `${n}ème année`;
|
||||
}
|
||||
|
||||
export default function EditStudents({ numEtud }: Props) {
|
||||
const [student, setStudent] = useState<Student | null>(null);
|
||||
const [promos, setPromos] = useState<Promo[]>([]);
|
||||
const [_modules, setModules] = useState<Module[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Edit form state
|
||||
const [nom, setNom] = useState("");
|
||||
const [prenom, setPrenom] = useState("");
|
||||
const [idPromo, setIdPromo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [sRes, pRes, mRes] = await Promise.all([
|
||||
fetch(`/students/api/students/${numEtud}`),
|
||||
fetch("/students/api/promotions"),
|
||||
fetch("/admin/api/modules"),
|
||||
]);
|
||||
if (!sRes.ok) throw new Error("Élève introuvable");
|
||||
const s: Student = await sRes.json();
|
||||
setStudent(s);
|
||||
setNom(s.nom);
|
||||
setPrenom(s.prenom);
|
||||
setIdPromo(s.idPromo);
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
if (mRes.ok) setModules(await mRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [numEtud]);
|
||||
|
||||
async function saveInfos() {
|
||||
if (!student) return;
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
const res = await fetch(`/students/api/students/${numEtud}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
nom: nom.trim(),
|
||||
prenom: prenom.trim(),
|
||||
idPromo,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Modification échouée");
|
||||
const updated: Student = await res.json();
|
||||
setStudent(updated);
|
||||
setSaveMsg("Informations enregistrées.");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStudent() {
|
||||
if (!confirm(`Supprimer définitivement l'élève #${numEtud} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/students/api/students/${numEtud}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
globalThis.location.href = "/students/consult";
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !student) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-error">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!student) return null;
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<a
|
||||
class="back-link"
|
||||
href="/students/consult"
|
||||
f-partial="/students/partials/consult"
|
||||
>
|
||||
← Retour à la liste
|
||||
</a>
|
||||
|
||||
<h2 class="page-title" style="border-bottom: none; margin-bottom: 0.5rem">
|
||||
Édition – {student.prenom} {student.nom}
|
||||
</h2>
|
||||
|
||||
{/* Info bar */}
|
||||
<div class="info-bar">
|
||||
<span class="numEtud-chip">{student.numEtud}</span>
|
||||
<span>{student.idPromo}</span>
|
||||
<span class="col-dim">{anneeLabel(student.idPromo)}</span>
|
||||
</div>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
{saveMsg && (
|
||||
<p style="font-size: 0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.5rem">
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Section 1: Informations générales */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Informations générales</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>Nom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={nom}
|
||||
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Prénom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={prenom}
|
||||
onInput={(e) => setPrenom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>N° Étudiant</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={student.numEtud}
|
||||
disabled
|
||||
style="opacity: 0.6"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Promo</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={idPromo}
|
||||
onChange={(e) =>
|
||||
setIdPromo((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0"
|
||||
>
|
||||
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}
|
||||
</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: space-between; flex-wrap: wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={saveInfos}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "…" : "Enregistrer infos"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onClick={deleteStudent}
|
||||
>
|
||||
Supprimer l'élève
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Spécialisations */}
|
||||
<div class="edit-section">
|
||||
<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">
|
||||
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}
|
||||
>
|
||||
Récap notes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +1,175 @@
|
||||
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
|
||||
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
|
||||
import { Signal, useSignal } from "@preact/signals";
|
||||
import { useRef } from "preact/hooks";
|
||||
import { useSignal } from "@preact/signals";
|
||||
import ImportResultPopup, {
|
||||
type ImportDetail,
|
||||
type ImportResult,
|
||||
} from "$root/defaults/ImportResultPopup.tsx";
|
||||
|
||||
/**
|
||||
* Create a new handler for file change that displays
|
||||
* messages in statusMessage and gets file data in fileData.
|
||||
* @param statusMessage The status message signal.
|
||||
* @param fileData The file data signal.
|
||||
* @returns The file change handler.
|
||||
*/
|
||||
function getFileChangeHandler(
|
||||
statusMessage: Signal<string>,
|
||||
fileData: Signal<File | null>,
|
||||
): (event: Event) => void {
|
||||
/**
|
||||
* Handle file change.
|
||||
* @param event The file change event.
|
||||
*/
|
||||
return (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
fileData.value = input.files[0];
|
||||
statusMessage.value = `File selected: ${input.files[0].name}`;
|
||||
} else {
|
||||
fileData.value = null;
|
||||
statusMessage.value = "No file selected";
|
||||
}
|
||||
};
|
||||
}
|
||||
export default function UploadStudents() {
|
||||
const file = useSignal<File | null>(null);
|
||||
const dragging = useSignal(false);
|
||||
const uploading = useSignal(false);
|
||||
const error = useSignal<string | null>(null);
|
||||
const importResult = useSignal<ImportResult | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
/**
|
||||
* Create a new handler that sends data file to server.
|
||||
* @param statusMessage The status message signal.
|
||||
* @param fileData The file data signal.
|
||||
* @returns The file confirmation handler.
|
||||
*/
|
||||
function getUploadConfirmationFunction(
|
||||
statusMessage: Signal<string>,
|
||||
fileData: Signal<File | null>,
|
||||
): () => void {
|
||||
/**
|
||||
* Add students to database.
|
||||
* @returns Confirm upload of students.
|
||||
*/
|
||||
return () => {
|
||||
if (!fileData.value) {
|
||||
statusMessage.value = "Please select a file before confirming upload.";
|
||||
function pickFile(f: File) {
|
||||
if (!f.name.match(/\.xlsx?$/i)) {
|
||||
error.value = "Fichier invalide — format attendu : .xlsx";
|
||||
return;
|
||||
}
|
||||
file.value = f;
|
||||
error.value = null;
|
||||
importResult.value = null;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragging.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send all data to the server.
|
||||
* @param event The finished progress event.
|
||||
*/
|
||||
reader.onload = async (event: ProgressEvent<FileReader>) => {
|
||||
const arrayBuffer = event.target!.result as ArrayBuffer;
|
||||
function onDragLeave() {
|
||||
dragging.value = false;
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragging.value = false;
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}
|
||||
|
||||
function onInputChange(e: Event) {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!file.value) return;
|
||||
uploading.value = true;
|
||||
error.value = null;
|
||||
importResult.value = null;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.value.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
let allOK = true;
|
||||
let added = 0;
|
||||
let errors = 0;
|
||||
const details: ImportDetail[] = [];
|
||||
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(sheet, {
|
||||
header: ["userId", "lastName", "firstName", "mail"],
|
||||
range: 1,
|
||||
const rows = XLSX.utils.sheet_to_json<{
|
||||
nom: string;
|
||||
prenom: string;
|
||||
numEtud: number;
|
||||
idPromo: string;
|
||||
}>(sheet, {
|
||||
header: ["nom", "prenom", "numEtud", "idPromo"],
|
||||
range: 2,
|
||||
});
|
||||
|
||||
const response = await fetch("/students/api/students", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ promoName: sheetName, data }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
allOK = false;
|
||||
for (const row of rows) {
|
||||
const res = await fetch("/students/api/students", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (res.ok) {
|
||||
added++;
|
||||
details.push({
|
||||
type: "change",
|
||||
message:
|
||||
`${row.numEtud} : ${row.nom} ${row.prenom} -> ${row.idPromo}`,
|
||||
});
|
||||
} else {
|
||||
errors++;
|
||||
const body = await res.json().catch(() => ({}));
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `${row.numEtud} : ${body.error ?? "Erreur creation"}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusMessage.value = allOK
|
||||
? "Failed to insert all data."
|
||||
: "Data uploaded and inserted successfully!";
|
||||
};
|
||||
importResult.value = {
|
||||
added,
|
||||
modified: 0,
|
||||
ignored: 0,
|
||||
errors,
|
||||
details,
|
||||
};
|
||||
} catch {
|
||||
error.value = "Erreur lors de la lecture du fichier.";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error message if any.
|
||||
*/
|
||||
reader.onerror = () => {
|
||||
statusMessage.value = "Error reading the file.";
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(fileData.value);
|
||||
};
|
||||
}
|
||||
|
||||
export default function UploadStudents() {
|
||||
const statusMessage = useSignal<string>("");
|
||||
const fileData = useSignal<File | null>(null);
|
||||
|
||||
const handleFileChange = getFileChangeHandler(statusMessage, fileData);
|
||||
const confirmUpload = getUploadConfirmationFunction(statusMessage, fileData);
|
||||
function downloadTemplate() {
|
||||
globalThis.open("/templates/modele_etudiants.xlsx", "_blank");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input type="file" accept=".xlsx, .xls" onChange={handleFileChange} />
|
||||
<button type="button" onClick={confirmUpload}>Confirm Upload</button>
|
||||
<p>{statusMessage.value}</p>
|
||||
</>
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style="display:none"
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
class={`drop-zone${dragging.value ? " dragging" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<span class="drop-zone-icon">⬇</span>
|
||||
{file.value ? <span class="drop-zone-file">{file.value.name}</span> : (
|
||||
<>
|
||||
<span class="drop-zone-text">Glisser le fichier .xlsx ici</span>
|
||||
<span class="drop-zone-hint">ou cliquer pour parcourir</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error.value && <p class="state-error">{error.value}</p>}
|
||||
|
||||
{importResult.value && (
|
||||
<ImportResultPopup
|
||||
result={importResult.value}
|
||||
onClose={() => (importResult.value = null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class="upload-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={doImport}
|
||||
disabled={!file.value || uploading.value}
|
||||
>
|
||||
{uploading.value ? "…" : "⊕ Importer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={downloadTemplate}
|
||||
>
|
||||
⊕ Télécharger Modèle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="upload-format">
|
||||
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
|
||||
<strong>Numero-etudiant</strong> | <strong>Promotion</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ const properties: AppProperties = {
|
||||
name: "Students",
|
||||
icon: "badge",
|
||||
pages: {
|
||||
index: "Homepage",
|
||||
upload: "Upload students",
|
||||
consult: "Consult students",
|
||||
index: "Accueil",
|
||||
consult: "Élèves",
|
||||
upload: "Import xlsx",
|
||||
},
|
||||
adminOnly: ["upload", "consult"],
|
||||
adminOnly: ["consult", "upload"],
|
||||
hint: "Create students promotion and see informations",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { promotions } from "$root/databases/schema.ts";
|
||||
import {
|
||||
ajustements,
|
||||
enseignements,
|
||||
modules,
|
||||
notes,
|
||||
promotions,
|
||||
students,
|
||||
ueModules,
|
||||
ues,
|
||||
} from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
const FORBIDDEN = new Response(null, { status: 403 });
|
||||
const FORBIDDEN = () => new Response(null, { status: 403 });
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #15 GET /promotions/{idPromo}
|
||||
@@ -18,7 +28,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const promo = await db
|
||||
@@ -27,7 +37,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(promotions.id, context.params.idPromo))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!promo) return NOT_FOUND;
|
||||
if (!promo) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(promo), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -40,7 +50,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const body: { annee: string } = await request.json();
|
||||
@@ -51,7 +61,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(promotions.id, context.params.idPromo))
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND;
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -59,20 +69,104 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
},
|
||||
|
||||
// #17 DELETE /promotions/{idPromo}
|
||||
// Blocked if students are still assigned (409).
|
||||
// Cascade: deletes linked ue_modules, enseignements, and orphaned
|
||||
// modules (+ their notes) & UEs (+ their ajustements).
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(promotions)
|
||||
.where(eq(promotions.id, context.params.idPromo))
|
||||
.returning();
|
||||
const idPromo = context.params.idPromo;
|
||||
|
||||
if (!deleted) return NOT_FOUND;
|
||||
const promo = await db
|
||||
.select()
|
||||
.from(promotions)
|
||||
.where(eq(promotions.id, idPromo))
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (!promo) return NOT_FOUND();
|
||||
|
||||
// Block deletion if students are still assigned
|
||||
const assignedStudents = await db
|
||||
.select()
|
||||
.from(students)
|
||||
.where(eq(students.idPromo, idPromo))
|
||||
.then((r) => r.length);
|
||||
|
||||
if (assignedStudents > 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
`Impossible de supprimer : ${assignedStudents} étudiant(s) encore assigné(s) à cette promotion`,
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Collect linked module IDs and UE IDs before deleting junction rows
|
||||
const linkedUeModules = await tx
|
||||
.select({ idModule: ueModules.idModule, idUE: ueModules.idUE })
|
||||
.from(ueModules)
|
||||
.where(eq(ueModules.idPromo, idPromo));
|
||||
|
||||
const linkedEns = await tx
|
||||
.select({ idModule: enseignements.idModule })
|
||||
.from(enseignements)
|
||||
.where(eq(enseignements.idPromo, idPromo));
|
||||
|
||||
const moduleIds = [
|
||||
...new Set([
|
||||
...linkedUeModules.map((um) => um.idModule),
|
||||
...linkedEns.map((e) => e.idModule),
|
||||
]),
|
||||
];
|
||||
const ueIds = [...new Set(linkedUeModules.map((um) => um.idUE))];
|
||||
|
||||
// Delete junction rows that directly reference this promo
|
||||
await tx.delete(ueModules).where(eq(ueModules.idPromo, idPromo));
|
||||
await tx.delete(enseignements).where(eq(enseignements.idPromo, idPromo));
|
||||
|
||||
// Delete orphaned modules (not used by another promo) and their notes
|
||||
for (const modId of moduleIds) {
|
||||
const stillInUeModules = await tx
|
||||
.select()
|
||||
.from(ueModules)
|
||||
.where(eq(ueModules.idModule, modId))
|
||||
.then((r) => r.length > 0);
|
||||
const stillInEns = await tx
|
||||
.select()
|
||||
.from(enseignements)
|
||||
.where(eq(enseignements.idModule, modId))
|
||||
.then((r) => r.length > 0);
|
||||
|
||||
if (!stillInUeModules && !stillInEns) {
|
||||
await tx.delete(notes).where(eq(notes.idModule, modId));
|
||||
await tx.delete(modules).where(eq(modules.id, modId));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned UEs (not used by another promo) and their ajustements
|
||||
for (const ueId of ueIds) {
|
||||
const stillUsed = await tx
|
||||
.select()
|
||||
.from(ueModules)
|
||||
.where(eq(ueModules.idUE, ueId))
|
||||
.then((r) => r.length > 0);
|
||||
|
||||
if (!stillUsed) {
|
||||
await tx.delete(ajustements).where(eq(ajustements.idUE, ueId));
|
||||
await tx.delete(ues).where(eq(ues.id, ueId));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the promotion
|
||||
await tx.delete(promotions).where(eq(promotions.id, idPromo));
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
|
||||
@@ -44,13 +44,25 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
idPromo: string;
|
||||
} = await request.json();
|
||||
|
||||
if (!body.nom || !body.prenom || !body.idPromo) {
|
||||
if (!body.nom || !body.prenom) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
const values: {
|
||||
numEtud?: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
idPromo?: string;
|
||||
} = {
|
||||
nom: body.nom,
|
||||
prenom: body.prenom,
|
||||
};
|
||||
if (body.numEtud) values.numEtud = body.numEtud;
|
||||
if (body.idPromo) values.idPromo = body.idPromo;
|
||||
|
||||
const [created] = await db
|
||||
.insert(students)
|
||||
.values({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo })
|
||||
.values(values)
|
||||
.returning();
|
||||
|
||||
return new Response(JSON.stringify(created), {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { students } from "$root/databases/schema.ts";
|
||||
import {
|
||||
ajustements,
|
||||
mobility,
|
||||
notes,
|
||||
students,
|
||||
} from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
const FORBIDDEN = new Response(null, { status: 403 });
|
||||
const FORBIDDEN = () => new Response(null, { status: 403 });
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// #10 GET /students/{numEtud}
|
||||
@@ -18,7 +24,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
@@ -28,7 +34,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(students.numEtud, numEtud))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!student) return NOT_FOUND;
|
||||
if (!student) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(student), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -41,20 +47,32 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
const body: { nom: string; prenom: string; idPromo: string } = await request
|
||||
.json();
|
||||
const body: { nom?: string; prenom?: string; idPromo?: string } =
|
||||
await request.json();
|
||||
|
||||
const set: { nom?: string; prenom?: string; idPromo?: string } = {};
|
||||
if (body.nom !== undefined) set.nom = body.nom;
|
||||
if (body.prenom !== undefined) set.prenom = body.prenom;
|
||||
if (body.idPromo !== undefined) set.idPromo = body.idPromo;
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Au moins un champ requis" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(students)
|
||||
.set({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo })
|
||||
.set(set)
|
||||
.where(eq(students.numEtud, numEtud))
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND;
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -62,21 +80,31 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
},
|
||||
|
||||
// #12 DELETE /students/{numEtud}
|
||||
// Cascade: deletes notes, ajustements, mobility for this student.
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
const [deleted] = await db
|
||||
.delete(students)
|
||||
.where(eq(students.numEtud, numEtud))
|
||||
.returning();
|
||||
|
||||
if (!deleted) return NOT_FOUND;
|
||||
const student = await db
|
||||
.select()
|
||||
.from(students)
|
||||
.where(eq(students.numEtud, numEtud))
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (!student) return NOT_FOUND();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(notes).where(eq(notes.numEtud, numEtud));
|
||||
await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud));
|
||||
await tx.delete(mobility).where(eq(mobility.studentId, numEtud));
|
||||
await tx.delete(students).where(eq(students.numEtud, numEtud));
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import EditStudents from "../(_islands)/EditStudents.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export default async function EditPage(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
) {
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
return <EditStudents numEtud={numEtud} />;
|
||||
}
|
||||
@@ -8,12 +8,7 @@ import { State } from "$root/defaults/interfaces.ts";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Students(_request: Request, _context: FreshContext<State>) {
|
||||
return (
|
||||
<>
|
||||
<h2>Consult students</h2>
|
||||
<ConsultStudents />
|
||||
</>
|
||||
);
|
||||
return <ConsultStudents />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
|
||||
@@ -9,10 +9,10 @@ import { State } from "$root/defaults/interfaces.ts";
|
||||
// deno-lint-ignore require-await
|
||||
async function Students(_request: Request, _context: FreshContext<State>) {
|
||||
return (
|
||||
<>
|
||||
<h2>Upload Students</h2>
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Importer des Élèves</h2>
|
||||
<UploadStudents />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,44 @@ import {
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import SelfPortrait from "$root/routes/(apps)/students/(_components)/SelfPortrait.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export async function Index(_request: Request, context: FreshContext<State>) {
|
||||
export async function Index(
|
||||
_request: Request,
|
||||
context: FreshContext<State>,
|
||||
) {
|
||||
const isEmployee =
|
||||
(context.state as unknown as { session: Record<string, string> }).session
|
||||
.eduPersonPrimaryAffiliation === "employee";
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Welcome {context.state.session?.givenName}!</h2>
|
||||
<h3>Your amU identity</h3>
|
||||
<SelfPortrait self={context.state.session!} />
|
||||
</>
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Étudiants</h2>
|
||||
<p>
|
||||
Bienvenue{" "}
|
||||
<strong>
|
||||
{(context.state as unknown as { session: Record<string, string> })
|
||||
.session.displayName}
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
{isEmployee && (
|
||||
<p>
|
||||
Consultez la{" "}
|
||||
<a href="/students/consult" f-partial="/students/partials/consult">
|
||||
liste des élèves
|
||||
</a>{" "}
|
||||
ou gérez les{" "}
|
||||
<a
|
||||
href="/students/promotions"
|
||||
f-partial="/students/partials/promotions"
|
||||
>
|
||||
promotions
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -26,8 +26,9 @@ export default async function App(
|
||||
/>
|
||||
<link rel="stylesheet" href="/styles/main.css" />
|
||||
<link rel="stylesheet" href="/styles/app.css" />
|
||||
<link rel="stylesheet" href="styles/app-cards.css" />
|
||||
<link rel="stylesheet" href="styles/students.css" />
|
||||
<link rel="stylesheet" href="/styles/app-cards.css" />
|
||||
<link rel="stylesheet" href="/styles/students.css" />
|
||||
<link rel="stylesheet" href="/styles/ui.css" />
|
||||
</head>
|
||||
<body f-client-nav>
|
||||
<Header link={link} />
|
||||
|
||||
@@ -10,6 +10,7 @@ const PUBLIC_ROUTES = [
|
||||
"/about",
|
||||
"/partials/about",
|
||||
"/contact",
|
||||
"/dev-login",
|
||||
];
|
||||
|
||||
const jwtKeyCache: Record<string, string> = {};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { CasContent, LoginJWT, State } from "$root/defaults/interfaces.ts";
|
||||
import { createJwt } from "@popov/jwt";
|
||||
import { setCookie } from "$std/http/cookie.ts";
|
||||
import { getKey } from "$root/routes/_middleware.ts";
|
||||
|
||||
function makeFakeUser(
|
||||
role: "employee" | "student",
|
||||
numEtud?: string,
|
||||
): CasContent {
|
||||
if (role === "student" && numEtud) {
|
||||
return {
|
||||
amuCampus: "local",
|
||||
amuComposante: "local",
|
||||
amuDateValidation: "",
|
||||
coGroup: "",
|
||||
eduPersonPrimaryAffiliation: "student",
|
||||
eduPersonPrincipalName: `${numEtud}@local`,
|
||||
mail: `${numEtud}@local`,
|
||||
displayName: `Etudiant ${numEtud}`,
|
||||
givenName: "",
|
||||
memberOf: [],
|
||||
sn: "",
|
||||
supannCivilite: "",
|
||||
supannEntiteAffectation: "",
|
||||
supannEtuAnneeInscription: "",
|
||||
supannEtuEtape: "",
|
||||
uid: `e${numEtud}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
amuCampus: "local",
|
||||
amuComposante: "local",
|
||||
amuDateValidation: "",
|
||||
coGroup: "",
|
||||
eduPersonPrimaryAffiliation: "employee",
|
||||
eduPersonPrincipalName: "admin@local",
|
||||
mail: "admin@local",
|
||||
displayName: "Admin Local",
|
||||
givenName: "Admin",
|
||||
memberOf: [],
|
||||
sn: "Local",
|
||||
supannCivilite: "",
|
||||
supannEntiteAffectation: "",
|
||||
supannEtuAnneeInscription: "",
|
||||
supannEtuEtape: "",
|
||||
uid: "admin-local",
|
||||
};
|
||||
}
|
||||
|
||||
export const handler: Handlers<null, State> = {
|
||||
async GET(request: Request, _context: FreshContext<State, null>) {
|
||||
if (Deno.env.get("LOCAL") !== "true") {
|
||||
return new Response("Not available outside LOCAL mode.", { status: 403 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const role = url.searchParams.get("role") === "student"
|
||||
? "student"
|
||||
: "employee";
|
||||
const numEtud = url.searchParams.get("numEtud") ?? undefined;
|
||||
const user = makeFakeUser(role, numEtud);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: LoginJWT = {
|
||||
iss: "PolyMPR",
|
||||
iat: now,
|
||||
exp: now + 0xe10,
|
||||
aud: "PolyMPR",
|
||||
user,
|
||||
};
|
||||
|
||||
const token = await createJwt(payload, getKey(user.uid));
|
||||
const headers = new Headers();
|
||||
setCookie(headers, { name: "sessionToken", value: token });
|
||||
headers.set("Location", "/apps");
|
||||
|
||||
return new Response(null, { status: 302, headers });
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
|
||||
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
|
||||
|
||||
// --- Template 1: Students ---
|
||||
{
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet([
|
||||
[null, null, null, "Promotion peut etre vide mais doit prealablement Exister"],
|
||||
["Nom", "Prenom", "Numero-etudiant", "Promotion"],
|
||||
["NOM", "PRENOM", 12345678, "3AFISE24-25"],
|
||||
]);
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Eleves");
|
||||
XLSX.writeFile(wb, "static/templates/modele_etudiants.xlsx");
|
||||
console.log("Created static/templates/modele_etudiants.xlsx");
|
||||
}
|
||||
|
||||
// --- Template 2: Notes ---
|
||||
{
|
||||
const headers = [
|
||||
null,
|
||||
null,
|
||||
"MOD01 - Module 1",
|
||||
"MOD02 - Module 2",
|
||||
"MOD03 - Module 3",
|
||||
];
|
||||
const coeffs = [null, null, 2, 3, 2];
|
||||
const row1 = ["NOM", "PRENOM", 12, 15.5, 14];
|
||||
const row2 = ["DUPONT", "JEAN", 8, 10, 16.5];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, coeffs, row1, row2]);
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Session 1");
|
||||
XLSX.writeFile(wb, "static/templates/modele_notes.xlsx");
|
||||
console.log("Created static/templates/modele_notes.xlsx");
|
||||
}
|
||||
|
||||
// --- Template 3: Maquette ---
|
||||
{
|
||||
const data = [
|
||||
["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."],
|
||||
["Description des UE du diplome", null, null, null, null, null, "Nombre d'heures"],
|
||||
["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\n ECTS", "Coeff.", "CM", "TD", "TP"],
|
||||
["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"],
|
||||
["SEM 5", null, null, null, 30],
|
||||
["UE", "CODE_UE1", "Nom de l'UE 1", null, 6],
|
||||
[null, "MOD01", null, "Module 1", null, 2, 10, 10, 10],
|
||||
[null, "MOD02", null, "Module 2", null, 2, 10, 10, 10],
|
||||
[null, "MOD03", null, "Module 3", null, 2, 10, 10, 10],
|
||||
[],
|
||||
["UE", "CODE_UE2", "Nom de l'UE 2", null, 4],
|
||||
[null, "MOD04", null, "Module 4", null, 2, 10, 10, 10],
|
||||
[null, "MOD05", null, "Module 5", null, 2, 10, 10, 10],
|
||||
];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Maquette");
|
||||
XLSX.writeFile(wb, "static/templates/modele_maquette.xlsx");
|
||||
console.log("Created static/templates/modele_maquette.xlsx");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
|
||||
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
|
||||
|
||||
for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) {
|
||||
console.log(`\n=== ${file} ===`);
|
||||
const wb = XLSX.read(Deno.readFileSync(`Excels/${file}`), { type: "array" });
|
||||
console.log(`Sheets: ${wb.SheetNames.join(", ")}`);
|
||||
|
||||
for (const sheetName of wb.SheetNames) {
|
||||
console.log(`\n--- Sheet: ${sheetName} ---`);
|
||||
const sheet = wb.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { header: 1 });
|
||||
// Print first 5 cols of each row, mark rows that look like year/semester headers
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row || row.length === 0) continue;
|
||||
const col0 = row[0] != null ? String(row[0]).trim() : "";
|
||||
// Show rows that are structural (year, semester, UE headers)
|
||||
if (col0 || (row[1] != null && String(row[1]).trim())) {
|
||||
const preview = row.slice(0, 6).map(c => c != null ? String(c).substring(0, 25) : "").join(" | ");
|
||||
console.log(` [${i}] ${preview}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
name = "polympr-dev";
|
||||
nativeBuildInputs = [
|
||||
pkgs.deno
|
||||
pkgs.patchelf
|
||||
pkgs.tea
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
pkgs.stdenv.cc.cc.lib
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH"
|
||||
# Find the dynamic linker
|
||||
export NIX_LD_INTERPRETER=$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)
|
||||
echo "Welcome to PolyMPR development shell!"
|
||||
echo "Use 'deno task compile' to build the CLI."
|
||||
echo "If on NixOS, it will be automatically patched."
|
||||
'';
|
||||
}
|
||||
@@ -29,6 +29,10 @@
|
||||
font-family: var(--font-family-text);
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 130%; /* scale up from browser default 16px → ~20.8px */
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user