Compare commits

..

7 Commits

Author SHA1 Message Date
djalim bb09c1cce5 chore: formated tests
Check Deno code / Check Deno code (pull_request) Successful in 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m18s
Check Deno code / Check Deno code (push) Successful in 5s
Tests / Unit tests (push) Successful in 11s
Tests / Integration tests (push) Successful in 1m13s
2026-04-27 18:58:19 +02:00
djalim f162fcaadc feat: add role_write permission and update e2e tests
Check Deno code / Check Deno code (pull_request) Failing after 5s
Tests / Unit tests (pull_request) Successful in 13s
Tests / Integration tests (pull_request) Successful in 1m12s
Add role_write permission to permissions table and update migrations.
Update e2e tests to use DB integration and seed permissions.
Add seedPermissions helper.
2026-04-27 18:56:04 +02:00
djalim 2c5e4ebf11 feat(fresh.gen.ts): add routes for notes edition, recap and island recap
Check Deno code / Check Deno code (pull_request) Failing after 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Failing after 1m17s
feat(notes): add NoteRecap island component for student grade recap

feat: add adjust controls to UI component

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

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

feat(upload): add drag‑and‑drop upload, template download, UI tweaks
2026-04-27 17:08:58 +02:00
djalim 733259e317 feat : fixed some page not being as described in the figma 2026-04-27 11:21:32 +02:00
34 changed files with 2779 additions and 365 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
.git
coverage
.env
+5
View File
@@ -1,7 +1,12 @@
FROM denoland/deno:alpine FROM denoland/deno:alpine
RUN apk add --no-cache nodejs npm
WORKDIR /app WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY . . COPY . .
RUN deno cache main.ts --allow-import RUN deno cache main.ts --allow-import
RUN deno task build RUN deno task build
+3 -1
View File
@@ -16,7 +16,9 @@ services:
migrate: migrate:
image: registry.docker.polytech.djalim.fr/polympr:latest image: registry.docker.polytech.djalim.fr/polympr:latest
command: node_modules/.bin/drizzle-kit migrate working_dir: /app
restart: "no"
command: ["node", "node_modules/.bin/drizzle-kit", "migrate"]
env_file: .env env_file: .env
depends_on: depends_on:
db: db:
+19 -5
View File
@@ -7,9 +7,6 @@ services:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: polympr_test POSTGRES_DB: polympr_test
volumes: volumes:
# Init script strips drizzle-kit markers and applies migrations on first start
- ./databases/docker-init.sh:/docker-entrypoint-initdb.d/01-migrate.sh:ro
- ./databases/migrations:/migrations:ro
- db_data_test:/var/lib/postgresql/data - db_data_test:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
@@ -17,6 +14,23 @@ services:
timeout: 5s timeout: 5s
retries: 10 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: app:
image: denoland/deno:alpine image: denoland/deno:alpine
working_dir: /app working_dir: /app
@@ -34,8 +48,8 @@ services:
POSTGRES_DB: polympr_test POSTGRES_DB: polympr_test
LOCAL: "true" LOCAL: "true"
depends_on: depends_on:
db: migrate:
condition: service_healthy condition: service_completed_successfully
volumes: volumes:
db_data_test: db_data_test:
@@ -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";
+14
View File
@@ -8,6 +8,20 @@
"when": 1777155028708, "when": 1777155028708,
"tag": "0000_square_jetstream", "tag": "0000_square_jetstream",
"breakpoints": true "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
} }
] ]
} }
+29
View File
@@ -15,8 +15,10 @@ import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles
import * as $_apps_admin_api_users from "./routes/(apps)/admin/api/users.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_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_index from "./routes/(apps)/admin/index.tsx";
import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx";
import * as $_apps_admin_partials_index from "./routes/(apps)/admin/partials/index.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_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_roles from "./routes/(apps)/admin/partials/roles.tsx"; import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx";
import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx";
import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts";
@@ -32,8 +34,11 @@ import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modul
import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts";
import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts"; import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts";
import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx";
import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx";
import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx";
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx";
import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx";
import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx"; import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx";
import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx";
import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx";
@@ -42,6 +47,7 @@ import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/studen
import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts";
import * as $_apps_students_api_students_numEtud_ from "./routes/(apps)/students/api/students/[numEtud].ts"; import * as $_apps_students_api_students_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_api_students_import_csv from "./routes/(apps)/students/api/students/import-csv.ts";
import * as $_apps_students_edit_numEtud_ from "./routes/(apps)/students/edit/[numEtud].tsx";
import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx";
import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx";
import * as $_apps_students_partials_admin_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx"; import * as $_apps_students_partials_admin_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx";
@@ -59,7 +65,9 @@ import * as $login from "./routes/login.tsx";
import * as $logout from "./routes/logout.tsx"; import * as $logout from "./routes/logout.tsx";
import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx"; import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx";
import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx"; import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx";
import * as $_apps_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_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_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx"; import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx";
import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx"; import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx";
import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
@@ -67,6 +75,8 @@ import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/
import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.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_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx";
import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx"; import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx";
import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx";
import * as $_apps_notes_islands_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.tsx";
import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx";
import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx"; import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx";
import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx";
@@ -93,8 +103,12 @@ const manifest = {
"./routes/(apps)/admin/api/users.ts": $_apps_admin_api_users, "./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/api/users/[id].ts": $_apps_admin_api_users_id_,
"./routes/(apps)/admin/index.tsx": $_apps_admin_index, "./routes/(apps)/admin/index.tsx": $_apps_admin_index,
"./routes/(apps)/admin/partials/enseignements.tsx":
$_apps_admin_partials_enseignements,
"./routes/(apps)/admin/partials/index.tsx": $_apps_admin_partials_index, "./routes/(apps)/admin/partials/index.tsx": $_apps_admin_partials_index,
"./routes/(apps)/admin/partials/modules.tsx": $_apps_admin_partials_modules, "./routes/(apps)/admin/partials/modules.tsx": $_apps_admin_partials_modules,
"./routes/(apps)/admin/partials/permissions.tsx":
$_apps_admin_partials_permissions,
"./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles, "./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles,
"./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users,
"./routes/(apps)/mobility/api/insert_mobility.ts": "./routes/(apps)/mobility/api/insert_mobility.ts":
@@ -117,9 +131,14 @@ const manifest = {
$_apps_notes_api_ue_modules_idModule_idUE_idPromo_, $_apps_notes_api_ue_modules_idModule_idUE_idPromo_,
"./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues,
"./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_, "./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_,
"./routes/(apps)/notes/edition/[numEtud].tsx":
$_apps_notes_edition_numEtud_,
"./routes/(apps)/notes/index.tsx": $_apps_notes_index, "./routes/(apps)/notes/index.tsx": $_apps_notes_index,
"./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_,
"./routes/(apps)/notes/partials/(admin)/courses.tsx": "./routes/(apps)/notes/partials/(admin)/courses.tsx":
$_apps_notes_partials_admin_courses, $_apps_notes_partials_admin_courses,
"./routes/(apps)/notes/partials/(admin)/import.tsx":
$_apps_notes_partials_admin_import,
"./routes/(apps)/notes/partials/(admin)/ues.tsx": "./routes/(apps)/notes/partials/(admin)/ues.tsx":
$_apps_notes_partials_admin_ues, $_apps_notes_partials_admin_ues,
"./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index,
@@ -133,6 +152,8 @@ const manifest = {
$_apps_students_api_students_numEtud_, $_apps_students_api_students_numEtud_,
"./routes/(apps)/students/api/students/import-csv.ts": "./routes/(apps)/students/api/students/import-csv.ts":
$_apps_students_api_students_import_csv, $_apps_students_api_students_import_csv,
"./routes/(apps)/students/edit/[numEtud].tsx":
$_apps_students_edit_numEtud_,
"./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/index.tsx": $_apps_students_index,
"./routes/(apps)/students/partials/(admin)/consult.tsx": "./routes/(apps)/students/partials/(admin)/consult.tsx":
$_apps_students_partials_admin_consult, $_apps_students_partials_admin_consult,
@@ -156,8 +177,12 @@ const manifest = {
islands: { islands: {
"./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator, "./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator,
"./routes/(_islands)/Navbar.tsx": $_islands_Navbar, "./routes/(_islands)/Navbar.tsx": $_islands_Navbar,
"./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx":
$_apps_admin_islands_AdminEnseignements,
"./routes/(apps)/admin/(_islands)/AdminModules.tsx": "./routes/(apps)/admin/(_islands)/AdminModules.tsx":
$_apps_admin_islands_AdminModules, $_apps_admin_islands_AdminModules,
"./routes/(apps)/admin/(_islands)/AdminPermissions.tsx":
$_apps_admin_islands_AdminPermissions,
"./routes/(apps)/admin/(_islands)/AdminRoles.tsx": "./routes/(apps)/admin/(_islands)/AdminRoles.tsx":
$_apps_admin_islands_AdminRoles, $_apps_admin_islands_AdminRoles,
"./routes/(apps)/admin/(_islands)/AdminUsers.tsx": "./routes/(apps)/admin/(_islands)/AdminUsers.tsx":
@@ -172,6 +197,10 @@ const manifest = {
$_apps_notes_islands_AdminConsultNotes, $_apps_notes_islands_AdminConsultNotes,
"./routes/(apps)/notes/(_islands)/AdminUEs.tsx": "./routes/(apps)/notes/(_islands)/AdminUEs.tsx":
$_apps_notes_islands_AdminUEs, $_apps_notes_islands_AdminUEs,
"./routes/(apps)/notes/(_islands)/ImportNotes.tsx":
$_apps_notes_islands_ImportNotes,
"./routes/(apps)/notes/(_islands)/NoteRecap.tsx":
$_apps_notes_islands_NoteRecap,
"./routes/(apps)/notes/(_islands)/NotesView.tsx": "./routes/(apps)/notes/(_islands)/NotesView.tsx":
$_apps_notes_islands_NotesView, $_apps_notes_islands_NotesView,
"./routes/(apps)/students/(_islands)/AdminPromotions.tsx": "./routes/(apps)/students/(_islands)/AdminPromotions.tsx":
+1 -1
View File
@@ -1,12 +1,12 @@
{ {
"dependencies": { "dependencies": {
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"pg": "^8.20.0" "pg": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"drizzle-kit": "^0.31.10",
"tsx": "^4.21.0" "tsx": "^4.21.0"
} }
} }
@@ -0,0 +1,292 @@
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="form-row" style="margin-bottom: 1.25rem">
{addError && (
<span class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</span>
)}
<select
class="filter-select"
value={addPromo}
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
style="min-width: 10rem"
>
<option value="">Promo</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<select
class="filter-select"
value={addModule}
onChange={(e) =>
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 14rem"
>
<option value="">Module</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>{m.id} {m.nom}</option>
))}
</select>
<input
class="form-input"
placeholder="User ID enseignant…"
value={addProf}
onInput={(e) => setAddProf((e.target as HTMLInputElement).value)}
style="min-width: 10rem"
/>
<button
type="button"
class="btn btn-primary"
onClick={addEnseignement}
disabled={adding}
>
{adding ? "…" : "Créer"}
</button>
<button
type="button"
class="btn btn-secondary"
onClick={() => setShowAdd(false)}
>
Annuler
</button>
</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,
)}
>
🗑
</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,107 @@
import { useEffect, useState } from "preact/hooks";
type Perm = { id: string; nom: string };
type Role = { id: number; nom: string; permissions: string[] };
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">{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>
);
}
+163 -94
View File
@@ -1,17 +1,23 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
type Role = { id: number; nom: string }; type Role = { id: number; nom: string; permissions: string[] };
type Permission = { id: string; nom: string }; type Perm = { id: string; nom: string };
const MAX_CHIPS = 3;
export default function AdminRoles() { export default function AdminRoles() {
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]); const [permissions, setPermissions] = useState<Perm[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [newNom, setNewNom] = useState(""); const [newNom, setNewNom] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [editNom, setEditNom] = useState(""); // 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() { async function load() {
try { try {
@@ -55,21 +61,6 @@ export default function AdminRoles() {
} }
} }
async function saveEdit(id: number) {
try {
const res = await fetch(`/admin/api/roles/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: editNom.trim() }),
});
if (!res.ok) throw new Error("Modification échouée");
setEditId(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function deleteRole(id: number) { async function deleteRole(id: number) {
if (!confirm("Supprimer ce rôle ?")) return; if (!confirm("Supprimer ce rôle ?")) return;
try { try {
@@ -81,19 +72,141 @@ export default function AdminRoles() {
} }
} }
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="toolbar"
style="margin-bottom: 1.25rem; align-items: center"
>
<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-foreground-dim), var(--dark-foreground-dim))">
{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>
);
}
// ---- Main list view ----
return ( return (
<div class="page-content"> <div class="page-content">
<h2 class="page-title">Gestion des Rôles</h2> <h2 class="page-title">Gestion des Rôles</h2>
{error && <p class="state-error">{error}</p>} {error && <p class="state-error">{error}</p>}
<div class="form-row"> <div class="toolbar">
<input <input
class="form-input" class="form-input"
placeholder="Nom du rôle" placeholder="Nom du rôle"
value={newNom} value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)} onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && createRole()} onKeyDown={(e) => e.key === "Enter" && createRole()}
style="min-width: 14rem"
/> />
<button <button
type="button" type="button"
@@ -101,7 +214,7 @@ export default function AdminRoles() {
onClick={createRole} onClick={createRole}
disabled={creating} disabled={creating}
> >
+ Ajouter + Créer rôle
</button> </button>
</div> </div>
@@ -112,8 +225,9 @@ export default function AdminRoles() {
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>idRole</th>
<th>Nom</th> <th>Nom du rôle</th>
<th>Permissions</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -121,61 +235,43 @@ export default function AdminRoles() {
{roles.length === 0 {roles.length === 0
? ( ? (
<tr> <tr>
<td colspan={3} class="state-empty"> <td colspan={4} class="state-empty">
Aucun rôle enregistré Aucun rôle enregistré
</td> </td>
</tr> </tr>
) )
: roles.map((r) => ( : roles.map((r) => {
const shown = r.permissions.slice(0, MAX_CHIPS);
const overflow = r.permissions.length - MAX_CHIPS;
return (
<tr key={r.id}> <tr key={r.id}>
<td class="col-dim">{r.id}</td> <td class="col-dim">{r.id}</td>
<td style="font-weight: var(--font-weight-bold)">
{r.nom}
</td>
<td> <td>
{editId === r.id <div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
? ( {shown.map((p) => (
<input <span key={p} class="perm-chip">{p}</span>
class="form-input" ))}
value={editNom} {overflow > 0 && (
onInput={(e) => <span
setEditNom( class="col-dim"
(e.target as HTMLInputElement).value, style="font-size: 0.72rem; margin-left: 0.2rem"
>
+{overflow}
</span>
)} )}
style="min-width: 0; width: 100%" </div>
/>
)
: r.nom}
</td> </td>
<td> <td>
<div class="col-actions"> <div class="col-actions">
{editId === r.id
? (
<>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => saveEdit(r.id)}
>
</button>
<button <button
type="button" type="button"
class="btn btn-sm btn-secondary" class="btn btn-sm btn-secondary"
onClick={() => setEditId(null)} onClick={() => openManage(r)}
> >
Gérer perms
</button>
</>
)
: (
<>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => {
setEditId(r.id);
setEditNom(r.nom);
}}
>
</button> </button>
<button <button
type="button" type="button"
@@ -184,42 +280,15 @@ export default function AdminRoles() {
> >
🗑 🗑
</button> </button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
{permissions.length > 0 && (
<div style="margin-top: 2rem">
<h3 style="font-size: 0.9rem; font-weight: var(--font-weight-bold); margin-bottom: 0.75rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
Permissions disponibles
</h3>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
</tr>
</thead>
<tbody>
{permissions.map((p) => (
<tr key={p.id}>
<td class="col-dim">{p.id}</td>
<td>{p.nom}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div> </div>
); );
} }
+4 -2
View File
@@ -5,11 +5,13 @@ const properties: AppProperties = {
icon: "school", icon: "school",
pages: { pages: {
index: "Accueil", index: "Accueil",
modules: "Modules",
users: "Utilisateurs", users: "Utilisateurs",
roles: "Rôles", roles: "Rôles",
permissions: "Permissions",
modules: "Modules",
enseignements: "Enseignements",
}, },
adminOnly: ["modules", "users", "roles"], adminOnly: ["users", "roles", "permissions", "modules", "enseignements"],
hint: "PolyMPR module", hint: "PolyMPR module",
}; };
+16
View File
@@ -17,6 +17,22 @@ const CONFLICT = new Response(
); );
export const handler: Handlers<null, AuthenticatedState> = { 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 // #29 POST /enseignements
async POST( async POST(
request: Request, request: Request,
+9 -15
View File
@@ -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"; 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> = { export const handler: Handlers<null, AuthenticatedState> = {
GET(_request, _context): Response { async GET(
return new Response(JSON.stringify(PERMISSIONS), { _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" }, headers: { "content-type": "application/json" },
}); });
}, },
@@ -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,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);
+267 -44
View File
@@ -1,19 +1,54 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
type UE = { id: number; nom: string }; 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() { export default function AdminUEs() {
const [ues, setUes] = useState<UE[]>([]); 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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [newNom, setNewNom] = useState("");
const [creating, setCreating] = useState(false); const [selectedUe, setSelectedUe] = useState<UE | null>(null);
// 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);
async function load() { async function load() {
try { try {
const res = await fetch("/notes/api/ues"); const [uRes, umRes, mRes, pRes] = await Promise.all([
if (!res.ok) throw new Error("Impossible de charger les UEs"); fetch("/notes/api/ues"),
setUes(await res.json()); fetch("/notes/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) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
@@ -26,28 +61,37 @@ export default function AdminUEs() {
}, []); }, []);
async function createUE() { async function createUE() {
if (!newNom.trim()) return; if (!newUeNom.trim()) return;
setCreating(true); setCreatingUe(true);
try { try {
const res = await fetch("/notes/api/ues", { const res = await fetch("/notes/api/ues", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: newNom.trim() }), body: JSON.stringify({ nom: newUeNom.trim() }),
}); });
if (!res.ok) throw new Error("Création échouée"); if (!res.ok) throw new Error("Création échouée");
setNewNom(""); setNewUeNom("");
await load(); await load();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
setCreating(false); setCreatingUe(false);
} }
} }
async function deleteUE(id: number) { async function deleteUeModule(
if (!confirm("Supprimer cette UE ?")) return; idModule: string,
idUE: number,
idPromo: string,
) {
if (!confirm("Supprimer ce module de la UE ?")) return;
try { try {
const res = await fetch(`/notes/api/ues/${id}`, { method: "DELETE" }); const res = await fetch(
`/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
encodeURIComponent(idPromo)
}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée"); if (!res.ok) throw new Error("Suppression échouée");
await load(); await load();
} catch (e) { } catch (e) {
@@ -55,69 +99,248 @@ export default function AdminUEs() {
} }
} }
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("/notes/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);
}
}
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
const selectedUeModules = selectedUe
? ueModules.filter((um) => um.idUE === selectedUe.id)
: [];
return ( return (
<div class="page-content"> <div class="page-content">
<h2 class="page-title">Gestion des UEs</h2> <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>} {error && <p class="state-error">{error}</p>}
<div class="form-row">
<input
class="form-input"
placeholder="Nom de la nouvelle UE"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && createUE()}
/>
<button
type="button"
class="btn btn-primary"
onClick={createUE}
disabled={creating}
>
+ Ajouter
</button>
</div>
{loading {loading
? <p class="state-loading">Chargement</p> ? <p class="state-loading">Chargement</p>
: ( : (
<div class="data-table-wrap"> <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>
<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>
{ues.map((ue) => (
<div
key={ue.id}
class={`ue-list-item${
selectedUe?.id === ue.id ? " active" : ""
}`}
onClick={() => {
setSelectedUe(ue);
setAddError(null);
}}
>
{ue.nom}
</div>
))}
{ues.length === 0 && (
<p class="state-empty" style="padding: 1rem 0">
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"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>Module</th>
<th>Nom</th> <th>Promo</th>
<th>Action</th> <th>Coeff</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ues.length === 0 {selectedUeModules.length === 0
? ( ? (
<tr> <tr>
<td colspan={3} class="state-empty"> <td colspan={4} class="state-empty">
Aucune UE enregistrée Aucun module assigné
</td> </td>
</tr> </tr>
) )
: ues.map((ue) => ( : selectedUeModules.map((um) => {
<tr key={ue.id}> const mod = moduleMap[um.idModule];
<td class="col-dim">{ue.id}</td> return (
<td>{ue.nom}</td> <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>{um.coeff}</td>
<td> <td>
<button <button
type="button" type="button"
class="btn btn-sm btn-danger" class="btn btn-sm btn-danger"
onClick={() => deleteUE(ue.id)} onClick={() =>
deleteUeModule(
um.idModule,
um.idUE,
um.idPromo,
)}
> >
🗑 🗑
</button> </button>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </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> </div>
); );
@@ -0,0 +1,150 @@
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
import { useRef } from "preact/hooks";
import { useSignal } from "@preact/signals";
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 success = useSignal<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
function pickFile(f: File) {
if (!f.name.match(/\.xlsx?$/i)) {
error.value = "Fichier invalide — format attendu : .xlsx";
return;
}
file.value = f;
error.value = null;
success.value = null;
}
function onDragOver(e: DragEvent) {
e.preventDefault();
dragging.value = true;
}
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;
success.value = null;
try {
const arrayBuffer = await file.value.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: "array" });
let imported = 0;
let failed = 0;
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<{
numEtud: number;
idModule: string;
note: number;
}>(sheet, { header: ["numEtud", "idModule", "note"], range: 1 });
for (const row of rows) {
const res = await fetch("/notes/api/notes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(row),
});
if (res.ok) imported++;
else failed++;
}
}
success.value = `Import terminé — ${imported} ajouté${
imported !== 1 ? "s" : ""
}${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`;
} catch {
error.value = "Erreur lors de la lecture du fichier.";
} finally {
uploading.value = false;
}
}
function downloadTemplate() {
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([["numEtud", "idModule", "note"]]);
XLSX.utils.book_append_sheet(wb, ws, "Notes");
XLSX.writeFile(wb, "modele_notes.xlsx");
}
return (
<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>}
{success.value && (
<p style="font-size:0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.75rem">
{success.value}
</p>
)}
<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>numEtud</strong> | <strong>idModule</strong> |{" "}
<strong>note</strong>
</p>
</div>
);
}
@@ -0,0 +1,391 @@
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 };
type Ajustement = { numEtud: number; idUE: number; valeur: number };
type Props = { numEtud: number };
function fmt(n: number): string {
return `${Math.round(n * 10) / 10}/20`;
}
function noteClass(n: number): string {
return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail";
}
export default function NoteRecap({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null);
const [ueList, setUeList] = useState<UE[]>([]);
const [ueModules, setUeModules] = useState<UEModule[]>([]);
const [moduleMap, setModuleMap] = useState<Map<string, string>>(new Map());
const [noteMap, setNoteMap] = useState<Map<string, number>>(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; value: string } | null
>(null);
const [ajustInputs, setAjustInputs] = useState<Record<number, string>>({});
async function load() {
try {
const sRes = await fetch(`/students/api/students/${numEtud}`);
if (!sRes.ok) throw new Error("Élève introuvable");
const s: Student = await sRes.json();
setStudent(s);
const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([
fetch("/notes/api/ues"),
fetch(
`/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
),
fetch("/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.note])));
}
if (ajustRes.ok) {
const aj: Ajustement[] = await ajustRes.json();
setAjustements(aj);
const inputs: Record<number, string> = {};
for (const a of aj) inputs[a.idUE] = String(a.valeur);
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;
total += n * um.coeff;
coeff += um.coeff;
}
return coeff > 0 ? total / coeff : null;
}
async function saveNote(idModule: string, value: string) {
const note = parseFloat(value.replace(",", "."));
if (isNaN(note) || note < 0 || note > 20) {
setEditingNote(null);
return;
}
const res = await fetch(
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ note }),
},
);
if (res.ok) {
const updated: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, updated.note));
}
setEditingNote(null);
}
async function applyAjust(idUE: number) {
const val = parseFloat((ajustInputs[idUE] ?? "").replace(",", "."));
if (isNaN(val) || val < 0 || val > 20) 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 }),
})
: await fetch("/notes/api/ajustements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ numEtud, idUE, valeur: val }),
});
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 à la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Récap 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 configurée 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);
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. calculée : {fmt(avg)}
</span>
)}
{ajust && (
<span
class="note-chip note-chip--ajust"
style="font-size: 0.78rem"
>
Ajust. actif : {fmt(ajust.valeur)}
</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 associé à cette UE pour cette promotion.
</p>
)
: (
<div style="margin-bottom: 0.75rem">
{ueMods.map((um) => {
const noteVal = noteMap.get(um.idModule);
const nomMod = moduleMap.get(um.idModule) ?? um.idModule;
const isEditing = editingNote?.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>
{isEditing
? (
<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({
idModule: um.idModule,
value:
(e.target as HTMLInputElement).value,
})}
onKeyDown={(e) => {
if (e.key === "Enter") {
saveNote(
um.idModule,
editingNote!.value,
);
}
if (e.key === "Escape") {
setEditingNote(null);
}
}}
onBlur={() =>
saveNote(um.idModule, 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="Cliquer pour modifier"
onClick={() =>
setEditingNote({
idModule: um.idModule,
value: noteVal !== undefined
? String(noteVal)
: "",
})}
>
{noteVal !== undefined ? fmt(noteVal) : "—/20"}
</span>
)}
<button
type="button"
class="btn btn-sm btn-secondary"
style="font-size: 0.75rem"
onClick={() =>
setEditingNote({
idModule: um.idModule,
value: noteVal !== undefined
? String(noteVal)
: "",
})}
>
note
</button>
</div>
);
})}
</div>
)}
{/* Ajustement */}
<div class="ajust-section">
<p class="ajust-title">Ajustement de la moyenne UE</p>
<p class="ajust-hint">
Override ponctuel laisser vide pour utiliser la moy.
calculée
</p>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap">
<div style="display: flex; align-items: center; gap: 0.25rem">
<input
class="form-input"
style="width: 4.5rem; text-align: center"
placeholder="—"
value={ajustInputs[ue.id] ?? ""}
onInput={(e) =>
setAjustInputs((prev) => ({
...prev,
[ue.id]: (e.target as HTMLInputElement).value,
}))}
/>
<span class="col-dim" style="font-size: 0.8rem">/20</span>
</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)}
>
Réinitialiser
</button>
<span
class="col-dim"
style="font-size: 0.75rem; font-family: monospace"
>
Affiché à l'élève : {fmt(ajust.valeur)}
{avg !== null ? ` (calculée : ${fmt(avg)})` : ""}
</span>
</>
)}
</div>
</div>
</div>
);
})}
</div>
);
}
+2 -1
View File
@@ -8,8 +8,9 @@ const properties: AppProperties = {
notes: "Mes notes", notes: "Mes notes",
courses: "Consulter", courses: "Consulter",
ues: "UEs", ues: "UEs",
import: "Import xlsx",
}, },
adminOnly: ["courses", "ues"], adminOnly: ["courses", "ues", "import"],
hint: "Student grading management", hint: "Student grading management",
}; };
+12
View File
@@ -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} />;
}
@@ -0,0 +1,29 @@
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>
<p
class="upload-format"
style="margin-bottom: 1.25rem"
>
POST /notes/api/notes
</p>
<ImportNotes />
</div>
);
}
export const config = getPartialsConfig();
export default makePartials(ImportNotesPage);
+12
View File
@@ -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,20 +1,42 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
type Promotion = { id: string; annee: string | null }; 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() { export default function AdminPromotions() {
const [promos, setPromos] = useState<Promotion[]>([]); const [promos, setPromos] = useState<Promotion[]>([]);
const [students, setStudents] = useState<Student[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [newId, setNewId] = useState("");
const [newAnnee, setNewAnnee] = useState("");
const [creating, setCreating] = useState(false); 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()}`
: "";
async function load() { async function load() {
try { try {
const res = await fetch("/students/api/promotions"); const [pRes, sRes] = await Promise.all([
if (!res.ok) throw new Error("Impossible de charger les promotions"); fetch("/students/api/promotions"),
setPromos(await res.json()); 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) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
@@ -27,23 +49,22 @@ export default function AdminPromotions() {
}, []); }, []);
async function createPromo() { async function createPromo() {
if (!newId.trim()) return; if (!generatedId) return;
setCreating(true); setCreating(true);
try { try {
const res = await fetch("/students/api/promotions", { const res = await fetch("/students/api/promotions", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
idPromo: newId.trim(), idPromo: generatedId,
annee: newAnnee.trim() || null, annee: selectedAnnee,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée"); throw new Error(body.error ?? "Création échouée");
} }
setNewId(""); setAnneeSco("");
setNewAnnee("");
await load(); await load();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
@@ -57,9 +78,7 @@ export default function AdminPromotions() {
try { try {
const res = await fetch( const res = await fetch(
`/students/api/promotions/${encodeURIComponent(id)}`, `/students/api/promotions/${encodeURIComponent(id)}`,
{ { method: "DELETE" },
method: "DELETE",
},
); );
if (!res.ok) throw new Error("Suppression échouée"); if (!res.ok) throw new Error("Suppression échouée");
await load(); await load();
@@ -68,35 +87,92 @@ export default function AdminPromotions() {
} }
} }
function studentCount(idPromo: string) {
return students.filter((s) => s.idPromo === idPromo).length;
}
return ( return (
<div class="page-content"> <div class="page-content">
<h2 class="page-title">Gestion des Promotions</h2> <h2 class="page-title">Gestion des Promotions</h2>
{error && <p class="state-error">{error}</p>} {error && <p class="state-error">{error}</p>}
<div class="form-row"> {/* PromoBuilder */}
<div class="promo-builder">
<p class="promo-builder-title">Créer une promotion</p>
<p class="promo-builder-subtitle">
POST /promotions 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 <input
class="form-input" class="form-input"
placeholder="Identifiant (ex: 4A22)" placeholder="ex: 25/26, 24/27…"
value={newId} value={anneeSco}
onInput={(e) => setNewId((e.target as HTMLInputElement).value)} onInput={(e) => setAnneeSco((e.target as HTMLInputElement).value)}
/> style="min-width: 9rem"
<input
class="form-input"
placeholder="Année (ex: 2022-2023)"
value={newAnnee}
onInput={(e) => setNewAnnee((e.target as HTMLInputElement).value)}
style="min-width: 14rem"
/> />
</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 <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
onClick={createPromo} onClick={createPromo}
disabled={creating} disabled={creating || !generatedId}
> >
+ Ajouter {creating ? "…" : "+ Créer la promo"}
</button> </button>
</div> </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 {loading
? <p class="state-loading">Chargement</p> ? <p class="state-loading">Chargement</p>
@@ -105,24 +181,39 @@ export default function AdminPromotions() {
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Identifiant</th> <th>idPromo</th>
<th>Année</th> <th>Année</th>
<th>Action</th> <th>Filière</th>
<th>Année sco.</th>
<th>Nb étudiants</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{promos.length === 0 {promos.length === 0
? ( ? (
<tr> <tr>
<td colspan={3} class="state-empty"> <td colspan={6} class="state-empty">
Aucune promotion enregistrée Aucune promotion enregistrée
</td> </td>
</tr> </tr>
) )
: promos.map((p) => ( : promos.map((p) => {
const parsed = parsePromo(p.id);
const count = studentCount(p.id);
return (
<tr key={p.id}> <tr key={p.id}>
<td class="col-promo">{p.id}</td> <td>
<td>{p.annee ?? "—"}</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> <td>
<button <button
type="button" type="button"
@@ -133,7 +224,8 @@ export default function AdminPromotions() {
</button> </button>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -0,0 +1,247 @@
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>
<p class="edit-section-subtitle">PUT /students/{"{numEtud}"}</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="edit-section-subtitle">
GET·POST·DELETE /spe5a plusieurs modules possibles
</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>
<p class="edit-section-subtitle">
GET /students/{"{numEtud}"}/notes voir récap complet
</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,151 @@
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" // @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 * 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";
/** export default function UploadStudents() {
* Create a new handler for file change that displays const file = useSignal<File | null>(null);
* messages in statusMessage and gets file data in fileData. const dragging = useSignal(false);
* @param statusMessage The status message signal. const uploading = useSignal(false);
* @param fileData The file data signal. const error = useSignal<string | null>(null);
* @returns The file change handler. const success = useSignal<string | null>(null);
*/ const inputRef = useRef<HTMLInputElement>(null);
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";
}
};
}
/** function pickFile(f: File) {
* Create a new handler that sends data file to server. if (!f.name.match(/\.xlsx?$/i)) {
* @param statusMessage The status message signal. error.value = "Fichier invalide — format attendu : .xlsx";
* @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.";
return; return;
} }
file.value = f;
error.value = null;
success.value = null;
}
const reader = new FileReader(); function onDragOver(e: DragEvent) {
e.preventDefault();
dragging.value = true;
}
/** function onDragLeave() {
* Send all data to the server. dragging.value = false;
* @param event The finished progress event. }
*/
reader.onload = async (event: ProgressEvent<FileReader>) => { function onDrop(e: DragEvent) {
const arrayBuffer = event.target!.result as ArrayBuffer; 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;
success.value = null;
try {
const arrayBuffer = await file.value.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: "array" }); const workbook = XLSX.read(arrayBuffer, { type: "array" });
let allOK = true; let imported = 0;
let failed = 0;
for (const sheetName of workbook.SheetNames) { for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet, { const rows = XLSX.utils.sheet_to_json<{
header: ["userId", "lastName", "firstName", "mail"], numEtud: number;
range: 1, nom: string;
}); prenom: string;
}>(sheet, { header: ["numEtud", "nom", "prenom"], range: 1 });
const response = await fetch("/students/api/students", { for (const row of rows) {
const res = await fetch("/students/api/students", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ promoName: sheetName, data }), body: JSON.stringify({ ...row, idPromo: sheetName }),
}); });
if (res.ok) imported++;
if (!response.ok) { else failed++;
allOK = false;
} }
} }
statusMessage.value = allOK success.value = `Import terminé — ${imported} ajouté${
? "Failed to insert all data." imported !== 1 ? "s" : ""
: "Data uploaded and inserted successfully!"; }${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`;
}; } catch {
error.value = "Erreur lors de la lecture du fichier.";
} finally {
uploading.value = false;
}
}
/** function downloadTemplate() {
* Display error message if any. const wb = XLSX.utils.book_new();
*/ const ws = XLSX.utils.aoa_to_sheet([["numEtud", "nom", "prenom"]]);
reader.onerror = () => { XLSX.utils.book_append_sheet(wb, ws, "4A22");
statusMessage.value = "Error reading the file."; XLSX.writeFile(wb, "modele_etudiants.xlsx");
}; }
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);
return ( return (
<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> : (
<> <>
<input type="file" accept=".xlsx, .xls" onChange={handleFileChange} /> <span class="drop-zone-text">Glisser le fichier .xlsx ici</span>
<button type="button" onClick={confirmUpload}>Confirm Upload</button> <span class="drop-zone-hint">ou cliquer pour parcourir</span>
<p>{statusMessage.value}</p>
</> </>
)}
</div>
{error.value && <p class="state-error">{error.value}</p>}
{success.value && (
<p style="font-size:0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.75rem">
{success.value}
</p>
)}
<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>promo</strong> (nom de la feuille) |{" "}
<strong>numEtud</strong> | <strong>nom</strong> |{" "}
<strong>prénom</strong>
</p>
</div>
); );
} }
+12
View File
@@ -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} />;
}
@@ -9,10 +9,16 @@ import { State } from "$root/defaults/interfaces.ts";
// deno-lint-ignore require-await // deno-lint-ignore require-await
async function Students(_request: Request, _context: FreshContext<State>) { async function Students(_request: Request, _context: FreshContext<State>) {
return ( return (
<> <div class="page-content">
<h2>Upload Students</h2> <h2 class="page-title">Importer des Élèves</h2>
<p
class="upload-format"
style="margin-bottom: 1.25rem"
>
POST /students/api/students/import-csv
</p>
<UploadStudents /> <UploadStudents />
</> </div>
); );
} }
+3 -3
View File
@@ -26,9 +26,9 @@ export default async function App(
/> />
<link rel="stylesheet" href="/styles/main.css" /> <link rel="stylesheet" href="/styles/main.css" />
<link rel="stylesheet" href="/styles/app.css" /> <link rel="stylesheet" href="/styles/app.css" />
<link rel="stylesheet" href="styles/app-cards.css" /> <link rel="stylesheet" href="/styles/app-cards.css" />
<link rel="stylesheet" href="styles/students.css" /> <link rel="stylesheet" href="/styles/students.css" />
<link rel="stylesheet" href="styles/ui.css" /> <link rel="stylesheet" href="/styles/ui.css" />
</head> </head>
<body f-client-nav> <body f-client-nav>
<Header link={link} /> <Header link={link} />
+4
View File
@@ -29,6 +29,10 @@
font-family: var(--font-family-text); font-family: var(--font-family-text);
} }
html {
font-size: 130%; /* scale up from browser default 16px → ~20.8px */
}
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
+563 -1
View File
@@ -6,7 +6,6 @@
.page-content { .page-content {
padding: 1.5rem; padding: 1.5rem;
max-width: 960px;
} }
.page-title { .page-title {
@@ -391,3 +390,566 @@
margin-bottom: 1rem; margin-bottom: 1rem;
gap: 1rem; gap: 1rem;
} }
/* -------------------------------------------------------
Chips: perm, role, promo, module
------------------------------------------------------- */
.perm-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.45rem;
border-radius: 10px;
border: 1px solid
light-dark(var(--light-accent-color), var(--dark-accent-color));
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
font-size: 0.68rem;
font-family: monospace;
margin: 0.1rem;
}
.role-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
border-radius: 10px;
border: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-size: 0.72rem;
margin: 0.1rem;
}
.promo-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
border-radius: 10px;
border: 1px solid #b8820a;
color: #d4a017;
font-size: 0.72rem;
font-weight: var(--font-weight-bold);
}
.filiere-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
border-radius: 10px;
border: 1px solid
light-dark(var(--light-accent-color), var(--dark-accent-color));
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
font-size: 0.72rem;
font-weight: var(--font-weight-bold);
}
.module-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
border-radius: 10px;
border: 1px solid
light-dark(var(--light-accent-color), var(--dark-accent-color));
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
font-size: 0.7rem;
font-family: monospace;
}
.numEtud-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
border-radius: 10px;
border: 1px solid
light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-size: 0.72rem;
font-weight: var(--font-weight-bold);
}
/* -------------------------------------------------------
Permission toggle cards (role management)
------------------------------------------------------- */
.perm-toggle-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
}
.perm-toggle-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.55rem 0.75rem;
background: light-dark(white, #141228);
border: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
border-radius: 4px;
cursor: pointer;
gap: 0.5rem;
}
.perm-toggle-card.active {
border-color: light-dark(
var(--light-accent-color),
var(--dark-accent-color)
);
background: light-dark(#f0fff4, #0d1f12);
}
.perm-toggle-label {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.perm-toggle-id {
font-size: 0.7rem;
font-weight: var(--font-weight-bold);
font-family: monospace;
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
}
.perm-toggle-nom {
font-size: 0.78rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
/* Simple toggle switch */
.toggle-switch {
position: relative;
width: 2.4rem;
height: 1.3rem;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-slider {
position: absolute;
inset: 0;
background: light-dark(#ccc, #444);
border-radius: 1rem;
transition: background 150ms;
}
.toggle-slider::before {
content: "";
position: absolute;
width: 1rem;
height: 1rem;
left: 0.15rem;
top: 0.15rem;
background: white;
border-radius: 50%;
transition: transform 150ms;
}
.toggle-switch input:checked + .toggle-slider {
background: light-dark(var(--light-accent-color), var(--dark-accent-color));
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(1.1rem);
}
/* -------------------------------------------------------
UE split layout
------------------------------------------------------- */
.ue-split {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.ue-panel-left {
width: 270px;
flex-shrink: 0;
}
.ue-panel-right {
flex: 1;
min-width: 0;
}
.panel-box {
background: light-dark(white, #141228);
border: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
border-radius: 4px;
padding: 0.75rem;
}
.panel-box-title {
font-size: 0.8rem;
font-weight: var(--font-weight-bold);
margin: 0 0 0.6rem;
}
.ue-list-item {
padding: 0.45rem 0.6rem;
cursor: pointer;
border-radius: 3px;
font-size: 0.82rem;
border: 1px solid transparent;
margin-bottom: 0.2rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
.ue-list-item:hover {
background: light-dark(#f0efff, #1a172d);
color: light-dark(var(--light-foreground), var(--dark-foreground));
}
.ue-list-item.active {
background: light-dark(var(--light-accent-color), var(--dark-accent-color));
color: light-dark(
var(--light-background-color),
var(--dark-background-color)
);
font-weight: var(--font-weight-bold);
}
/* -------------------------------------------------------
Pill buttons (PromoBuilder)
------------------------------------------------------- */
.pill-group {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.pill-btn {
padding: 0.3rem 0.8rem;
border-radius: 20px;
border: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
background: transparent;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-size: 0.82rem;
font-family: inherit;
cursor: pointer;
}
.pill-btn::before {
all: unset;
}
.pill-btn.active {
border-color: light-dark(var(--light-accent-color), var(--dark-accent-color));
background: light-dark(
var(--light-accent-color),
var(--dark-accent-color)
);
color: light-dark(
var(--light-background-color),
var(--dark-background-color)
);
font-weight: var(--font-weight-bold);
}
/* PromoBuilder box */
.promo-builder {
background: light-dark(white, #141228);
border: 1px solid
light-dark(var(--light-accent-color), var(--dark-accent-color));
border-radius: 4px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.promo-builder-title {
font-size: 0.85rem;
font-weight: var(--font-weight-bold);
margin: 0 0 0.15rem;
}
.promo-builder-subtitle {
font-size: 0.72rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-family: monospace;
margin: 0 0 0.85rem;
}
.promo-builder-row {
display: flex;
gap: 1.5rem;
align-items: flex-start;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.promo-builder-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.promo-builder-field label {
font-size: 0.72rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
.promo-id-preview {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.75rem;
border: 1px solid
light-dark(var(--light-accent-color), var(--dark-accent-color));
border-radius: 3px;
font-size: 0.85rem;
font-weight: var(--font-weight-bold);
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
min-width: 10rem;
font-family: monospace;
}
/* -------------------------------------------------------
Edit student sections
------------------------------------------------------- */
.edit-section {
background: light-dark(white, #141228);
border: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
}
.edit-section-title {
font-size: 0.88rem;
font-weight: var(--font-weight-bold);
margin: 0 0 0.15rem;
}
.edit-section-subtitle {
font-size: 0.7rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-family: monospace;
margin: 0 0 0.8rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.form-field label {
font-size: 0.7rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
.info-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: light-dark(white, #141228);
border: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.82rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
text-decoration: none;
margin-bottom: 0.5rem;
}
.back-link:hover {
text-decoration: underline;
}
/* -------------------------------------------------------
File drop zone (import pages)
------------------------------------------------------- */
.drop-zone {
border: 2px dashed
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
border-radius: 6px;
background: light-dark(white, #141228);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 3rem 2rem;
cursor: pointer;
transition: border-color 150ms, background 150ms;
margin-bottom: 1.25rem;
text-align: center;
}
.drop-zone:hover,
.drop-zone.dragging {
border-color: light-dark(var(--light-accent-color), var(--dark-accent-color));
background: light-dark(#f0fff4, #0a1a10);
}
.drop-zone-icon {
font-size: 2.4rem;
line-height: 1;
opacity: 0.8;
}
.drop-zone-text {
font-size: 0.88rem;
font-weight: var(--font-weight-bold);
}
.drop-zone-hint {
font-size: 0.75rem;
font-family: monospace;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
.drop-zone-file {
font-size: 0.78rem;
font-family: monospace;
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
font-weight: var(--font-weight-bold);
}
.upload-actions {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.75rem;
}
.upload-format {
font-size: 0.72rem;
font-family: monospace;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
/* Info note box */
.info-note {
padding: 0.75rem 1rem;
border: 1px solid
light-dark(var(--light-accent-color), var(--dark-accent-color));
border-radius: 4px;
background: light-dark(#f0fff4, #0a1a10);
margin-top: 1.5rem;
font-size: 0.82rem;
}
/* -------------------------------------------------------
Note recap chips & rows
------------------------------------------------------- */
.note-chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 10px;
border: 1px solid currentColor;
font-size: 0.78rem;
font-weight: var(--font-weight-bold);
font-family: monospace;
white-space: nowrap;
}
.note-chip--ok {
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
}
.note-chip--fail {
color: light-dark(#dc2626, #f87171);
}
.note-chip--none {
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
.note-chip--promo {
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
background: transparent;
}
.note-chip--ajust {
color: #f59e0b;
}
.note-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
flex-wrap: wrap;
}
.note-row-label {
flex: 1;
min-width: 10rem;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.note-row-chip {
font-size: 0.68rem;
padding: 0.1rem 0.4rem;
}
.note-row-coef {
font-size: 0.75rem;
white-space: nowrap;
}
.ajust-section {
margin-top: 0.75rem;
padding-top: 0.65rem;
border-top: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
}
.ajust-title {
font-size: 0.78rem;
font-weight: var(--font-weight-bold);
margin: 0 0 0.15rem;
}
.ajust-hint {
font-size: 0.7rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-family: monospace;
margin: 0 0 0.5rem;
}
/* -------------------------------------------------------
(end note recap)
------------------------------------------------------- */
.info-note-dim {
font-size: 0.7rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-family: monospace;
margin-top: 0.25rem;
}
+24 -6
View File
@@ -1,24 +1,40 @@
// #115 - E2E tests for GET /permissions // #115 - E2E tests for GET /permissions
// Handler statique (pas de DB), test direct du handler
import { assertEquals, assertExists } from "@std/assert"; import { assertEquals, assertExists } from "@std/assert";
import { makeEmployeeContext, makeGetRequest } from "../helpers/handler.ts"; import { makeEmployeeContext, makeGetRequest } from "../helpers/handler.ts";
import { seedPermissions, truncateAll } from "../helpers/db_integration.ts";
import { handler as permissionsHandler } from "$apps/admin/api/permissions.ts"; import { handler as permissionsHandler } from "$apps/admin/api/permissions.ts";
const PERMISSIONS = [
{ id: "note_read", nom: "Consulter les notes des étudiants" },
{ id: "note_write", nom: "Saisir et modifier les notes" },
{ id: "student_read", nom: "Consulter la liste des étudiants" },
{
id: "student_write",
nom: "Gérer les étudiants (ajout, modification, suppression)",
},
{ id: "module_read", nom: "Consulter les modules et enseignements" },
{ id: "module_write", nom: "Gérer les modules et enseignements" },
{ id: "user_read", nom: "Consulter les utilisateurs et leurs rôles" },
{ id: "user_write", nom: "Gérer les utilisateurs et leurs rôles" },
{ id: "role_write", nom: "Gérer les rôles et leurs permissions" },
];
Deno.test({ Deno.test({
name: "e2e permissions: GET /permissions returns all 9 permissions", name: "e2e permissions: GET /permissions returns all 9 permissions",
fn() { async fn() {
const res = permissionsHandler.GET!( await truncateAll();
await seedPermissions(PERMISSIONS);
const res = await permissionsHandler.GET!(
makeGetRequest("/permissions"), makeGetRequest("/permissions"),
makeEmployeeContext(), makeEmployeeContext(),
); );
assertEquals(res.status, 200); assertEquals(res.status, 200);
return res.text().then((text) => { const text = await res.text();
const data = JSON.parse(text); const data = JSON.parse(text);
assertEquals(data.length, 9); assertEquals(data.length, 9);
assertExists(data.find((p: { id: string }) => p.id === "student_read")); assertExists(data.find((p: { id: string }) => p.id === "student_read"));
assertExists(data.find((p: { id: string }) => p.id === "role_write")); assertExists(data.find((p: { id: string }) => p.id === "role_write"));
});
}, },
sanitizeResources: false, sanitizeResources: false,
sanitizeOps: false, sanitizeOps: false,
@@ -27,7 +43,9 @@ Deno.test({
Deno.test({ Deno.test({
name: "e2e permissions: GET /permissions - all entries have id and nom", name: "e2e permissions: GET /permissions - all entries have id and nom",
async fn() { async fn() {
const res = permissionsHandler.GET!( await truncateAll();
await seedPermissions(PERMISSIONS);
const res = await permissionsHandler.GET!(
makeGetRequest("/permissions"), makeGetRequest("/permissions"),
makeEmployeeContext(), makeEmployeeContext(),
); );
+6
View File
@@ -111,3 +111,9 @@ export async function seedAjustements(
): Promise<typeof schema.ajustements.$inferSelect[]> { ): Promise<typeof schema.ajustements.$inferSelect[]> {
return await testDb.insert(schema.ajustements).values(rows).returning(); return await testDb.insert(schema.ajustements).values(rows).returning();
} }
export async function seedPermissions(
rows: { id: string; nom: string }[],
): Promise<typeof schema.permissions.$inferSelect[]> {
return await testDb.insert(schema.permissions).values(rows).returning();
}