Compare commits

..

1 Commits

Author SHA1 Message Date
djalim 6c38cd0019 feat: cascade deletes, student notes, import popups, module reorganization
- Cascade delete on all entities (student, module, UE, user, role, promotion)
- Fix Response body reuse bug (factory functions instead of constants)
- Student note viewing via CAS uid (strip non-digit prefix)
- Fix middleware page visibility for students in LOCAL mode
- Import result popup component (shared across all import pages)
- Fix student import to use numEtud from Excel
- Bulk student selection with promo change and delete
- Move UE/UE-Module API and pages from notes to admin module
- Move promotions page from students to admin module
- Multi-year maquette import with per-year promo selection
- Inline promo creation in maquette import
- Static Excel templates (students, notes, maquette)
- Fix XLSX export using blob download instead of writeFile
- Allow students to read modules list (GET /modules)
2026-04-30 13:47:16 +02:00
81 changed files with 1111 additions and 4429 deletions
-4
View File
@@ -56,10 +56,6 @@ jobs:
run: | run: |
sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \ sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
sed 's/--> statement-breakpoint/;/g' databases/migrations/0003_add_session2_and_malus.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
sed 's/--> statement-breakpoint/;/g' databases/migrations/0004_add_stages_and_mobilites.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
- name: Install dependencies - name: Install dependencies
run: npm install --ignore-scripts && deno install run: npm install --ignore-scripts && deno install
-3
View File
@@ -30,12 +30,9 @@ services:
ports: ports:
- "4430:443" - "4430:443"
env_file: .env env_file: .env
volumes:
- contracts:/app/uploads/contracts
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
volumes: volumes:
db_data: db_data:
contracts:
@@ -1,28 +0,0 @@
DROP TABLE IF EXISTS "mobility";
--> statement-breakpoint
CREATE TYPE "mobility_status" AS ENUM ('contracts_received', 'under_revision', 'done', 'validated', 'canceled');
--> statement-breakpoint
CREATE TABLE "stages" (
"idStage" serial PRIMARY KEY NOT NULL,
"numEtud" integer NOT NULL,
"duree" integer NOT NULL,
"nomEntreprise" text NOT NULL,
"mission" text
);
--> statement-breakpoint
CREATE TABLE "mobilites" (
"idMob" serial PRIMARY KEY NOT NULL,
"numEtud" integer NOT NULL,
"duree" integer NOT NULL,
"contratMob" text,
"ecole" text,
"pays" text,
"status" "mobility_status" NOT NULL DEFAULT 'contracts_received',
"idStage" integer
);
--> statement-breakpoint
ALTER TABLE "stages" ADD CONSTRAINT "stages_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "mobilites" ADD CONSTRAINT "mobilites_idStage_stages_idStage_fk" FOREIGN KEY ("idStage") REFERENCES "public"."stages"("idStage") ON DELETE no action ON UPDATE no action;
-7
View File
@@ -29,13 +29,6 @@
"when": 1777155028711, "when": 1777155028711,
"tag": "0003_add_session2_and_malus", "tag": "0003_add_session2_and_malus",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1777155028712,
"tag": "0004_add_stages_and_mobilites",
"breakpoints": true
} }
] ]
} }
+10 -26
View File
@@ -1,7 +1,7 @@
import { import {
date,
doublePrecision, doublePrecision,
integer, integer,
pgEnum,
pgTable, pgTable,
primaryKey, primaryKey,
serial, serial,
@@ -89,29 +89,13 @@ export const ajustements = pgTable("ajustements", {
pk: primaryKey({ columns: [t.numEtud, t.idUE] }), pk: primaryKey({ columns: [t.numEtud, t.idUE] }),
})); }));
export const stages = pgTable("stages", { export const mobility = pgTable("mobility", {
id: serial("idStage").primaryKey(), id: serial("id").primaryKey(),
numEtud: integer("numEtud").notNull().references(() => students.numEtud), studentId: integer("studentId").references(() => students.numEtud),
duree: integer("duree").notNull(), startDate: date("startDate"),
nomEntreprise: text("nomEntreprise").notNull(), endDate: date("endDate"),
mission: text("mission"), weeksCount: integer("weeksCount"),
}); destinationCountry: text("destinationCountry"),
destinationName: text("destinationName"),
export const mobilityStatusEnum = pgEnum("mobility_status", [ mobilityStatus: text("mobilityStatus").default("N/A"),
"contracts_received",
"under_revision",
"done",
"validated",
"canceled",
]);
export const mobilites = pgTable("mobilites", {
id: serial("idMob").primaryKey(),
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
duree: integer("duree").notNull(),
contratMob: text("contratMob"),
ecole: text("ecole"),
pays: text("pays"),
status: mobilityStatusEnum("status").notNull().default("contracts_received"),
idStage: integer("idStage").references(() => stages.id),
}); });
+358
View File
@@ -0,0 +1,358 @@
-- ============================================================
-- PolyMPR — Test seed data
-- Source: JIN7SAA Semestre 7 Informatique FISE
-- ============================================================
-- ------------------------------------------------------------
-- 1. Promotion
-- ------------------------------------------------------------
INSERT INTO promotions ("idPromo", "annee") VALUES
('4AFISE', '2024')
ON CONFLICT ("idPromo") DO NOTHING;
-- ------------------------------------------------------------
-- 2. Students
-- ------------------------------------------------------------
INSERT INTO students ("numEtud", nom, prenom, "idPromo") VALUES
(24029625, 'MARTIN', 'Sophie', '4AFISE'),
(22005810, 'DUPONT', 'Lucas', '4AFISE'),
(24026283, 'BERNARD', 'Emma', '4AFISE'),
(24024101, 'THOMAS', 'Hugo', '4AFISE'),
(24021136, 'PETIT', 'Léa', '4AFISE'),
(24027947, 'ROBERT', 'Nathan', '4AFISE'),
(22001491, 'RICHARD', 'Alice', '4AFISE'),
(22023423, 'SIMON', 'Théo', '4AFISE'),
(21217292, 'LAURENT', 'Camille', '4AFISE'),
(24024550, 'LEFEBVRE', 'Maxime', '4AFISE'),
(22019957, 'MICHEL', 'Inès', '4AFISE'),
(22008222, 'GARCIA', 'Baptiste', '4AFISE'),
(22006557, 'DAVID', 'Manon', '4AFISE'),
(22019337, 'BERTRAND', 'Julien', '4AFISE'),
(22017498, 'ROUX', 'Chloé', '4AFISE'),
(22019070, 'VINCENT', 'Tom', '4AFISE'),
(21213966, 'FOURNIER', 'Jade', '4AFISE'),
(25027910, 'MOREL', 'Enzo', '4AFISE'),
(24025920, 'GIRARD', 'Anaïs', '4AFISE'),
(21212006, 'ANDRÉ', 'Romain', '4AFISE'),
(21230594, 'LEFÈVRE', 'Pauline', '4AFISE'),
(22010753, 'MERCIER', 'Axel', '4AFISE'),
(24031005, 'DUPUIS', 'Clara', '4AFISE'),
(24029523, 'LAMBERT', 'Florian', '4AFISE'),
(24025433, 'BONNET', 'Mathilde', '4AFISE'),
(23024748, 'FRANCOIS', 'Louis', '4AFISE'),
(24016406, 'MARTINEZ', 'Zoé', '4AFISE'),
(24028169, 'LEBLANC', 'Kévin', '4AFISE'),
(22017365, 'GARNIER', 'Julie', '4AFISE'),
(23026015, 'CHEVALIER', 'Antoine', '4AFISE')
ON CONFLICT ("numEtud") DO NOTHING;
-- ------------------------------------------------------------
-- 3. Role: Professeur
-- ------------------------------------------------------------
INSERT INTO roles (nom) VALUES ('Professeur')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 4. Permissions du rôle Professeur
-- ------------------------------------------------------------
INSERT INTO role_permissions ("idRole", "idPermission")
SELECT r.id, p.id
FROM roles r, permissions p
WHERE r.nom = 'Professeur'
AND p.id IN ('note_read', 'note_write', 'student_read', 'module_read')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 5. Professor users
-- ------------------------------------------------------------
INSERT INTO users (id, nom, prenom, "idRole")
SELECT prof.id, prof.nom, prof.prenom, r.id
FROM roles r,
(VALUES
('prof.jin701b', 'DUBOIS', 'Pierre'),
('prof.jin701c', 'MOREAU', 'Claire'),
('prof.jin710a', 'DURAND', 'François'),
('prof.jin702a', 'LEROY', 'Anne'),
('prof.jin702b', 'GIRARD', 'Michel'),
('prof.jin702c', 'MOREL', 'Patricia'),
('prof.jin703a', 'SIMON', 'Jacques'),
('prof.jin703b', 'LAURENT', 'Sylvie'),
('prof.jin703c', 'LEFEBVRE', 'René'),
('prof.jin712a', 'ROUSSEAU', 'Hélène'),
('prof.jin712b', 'BLANC', 'Philippe'),
('prof.jin712c', 'GUERIN', 'Stéphane'),
('prof.jtr701a', 'THOMAS', 'Sarah'),
('prof.jtr701d', 'ROBIN', 'Christine'),
('prof.jtr701e', 'DAVID', 'Bernard'),
('prof.jtr701f', 'FAURE', 'Daniel')
) AS prof(id, nom, prenom)
WHERE r.nom = 'Professeur'
ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------
-- 6. Modules
-- ------------------------------------------------------------
INSERT INTO modules (id, nom) VALUES
('JIN701B', 'Programmation distribuée'),
('JIN701C', 'Projet développement 1'),
('JIN710A', 'Virtualisation et Conteneurisation'),
('JIN702A', 'Probabilités'),
('JIN702B', 'Statistiques'),
('JIN702C', 'Optimisation'),
('JIN703A', 'Programmation graphique'),
('JIN703B', 'Analyse d''images'),
('JIN703C', 'Modélisation géométrique'),
('JIN712A', 'Apprentissage automatique'),
('JIN712B', 'Fouilles de données'),
('JIN712C', 'Base de données avancées'),
('JTR701A', 'Anglais'),
('JTR701D', 'Gestion commerciale et marketing'),
('JTR701E', 'Management de la qualité'),
('JTR701F', 'Management de Projet')
ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------
-- 7. UEs
-- ------------------------------------------------------------
INSERT INTO ues (nom) VALUES
('Conception et développement Avancés 1'),
('Mathématiques pour l''Informatique 2'),
('Introduction à la réalité mixte'),
('IA et Science des Données Avancées'),
('Langues et SHEJS 3')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 8. UE-Module associations (coeff 1 for all)
-- ------------------------------------------------------------
INSERT INTO ue_modules ("idModule", "idUE", "idPromo", coeff)
SELECT m, u.id, '4AFISE', 1
FROM ues u,
(VALUES
('JIN701B', 'Conception et développement Avancés 1'),
('JIN701C', 'Conception et développement Avancés 1'),
('JIN710A', 'Conception et développement Avancés 1'),
('JIN702A', 'Mathématiques pour l''Informatique 2'),
('JIN702B', 'Mathématiques pour l''Informatique 2'),
('JIN702C', 'Mathématiques pour l''Informatique 2'),
('JIN703A', 'Introduction à la réalité mixte'),
('JIN703B', 'Introduction à la réalité mixte'),
('JIN703C', 'Introduction à la réalité mixte'),
('JIN712A', 'IA et Science des Données Avancées'),
('JIN712B', 'IA et Science des Données Avancées'),
('JIN712C', 'IA et Science des Données Avancées'),
('JTR701A', 'Langues et SHEJS 3'),
('JTR701D', 'Langues et SHEJS 3'),
('JTR701E', 'Langues et SHEJS 3'),
('JTR701F', 'Langues et SHEJS 3')
) AS mapping(m, ue_nom)
WHERE u.nom = mapping.ue_nom
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 9. Enseignements
-- ------------------------------------------------------------
INSERT INTO enseignements ("idProf", "idModule", "idPromo") VALUES
('prof.jin701b', 'JIN701B', '4AFISE'),
('prof.jin701c', 'JIN701C', '4AFISE'),
('prof.jin710a', 'JIN710A', '4AFISE'),
('prof.jin702a', 'JIN702A', '4AFISE'),
('prof.jin702b', 'JIN702B', '4AFISE'),
('prof.jin702c', 'JIN702C', '4AFISE'),
('prof.jin703a', 'JIN703A', '4AFISE'),
('prof.jin703b', 'JIN703B', '4AFISE'),
('prof.jin703c', 'JIN703C', '4AFISE'),
('prof.jin712a', 'JIN712A', '4AFISE'),
('prof.jin712b', 'JIN712B', '4AFISE'),
('prof.jin712c', 'JIN712C', '4AFISE'),
('prof.jtr701a', 'JTR701A', '4AFISE'),
('prof.jtr701d', 'JTR701D', '4AFISE'),
('prof.jtr701e', 'JTR701E', '4AFISE'),
('prof.jtr701f', 'JTR701F', '4AFISE')
ON CONFLICT DO NOTHING;
-- ------------------------------------------------------------
-- 10. Notes (ABI / #VALEUR! entries are skipped)
-- ------------------------------------------------------------
INSERT INTO notes ("numEtud", "idModule", note) VALUES
-- 24029625 MARTIN Sophie
(24029625,'JIN701B',14.00),(24029625,'JIN701C',13.50),(24029625,'JIN710A',15.00),
(24029625,'JIN702A',15.50),(24029625,'JIN702B',17.00),(24029625,'JIN702C',13.00),
(24029625,'JIN703A',15.00),(24029625,'JIN703B',14.00),(24029625,'JIN703C',8.44),
(24029625,'JIN712A',15.50),(24029625,'JIN712B',13.80),(24029625,'JIN712C',13.10),
(24029625,'JTR701A',16.10),(24029625,'JTR701D',11.00),(24029625,'JTR701E',14.21),(24029625,'JTR701F',15.48),
-- 22005810 DUPONT Lucas
(22005810,'JIN701B',10.50),(22005810,'JIN701C',14.00),(22005810,'JIN710A',14.00),
(22005810,'JIN702A',20.00),(22005810,'JIN702B',16.00),(22005810,'JIN702C',16.00),
(22005810,'JIN703A',15.00),(22005810,'JIN703B',13.00),(22005810,'JIN703C',7.50),
(22005810,'JIN712A',10.85),(22005810,'JIN712B',11.50),(22005810,'JIN712C',10.80),
(22005810,'JTR701A',12.10),(22005810,'JTR701D',10.00),(22005810,'JTR701E',11.85),(22005810,'JTR701F',12.47),
-- 24026283 BERNARD Emma
(24026283,'JIN701B',11.50),(24026283,'JIN701C',16.50),(24026283,'JIN710A',13.00),
(24026283,'JIN702A',17.00),(24026283,'JIN702B',15.00),(24026283,'JIN702C',14.00),
(24026283,'JIN703A',14.00),(24026283,'JIN703B',12.00),(24026283,'JIN703C',11.25),
(24026283,'JIN712A',13.25),(24026283,'JIN712B',13.00),(24026283,'JIN712C',15.70),
(24026283,'JTR701A',16.10),(24026283,'JTR701D',14.00),(24026283,'JTR701E',15.50),(24026283,'JTR701F',13.91),
-- 24024101 THOMAS Hugo
(24024101,'JIN701B',10.50),(24024101,'JIN701C',13.50),(24024101,'JIN710A',12.00),
(24024101,'JIN702A',13.00),(24024101,'JIN702B',11.00),(24024101,'JIN702C',14.00),
(24024101,'JIN703A',17.00),(24024101,'JIN703B',12.00),(24024101,'JIN703C',11.88),
(24024101,'JIN712A',6.50),(24024101,'JIN712B',18.30),(24024101,'JIN712C',13.90),
(24024101,'JTR701A',17.50),(24024101,'JTR701D',17.50),(24024101,'JTR701E',16.09),(24024101,'JTR701F',15.04),
-- 24021136 PETIT Léa
(24021136,'JIN701B',15.00),(24021136,'JIN701C',15.00),(24021136,'JIN710A',12.00),
(24021136,'JIN702A',16.50),(24021136,'JIN702B',17.00),(24021136,'JIN702C',10.00),
(24021136,'JIN703A',14.00),(24021136,'JIN703B',13.00),(24021136,'JIN703C',9.38),
(24021136,'JIN712A',8.50),(24021136,'JIN712B',13.90),(24021136,'JIN712C',15.20),
(24021136,'JTR701A',18.20),(24021136,'JTR701D',15.00),(24021136,'JTR701E',12.83),(24021136,'JTR701F',14.06),
-- 24027947 ROBERT Nathan
(24027947,'JIN701B',7.50),(24027947,'JIN701C',15.00),(24027947,'JIN710A',13.50),
(24027947,'JIN702A',20.00),(24027947,'JIN702B',17.00),(24027947,'JIN702C',14.00),
(24027947,'JIN703A',13.00),(24027947,'JIN703B',10.00),(24027947,'JIN703C',8.75),
(24027947,'JIN712A',7.50),(24027947,'JIN712B',13.90),(24027947,'JIN712C',10.00),
(24027947,'JTR701A',17.30),(24027947,'JTR701D',16.00),(24027947,'JTR701E',14.71),(24027947,'JTR701F',11.61),
-- 22001491 RICHARD Alice
(22001491,'JIN701B',17.50),(22001491,'JIN701C',13.50),(22001491,'JIN710A',15.00),
(22001491,'JIN702A',20.00),(22001491,'JIN702B',15.00),(22001491,'JIN702C',14.00),
(22001491,'JIN703A',15.00),(22001491,'JIN703B',15.00),(22001491,'JIN703C',15.94),
(22001491,'JIN712A',5.50),(22001491,'JIN712B',18.10),(22001491,'JIN712C',15.75),
(22001491,'JTR701A',17.70),(22001491,'JTR701D',11.50),(22001491,'JTR701E',14.35),(22001491,'JTR701F',15.61),
-- 22023423 SIMON Théo
(22023423,'JIN701B',13.00),(22023423,'JIN701C',16.00),(22023423,'JIN710A',13.00),
(22023423,'JIN702A',20.00),(22023423,'JIN702B',18.00),(22023423,'JIN702C',14.50),
(22023423,'JIN703A',13.00),(22023423,'JIN703B',11.00),(22023423,'JIN703C',14.69),
(22023423,'JIN712A',7.00),(22023423,'JIN712B',13.70),(22023423,'JIN712C',14.95),
(22023423,'JTR701A',18.10),(22023423,'JTR701D',18.00),(22023423,'JTR701E',14.82),(22023423,'JTR701F',14.36),
-- 21217292 LAURENT Camille
(21217292,'JIN701B',16.00),(21217292,'JIN701C',13.50),(21217292,'JIN710A',15.00),
(21217292,'JIN702A',5.00),(21217292,'JIN702B',8.00),(21217292,'JIN702C',19.00),
(21217292,'JIN703A',14.00),(21217292,'JIN703B',14.00),(21217292,'JIN703C',11.88),
(21217292,'JIN712A',15.00),(21217292,'JIN712B',16.10),(21217292,'JIN712C',12.95),
(21217292,'JTR701A',18.10),(21217292,'JTR701D',14.50),(21217292,'JTR701E',12.57),(21217292,'JTR701F',13.61),
-- 24024550 LEFEBVRE Maxime
(24024550,'JIN701B',16.00),(24024550,'JIN701C',13.50),(24024550,'JIN710A',13.00),
(24024550,'JIN702A',20.00),(24024550,'JIN702B',9.00),(24024550,'JIN702C',15.00),
(24024550,'JIN703A',17.00),(24024550,'JIN703B',12.00),(24024550,'JIN703C',10.31),
(24024550,'JIN712A',16.50),(24024550,'JIN712B',12.40),(24024550,'JIN712C',16.60),
(24024550,'JTR701A',16.80),(24024550,'JTR701D',18.00),(24024550,'JTR701E',14.70),(24024550,'JTR701F',13.41),
-- 22019957 MICHEL Inès (JIN703C=ABI skipped)
(22019957,'JIN701B',10.50),(22019957,'JIN701C',14.00),(22019957,'JIN710A',12.00),
(22019957,'JIN702A',14.00),(22019957,'JIN702B',18.00),(22019957,'JIN702C',11.50),
(22019957,'JIN703A',15.00),(22019957,'JIN703B',10.00),
(22019957,'JIN712A',4.00),(22019957,'JIN712B',15.90),(22019957,'JIN712C',10.00),
(22019957,'JTR701A',15.80),(22019957,'JTR701D',18.00),(22019957,'JTR701E',14.77),(22019957,'JTR701F',14.11),
-- 22008222 GARCIA Baptiste
(22008222,'JIN701B',16.00),(22008222,'JIN701C',14.00),(22008222,'JIN710A',18.00),
(22008222,'JIN702A',18.00),(22008222,'JIN702B',17.00),(22008222,'JIN702C',14.50),
(22008222,'JIN703A',15.00),(22008222,'JIN703B',13.00),(22008222,'JIN703C',15.31),
(22008222,'JIN712A',15.50),(22008222,'JIN712B',17.00),(22008222,'JIN712C',16.40),
(22008222,'JTR701A',17.90),(22008222,'JTR701D',16.00),(22008222,'JTR701E',13.62),(22008222,'JTR701F',13.04),
-- 22006557 DAVID Manon
(22006557,'JIN701B',13.50),(22006557,'JIN701C',17.50),(22006557,'JIN710A',14.00),
(22006557,'JIN702A',20.00),(22006557,'JIN702B',18.00),(22006557,'JIN702C',10.50),
(22006557,'JIN703A',15.00),(22006557,'JIN703B',15.00),(22006557,'JIN703C',9.38),
(22006557,'JIN712A',17.00),(22006557,'JIN712B',14.40),(22006557,'JIN712C',16.50),
(22006557,'JTR701A',13.80),(22006557,'JTR701D',17.00),(22006557,'JTR701E',15.77),(22006557,'JTR701F',14.75),
-- 22019337 BERTRAND Julien
(22019337,'JIN701B',11.00),(22019337,'JIN701C',14.00),(22019337,'JIN710A',13.50),
(22019337,'JIN702A',15.00),(22019337,'JIN702B',17.00),(22019337,'JIN702C',11.50),
(22019337,'JIN703A',15.00),(22019337,'JIN703B',13.00),(22019337,'JIN703C',10.00),
(22019337,'JIN712A',2.00),(22019337,'JIN712B',12.70),(22019337,'JIN712C',14.05),
(22019337,'JTR701A',14.20),(22019337,'JTR701D',10.00),(22019337,'JTR701E',10.84),(22019337,'JTR701F',14.23),
-- 22017498 ROUX Chloé
(22017498,'JIN701B',8.00),(22017498,'JIN701C',16.00),(22017498,'JIN710A',12.00),
(22017498,'JIN702A',20.00),(22017498,'JIN702B',18.00),(22017498,'JIN702C',10.00),
(22017498,'JIN703A',15.00),(22017498,'JIN703B',14.00),(22017498,'JIN703C',9.06),
(22017498,'JIN712A',16.00),(22017498,'JIN712B',10.60),(22017498,'JIN712C',16.00),
(22017498,'JTR701A',15.80),(22017498,'JTR701D',13.00),(22017498,'JTR701E',14.19),(22017498,'JTR701F',13.61),
-- 22019070 VINCENT Tom
(22019070,'JIN701B',18.00),(22019070,'JIN701C',15.00),(22019070,'JIN710A',13.50),
(22019070,'JIN702A',14.00),(22019070,'JIN702B',13.00),(22019070,'JIN702C',17.50),
(22019070,'JIN703A',16.00),(22019070,'JIN703B',14.00),(22019070,'JIN703C',16.88),
(22019070,'JIN712A',18.50),(22019070,'JIN712B',16.20),(22019070,'JIN712C',12.65),
(22019070,'JTR701A',18.80),(22019070,'JTR701D',16.50),(22019070,'JTR701E',11.63),(22019070,'JTR701F',15.11),
-- 21213966 FOURNIER Jade (JTR701D=ABI skipped)
(21213966,'JIN701B',7.50),(21213966,'JIN701C',14.00),(21213966,'JIN710A',14.00),
(21213966,'JIN702A',17.00),(21213966,'JIN702B',17.00),(21213966,'JIN702C',12.50),
(21213966,'JIN703A',15.00),(21213966,'JIN703B',13.00),(21213966,'JIN703C',3.75),
(21213966,'JIN712A',4.50),(21213966,'JIN712B',12.50),(21213966,'JIN712C',13.75),
(21213966,'JTR701A',11.00),(21213966,'JTR701E',15.99),(21213966,'JTR701F',11.61),
-- 25027910 MOREL Enzo
(25027910,'JIN701B',7.50),(25027910,'JIN701C',15.00),(25027910,'JIN710A',13.00),
(25027910,'JIN702A',3.00),(25027910,'JIN702B',4.00),(25027910,'JIN702C',10.50),
(25027910,'JIN703A',12.00),(25027910,'JIN703B',12.00),(25027910,'JIN703C',6.25),
(25027910,'JIN712A',7.85),(25027910,'JIN712B',10.20),(25027910,'JIN712C',13.00),
(25027910,'JTR701A',14.30),(25027910,'JTR701D',12.00),(25027910,'JTR701E',13.36),(25027910,'JTR701F',14.86),
-- 24025920 GIRARD Anaïs (JIN712C=ABI, JTR701D=ABI skipped)
(24025920,'JIN701B',10.50),(24025920,'JIN701C',12.00),(24025920,'JIN710A',13.00),
(24025920,'JIN702A',19.00),(24025920,'JIN702B',19.00),(24025920,'JIN702C',7.50),
(24025920,'JIN703A',11.00),(24025920,'JIN703B',10.00),(24025920,'JIN703C',7.19),
(24025920,'JIN712A',8.25),(24025920,'JIN712B',10.20),
(24025920,'JTR701A',10.60),(24025920,'JTR701E',4.29),(24025920,'JTR701F',8.73),
-- 21212006 ANDRÉ Romain
(21212006,'JIN701B',12.50),(21212006,'JIN701C',17.50),(21212006,'JIN710A',17.00),
(21212006,'JIN702A',12.00),(21212006,'JIN702B',9.00),(21212006,'JIN702C',10.00),
(21212006,'JIN703A',15.00),(21212006,'JIN703B',14.00),(21212006,'JIN703C',13.44),
(21212006,'JIN712A',16.50),(21212006,'JIN712B',12.00),(21212006,'JIN712C',16.40),
(21212006,'JTR701A',18.30),(21212006,'JTR701D',16.00),(21212006,'JTR701E',10.85),(21212006,'JTR701F',13.29),
-- 21230594 LEFÈVRE Pauline
(21230594,'JIN701B',18.00),(21230594,'JIN701C',16.50),(21230594,'JIN710A',11.00),
(21230594,'JIN702A',5.00),(21230594,'JIN702B',13.00),(21230594,'JIN702C',17.50),
(21230594,'JIN703A',11.00),(21230594,'JIN703B',12.00),(21230594,'JIN703C',18.44),
(21230594,'JIN712A',16.00),(21230594,'JIN712B',15.90),(21230594,'JIN712C',11.60),
(21230594,'JTR701A',18.80),(21230594,'JTR701D',16.00),(21230594,'JTR701E',14.61),(21230594,'JTR701F',14.61),
-- 22010753 MERCIER Axel
(22010753,'JIN701B',9.00),(22010753,'JIN701C',14.00),(22010753,'JIN710A',14.00),
(22010753,'JIN702A',17.00),(22010753,'JIN702B',15.00),(22010753,'JIN702C',13.00),
(22010753,'JIN703A',13.00),(22010753,'JIN703B',13.00),(22010753,'JIN703C',7.50),
(22010753,'JIN712A',5.50),(22010753,'JIN712B',15.00),(22010753,'JIN712C',15.15),
(22010753,'JTR701A',13.40),(22010753,'JTR701D',15.50),(22010753,'JTR701E',15.80),(22010753,'JTR701F',13.11),
-- 24031005 DUPUIS Clara
(24031005,'JIN701B',8.00),(24031005,'JIN701C',15.00),(24031005,'JIN710A',13.00),
(24031005,'JIN702A',16.00),(24031005,'JIN702B',19.00),(24031005,'JIN702C',9.50),
(24031005,'JIN703A',15.00),(24031005,'JIN703B',13.00),(24031005,'JIN703C',9.06),
(24031005,'JIN712A',5.00),(24031005,'JIN712B',10.90),(24031005,'JIN712C',14.35),
(24031005,'JTR701A',15.00),(24031005,'JTR701D',12.00),(24031005,'JTR701E',10.78),(24031005,'JTR701F',13.36),
-- 24029523 LAMBERT Florian
(24029523,'JIN701B',13.00),(24029523,'JIN701C',16.50),(24029523,'JIN710A',12.50),
(24029523,'JIN702A',14.00),(24029523,'JIN702B',6.00),(24029523,'JIN702C',10.50),
(24029523,'JIN703A',14.00),(24029523,'JIN703B',11.00),(24029523,'JIN703C',14.38),
(24029523,'JIN712A',11.50),(24029523,'JIN712B',9.50),(24029523,'JIN712C',15.60),
(24029523,'JTR701A',16.30),(24029523,'JTR701D',18.00),(24029523,'JTR701E',10.99),(24029523,'JTR701F',12.23),
-- 24025433 BONNET Mathilde
(24025433,'JIN701B',9.50),(24025433,'JIN701C',10.00),(24025433,'JIN710A',11.00),
(24025433,'JIN702A',8.00),(24025433,'JIN702B',6.00),(24025433,'JIN702C',20.00),
(24025433,'JIN703A',2.50),(24025433,'JIN703B',12.00),(24025433,'JIN703C',18.44),
(24025433,'JIN712A',13.50),(24025433,'JIN712B',14.20),(24025433,'JIN712C',5.25),
(24025433,'JTR701A',16.00),(24025433,'JTR701D',17.50),(24025433,'JTR701E',11.38),(24025433,'JTR701F',13.11),
-- 23024748 FRANCOIS Louis
(23024748,'JIN701B',11.50),(23024748,'JIN701C',11.00),(23024748,'JIN710A',15.00),
(23024748,'JIN702A',18.00),(23024748,'JIN702B',15.00),(23024748,'JIN702C',10.00),
(23024748,'JIN703A',15.00),(23024748,'JIN703B',13.00),(23024748,'JIN703C',7.50),
(23024748,'JIN712A',4.00),(23024748,'JIN712B',10.90),(23024748,'JIN712C',10.00),
(23024748,'JTR701A',14.30),(23024748,'JTR701D',12.00),(23024748,'JTR701E',14.42),(23024748,'JTR701F',12.61),
-- 24016406 MARTINEZ Zoé
(24016406,'JIN701B',11.50),(24016406,'JIN701C',16.50),(24016406,'JIN710A',12.00),
(24016406,'JIN702A',19.00),(24016406,'JIN702B',15.00),(24016406,'JIN702C',7.00),
(24016406,'JIN703A',15.00),(24016406,'JIN703B',14.00),(24016406,'JIN703C',11.56),
(24016406,'JIN712A',14.00),(24016406,'JIN712B',12.20),(24016406,'JIN712C',16.20),
(24016406,'JTR701A',18.50),(24016406,'JTR701D',13.00),(24016406,'JTR701E',15.01),(24016406,'JTR701F',12.81),
-- 24028169 LEBLANC Kévin
(24028169,'JIN701B',9.00),(24028169,'JIN701C',11.00),(24028169,'JIN710A',17.00),
(24028169,'JIN702A',15.50),(24028169,'JIN702B',13.00),(24028169,'JIN702C',14.50),
(24028169,'JIN703A',14.00),(24028169,'JIN703B',14.00),(24028169,'JIN703C',9.06),
(24028169,'JIN712A',7.00),(24028169,'JIN712B',15.10),(24028169,'JIN712C',13.30),
(24028169,'JTR701A',14.80),(24028169,'JTR701D',17.50),(24028169,'JTR701E',10.99),(24028169,'JTR701F',9.23),
-- 22017365 GARNIER Julie
(22017365,'JIN701B',15.50),(22017365,'JIN701C',17.50),(22017365,'JIN710A',15.00),
(22017365,'JIN702A',13.50),(22017365,'JIN702B',17.00),(22017365,'JIN702C',19.00),
(22017365,'JIN703A',15.00),(22017365,'JIN703B',14.00),(22017365,'JIN703C',17.50),
(22017365,'JIN712A',17.50),(22017365,'JIN712B',14.40),(22017365,'JIN712C',16.70),
(22017365,'JTR701A',17.50),(22017365,'JTR701D',15.00),(22017365,'JTR701E',15.35),(22017365,'JTR701F',15.61),
-- 23026015 CHEVALIER Antoine
(23026015,'JIN701B',9.00),(23026015,'JIN701C',11.00),(23026015,'JIN710A',15.00),
(23026015,'JIN702A',20.00),(23026015,'JIN702B',18.00),(23026015,'JIN702C',13.50),
(23026015,'JIN703A',14.00),(23026015,'JIN703B',12.00),(23026015,'JIN703C',11.56),
(23026015,'JIN712A',7.00),(23026015,'JIN712B',16.50),(23026015,'JIN712C',11.40),
(23026015,'JTR701A',12.30),(23026015,'JTR701D',11.00),(23026015,'JTR701E',10.67),(23026015,'JTR701F',12.40)
ON CONFLICT DO NOTHING;
-1
View File
@@ -20,7 +20,6 @@ export interface AppProperties {
pages: Record<string, string>; pages: Record<string, string>;
adminOnly: string[]; adminOnly: string[];
studentOnly?: string[]; studentOnly?: string[];
employeeOnly?: boolean;
hint: string; hint: string;
} }
-62
View File
@@ -1,62 +0,0 @@
import { FreshContext } from "$fresh/server.ts";
import { Route, State } from "$root/defaults/interfaces.ts";
import { ComponentChildren } from "preact";
/**
* Generates a catch-all [slug] route that dynamically loads partials.
* This enables direct URL navigation to sub-pages (e.g. /admin/modules).
* @param basePath The base path of the module, should be `import.meta.dirname!`.
* @returns A route handler that loads the partial matching the slug.
*/
export default function makeSlug(basePath: string): Route {
return async function SlugRoute(
request: Request,
context: FreshContext<State>,
): Promise<ComponentChildren | Response> {
const slug = context.params.slug;
// Try partials/<slug>.tsx, then partials/(admin)/<slug>.tsx
let page: Route | undefined;
try {
page = (await import(`${basePath}/partials/${slug}.tsx`)).Page;
} catch {
try {
page = (await import(`${basePath}/partials/(admin)/${slug}.tsx`)).Page;
} catch {
// No partial found for this slug
}
}
// For multi-segment slugs (e.g. "overview/12345"), try
// partials/<dir>/[param].tsx and inject the param into context.params
if (!page && slug.includes("/")) {
const idx = slug.indexOf("/");
const dir = slug.slice(0, idx);
const param = slug.slice(idx + 1);
// Discover the dynamic segment name from the file system
try {
const entries: string[] = [];
for await (const entry of Deno.readDir(`${basePath}/partials/${dir}`)) {
if (entry.isFile) entries.push(entry.name);
}
const dynFile = entries.find((n) =>
n.startsWith("[") && n.endsWith("].tsx")
);
if (dynFile) {
const paramName = dynFile.slice(1, -5); // "[numEtud].tsx" → "numEtud"
context.params[paramName] = param;
page = (await import(`${basePath}/partials/${dir}/${dynFile}`)).Page;
}
} catch {
// directory doesn't exist or no dynamic file
}
}
if (!page) {
return context.renderNotFound();
}
return page(request, context);
};
}
+15 -48
View File
@@ -4,7 +4,6 @@
import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; import * as $_apps_layout from "./routes/(apps)/_layout.tsx";
import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; import * as $_apps_middleware from "./routes/(apps)/_middleware.ts";
import * as $_apps_admin_slug_ from "./routes/(apps)/admin/[slug].tsx";
import * as $_apps_admin_api_enseignements from "./routes/(apps)/admin/api/enseignements.ts"; import * as $_apps_admin_api_enseignements from "./routes/(apps)/admin/api/enseignements.ts";
import * as $_apps_admin_api_enseignements_idProf_idModule_idPromo_ from "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts"; import * as $_apps_admin_api_enseignements_idProf_idModule_idPromo_ from "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts";
import * as $_apps_admin_api_example from "./routes/(apps)/admin/api/example.ts"; import * as $_apps_admin_api_example from "./routes/(apps)/admin/api/example.ts";
@@ -31,23 +30,16 @@ import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/rol
import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx"; import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx";
import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx";
import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx"; import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx";
import * as $_apps_mobility_slug_ from "./routes/(apps)/mobility/[...slug].tsx"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts";
import * as $_apps_mobility_api_mobilites from "./routes/(apps)/mobility/api/mobilites.ts";
import * as $_apps_mobility_api_mobilites_idMob_ from "./routes/(apps)/mobility/api/mobilites/[idMob].ts";
import * as $_apps_mobility_api_mobilites_idMob_contrat from "./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts";
import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx"; import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx";
import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx";
import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx"; import * as $_apps_mobility_partials_index from "./routes/(apps)/mobility/partials/index.tsx";
import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx"; import * as $_apps_mobility_partials_overview from "./routes/(apps)/mobility/partials/overview.tsx";
import * as $_apps_mobility_partials_overview_numEtud_ from "./routes/(apps)/mobility/partials/overview/[numEtud].tsx";
import * as $_apps_notes_slug_ from "./routes/(apps)/notes/[slug].tsx";
import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustements.ts"; import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustements.ts";
import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts"; import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts";
import * as $_apps_notes_api_modules from "./routes/(apps)/notes/api/modules.ts";
import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts"; import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts";
import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts"; import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts";
import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts"; import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts";
import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts";
import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts";
import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx"; import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx";
import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx";
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx";
@@ -55,14 +47,6 @@ import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/parti
import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx";
import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx";
import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx"; import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx";
import * as $_apps_stages_slug_ from "./routes/(apps)/stages/[...slug].tsx";
import * as $_apps_stages_api_stages from "./routes/(apps)/stages/api/stages.ts";
import * as $_apps_stages_api_stages_idStage_ from "./routes/(apps)/stages/api/stages/[idStage].ts";
import * as $_apps_stages_index from "./routes/(apps)/stages/index.tsx";
import * as $_apps_stages_partials_index from "./routes/(apps)/stages/partials/index.tsx";
import * as $_apps_stages_partials_overview from "./routes/(apps)/stages/partials/overview.tsx";
import * as $_apps_stages_partials_overview_numEtud_ from "./routes/(apps)/stages/partials/overview/[numEtud].tsx";
import * as $_apps_students_slug_ from "./routes/(apps)/students/[slug].tsx";
import * as $_apps_students_api_promotions from "./routes/(apps)/students/api/promotions.ts"; import * as $_apps_students_api_promotions from "./routes/(apps)/students/api/promotions.ts";
import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts"; import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts";
import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts";
@@ -95,12 +79,13 @@ import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_island
import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx"; import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx";
import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx"; import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx";
import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx"; import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx";
import * as $_apps_mobility_islands_MobilityOverview from "./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx"; import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx";
import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx";
import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx";
import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx"; import * as $_apps_notes_islands_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_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_stages_islands_StagesOverview from "./routes/(apps)/stages/(_islands)/StagesOverview.tsx";
import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx";
import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx"; import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx";
import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx"; import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx";
@@ -110,7 +95,6 @@ const manifest = {
routes: { routes: {
"./routes/(apps)/_layout.tsx": $_apps_layout, "./routes/(apps)/_layout.tsx": $_apps_layout,
"./routes/(apps)/_middleware.ts": $_apps_middleware, "./routes/(apps)/_middleware.ts": $_apps_middleware,
"./routes/(apps)/admin/[slug].tsx": $_apps_admin_slug_,
"./routes/(apps)/admin/api/enseignements.ts": "./routes/(apps)/admin/api/enseignements.ts":
$_apps_admin_api_enseignements, $_apps_admin_api_enseignements,
"./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts": "./routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts":
@@ -147,31 +131,23 @@ const manifest = {
"./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues, "./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues,
"./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users,
"./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_, "./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_,
"./routes/(apps)/mobility/[...slug].tsx": $_apps_mobility_slug_, "./routes/(apps)/mobility/api/insert_mobility.ts":
"./routes/(apps)/mobility/api/mobilites.ts": $_apps_mobility_api_mobilites, $_apps_mobility_api_insert_mobility,
"./routes/(apps)/mobility/api/mobilites/[idMob].ts":
$_apps_mobility_api_mobilites_idMob_,
"./routes/(apps)/mobility/api/mobilites/[idMob]/contrat.ts":
$_apps_mobility_api_mobilites_idMob_contrat,
"./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index,
"./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx":
$_apps_mobility_partials_admin_edit_mobility,
"./routes/(apps)/mobility/partials/index.tsx": "./routes/(apps)/mobility/partials/index.tsx":
$_apps_mobility_partials_index, $_apps_mobility_partials_index,
"./routes/(apps)/mobility/partials/overview.tsx": "./routes/(apps)/mobility/partials/overview.tsx":
$_apps_mobility_partials_overview, $_apps_mobility_partials_overview,
"./routes/(apps)/mobility/partials/overview/[numEtud].tsx":
$_apps_mobility_partials_overview_numEtud_,
"./routes/(apps)/notes/[slug].tsx": $_apps_notes_slug_,
"./routes/(apps)/notes/api/ajustements.ts": $_apps_notes_api_ajustements, "./routes/(apps)/notes/api/ajustements.ts": $_apps_notes_api_ajustements,
"./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts": "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts":
$_apps_notes_api_ajustements_numEtud_idUE_, $_apps_notes_api_ajustements_numEtud_idUE_,
"./routes/(apps)/notes/api/modules.ts": $_apps_notes_api_modules,
"./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes, "./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes,
"./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts": "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts":
$_apps_notes_api_notes_numEtud_idModule_, $_apps_notes_api_notes_numEtud_idModule_,
"./routes/(apps)/notes/api/notes/import-xlsx.ts": "./routes/(apps)/notes/api/notes/import-xlsx.ts":
$_apps_notes_api_notes_import_xlsx, $_apps_notes_api_notes_import_xlsx,
"./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules,
"./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues,
"./routes/(apps)/notes/edition/[numEtud].tsx": "./routes/(apps)/notes/edition/[numEtud].tsx":
$_apps_notes_edition_numEtud_, $_apps_notes_edition_numEtud_,
"./routes/(apps)/notes/index.tsx": $_apps_notes_index, "./routes/(apps)/notes/index.tsx": $_apps_notes_index,
@@ -182,17 +158,6 @@ const manifest = {
"./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index,
"./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes,
"./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_, "./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_,
"./routes/(apps)/stages/[...slug].tsx": $_apps_stages_slug_,
"./routes/(apps)/stages/api/stages.ts": $_apps_stages_api_stages,
"./routes/(apps)/stages/api/stages/[idStage].ts":
$_apps_stages_api_stages_idStage_,
"./routes/(apps)/stages/index.tsx": $_apps_stages_index,
"./routes/(apps)/stages/partials/index.tsx": $_apps_stages_partials_index,
"./routes/(apps)/stages/partials/overview.tsx":
$_apps_stages_partials_overview,
"./routes/(apps)/stages/partials/overview/[numEtud].tsx":
$_apps_stages_partials_overview_numEtud_,
"./routes/(apps)/students/[slug].tsx": $_apps_students_slug_,
"./routes/(apps)/students/api/promotions.ts": "./routes/(apps)/students/api/promotions.ts":
$_apps_students_api_promotions, $_apps_students_api_promotions,
"./routes/(apps)/students/api/promotions/[idPromo].ts": "./routes/(apps)/students/api/promotions/[idPromo].ts":
@@ -245,8 +210,12 @@ const manifest = {
$_apps_admin_islands_EditUser, $_apps_admin_islands_EditUser,
"./routes/(apps)/admin/(_islands)/ImportMaquette.tsx": "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx":
$_apps_admin_islands_ImportMaquette, $_apps_admin_islands_ImportMaquette,
"./routes/(apps)/mobility/(_islands)/MobilityOverview.tsx": "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx":
$_apps_mobility_islands_MobilityOverview, $_apps_mobility_islands_ConsultMobility,
"./routes/(apps)/mobility/(_islands)/EditMobility.tsx":
$_apps_mobility_islands_EditMobility,
"./routes/(apps)/mobility/(_islands)/ImportFile.tsx":
$_apps_mobility_islands_ImportFile,
"./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx": "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx":
$_apps_notes_islands_AdminConsultNotes, $_apps_notes_islands_AdminConsultNotes,
"./routes/(apps)/notes/(_islands)/ImportNotes.tsx": "./routes/(apps)/notes/(_islands)/ImportNotes.tsx":
@@ -255,8 +224,6 @@ const manifest = {
$_apps_notes_islands_NoteRecap, $_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)/stages/(_islands)/StagesOverview.tsx":
$_apps_stages_islands_StagesOverview,
"./routes/(apps)/students/(_islands)/ConsultStudents.tsx": "./routes/(apps)/students/(_islands)/ConsultStudents.tsx":
$_apps_students_islands_ConsultStudents, $_apps_students_islands_ConsultStudents,
"./routes/(apps)/students/(_islands)/EditStudents.tsx": "./routes/(apps)/students/(_islands)/EditStudents.tsx":
-1536
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -11,14 +11,6 @@ export default function Header(props: HeaderProps) {
<nav> <nav>
<a href="/apps" f-client-nav={false}>Catalog</a> <a href="/apps" f-client-nav={false}>Catalog</a>
<a href={`/log${props.link}`} f-client-nav={false}>Log {props.link}</a> <a href={`/log${props.link}`} f-client-nav={false}>Log {props.link}</a>
<button
id="theme-toggle"
type="button"
title="Changer de theme"
style="background:none;border:none;cursor:pointer;font-size:1.2rem;padding:0;line-height:1;"
>
<span class="material-symbols-outlined">dark_mode</span>
</button>
</nav> </nav>
</header> </header>
); );
+1 -7
View File
@@ -21,17 +21,11 @@ export const handler: MiddlewareHandler<AuthenticatedState>[] = [
`./${currentApp}/(_props)/props.ts` `./${currentApp}/(_props)/props.ts`
)).default; )).default;
context.state.availablePages = { ...properties.pages };
const isStudent = const isStudent =
context.state.session.eduPersonPrimaryAffiliation === "student"; context.state.session.eduPersonPrimaryAffiliation === "student";
const isLocal = Deno.env.get("LOCAL") === "true"; const isLocal = Deno.env.get("LOCAL") === "true";
// Block students from accessing employeeOnly modules entirely
if (isStudent && properties.employeeOnly) {
return new Response(null, { status: 403 });
}
context.state.availablePages = { ...properties.pages };
if (isStudent) { if (isStudent) {
// Students only see studentOnly pages (+ non-restricted pages) // Students only see studentOnly pages (+ non-restricted pages)
properties.adminOnly.forEach((page) => properties.adminOnly.forEach((page) =>
@@ -115,7 +115,7 @@ export default function AdminEnseignements() {
return ( return (
<div class="page-content"> <div class="page-content">
<h2 class="page-title">Assignations Enseignant ECUE / Promo</h2> <h2 class="page-title">Assignations Enseignant Module / Promo</h2>
{error && <p class="state-error">{error}</p>} {error && <p class="state-error">{error}</p>}
@@ -135,7 +135,7 @@ export default function AdminEnseignements() {
onChange={(e) => onChange={(e) =>
setFilterModule((e.target as HTMLSelectElement).value)} setFilterModule((e.target as HTMLSelectElement).value)}
> >
<option value="">ECUE </option> <option value="">Module </option>
{modules.map((m) => ( {modules.map((m) => (
<option key={m.id} value={m.id}>{m.id} {m.nom}</option> <option key={m.id} value={m.id}>{m.id} {m.nom}</option>
))} ))}
@@ -194,7 +194,7 @@ export default function AdminEnseignements() {
</select> </select>
</div> </div>
<div class="form-field"> <div class="form-field">
<label>ECUE</label> <label>Module</label>
<select <select
class="filter-select" class="filter-select"
value={addModule} value={addModule}
@@ -202,7 +202,7 @@ export default function AdminEnseignements() {
setAddModule((e.target as HTMLSelectElement).value)} setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%" style="min-width: 0; width: 100%"
> >
<option value="">ECUE...</option> <option value="">Module...</option>
{modules.map((m) => ( {modules.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.id} -- {m.nom} {m.id} -- {m.nom}
@@ -251,7 +251,7 @@ export default function AdminEnseignements() {
<thead> <thead>
<tr> <tr>
<th>Promo</th> <th>Promo</th>
<th>ECUE</th> <th>Module</th>
<th>Enseignant (User.id)</th> <th>Enseignant (User.id)</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -319,7 +319,7 @@ export default function AdminEnseignements() {
<div class="info-note"> <div class="info-note">
<p> <p>
Un même ECUE peut être enseigné par plusieurs utilisateurs sur une Un même module peut être enseigné par plusieurs utilisateurs sur une
même promo. même promo.
</p> </p>
<p class="info-note-dim"> <p class="info-note-dim">
@@ -22,7 +22,7 @@ export default function AdminModules() {
fetch("/admin/api/enseignements"), fetch("/admin/api/enseignements"),
fetch("/admin/api/users"), fetch("/admin/api/users"),
]); ]);
if (!mRes.ok) throw new Error("Impossible de charger les ECUEs"); if (!mRes.ok) throw new Error("Impossible de charger les modules");
setModules(await mRes.json()); setModules(await mRes.json());
if (eRes.ok) setEnseignements(await eRes.json()); if (eRes.ok) setEnseignements(await eRes.json());
if (uRes.ok) setUsers(await uRes.json()); if (uRes.ok) setUsers(await uRes.json());
@@ -61,7 +61,7 @@ export default function AdminModules() {
} }
async function deleteModule(id: string) { async function deleteModule(id: string) {
if (!confirm(`Supprimer l'ECUE ${id} ?`)) return; if (!confirm(`Supprimer le module ${id} ?`)) return;
try { try {
const res = await fetch( const res = await fetch(
`/admin/api/modules/${encodeURIComponent(id)}`, `/admin/api/modules/${encodeURIComponent(id)}`,
@@ -102,7 +102,7 @@ export default function AdminModules() {
return ( return (
<div class="page-content"> <div class="page-content">
<h2 class="page-title">Gestion des ECUEs</h2> <h2 class="page-title">Gestion des Modules</h2>
{error && <p class="state-error">{error}</p>} {error && <p class="state-error">{error}</p>}
@@ -122,7 +122,7 @@ export default function AdminModules() {
}} }}
style="margin-left: auto" style="margin-left: auto"
> >
+ Ajouter ECUE + Ajouter module
</button> </button>
</div> </div>
@@ -134,7 +134,7 @@ export default function AdminModules() {
<thead> <thead>
<tr> <tr>
<th>id (code)</th> <th>id (code)</th>
<th>Nom de l'ECUE</th> <th>Nom du module</th>
<th>Enseignants assignes</th> <th>Enseignants assignes</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -144,7 +144,7 @@ export default function AdminModules() {
? ( ? (
<tr> <tr>
<td colspan={4} class="state-empty"> <td colspan={4} class="state-empty">
Aucun ECUE enregistré Aucun module enregistré
</td> </td>
</tr> </tr>
) )
@@ -218,13 +218,13 @@ export default function AdminModules() {
</div> </div>
)} )}
{/* Nouvel ECUE */} {/* Nouveau module */}
<div <div
id="new-module-section" id="new-module-section"
class="edit-section" class="edit-section"
style="margin-top: 1.5rem" style="margin-top: 1.5rem"
> >
<p class="edit-section-title">Nouvel ECUE</p> <p class="edit-section-title">Nouveau module</p>
<div class="form-row"> <div class="form-row">
<input <input
class="form-input" class="form-input"
@@ -235,7 +235,7 @@ export default function AdminModules() {
/> />
<input <input
class="form-input" class="form-input"
placeholder="Nom de l'ECUE" placeholder="Nom du module"
value={newNom} value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)} onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/> />
+9 -9
View File
@@ -104,7 +104,7 @@ export default function AdminUEs() {
idUE: number, idUE: number,
idPromo: string, idPromo: string,
) { ) {
if (!confirm("Supprimer cet ECUE de la UE ?")) return; if (!confirm("Supprimer ce module de la UE ?")) return;
try { try {
const res = await fetch( const res = await fetch(
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ `/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
@@ -121,7 +121,7 @@ export default function AdminUEs() {
async function addUeModule() { async function addUeModule() {
if (!selectedUe || !addModuleId || !addPromoId) { if (!selectedUe || !addModuleId || !addPromoId) {
setAddError("ECUE et Promo sont requis"); setAddError("Module et Promo sont requis");
return; return;
} }
const coeff = parseFloat(addCoeff); const coeff = parseFloat(addCoeff);
@@ -203,7 +203,7 @@ export default function AdminUEs() {
class="col-dim" class="col-dim"
style="font-size: 0.78rem; margin: -0.5rem 0 1rem" style="font-size: 0.78rem; margin: -0.5rem 0 1rem"
> >
UE = Unité d'Enseignement regroupant plusieurs ECUEs UE = Unité d'Enseignement regroupant plusieurs modules
</p> </p>
{error && <p class="state-error">{error}</p>} {error && <p class="state-error">{error}</p>}
@@ -314,13 +314,13 @@ export default function AdminUEs() {
<div class="panel-box"> <div class="panel-box">
<p class="panel-box-title">{selectedUe.nom}</p> <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"> <p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
ECUEs assignés (UE_Module) Modules assignés (UE_Module)
</p> </p>
<div class="data-table-wrap" style="margin-bottom: 1rem"> <div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>ECUE</th> <th>Module</th>
<th>Promo</th> <th>Promo</th>
<th>Coeff</th> <th>Coeff</th>
<th>Actions</th> <th>Actions</th>
@@ -331,7 +331,7 @@ export default function AdminUEs() {
? ( ? (
<tr> <tr>
<td colspan={4} class="state-empty"> <td colspan={4} class="state-empty">
Aucun ECUE assigné Aucun module assigné
</td> </td>
</tr> </tr>
) )
@@ -441,7 +441,7 @@ export default function AdminUEs() {
</div> </div>
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem"> <p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un ECUE à cette UE Ajouter un module à cette UE
</p> </p>
{addError && ( {addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem"> <p class="state-error" style="padding: 0.3rem 0.5rem">
@@ -458,7 +458,7 @@ export default function AdminUEs() {
)} )}
style="min-width: 12rem" style="min-width: 12rem"
> >
<option value="">ECUE </option> <option value="">Module </option>
{modules.map((m) => ( {modules.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.id} {m.nom} {m.id} {m.nom}
@@ -504,7 +504,7 @@ export default function AdminUEs() {
: ( : (
<div class="panel-box"> <div class="panel-box">
<p class="state-empty" style="padding: 2rem 0"> <p class="state-empty" style="padding: 2rem 0">
Sélectionnez une UE pour voir ses ECUEs Sélectionnez une UE pour voir ses modules
</p> </p>
</div> </div>
)} )}
@@ -33,7 +33,7 @@ export default function EditModule({ moduleId }: Props) {
fetch("/admin/api/users"), fetch("/admin/api/users"),
fetch("/students/api/promotions"), fetch("/students/api/promotions"),
]); ]);
if (!mRes.ok) throw new Error("ECUE introuvable"); if (!mRes.ok) throw new Error("Module introuvable");
const m: Module = await mRes.json(); const m: Module = await mRes.json();
setMod(m); setMod(m);
setNom(m.nom); setNom(m.nom);
@@ -70,7 +70,7 @@ export default function EditModule({ moduleId }: Props) {
if (!res.ok) throw new Error("Modification échouée"); if (!res.ok) throw new Error("Modification échouée");
const updated: Module = await res.json(); const updated: Module = await res.json();
setMod(updated); setMod(updated);
setSaveMsg("ECUE enregistré."); setSaveMsg("Module enregistré.");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
@@ -79,7 +79,7 @@ export default function EditModule({ moduleId }: Props) {
} }
async function deleteModule() { async function deleteModule() {
if (!confirm(`Supprimer définitivement l'ECUE ${moduleId} ?`)) return; if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return;
try { try {
const res = await fetch( const res = await fetch(
`/admin/api/modules/${encodeURIComponent(moduleId)}`, `/admin/api/modules/${encodeURIComponent(moduleId)}`,
@@ -173,7 +173,7 @@ export default function EditModule({ moduleId }: Props) {
class="page-title" class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem" style="border-bottom: none; margin-bottom: 0.5rem"
> >
ECUE -- {mod.id} Module -- {mod.id}
</h2> </h2>
<div class="info-bar"> <div class="info-bar">
@@ -202,7 +202,7 @@ export default function EditModule({ moduleId }: Props) {
/> />
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Nom de l'ECUE</label> <label>Nom du module</label>
<input <input
class="form-input" class="form-input"
value={nom} value={nom}
@@ -224,7 +224,7 @@ export default function EditModule({ moduleId }: Props) {
class="btn btn-danger" class="btn btn-danger"
onClick={deleteModule} onClick={deleteModule}
> >
Supprimer l'ECUE Supprimer le module
</button> </button>
</div> </div>
</div> </div>
+4 -4
View File
@@ -106,7 +106,7 @@ export default function EditUser({ userId }: Props) {
async function addEnseignement() { async function addEnseignement() {
if (!addModule || !addPromo) { if (!addModule || !addPromo) {
setAddError("ECUE et Promo sont requis"); setAddError("Module et Promo sont requis");
return; return;
} }
setAdding(true); setAdding(true);
@@ -276,7 +276,7 @@ export default function EditUser({ userId }: Props) {
class="col-dim" class="col-dim"
style="font-size: 0.75rem; margin: 0 0 0.75rem" style="font-size: 0.75rem; margin: 0 0 0.75rem"
> >
ECUEs enseignes par cet utilisateur Modules enseignes par cet utilisateur
</p> </p>
{enseignements.length > 0 {enseignements.length > 0
@@ -285,7 +285,7 @@ export default function EditUser({ userId }: Props) {
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>ECUE</th> <th>Module</th>
<th>Promo</th> <th>Promo</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -360,7 +360,7 @@ export default function EditUser({ userId }: Props) {
setAddModule((e.target as HTMLSelectElement).value)} setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 12rem" style="min-width: 12rem"
> >
<option value="">ECUE</option> <option value="">Module</option>
{modules.map((m) => ( {modules.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.id} -- {m.nom} {m.id} -- {m.nom}
@@ -151,10 +151,7 @@ export default function ImportMaquette() {
}); });
if (res.ok) { if (res.ok) {
const created = await res.json(); const created = await res.json();
promos.value = [...promos.value, { promos.value = [...promos.value, { id: created.id, annee: created.annee }];
id: created.id,
annee: created.annee,
}];
newPromoId.value = ""; newPromoId.value = "";
newPromoAnnee.value = ""; newPromoAnnee.value = "";
} else { } else {
@@ -229,13 +226,13 @@ export default function ImportMaquette() {
added++; added++;
details.push({ details.push({
type: "change", type: "change",
message: `ECUE ${mod.code} "${mod.name}" cree`, message: `Module ${mod.code} "${mod.name}" cree`,
}); });
} else if (modRes.status !== 409) { } else if (modRes.status !== 409) {
errCount++; errCount++;
details.push({ details.push({
type: "error", type: "error",
message: `ECUE "${mod.code}" : creation echouee`, message: `Module "${mod.code}" : creation echouee`,
}); });
continue; continue;
} }
@@ -281,7 +278,7 @@ export default function ImportMaquette() {
globalThis.open("/templates/modele_maquette.xlsx", "_blank"); globalThis.open("/templates/modele_maquette.xlsx", "_blank");
} }
function _downloadExport() { function downloadExport() {
Promise.all([ Promise.all([
fetch("/admin/api/ues").then((r) => r.json()), fetch("/admin/api/ues").then((r) => r.json()),
fetch("/admin/api/ue-modules").then((r) => r.json()), fetch("/admin/api/ue-modules").then((r) => r.json()),
@@ -292,14 +289,7 @@ export default function ImportMaquette() {
); );
const data: (string | number | null)[][] = [ const data: (string | number | null)[][] = [
[ ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\nECTS", "Coeff."],
"Annee\nSemestres",
"Codes APOGEE",
null,
null,
"Credits\nECTS",
"Coeff.",
],
]; ];
for (const ue of uesData) { for (const ue of uesData) {
@@ -313,14 +303,7 @@ export default function ImportMaquette() {
data.push(["UE", null, ue.nom, null, totalCoeff]); data.push(["UE", null, ue.nom, null, totalCoeff]);
for (const um of mods) { for (const um of mods) {
const mod = modMap[um.idModule]; const mod = modMap[um.idModule];
data.push([ data.push([null, um.idModule, null, mod ? mod.nom : um.idModule, null, um.coeff]);
null,
um.idModule,
null,
mod ? mod.nom : um.idModule,
null,
um.coeff,
]);
} }
data.push([]); data.push([]);
} }
@@ -329,10 +312,7 @@ export default function ImportMaquette() {
const ws = XLSX.utils.aoa_to_sheet(data); const ws = XLSX.utils.aoa_to_sheet(data);
XLSX.utils.book_append_sheet(wb, ws, "Maquette"); XLSX.utils.book_append_sheet(wb, ws, "Maquette");
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([buf], { const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
type:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -404,9 +384,8 @@ export default function ImportMaquette() {
class="filter-select" class="filter-select"
placeholder="ID (ex: 3AFISE24-25)" placeholder="ID (ex: 3AFISE24-25)"
value={newPromoId.value} value={newPromoId.value}
onInput={( onInput={(e) =>
e, (newPromoId.value = (e.target as HTMLInputElement).value)}
) => (newPromoId.value = (e.target as HTMLInputElement).value)}
style="min-width: 10rem" style="min-width: 10rem"
/> />
<input <input
@@ -414,9 +393,8 @@ export default function ImportMaquette() {
class="filter-select" class="filter-select"
placeholder="Annee (ex: 2024-2025)" placeholder="Annee (ex: 2024-2025)"
value={newPromoAnnee.value} value={newPromoAnnee.value}
onInput={( onInput={(e) =>
e, (newPromoAnnee.value = (e.target as HTMLInputElement).value)}
) => (newPromoAnnee.value = (e.target as HTMLInputElement).value)}
style="min-width: 8rem" style="min-width: 8rem"
/> />
<button <button
@@ -446,7 +424,7 @@ export default function ImportMaquette() {
<p style="font-size: 0.85rem; font-weight: 700; margin: 0"> <p style="font-size: 0.85rem; font-weight: 700; margin: 0">
{year.label} {year.label}
<span class="col-dim" style="font-weight: 400"> <span class="col-dim" style="font-weight: 400">
{year.ues.length} UE, {totalMods} ECUEs {" "} {year.ues.length} UE, {totalMods} modules
</span> </span>
</p> </p>
<select <select
@@ -473,7 +451,7 @@ export default function ImportMaquette() {
<thead> <thead>
<tr> <tr>
<th>UE</th> <th>UE</th>
<th>ECUE</th> <th>Module</th>
<th>Code</th> <th>Code</th>
<th>Coeff</th> <th>Coeff</th>
</tr> </tr>
@@ -485,7 +463,7 @@ export default function ImportMaquette() {
<tr key={`ue-${i}`}> <tr key={`ue-${i}`}>
<td style="font-weight: 600">{ue.name}</td> <td style="font-weight: 600">{ue.name}</td>
<td class="col-dim" colspan={3}> <td class="col-dim" colspan={3}>
Aucun ECUE Aucun module
</td> </td>
</tr> </tr>
) )
@@ -499,7 +477,7 @@ export default function ImportMaquette() {
{ue.name} {ue.name}
{ue.ects != null && ( {ue.ects != null && (
<span class="col-dim"> <span class="col-dim">
({ue.ects} ECTS) {" "}({ue.ects} ECTS)
</span> </span>
)} )}
</td> </td>
@@ -535,8 +513,6 @@ export default function ImportMaquette() {
> >
Telecharger Modele Telecharger Modele
</button> </button>
{
/* TODO: fix blob download in Fresh
<button <button
type="button" type="button"
class="btn btn-secondary" class="btn btn-secondary"
@@ -544,13 +520,11 @@ export default function ImportMaquette() {
> >
Exporter Maquette Exporter Maquette
</button> </button>
*/
}
</div> </div>
<p class="upload-format"> <p class="upload-format">
Format : fichier maquette FISE / FISA avec lignes <strong>UE</strong> Format : fichier maquette FISE / FISA avec lignes <strong>UE</strong>
et <strong>ECUEs</strong> (colonnes code, nom, coefficient) {" "}et <strong>modules</strong> (colonnes code, nom, coefficient)
</p> </p>
</div> </div>
); );
+3 -13
View File
@@ -8,24 +8,14 @@ const properties: AppProperties = {
users: "Utilisateurs", users: "Utilisateurs",
roles: "Rôles", roles: "Rôles",
permissions: "Permissions", permissions: "Permissions",
modules: "ECUEs", modules: "Modules",
enseignements: "Enseignements", enseignements: "Enseignements",
promotions: "Promotions", promotions: "Promotions",
ues: "UEs", ues: "UEs",
"import-maquette": "Import Maquette", "import-maquette": "Import Maquette",
}, },
adminOnly: [ adminOnly: ["users", "roles", "permissions", "modules", "enseignements", "promotions", "ues", "import-maquette"],
"users", hint: "PolyMPR module",
"roles",
"permissions",
"modules",
"enseignements",
"promotions",
"ues",
"import-maquette",
],
employeeOnly: true,
hint: "PolyMPR ECUE",
}; };
export default properties; export default properties;
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
+1 -1
View File
@@ -44,7 +44,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
if (existing) { if (existing) {
return new Response( return new Response(
JSON.stringify({ error: "Un ECUE avec cet identifiant existe déjà" }), JSON.stringify({ error: "Un module avec cet identifiant existe déjà" }),
{ status: 409, headers: { "content-type": "application/json" } }, { status: 409, headers: { "content-type": "application/json" } },
); );
} }
+2 -2
View File
@@ -65,8 +65,8 @@ export const handler: Handlers = {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} catch (error) { } catch (error) {
console.error("Error creating UE-ECUE:", error); console.error("Error creating UE-module:", error);
return new Response("Failed to create UE-ECUE", { status: 500 }); return new Response("Failed to create UE-module", { status: 500 });
} }
}, },
}; };
@@ -6,7 +6,7 @@ import { and, eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = () => const NOT_FOUND = () =>
new Response( new Response(
JSON.stringify({ error: "Association UE-ECUE introuvable" }), JSON.stringify({ error: "Association UE-Module introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } }, { status: 404, headers: { "content-type": "application/json" } },
); );
@@ -14,6 +14,5 @@ async function Enseignements(
return <AdminEnseignements />; return <AdminEnseignements />;
} }
export { Enseignements as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Enseignements); export default makePartials(Enseignements);
@@ -19,6 +19,5 @@ async function ImportMaquettePage(
); );
} }
export { ImportMaquettePage as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(ImportMaquettePage); export default makePartials(ImportMaquettePage);
-1
View File
@@ -14,6 +14,5 @@ async function Modules(
return <AdminModules />; return <AdminModules />;
} }
export { Modules as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Modules); export default makePartials(Modules);
@@ -14,6 +14,5 @@ async function Permissions(
return <AdminPermissions />; return <AdminPermissions />;
} }
export { Permissions as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Permissions); export default makePartials(Permissions);
@@ -14,6 +14,5 @@ async function Promotions(
return <AdminPromotions />; return <AdminPromotions />;
} }
export { Promotions as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Promotions); export default makePartials(Promotions);
-1
View File
@@ -14,6 +14,5 @@ async function Roles(
return <AdminRoles />; return <AdminRoles />;
} }
export { Roles as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Roles); export default makePartials(Roles);
-1
View File
@@ -14,6 +14,5 @@ async function UEs(
return <AdminUEs />; return <AdminUEs />;
} }
export { UEs as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(UEs); export default makePartials(UEs);
-1
View File
@@ -14,6 +14,5 @@ async function Users(
return <AdminUsers />; return <AdminUsers />;
} }
export { Users as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Users); export default makePartials(Users);
@@ -0,0 +1,115 @@
import { useEffect, useState } from "preact/hooks";
interface Promotion {
id: number;
name: string;
}
interface Student {
id: string;
firstName: string;
lastName: string;
promotionId: number;
}
interface Mobility {
id: number;
studentId: string;
startDate: string | null;
endDate: string | null;
weeksCount: number | null;
destinationCountry: string | null;
destinationName: string | null;
mobilityStatus: string;
}
export default function ConsultMobility() {
const [data, setData] = useState<
| {
promotions?: Promotion[];
students?: Student[];
mobilities?: Mobility[];
}
| null
>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
console.log("ConsultMobility: Fetching data from API...");
try {
const response = await fetch("/mobility/api/insert_mobility");
console.log("ConsultMobility: API response status:", response.status);
if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`);
}
const result = await response.json();
console.log("ConsultMobility: Data fetched successfully:", result);
setData(result);
} catch (err) {
console.error("ConsultMobility: Error fetching data:", err);
setError("Failed to load mobility data. Please try again later.");
}
};
fetchData();
}, []);
if (error) {
return <p className="error">{error}</p>;
}
if (!data?.promotions) {
return <p>No promotions found.</p>;
}
return (
<section>
<h2>Consult Mobility</h2>
{data.promotions.map((promo) => (
<div key={promo.id}>
<h3>Promotion: {promo.name}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Start Date</th>
<th>End Date</th>
<th>Weeks Count</th>
<th>Destination Country</th>
<th>Destination Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{data.students
?.filter((student) => student.promotionId === promo.id)
.map((student) => {
const mobility = data.mobilities?.find((mob) =>
mob.studentId === student.id
);
return (
<tr key={student.id}>
<td>{student.id}</td>
<td>{student.firstName}</td>
<td>{student.lastName}</td>
<td>{mobility?.startDate || "N/A"}</td>
<td>{mobility?.endDate || "N/A"}</td>
<td>{mobility?.weeksCount ?? "N/A"}</td>
<td>{mobility?.destinationCountry || "N/A"}</td>
<td>{mobility?.destinationName || "N/A"}</td>
<td>{mobility?.mobilityStatus || "N/A"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
</section>
);
}
@@ -0,0 +1,75 @@
import { useEffect, useState } from "preact/hooks";
interface Promotion {
id: number;
name: string;
}
interface Student {
id: number;
firstName: string;
lastName: string;
mail: string;
promotionId: number;
promotionName: string;
}
export default function ConsultStudents_test() {
const [data, setData] = useState<
{ promotions: Promotion[]; students: Student[] } | null
>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch("/students/api/insert_students");
if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error("Error fetching data:", err);
setError("Failed to load data. Please try again later.");
}
};
fetchData();
}, []);
return (
<section>
<h2>Consult Students</h2>
{error && <p className="error">{error}</p>}
{data?.promotions.map((promo) => (
<div key={promo.id}>
<h3>Promotion: {promo.id}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{data.students
.filter((student) => student.promotionId === promo.id)
.map((student) => (
<tr key={student.id}>
<td>{student.id}</td>
<td>{student.firstName}</td>
<td>{student.lastName}</td>
<td>{student.mail}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</section>
);
}
@@ -0,0 +1,248 @@
import { useEffect, useState } from "preact/hooks";
interface Student {
id: string;
firstName: string;
lastName: string;
promotionId: number;
}
interface Promotion {
id: number;
name: string;
}
interface Mobility {
id: number | null;
studentId: string;
startDate: string | null;
endDate: string | null;
weeksCount: number | null;
destinationCountry: string | null;
destinationName: string | null;
mobilityStatus: string;
}
export default function EditMobility() {
const [data, setData] = useState<
| {
promotions?: Promotion[];
students?: Student[];
mobilities?: Mobility[];
}
| null
>(null);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const fetchData = async () => {
console.log("EditMobility: Fetching data from API...");
try {
const response = await fetch("/mobility/api/insert_mobility");
console.log("EditMobility: API response status:", response.status);
if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`);
}
const result = await response.json();
console.log("EditMobility: Data fetched successfully:", result);
setData(result);
} catch (err) {
console.error("EditMobility: Error fetching data:", err);
setError("Failed to load mobility data. Please try again later.");
}
};
fetchData();
}, []);
const handleChange = (
studentId: string,
field: keyof Mobility,
value: string | number | null,
) => {
if (!data) return;
setData((prevData) => {
if (!prevData) return null;
const updatedMobilities = prevData.mobilities?.map((mobility) => {
if (mobility.studentId === studentId) {
const updatedMobility = { ...mobility, [field]: value };
if (field === "startDate" || field === "endDate") {
const startDate = new Date(updatedMobility.startDate || "");
const endDate = new Date(updatedMobility.endDate || "");
if (startDate && endDate && startDate <= endDate) {
const weeks = Math.ceil(
(endDate.getTime() - startDate.getTime()) /
(7 * 24 * 60 * 60 * 1000),
);
updatedMobility.weeksCount = weeks;
} else {
updatedMobility.weeksCount = null;
}
}
return updatedMobility;
}
return mobility;
}) || [];
return { ...prevData, mobilities: updatedMobilities };
});
};
const handleSave = async () => {
setIsSaving(true);
try {
const response = await fetch("/mobility/api/insert_mobility", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: data?.mobilities }),
});
console.log("EditMobility: Save response status:", response.status);
if (response.ok) {
alert("Data saved successfully!");
globalThis.location.reload();
} else {
throw new Error(`Failed to save data: ${response.statusText}`);
}
} catch (error) {
console.error("EditMobility: Error saving data:", error);
alert("An error occurred while saving data.");
} finally {
setIsSaving(false);
}
};
if (error) {
return <p className="error">{error}</p>;
}
if (!data?.promotions) {
return <p>Loading data...</p>;
}
return (
<section>
<h2>Edit Mobility</h2>
{data.promotions.map((promo) => (
<div key={promo.id}>
<h3>Promotion: {promo.name}</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Start Date</th>
<th>End Date</th>
<th>Weeks Count</th>
<th>Destination Country</th>
<th>Destination Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{data.students
?.filter((student) => student.promotionId === promo.id)
.map((student) => {
const mobility = data.mobilities?.find((mob) =>
mob.studentId === student.id
) || {
id: null,
studentId: student.id,
startDate: null,
endDate: null,
weeksCount: null,
destinationCountry: null,
destinationName: null,
mobilityStatus: "N/A",
};
return (
<tr key={student.id}>
<td>{student.id}</td>
<td>{student.firstName}</td>
<td>{student.lastName}</td>
<td>
<input
type="date"
value={mobility.startDate || ""}
onChange={(e) =>
handleChange(
student.id,
"startDate",
e.target.value,
)}
/>
</td>
<td>
<input
type="date"
value={mobility.endDate || ""}
onChange={(e) =>
handleChange(student.id, "endDate", e.target.value)}
/>
</td>
<td>{mobility.weeksCount ?? "N/A"}</td>
<td>
<input
type="text"
value={mobility.destinationCountry || ""}
onChange={(e) =>
handleChange(
student.id,
"destinationCountry",
e.target.value,
)}
/>
</td>
<td>
<input
type="text"
value={mobility.destinationName || ""}
onChange={(e) =>
handleChange(
student.id,
"destinationName",
e.target.value,
)}
/>
</td>
<td>
<select
value={mobility.mobilityStatus}
onChange={(e) =>
handleChange(
student.id,
"mobilityStatus",
e.target.value,
)}
>
<option value="N/A">N/A</option>
<option value="Planned">Planned</option>
<option value="In Progress">In Progress</option>
<option value="Completed">Completed</option>
<option value="Validated">Validated</option>
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
<button type="button" onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Confirm"}
</button>
</section>
);
}
@@ -1,997 +0,0 @@
import { useEffect, useState } from "preact/hooks";
type Student = {
numEtud: number;
nom: string;
prenom: string;
idPromo: string;
};
type Promotion = { id: string; annee: string };
type Mobilite = {
id: number;
numEtud: number;
duree: number;
contratMob: string | null;
ecole: string | null;
pays: string | null;
status: string;
idStage: number | null;
};
type Stage = {
id: number;
numEtud: number;
duree: number;
nomEntreprise: string;
mission: string | null;
};
const REQUIRED_WEEKS = 12;
const STATUS_ORDER = [
"contracts_received",
"under_revision",
"done",
"validated",
"canceled",
] as const;
const STATUS_LABELS: Record<string, string> = {
contracts_received: "Contrats reçus",
under_revision: "En révision",
done: "Signé",
validated: "Validé",
canceled: "Annulé",
};
const STATUS_COLORS: Record<string, string> = {
contracts_received: "#f5a623",
under_revision: "#dc2626",
done: "#22c55e",
validated: "light-dark(var(--light-accent-color), var(--dark-accent-color))",
canceled:
"light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))",
};
function lowestStatus(mobs: Mobilite[]): string {
let lowest = STATUS_ORDER.length - 1;
for (const m of mobs) {
const idx = STATUS_ORDER.indexOf(m.status as typeof STATUS_ORDER[number]);
if (idx >= 0 && idx < lowest) lowest = idx;
}
return STATUS_ORDER[lowest];
}
function validatedWeeks(mobs: Mobilite[]): number {
return mobs
.filter((m) => m.status === "validated")
.reduce((sum, m) => sum + m.duree, 0);
}
export default function MobilityOverview(
{ initialNumEtud }: { initialNumEtud?: number } = {},
) {
const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]);
const [mobilites, setMobilites] = useState<Mobilite[]>([]);
const [stagesMap, setStagesMap] = useState<Record<number, Stage>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"liste" | "kanban">("liste");
const [filterPromo, setFilterPromo] = useState("");
const [filterNom, setFilterNom] = useState("");
// Detail view state
const [detailStudent, setDetailStudent] = useState<Student | null>(null);
const [editingMob, setEditingMob] = useState<Mobilite | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
async function load() {
try {
const [sRes, pRes, mRes, stRes] = await Promise.all([
fetch("/students/api/students"),
fetch("/students/api/promotions"),
fetch("/mobility/api/mobilites"),
fetch("/stages/api/stages"),
]);
if (!sRes.ok) throw new Error("Impossible de charger les données");
const [sData, pData, mData, stData] = await Promise.all([
sRes.json(),
pRes.ok ? pRes.json() : [],
mRes.ok ? mRes.json() : [],
stRes.ok ? stRes.json() : [],
]);
setStudents(sData);
setPromos(pData);
setMobilites(mData);
setStagesMap(
Object.fromEntries((stData as Stage[]).map((s) => [s.id, s])),
);
if (initialNumEtud) {
const s = (sData as Student[]).find((s) =>
s.numEtud === initialNumEtud
);
if (s) setDetailStudent(s);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
function openStudent(s: Student) {
setDetailStudent(s);
history.pushState(null, "", `/mobility/overview/${s.numEtud}`);
}
function closeStudent() {
setDetailStudent(null);
setEditingMob(null);
setShowAddForm(false);
history.pushState(null, "", "/mobility/overview");
}
// If in detail view, render that
if (detailStudent) {
return (
<DetailView
student={detailStudent}
mobilites={mobilites.filter((m) => m.numEtud === detailStudent.numEtud)}
allMobilites={mobilites}
stagesMap={stagesMap}
editingMob={editingMob}
setEditingMob={setEditingMob}
showAddForm={showAddForm}
setShowAddForm={setShowAddForm}
onBack={closeStudent}
onReload={load}
/>
);
}
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
const filtered = students.filter((s) => {
const matchPromo = !filterPromo || s.idPromo === filterPromo;
const matchNom = !filterNom ||
`${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase());
return matchPromo && matchNom;
});
const mobsByStudent = (numEtud: number) =>
mobilites.filter((m) => m.numEtud === numEtud);
return (
<div class="page-content">
<h2 class="page-title">Suivi des mobilités</h2>
<div class="tabs">
<button
type="button"
class={`tab-btn${tab === "liste" ? " active" : ""}`}
onClick={() => setTab("liste")}
>
Liste
</button>
<button
type="button"
class={`tab-btn${tab === "kanban" ? " active" : ""}`}
onClick={() => setTab("kanban")}
>
Kanban
</button>
</div>
<div class="filters">
<select
class="filter-select"
value={filterPromo}
onChange={(e) =>
setFilterPromo((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les promos</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<input
class="filter-input"
placeholder="Rechercher..."
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
</div>
{tab === "liste"
? (
<ListView
students={filtered}
mobsByStudent={mobsByStudent}
onConsult={(s) => openStudent(s)}
/>
)
: (
<KanbanView
students={filtered}
mobsByStudent={mobsByStudent}
onConsult={(s) => openStudent(s)}
/>
)}
</div>
);
}
// ─── Liste View ─────────────────────────────────────────────
function ListView(
{ students, mobsByStudent, onConsult }: {
students: Student[];
mobsByStudent: (n: number) => Mobilite[];
onConsult: (s: Student) => void;
},
) {
return (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>N° étud.</th>
<th>Nom</th>
<th>Prénom</th>
<th>Semaines</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{students.length === 0
? (
<tr>
<td colspan={5} class="state-empty">Aucun élève trouvé</td>
</tr>
)
: students.map((s) => {
const mobs = mobsByStudent(s.numEtud);
const weeks = validatedWeeks(mobs);
const ok = weeks >= REQUIRED_WEEKS;
return (
<tr key={s.numEtud}>
<td class="col-dim">{s.numEtud}</td>
<td>{s.nom}</td>
<td>{s.prenom}</td>
<td>
<span
style={{
color: ok ? "var(--ok-color,#22c55e)" : "#dc2626",
fontWeight: "var(--font-weight-bold)",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS}
</span>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => onConsult(s)}
>
Consulter
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── Kanban View ────────────────────────────────────────────
function KanbanView(
{ students, mobsByStudent, onConsult }: {
students: Student[];
mobsByStudent: (n: number) => Mobilite[];
onConsult: (s: Student) => void;
},
) {
// Students who have at least one mobility
const studentsWithMobs = students.filter(
(s) => mobsByStudent(s.numEtud).length > 0,
);
// Group students by their lowest status
const columns: Record<string, Student[]> = {};
for (const status of STATUS_ORDER) columns[status] = [];
for (const s of studentsWithMobs) {
const mobs = mobsByStudent(s.numEtud);
// Filter out canceled for lowest-status calc (canceled is separate)
const activeMobs = mobs.filter((m) => m.status !== "canceled");
if (activeMobs.length === 0) {
// All canceled
columns["canceled"].push(s);
} else {
const lowest = lowestStatus(activeMobs);
columns[lowest].push(s);
}
}
return (
<div
style={{
display: "flex",
gap: "0.75rem",
overflowX: "auto",
paddingBottom: "0.5rem",
}}
>
{STATUS_ORDER.map((status) => (
<div
key={status}
style={{
minWidth: "14rem",
flex: "1",
borderRadius: "4px",
border:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
background: "light-dark(white, #141228)",
}}
>
<div
style={{
padding: "0.6rem 0.75rem",
borderBottom:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span
style={{
width: "0.6rem",
height: "0.6rem",
borderRadius: "50%",
background: STATUS_COLORS[status],
display: "inline-block",
}}
/>
<span
style={{
fontWeight: "var(--font-weight-bold)",
fontSize: "0.82rem",
}}
>
{STATUS_LABELS[status]}
</span>
<span
style={{
fontSize: "0.7rem",
opacity: "0.7",
marginLeft: "auto",
}}
>
{columns[status].length}
</span>
</div>
<div style={{ padding: "0.5rem" }}>
{columns[status].length === 0
? (
<p
style={{
fontSize: "0.75rem",
opacity: "0.5",
textAlign: "center",
margin: "1rem 0",
}}
>
Aucun
</p>
)
: columns[status].map((s) => {
const mobs = mobsByStudent(s.numEtud);
const weeks = validatedWeeks(mobs);
return (
<div
key={s.numEtud}
style={{
padding: "0.5rem 0.6rem",
marginBottom: "0.4rem",
borderRadius: "3px",
border:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
cursor: "pointer",
fontSize: "0.8rem",
}}
onClick={() => onConsult(s)}
>
<div
style={{
fontWeight: "var(--font-weight-bold)",
marginBottom: "0.15rem",
}}
>
{s.nom} {s.prenom}
</div>
<div
style={{
fontSize: "0.72rem",
display: "flex",
justifyContent: "space-between",
}}
>
<span style={{ opacity: "0.7" }}>{s.numEtud}</span>
<span
style={{
color: weeks >= REQUIRED_WEEKS
? "#22c55e"
: "#dc2626",
fontWeight: "var(--font-weight-bold)",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS} sem.
</span>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// ─── Detail View ────────────────────────────────────────────
function DetailView(
{
student,
mobilites,
allMobilites,
stagesMap,
editingMob,
setEditingMob,
showAddForm,
setShowAddForm,
onBack,
onReload,
}: {
student: Student;
mobilites: Mobilite[];
allMobilites: Mobilite[];
stagesMap: Record<number, Stage>;
editingMob: Mobilite | null;
setEditingMob: (m: Mobilite | null) => void;
showAddForm: boolean;
setShowAddForm: (v: boolean) => void;
onBack: () => void;
onReload: () => Promise<void>;
},
) {
const weeks = validatedWeeks(mobilites);
const ecoles = [...new Set(allMobilites.map((m) => m.ecole).filter(Boolean))];
const paysList = [
...new Set(allMobilites.map((m) => m.pays).filter(Boolean)),
];
async function deleteMob(id: number) {
if (!confirm("Supprimer cette mobilité ?")) return;
await fetch(`/mobility/api/mobilites/${id}`, { method: "DELETE" });
await onReload();
}
return (
<div class="page-content">
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onBack}
style={{ marginBottom: "0.75rem" }}
>
Retour
</button>
<h2 class="page-title">
Consulter : {student.prenom} {student.nom}
<span
style={{
fontSize: "0.8rem",
fontWeight: "normal",
marginLeft: "0.75rem",
color: weeks >= REQUIRED_WEEKS ? "#22c55e" : "#dc2626",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS} semaines validées
</span>
</h2>
{mobilites.length === 0 && (
<p class="state-empty">Aucune mobilité enregistrée.</p>
)}
{mobilites.map((mob, i) => {
const stage = mob.idStage ? stagesMap[mob.idStage] : null;
const isEditing = editingMob?.id === mob.id;
if (isEditing) {
return (
<MobEditForm
key={mob.id}
mob={mob}
ecoles={ecoles}
paysList={paysList}
onCancel={() => setEditingMob(null)}
onSave={async () => {
setEditingMob(null);
await onReload();
}}
/>
);
}
return (
<div key={mob.id} class="ue-card" style={{ marginBottom: "1rem" }}>
<div class="ue-card-header">
<p class="ue-card-title">
Mobilité {i + 1}
{stage ? " : Stage" : " : Étude"}
</p>
<p
class="ue-card-avg"
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
}}
>
<span
class={`note-chip ${
mob.status === "validated"
? "note-chip--ok"
: mob.status === "canceled"
? "note-chip--fail"
: "note-chip--none"
}`}
>
{STATUS_LABELS[mob.status] ?? mob.status}
</span>
<span>Durée : {mob.duree} semaine(s)</span>
</p>
</div>
<div style={{ padding: "0.6rem 1.1rem" }}>
{stage
? (
<p style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
Entreprise : <strong>{stage.nomEntreprise}</strong>
{stage.mission && <span> {stage.mission}</span>}
</p>
)
: (
<p style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
{mob.ecole && (
<>
École : <strong>{mob.ecole}</strong>
</>
)}
{mob.ecole && mob.pays && <span>,</span>}
{mob.pays && (
<>
Pays : <strong>{mob.pays}</strong>
</>
)}
</p>
)}
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginTop: "0.5rem",
}}
>
{mob.contratMob && (
<a
class="btn btn-sm btn-primary"
href={`/mobility/api/mobilites/${mob.id}/contrat`}
target="_blank"
>
Télécharger contrat
</a>
)}
{!mob.idStage && (
<UploadContratBtn
mobId={mob.id}
hasContrat={!!mob.contratMob}
onDone={onReload}
/>
)}
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() =>
setEditingMob(mob)}
>
Modifier
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
deleteMob(mob.id)}
>
Supprimer
</button>
</div>
</div>
</div>
);
})}
{showAddForm
? (
<MobAddForm
numEtud={student.numEtud}
ecoles={ecoles}
paysList={paysList}
availableStages={Object.values(stagesMap)
.filter((s) => s.numEtud === student.numEtud)
.filter((s) => !mobilites.some((m) => m.idStage === s.id))}
onCancel={() => setShowAddForm(false)}
onSave={async () => {
setShowAddForm(false);
await onReload();
}}
/>
)
: (
<button
type="button"
class="btn btn-primary"
onClick={() => setShowAddForm(true)}
>
+ Nouvelle mobilité
</button>
)}
</div>
);
}
// ─── Inline forms ───────────────────────────────────────────
function MobEditForm(
{ mob, ecoles, paysList, onCancel, onSave }: {
mob: Mobilite;
ecoles: string[];
paysList: string[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
const [duree, setDuree] = useState(String(mob.duree));
const [ecole, setEcole] = useState(mob.ecole ?? "");
const [pays, setPays] = useState(mob.pays ?? "");
const [status, setStatus] = useState(mob.status);
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
try {
const res = await fetch(`/mobility/api/mobilites/${mob.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
duree: parseInt(duree),
ecole: ecole || null,
pays: pays || null,
status,
}),
});
if (!res.ok) throw new Error("Erreur");
await onSave();
} catch {
alert("Erreur lors de la modification");
} finally {
setBusy(false);
}
}
return (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Modifier la mobilité #{mob.id}</p>
<div class="form-grid">
<div class="form-field">
<label>Durée (semaines)</label>
<input
class="form-input"
type="number"
min="1"
value={duree}
onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/>
</div>
{!mob.idStage && (
<>
<div class="form-field">
<label>École</label>
<input
class="form-input"
list="edit-ecoles"
value={ecole}
onInput={(e) => setEcole((e.target as HTMLInputElement).value)}
/>
<datalist id="edit-ecoles">
{ecoles.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Pays</label>
<input
class="form-input"
list="edit-pays"
value={pays}
onInput={(e) => setPays((e.target as HTMLInputElement).value)}
/>
<datalist id="edit-pays">
{paysList.map((p) => <option key={p} value={p} />)}
</datalist>
</div>
<div class="form-field">
<label>Status</label>
<select
class="filter-select"
value={status}
onChange={(e) =>
setStatus((e.target as HTMLSelectElement).value)}
>
{STATUS_ORDER.map((s) => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</div>
</>
)}
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy}
onClick={submit}
>
Enregistrer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
function MobAddForm(
{ numEtud, ecoles, paysList, availableStages, onCancel, onSave }: {
numEtud: number;
ecoles: string[];
paysList: string[];
availableStages: Stage[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
const [duree, setDuree] = useState("4");
const [ecole, setEcole] = useState("");
const [pays, setPays] = useState("");
const [status, setStatus] = useState("contracts_received");
const [selectedStageId, setSelectedStageId] = useState("");
const [busy, setBusy] = useState(false);
const isStageLinked = selectedStageId !== "";
function onStageChange(value: string) {
setSelectedStageId(value);
if (value) {
const stage = availableStages.find((s) => s.id === Number(value));
if (stage) setDuree(String(stage.duree));
}
}
async function submit() {
setBusy(true);
try {
const res = await fetch("/mobility/api/mobilites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
numEtud,
duree: parseInt(duree),
ecole: isStageLinked ? null : (ecole || null),
pays: isStageLinked ? null : (pays || null),
status: isStageLinked ? "validated" : status,
idStage: isStageLinked ? Number(selectedStageId) : null,
}),
});
if (!res.ok) throw new Error("Erreur");
await onSave();
} catch {
alert("Erreur lors de la création");
} finally {
setBusy(false);
}
}
return (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Nouvelle mobilité</p>
<div class="form-grid">
{availableStages.length > 0 && (
<div class="form-field">
<label>Lier à un stage</label>
<select
class="filter-select"
value={selectedStageId}
onChange={(e) =>
onStageChange((e.target as HTMLSelectElement).value)}
>
<option value=""> Mobilité d'étude —</option>
{availableStages.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.nomEntreprise} ({s.duree} sem.)
</option>
))}
</select>
</div>
)}
<div class="form-field">
<label>Durée (semaines)</label>
<input
class="form-input"
type="number"
min="1"
value={duree}
onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/>
</div>
{!isStageLinked && (
<>
<div class="form-field">
<label>École</label>
<input
class="form-input"
list="add-ecoles"
value={ecole}
onInput={(e) => setEcole((e.target as HTMLInputElement).value)}
/>
<datalist id="add-ecoles">
{ecoles.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Pays</label>
<input
class="form-input"
list="add-pays"
value={pays}
onInput={(e) => setPays((e.target as HTMLInputElement).value)}
/>
<datalist id="add-pays">
{paysList.map((p) => <option key={p} value={p} />)}
</datalist>
</div>
<div class="form-field">
<label>Status</label>
<select
class="filter-select"
value={status}
onChange={(e) =>
setStatus((e.target as HTMLSelectElement).value)}
>
{STATUS_ORDER.map((s) => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</div>
</>
)}
</div>
{isStageLinked && (
<p
style={{
fontSize: "0.8rem",
opacity: 0.7,
margin: "0.4rem 0",
}}
>
Mobilité liée à un stage — status automatiquement « Validé »
</p>
)}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy || !duree}
onClick={submit}
>
Créer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
function UploadContratBtn(
{ mobId, hasContrat, onDone }: {
mobId: number;
hasContrat: boolean;
onDone: () => Promise<void>;
},
) {
const [busy, setBusy] = useState(false);
function upload() {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/pdf";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
setBusy(true);
try {
const fd = new FormData();
fd.append("contrat", file);
const res = await fetch(`/mobility/api/mobilites/${mobId}/contrat`, {
method: "POST",
body: fd,
});
if (!res.ok) throw new Error("Erreur upload");
await onDone();
} catch {
alert("Erreur lors de l'upload du contrat");
} finally {
setBusy(false);
}
};
input.click();
}
return (
<button
type="button"
class="btn btn-sm btn-secondary"
disabled={busy}
onClick={upload}
>
{busy ? "..." : hasContrat ? "Remplacer contrat" : "Ajouter contrat"}
</button>
);
}
+6 -6
View File
@@ -3,14 +3,14 @@ import { AppProperties } from "$root/defaults/interfaces.ts";
const properties: AppProperties = { const properties: AppProperties = {
name: "PolyMobility", name: "PolyMobility",
icon: "flight_takeoff", icon: "flight_takeoff",
hint: "Suivi des mobilités internationales", hint: "Student mobility management",
pages: { pages: {
index: "Accueil", index: "Homepage",
overview: "Suivi des mobilités", overview: "Mobility overview",
"my-mobility": "Ma mobilité", edit_mobility: "Mobility management",
consult_students_test: "Test consult students",
}, },
adminOnly: ["overview"], adminOnly: ["edit_mobility", "consult_students_test"],
studentOnly: ["my-mobility"],
}; };
export default properties; export default properties;
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
@@ -0,0 +1,122 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobility, promotions, students } from "$root/databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers = {
async GET() {
try {
const studentRows = await db
.select({
id: students.userId,
firstName: students.firstName,
lastName: students.lastName,
promotionId: students.promotionId,
endyear: promotions.endyear,
current: promotions.current,
})
.from(students)
.leftJoin(promotions, eq(students.promotionId, promotions.id));
const mobilityRows = await db.select().from(mobility);
const promotionRows = await db
.select({
id: promotions.id,
endyear: promotions.endyear,
current: promotions.current,
})
.from(promotions);
return new Response(
JSON.stringify({
mobilities: mobilityRows,
students: studentRows,
promotions: promotionRows,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error fetching mobility data:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
async POST(request) {
try {
const body = await request.json();
const { data } = body;
if (!Array.isArray(data)) {
throw new Error("Invalid request body");
}
for (const entry of data) {
const {
id,
studentId,
startDate,
endDate,
weeksCount,
destinationCountry,
destinationName,
mobilityStatus = "N/A",
} = entry;
const studentExists = await db
.select({ userId: students.userId })
.from(students)
.where(eq(students.userId, studentId))
.limit(1)
.then((rows) => rows.length > 0);
if (!studentExists) {
console.warn(`Skipping mobility for unknown studentId: ${studentId}`);
continue;
}
let calculatedWeeksCount = weeksCount;
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
calculatedWeeksCount = start <= end
? Math.ceil(
(end.getTime() - start.getTime()) / (7 * 24 * 60 * 60 * 1000),
)
: null;
}
await db
.insert(mobility)
.values({
id,
studentId,
startDate,
endDate,
weeksCount: calculatedWeeksCount,
destinationCountry,
destinationName,
mobilityStatus,
})
.onConflictDoUpdate({
target: mobility.id,
set: {
startDate,
endDate,
weeksCount: calculatedWeeksCount,
destinationCountry,
destinationName,
mobilityStatus,
},
});
}
return new Response("Data inserted/updated successfully", {
status: 200,
});
} catch (error) {
console.error("Error inserting mobility data:", error);
return new Response("Failed to insert/update data", { status: 500 });
}
},
};
-116
View File
@@ -1,116 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const VALID_STATUSES = [
"contracts_received",
"under_revision",
"done",
"validated",
"canceled",
] as const;
export const handler: Handlers<null, AuthenticatedState> = {
// GET /mobilites — list all, optional ?numEtud filter
async GET(request) {
try {
const url = new URL(request.url);
const numEtudParam = url.searchParams.get("numEtud");
let query = db.select().from(mobilites).$dynamic();
if (numEtudParam) {
const numEtud = parseInt(numEtudParam);
if (isNaN(numEtud)) {
return new Response("Paramètre numEtud invalide", { status: 400 });
}
query = query.where(eq(mobilites.numEtud, numEtud));
}
const result = await query;
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching mobilites:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
// POST /mobilites — create mobility
async POST(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const isEmployee =
context.state.session.eduPersonPrimaryAffiliation === "employee";
try {
const body = await request.json();
const { numEtud, duree, ecole, pays, status, idStage } = body;
// Students can only create mobilites for themselves
if (!isEmployee && numEtud !== undefined) {
// Students cannot set idStage or status
if (idStage || (status && status !== "contracts_received")) {
return new Response(null, { status: 403 });
}
}
if (!numEtud || duree === undefined) {
return new Response(
JSON.stringify({ error: "Champs requis: numEtud, duree" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (!Number.isInteger(duree) || duree < 1) {
return new Response(
JSON.stringify({ error: "duree doit être un entier >= 1" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (
status !== undefined &&
!VALID_STATUSES.includes(status)
) {
return new Response(
JSON.stringify({
error: `status invalide, valeurs: ${VALID_STATUSES.join(", ")}`,
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
// Stage-linked mobilities are always validated
const effectiveStatus = idStage
? "validated"
: (status ?? "contracts_received");
const [created] = await db
.insert(mobilites)
.values({
numEtud,
duree,
ecole: ecole ?? null,
pays: pays ?? null,
status: effectiveStatus,
idStage: idStage ?? null,
})
.returning();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
} catch (error) {
console.error("Error creating mobilite:", error);
return new Response("Failed to create mobilite", { status: 500 });
}
},
};
@@ -1,149 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const VALID_STATUSES = [
"contracts_received",
"under_revision",
"done",
"validated",
"canceled",
] as const;
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Mobilité introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// GET /mobilites/:idMob
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
const row = await db
.select()
.from(mobilites)
.where(eq(mobilites.id, idMob))
.then((rows) => rows[0] ?? null);
if (!row) return NOT_FOUND();
return new Response(JSON.stringify(row), {
headers: { "content-type": "application/json" },
});
},
// PUT /mobilites/:idMob (employee only)
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN();
}
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
const body = await request.json();
const { duree, ecole, pays, status, idStage } = body;
if (
status !== undefined &&
!VALID_STATUSES.includes(status)
) {
return new Response(
JSON.stringify({
error: `status invalide, valeurs: ${VALID_STATUSES.join(", ")}`,
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) {
return new Response(
JSON.stringify({ error: "duree doit être un entier >= 1" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const set: Record<string, unknown> = {};
if (duree !== undefined) set.duree = duree;
if (ecole !== undefined) set.ecole = ecole;
if (pays !== undefined) set.pays = pays;
if (status !== undefined) set.status = status;
if (idStage !== undefined) {
set.idStage = idStage;
if (idStage) set.status = "validated";
}
if (Object.keys(set).length === 0) {
return new Response(
JSON.stringify({ error: "Au moins un champ à modifier requis" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [updated] = await db
.update(mobilites)
.set(set)
.where(eq(mobilites.id, idMob))
.returning();
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
});
},
// DELETE /mobilites/:idMob (employee only)
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN();
}
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
// Delete contract file if exists
const row = await db
.select({ contratMob: mobilites.contratMob })
.from(mobilites)
.where(eq(mobilites.id, idMob))
.then((rows) => rows[0] ?? null);
if (row?.contratMob) {
try {
await Deno.remove(`uploads/contracts/${row.contratMob}`);
} catch { /* file may not exist */ }
}
const [deleted] = await db
.delete(mobilites)
.where(eq(mobilites.id, idMob))
.returning();
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
};
@@ -1,156 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const CONTRACTS_DIR = "uploads/contracts";
export const handler: Handlers<null, AuthenticatedState> = {
// GET /mobilites/:idMob/contrat — download contract PDF
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
const row = await db
.select({ contratMob: mobilites.contratMob })
.from(mobilites)
.where(eq(mobilites.id, idMob))
.then((rows) => rows[0] ?? null);
if (!row) {
return new Response(
JSON.stringify({ error: "Mobilité introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
if (!row.contratMob) {
return new Response(
JSON.stringify({ error: "Aucun contrat pour cette mobilité" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
try {
const file = await Deno.readFile(`${CONTRACTS_DIR}/${row.contratMob}`);
return new Response(file, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${row.contratMob}"`,
},
});
} catch {
return new Response(
JSON.stringify({ error: "Fichier contrat introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
},
// POST /mobilites/:idMob/contrat — upload contract PDF
async POST(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
// Check mobility exists
const row = await db
.select({ id: mobilites.id })
.from(mobilites)
.where(eq(mobilites.id, idMob))
.then((rows) => rows[0] ?? null);
if (!row) {
return new Response(
JSON.stringify({ error: "Mobilité introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
const formData = await request.formData();
const file = formData.get("contrat");
if (!file || !(file instanceof File)) {
return new Response(
JSON.stringify({ error: "Fichier 'contrat' requis (PDF)" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (file.type !== "application/pdf") {
return new Response(
JSON.stringify({ error: "Le fichier doit être un PDF" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const filename = `mob_${idMob}.pdf`;
await Deno.mkdir(CONTRACTS_DIR, { recursive: true });
await Deno.writeFile(
`${CONTRACTS_DIR}/${filename}`,
new Uint8Array(await file.arrayBuffer()),
);
const [updated] = await db
.update(mobilites)
.set({ contratMob: filename })
.where(eq(mobilites.id, idMob))
.returning();
return new Response(JSON.stringify(updated), {
status: 200,
headers: { "content-type": "application/json" },
});
},
// DELETE /mobilites/:idMob/contrat — remove contract (employee only)
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return new Response(null, { status: 403 });
}
const idMob = Number(context.params.idMob);
if (isNaN(idMob)) {
return new Response("Paramètre idMob invalide", { status: 400 });
}
const row = await db
.select({ contratMob: mobilites.contratMob })
.from(mobilites)
.where(eq(mobilites.id, idMob))
.then((rows) => rows[0] ?? null);
if (!row) {
return new Response(
JSON.stringify({ error: "Mobilité introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
if (row.contratMob) {
try {
await Deno.remove(`${CONTRACTS_DIR}/${row.contratMob}`);
} catch { /* file may not exist */ }
}
await db
.update(mobilites)
.set({ contratMob: null })
.where(eq(mobilites.id, idMob));
return new Response(null, { status: 204 });
},
};
@@ -0,0 +1,21 @@
import ConsultStudents_test from "$root/routes/(apps)/mobility/(_islands)/ConsultStudents_test.tsx";
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
//import EditStudents from "../(_islands)/EditStudents.tsx";
// deno-lint-ignore require-await
async function Mobility(_request: Request, _context: FreshContext<State>) {
return (
<>
<h1>Test consult students</h1>
<ConsultStudents_test />
</>
);
}
export const config = getPartialsConfig();
export default makePartials(Mobility);
@@ -0,0 +1,20 @@
import EditMobility from "$root/routes/(apps)/mobility/(_islands)/EditMobility.tsx";
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
// deno-lint-ignore require-await
async function Mobility(_request: Request, _context: FreshContext<State>) {
return (
<>
<h1>Edit mobility</h1>
<EditMobility />
</>
);
}
export const config = getPartialsConfig();
export default makePartials(Mobility);
+3 -19
View File
@@ -3,27 +3,11 @@ import {
makePartials, makePartials,
} from "$root/defaults/makePartials.tsx"; } from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts"; import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts"; import { State } from "$root/routes/_middleware.ts";
// deno-lint-ignore require-await // deno-lint-ignore require-await
export async function Index( export async function Index(_request: Request, context: FreshContext<State>) {
_request: Request, return <h2>Welcome to {context.state.session?.displayName}.</h2>;
context: FreshContext<State>,
) {
return (
<div class="page-content">
<h2 class="page-title">Mobilité internationale</h2>
<p>
Bienvenue{" "}
<strong>
{(context.state as unknown as { session: Record<string, string> })
.session.displayName}
</strong>
.
</p>
<p>Suivi des mobilités : 12 semaines validées requises par élève.</p>
</div>
);
} }
export const config = getPartialsConfig(); export const config = getPartialsConfig();
+10 -9
View File
@@ -1,19 +1,20 @@
import ConsultMobility from "$root/routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
import { import {
getPartialsConfig, getPartialsConfig,
makePartials, makePartials,
} from "$root/defaults/makePartials.tsx"; } from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts"; import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts"; import { State } from "$root/routes/_middleware.ts";
import MobilityOverview from "../(_islands)/MobilityOverview.tsx";
// deno-lint-ignore require-await // deno-lint-ignore require-await
async function Overview( async function Mobility(_request: Request, _context: FreshContext<State>) {
_request: Request, return (
_context: FreshContext<State>, <>
) { <h1>Edit mobility</h1>
return <MobilityOverview />; <ConsultMobility />
</>
);
} }
export { Overview as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Overview); export default makePartials(Mobility);
@@ -1,20 +0,0 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import MobilityOverview from "../../(_islands)/MobilityOverview.tsx";
// deno-lint-ignore require-await
async function Overview(
_request: Request,
context: FreshContext<State>,
) {
const numEtud = Number(context.params.numEtud);
return <MobilityOverview initialNumEtud={numEtud} />;
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
+7 -14
View File
@@ -268,14 +268,14 @@ export default function ImportNotes() {
globalThis.open("/templates/modele_notes.xlsx", "_blank"); globalThis.open("/templates/modele_notes.xlsx", "_blank");
} }
function _downloadExport() { function downloadExport() {
// Export notes from the API in the same format // Export notes from the API in the same format
Promise.all([ Promise.all([
fetch("/students/api/students").then((r) => r.json()), fetch("/students/api/students").then((r) => r.json()),
fetch("/notes/api/notes").then((r) => r.json()), fetch("/notes/api/notes").then((r) => r.json()),
fetch("/notes/api/modules").then((r) => r.json()), fetch("/admin/api/modules").then((r) => r.json()),
fetch("/notes/api/ue-modules").then((r) => r.json()), fetch("/admin/api/ue-modules").then((r) => r.json()),
fetch("/notes/api/ues").then((r) => r.json()), fetch("/admin/api/ues").then((r) => r.json()),
]).then( ]).then(
([ ([
studentsData, studentsData,
@@ -450,10 +450,7 @@ export default function ImportNotes() {
const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]); const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]);
XLSX.utils.book_append_sheet(wb, ws2, "Session 2"); XLSX.utils.book_append_sheet(wb, ws2, "Session 2");
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" }); const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([buf], { const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
type:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -581,7 +578,7 @@ export default function ImportNotes() {
))} ))}
</div> </div>
<p class="col-dim" style="font-size: 0.72rem; margin-top: 0.35rem"> <p class="col-dim" style="font-size: 0.72rem; margin-top: 0.35rem">
M = ECUE (importe) | UE = moyenne UE (ignore) | X = malus M = module (importe) | UE = moyenne UE (ignore) | X = malus
</p> </p>
</div> </div>
)} )}
@@ -603,8 +600,6 @@ export default function ImportNotes() {
> >
Telecharger Modele Telecharger Modele
</button> </button>
{
/* TODO: fix blob download in Fresh
<button <button
type="button" type="button"
class="btn btn-secondary" class="btn btn-secondary"
@@ -612,13 +607,11 @@ export default function ImportNotes() {
> >
Exporter Notes Exporter Notes
</button> </button>
*/
}
</div> </div>
<p class="upload-format"> <p class="upload-format">
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "} Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
<strong>CODE - ECUE</strong> (colonnes notes){" "} <strong>CODE - Module</strong> (colonnes notes){" "}
les colonnes UE et MALUS sont auto-detectees les colonnes UE et MALUS sont auto-detectees
</p> </p>
</div> </div>
+5 -5
View File
@@ -66,11 +66,11 @@ export default function NoteRecap({ numEtud }: Props) {
setStudent(s); setStudent(s);
const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([ const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([
fetch("/notes/api/ues"), fetch("/admin/api/ues"),
fetch( fetch(
`/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, `/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
), ),
fetch("/notes/api/modules"), fetch("/admin/api/modules"),
fetch(`/notes/api/notes?numEtud=${numEtud}`), fetch(`/notes/api/notes?numEtud=${numEtud}`),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`), fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]); ]);
@@ -324,14 +324,14 @@ export default function NoteRecap({ numEtud }: Props) {
)} )}
</div> </div>
{/* ECUE rows */} {/* Module rows */}
{ueMods.length === 0 {ueMods.length === 0
? ( ? (
<p <p
class="col-dim" class="col-dim"
style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem" style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem"
> >
Aucun ECUE associe a cette UE pour cette promotion. Aucun module associe a cette UE pour cette promotion.
</p> </p>
) )
: ( : (
+4 -4
View File
@@ -62,9 +62,9 @@ export default function NotesView({ numEtud, prenom }: Props) {
try { try {
const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([ const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([
fetch(`/notes/api/notes?numEtud=${numEtud}`), fetch(`/notes/api/notes?numEtud=${numEtud}`),
fetch("/notes/api/ues"), fetch("/admin/api/ues"),
fetch("/notes/api/ue-modules"), fetch("/admin/api/ue-modules"),
fetch("/notes/api/modules"), fetch("/admin/api/modules"),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`), fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]); ]);
@@ -225,7 +225,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
<div key={um.idModule} class="ue-module-row"> <div key={um.idModule} class="ue-module-row">
<span class="ue-module-name"> <span class="ue-module-name">
{mod ? mod.id : um.idModule} {" "} {mod ? mod.id : um.idModule} {" "}
{mod ? mod.nom : "ECUE inconnu"} (coef {um.coeff}) {mod ? mod.nom : "Module inconnu"} (coef {um.coeff})
</span> </span>
<span class={`score-chip ${scoreClass(effective)}`}> <span class={`score-chip ${scoreClass(effective)}`}>
{effective !== null ? `${effective}/20` : "—"} {effective !== null ? `${effective}/20` : "—"}
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
-12
View File
@@ -1,12 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { modules } from "$root/databases/schema.ts";
export const handler: Handlers = {
async GET() {
const rows = await db.select().from(modules);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
});
},
};
-28
View File
@@ -1,28 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { ueModules } from "$root/databases/schema.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers = {
async GET(request) {
const url = new URL(request.url);
const idPromo = url.searchParams.get("idPromo");
const idUEParam = url.searchParams.get("idUE");
const idUE = idUEParam ? parseInt(idUEParam) : null;
if (idUEParam && isNaN(idUE!)) {
return new Response("Paramètre idUE invalide", { status: 400 });
}
const rows = await db.select().from(ueModules).where(
and(
idPromo ? eq(ueModules.idPromo, idPromo) : undefined,
idUE ? eq(ueModules.idUE, idUE) : undefined,
),
);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
});
},
};
-12
View File
@@ -1,12 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { ues } from "$root/databases/schema.ts";
export const handler: Handlers = {
async GET() {
const rows = await db.select().from(ues);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
});
},
};
@@ -11,6 +11,5 @@ async function Courses(_request: Request, _context: FreshContext<State>) {
return <AdminConsultNotes />; return <AdminConsultNotes />;
} }
export { Courses as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Courses); export default makePartials(Courses);
@@ -19,6 +19,5 @@ async function ImportNotesPage(
); );
} }
export { ImportNotesPage as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(ImportNotesPage); export default makePartials(ImportNotesPage);
-1
View File
@@ -54,6 +54,5 @@ async function Notes(
); );
} }
export { Notes as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Notes); export default makePartials(Notes);
@@ -1,558 +0,0 @@
import { useEffect, useState } from "preact/hooks";
type Student = {
numEtud: number;
nom: string;
prenom: string;
idPromo: string;
};
type Promotion = { id: string; annee: string };
type Stage = {
id: number;
numEtud: number;
duree: number;
nomEntreprise: string;
mission: string | null;
};
const REQUIRED_WEEKS = 40;
export default function StagesOverview(
{ initialNumEtud }: { initialNumEtud?: number } = {},
) {
const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]);
const [stagesList, setStagesList] = useState<Stage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterPromo, setFilterPromo] = useState("");
const [filterNom, setFilterNom] = useState("");
// Detail view state
const [detailStudent, setDetailStudent] = useState<Student | null>(null);
const [editingStage, setEditingStage] = useState<Stage | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
async function load() {
try {
const [sRes, pRes, stRes] = await Promise.all([
fetch("/students/api/students"),
fetch("/students/api/promotions"),
fetch("/stages/api/stages"),
]);
if (!sRes.ok) throw new Error("Impossible de charger les données");
const [sData, pData, stData] = await Promise.all([
sRes.json(),
pRes.ok ? pRes.json() : [],
stRes.ok ? stRes.json() : [],
]);
setStudents(sData);
setPromos(pData);
setStagesList(stData);
if (initialNumEtud) {
const found = (sData as Student[]).find((s) =>
s.numEtud === initialNumEtud
);
if (found) setDetailStudent(found);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
function openStudent(s: Student) {
setDetailStudent(s);
history.pushState(null, "", `/stages/overview/${s.numEtud}`);
}
function closeStudent() {
setDetailStudent(null);
setEditingStage(null);
setShowAddForm(false);
history.pushState(null, "", "/stages/overview");
}
if (detailStudent) {
return (
<DetailView
student={detailStudent}
stages={stagesList.filter((s) => s.numEtud === detailStudent.numEtud)}
allStages={stagesList}
editingStage={editingStage}
setEditingStage={setEditingStage}
showAddForm={showAddForm}
setShowAddForm={setShowAddForm}
onBack={closeStudent}
onReload={load}
/>
);
}
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
const filtered = students.filter((s) => {
const matchPromo = !filterPromo || s.idPromo === filterPromo;
const matchNom = !filterNom ||
`${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase());
return matchPromo && matchNom;
});
const stagesByStudent = (numEtud: number) =>
stagesList.filter((s) => s.numEtud === numEtud);
return (
<div class="page-content">
<h2 class="page-title">Suivi des stages</h2>
<div class="filters">
<select
class="filter-select"
value={filterPromo}
onChange={(e) =>
setFilterPromo((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les promos</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<input
class="filter-input"
placeholder="Rechercher..."
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
</div>
<ListView
students={filtered}
stagesByStudent={stagesByStudent}
onConsult={(s) => openStudent(s)}
/>
</div>
);
}
// ─── Liste View ─────────────────────────────────────────────
function ListView(
{ students, stagesByStudent, onConsult }: {
students: Student[];
stagesByStudent: (n: number) => Stage[];
onConsult: (s: Student) => void;
},
) {
return (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>N° étud.</th>
<th>Nom</th>
<th>Prénom</th>
<th>Semaines</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{students.length === 0
? (
<tr>
<td colspan={5} class="state-empty">Aucun élève trouvé</td>
</tr>
)
: students.map((s) => {
const stages = stagesByStudent(s.numEtud);
const weeks = stages.reduce((sum, st) => sum + st.duree, 0);
const ok = weeks >= REQUIRED_WEEKS;
return (
<tr key={s.numEtud}>
<td class="col-dim">{s.numEtud}</td>
<td>{s.nom}</td>
<td>{s.prenom}</td>
<td>
<span
style={{
color: ok ? "var(--ok-color,#22c55e)" : "#dc2626",
fontWeight: "var(--font-weight-bold)",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS}
</span>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => onConsult(s)}
>
Consulter
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── Detail View ────────────────────────────────────────────
function DetailView(
{
student,
stages,
allStages,
editingStage,
setEditingStage,
showAddForm,
setShowAddForm,
onBack,
onReload,
}: {
student: Student;
stages: Stage[];
allStages: Stage[];
editingStage: Stage | null;
setEditingStage: (s: Stage | null) => void;
showAddForm: boolean;
setShowAddForm: (v: boolean) => void;
onBack: () => void;
onReload: () => Promise<void>;
},
) {
const weeks = stages.reduce((sum, s) => sum + s.duree, 0);
const entreprises = [
...new Set(allStages.map((s) => s.nomEntreprise).filter(Boolean)),
];
async function deleteStage(id: number) {
if (!confirm("Supprimer ce stage ?")) return;
await fetch(`/stages/api/stages/${id}`, { method: "DELETE" });
await onReload();
}
return (
<div class="page-content">
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onBack}
style={{ marginBottom: "0.75rem" }}
>
Retour
</button>
<h2 class="page-title">
Consulter : {student.prenom} {student.nom}
<span
style={{
fontSize: "0.8rem",
fontWeight: "normal",
marginLeft: "0.75rem",
color: weeks >= REQUIRED_WEEKS ? "#22c55e" : "#dc2626",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS} semaines
</span>
</h2>
{stages.length === 0 && (
<p class="state-empty">
Aucun stage enregistré.
</p>
)}
{stages.map((stage, i) => {
const isEditing = editingStage?.id === stage.id;
if (isEditing) {
return (
<StageEditForm
key={stage.id}
stage={stage}
entreprises={entreprises}
onCancel={() => setEditingStage(null)}
onSave={async () => {
setEditingStage(null);
await onReload();
}}
/>
);
}
return (
<div key={stage.id} class="ue-card" style={{ marginBottom: "1rem" }}>
<div class="ue-card-header">
<p class="ue-card-title">
Stage {i + 1}
</p>
<p class="ue-card-avg">
Durée : {stage.duree} semaine(s)
</p>
</div>
<div style={{ padding: "0.6rem 1.1rem" }}>
<p style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
Entreprise : <strong>{stage.nomEntreprise}</strong>
{stage.mission && <span> {stage.mission}</span>}
</p>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginTop: "0.5rem",
}}
>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() =>
setEditingStage(stage)}
>
Modifier
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
deleteStage(stage.id)}
>
Supprimer
</button>
</div>
</div>
</div>
);
})}
{showAddForm
? (
<StageAddForm
numEtud={student.numEtud}
entreprises={entreprises}
onCancel={() => setShowAddForm(false)}
onSave={async () => {
setShowAddForm(false);
await onReload();
}}
/>
)
: (
<button
type="button"
class="btn btn-primary"
onClick={() => setShowAddForm(true)}
>
+ Nouveau stage
</button>
)}
</div>
);
}
// ─── Inline forms ───────────────────────────────────────────
function StageEditForm(
{ stage, entreprises, onCancel, onSave }: {
stage: Stage;
entreprises: string[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
const [duree, setDuree] = useState(String(stage.duree));
const [nomEntreprise, setNomEntreprise] = useState(stage.nomEntreprise);
const [mission, setMission] = useState(stage.mission ?? "");
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
try {
const res = await fetch(`/stages/api/stages/${stage.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
duree: parseInt(duree),
nomEntreprise,
mission: mission || null,
}),
});
if (!res.ok) throw new Error("Erreur");
await onSave();
} catch {
alert("Erreur lors de la modification");
} finally {
setBusy(false);
}
}
return (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Modifier le stage #{stage.id}</p>
<div class="form-grid">
<div class="form-field">
<label>Durée (semaines)</label>
<input
class="form-input"
type="number"
min="1"
value={duree}
onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>Entreprise</label>
<input
class="form-input"
list="edit-entreprises"
value={nomEntreprise}
onInput={(e) =>
setNomEntreprise((e.target as HTMLInputElement).value)}
/>
<datalist id="edit-entreprises">
{entreprises.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Mission</label>
<input
class="form-input"
value={mission}
onInput={(e) => setMission((e.target as HTMLInputElement).value)}
/>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy || !nomEntreprise}
onClick={submit}
>
Enregistrer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
function StageAddForm(
{ numEtud, entreprises, onCancel, onSave }: {
numEtud: number;
entreprises: string[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
const [duree, setDuree] = useState("4");
const [nomEntreprise, setNomEntreprise] = useState("");
const [mission, setMission] = useState("");
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
try {
const res = await fetch("/stages/api/stages", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
numEtud,
duree: parseInt(duree),
nomEntreprise,
mission: mission || null,
}),
});
if (!res.ok) throw new Error("Erreur");
await onSave();
} catch {
alert("Erreur lors de la création");
} finally {
setBusy(false);
}
}
return (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Nouveau stage</p>
<div class="form-grid">
<div class="form-field">
<label>Durée (semaines)</label>
<input
class="form-input"
type="number"
min="1"
value={duree}
onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>Entreprise</label>
<input
class="form-input"
list="add-entreprises"
value={nomEntreprise}
onInput={(e) =>
setNomEntreprise((e.target as HTMLInputElement).value)}
/>
<datalist id="add-entreprises">
{entreprises.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Mission</label>
<input
class="form-input"
value={mission}
onInput={(e) => setMission((e.target as HTMLInputElement).value)}
/>
</div>
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy || !nomEntreprise || !duree}
onClick={submit}
>
Créer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
import { AppProperties } from "$root/defaults/interfaces.ts";
const properties: AppProperties = {
name: "Stages",
icon: "work",
pages: {
index: "Accueil",
overview: "Suivi des stages",
},
adminOnly: ["overview"],
employeeOnly: true,
hint: "Suivi des stages et semaines",
};
export default properties;
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
-84
View File
@@ -1,84 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { stages } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers<null, AuthenticatedState> = {
// GET /stages — list all, optional ?numEtud filter
async GET(request) {
try {
const url = new URL(request.url);
const numEtudParam = url.searchParams.get("numEtud");
let query = db.select().from(stages).$dynamic();
if (numEtudParam) {
const numEtud = parseInt(numEtudParam);
if (isNaN(numEtud)) {
return new Response("Paramètre numEtud invalide", { status: 400 });
}
query = query.where(eq(stages.numEtud, numEtud));
}
const result = await query;
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching stages:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
// POST /stages — create stage (employee only)
async POST(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return new Response(null, { status: 403 });
}
try {
const body = await request.json();
const { numEtud, duree, nomEntreprise, mission } = body;
if (!numEtud || duree === undefined || !nomEntreprise) {
return new Response(
JSON.stringify({
error: "Champs requis: numEtud, duree, nomEntreprise",
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
if (!Number.isInteger(duree) || duree < 1) {
return new Response(
JSON.stringify({ error: "duree doit être un entier >= 1" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [created] = await db
.insert(stages)
.values({
numEtud,
duree,
nomEntreprise,
mission: mission ?? null,
})
.returning();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
} catch (error) {
console.error("Error creating stage:", error);
return new Response("Failed to create stage", { status: 500 });
}
},
};
@@ -1,122 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { mobilites, stages } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Stage introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// GET /stages/:idStage
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const idStage = Number(context.params.idStage);
if (isNaN(idStage)) {
return new Response("Paramètre idStage invalide", { status: 400 });
}
const row = await db
.select()
.from(stages)
.where(eq(stages.id, idStage))
.then((rows) => rows[0] ?? null);
if (!row) return NOT_FOUND();
return new Response(JSON.stringify(row), {
headers: { "content-type": "application/json" },
});
},
// PUT /stages/:idStage (employee only)
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN();
}
const idStage = Number(context.params.idStage);
if (isNaN(idStage)) {
return new Response("Paramètre idStage invalide", { status: 400 });
}
const body = await request.json();
const { duree, nomEntreprise, mission } = body;
if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) {
return new Response(
JSON.stringify({ error: "duree doit être un entier >= 1" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const set: Record<string, unknown> = {};
if (duree !== undefined) set.duree = duree;
if (nomEntreprise !== undefined) set.nomEntreprise = nomEntreprise;
if (mission !== undefined) set.mission = mission;
if (Object.keys(set).length === 0) {
return new Response(
JSON.stringify({ error: "Au moins un champ à modifier requis" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [updated] = await db
.update(stages)
.set(set)
.where(eq(stages.id, idStage))
.returning();
if (!updated) return NOT_FOUND();
// If duration changed and this stage is linked as a mobility, update the mobility too
if (duree !== undefined) {
await db
.update(mobilites)
.set({ duree })
.where(eq(mobilites.idStage, idStage));
}
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
});
},
// DELETE /stages/:idStage (employee only)
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN();
}
const idStage = Number(context.params.idStage);
if (isNaN(idStage)) {
return new Response("Paramètre idStage invalide", { status: 400 });
}
// Remove linked mobilites first (FK constraint)
await db.delete(mobilites).where(eq(mobilites.idStage, idStage));
const [deleted] = await db
.delete(stages)
.where(eq(stages.id, idStage))
.returning();
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
};
-2
View File
@@ -1,2 +0,0 @@
import makeIndex from "$root/defaults/makeIndex.ts";
export default makeIndex(import.meta.dirname!);
-30
View File
@@ -1,30 +0,0 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
// deno-lint-ignore require-await
export async function Index(
_request: Request,
context: FreshContext<State>,
) {
return (
<div class="page-content">
<h2 class="page-title">Stages</h2>
<p>
Bienvenue{" "}
<strong>
{(context.state as unknown as { session: Record<string, string> })
.session.displayName}
</strong>
.
</p>
<p>Suivi des stages : 40 semaines requises par élève.</p>
</div>
);
}
export const config = getPartialsConfig();
export default makePartials(Index);
@@ -1,19 +0,0 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import StagesOverview from "../(_islands)/StagesOverview.tsx";
// deno-lint-ignore require-await
async function Overview(
_request: Request,
_context: FreshContext<State>,
) {
return <StagesOverview />;
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
@@ -1,20 +0,0 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import StagesOverview from "../../(_islands)/StagesOverview.tsx";
// deno-lint-ignore require-await
async function Overview(
_request: Request,
context: FreshContext<State>,
) {
const numEtud = Number(context.params.numEtud);
return <StagesOverview initialNumEtud={numEtud} />;
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
@@ -8,8 +8,6 @@ type Student = {
}; };
type Promo = { id: string; annee: string }; type Promo = { id: string; annee: string };
type Module = { id: string; nom: string }; type Module = { id: string; nom: string };
type Mobilite = { id: number; duree: number; status: string };
type Stage = { id: number; duree: number };
type Props = { numEtud: number }; type Props = { numEtud: number };
@@ -27,8 +25,6 @@ export default function EditStudents({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null); const [student, setStudent] = useState<Student | null>(null);
const [promos, setPromos] = useState<Promo[]>([]); const [promos, setPromos] = useState<Promo[]>([]);
const [_modules, setModules] = useState<Module[]>([]); const [_modules, setModules] = useState<Module[]>([]);
const [mobWeeks, setMobWeeks] = useState(0);
const [stageWeeks, setStageWeeks] = useState(0);
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 [saveMsg, setSaveMsg] = useState<string | null>(null); const [saveMsg, setSaveMsg] = useState<string | null>(null);
@@ -42,12 +38,10 @@ export default function EditStudents({ numEtud }: Props) {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
const [sRes, pRes, mRes, mobRes, stRes] = await Promise.all([ const [sRes, pRes, mRes] = await Promise.all([
fetch(`/students/api/students/${numEtud}`), fetch(`/students/api/students/${numEtud}`),
fetch("/students/api/promotions"), fetch("/students/api/promotions"),
fetch("/notes/api/modules"), fetch("/admin/api/modules"),
fetch(`/mobility/api/mobilites?numEtud=${numEtud}`),
fetch(`/stages/api/stages?numEtud=${numEtud}`),
]); ]);
if (!sRes.ok) throw new Error("Élève introuvable"); if (!sRes.ok) throw new Error("Élève introuvable");
const s: Student = await sRes.json(); const s: Student = await sRes.json();
@@ -57,19 +51,6 @@ export default function EditStudents({ numEtud }: Props) {
setIdPromo(s.idPromo); setIdPromo(s.idPromo);
if (pRes.ok) setPromos(await pRes.json()); if (pRes.ok) setPromos(await pRes.json());
if (mRes.ok) setModules(await mRes.json()); if (mRes.ok) setModules(await mRes.json());
if (mobRes.ok) {
const mobs: Mobilite[] = await mobRes.json();
setMobWeeks(
mobs.filter((m) => m.status === "validated").reduce(
(s, m) => s + m.duree,
0,
),
);
}
if (stRes.ok) {
const stages: Stage[] = await stRes.json();
setStageWeeks(stages.reduce((s, st) => s + st.duree, 0));
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
@@ -226,69 +207,30 @@ export default function EditStudents({ numEtud }: Props) {
</div> </div>
</div> </div>
{/* Section 2: Notes */} {/* Section 2: Spécialisations */}
<div class="edit-section"> <div class="edit-section">
<p class="edit-section-title">Notes</p> <p class="edit-section-title">Spécialisations</p>
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
>
Fonctionnalité non disponible (endpoint non implémenté).
</p>
</div>
{/* Section 3: Notes lecture seule */}
<div class="edit-section">
<p class="edit-section-title">Notes (lecture seule)</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap"> <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"> <span class="col-dim" style="font-size: 0.82rem">
Récap complet des notes et moyennes Voir le récap complet des notes et moyennes de cet étudiant
</span> </span>
<a <a
class="btn btn-secondary" class="btn btn-secondary"
href={`/notes/recap/${numEtud}`} href={`/notes/recap/${numEtud}`}
f-client-nav={false} f-client-nav={false}
> >
Voir les notes Récap notes
</a>
</div>
</div>
{/* Section 3: Mobilités */}
<div class="edit-section">
<p class="edit-section-title">Mobilités</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap">
<span
style={{
fontFamily: "monospace",
fontWeight: "var(--font-weight-bold)",
color: mobWeeks >= 12 ? "#22c55e" : "#dc2626",
}}
>
{mobWeeks}/12 semaines validées
</span>
</span>
<a
class="btn btn-secondary"
href={`/mobility/overview/${numEtud}`}
f-client-nav={false}
>
Consulter
</a>
</div>
</div>
{/* Section 4: Stages */}
<div class="edit-section">
<p class="edit-section-title">Stages</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap">
<span
style={{
fontFamily: "monospace",
fontWeight: "var(--font-weight-bold)",
color: stageWeeks >= 40 ? "#22c55e" : "#dc2626",
}}
>
{stageWeeks}/40 semaines
</span>
</span>
<a
class="btn btn-secondary"
href={`/stages/overview/${numEtud}`}
f-client-nav={false}
>
Consulter
</a> </a>
</div> </div>
</div> </div>
-1
View File
@@ -9,7 +9,6 @@ const properties: AppProperties = {
upload: "Import xlsx", upload: "Import xlsx",
}, },
adminOnly: ["consult", "upload"], adminOnly: ["consult", "upload"],
employeeOnly: true,
hint: "Create students promotion and see informations", hint: "Create students promotion and see informations",
}; };
-2
View File
@@ -1,2 +0,0 @@
import makeSlug from "$root/defaults/makeSlug.ts";
export default makeSlug(import.meta.dirname!);
@@ -2,9 +2,8 @@ import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts"; import { db } from "$root/databases/db.ts";
import { import {
ajustements, ajustements,
mobilites, mobility,
notes, notes,
stages,
students, students,
} from "$root/databases/schema.ts"; } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts";
@@ -81,7 +80,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
}, },
// #12 DELETE /students/{numEtud} // #12 DELETE /students/{numEtud}
// Cascade: deletes notes, ajustements, mobilites, stages for this student. // Cascade: deletes notes, ajustements, mobility for this student.
async DELETE( async DELETE(
_request: Request, _request: Request,
context: FreshContext<AuthenticatedState>, context: FreshContext<AuthenticatedState>,
@@ -103,8 +102,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.delete(notes).where(eq(notes.numEtud, numEtud)); await tx.delete(notes).where(eq(notes.numEtud, numEtud));
await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud)); await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud));
await tx.delete(mobilites).where(eq(mobilites.numEtud, numEtud)); await tx.delete(mobility).where(eq(mobility.studentId, numEtud));
await tx.delete(stages).where(eq(stages.numEtud, numEtud));
await tx.delete(students).where(eq(students.numEtud, numEtud)); await tx.delete(students).where(eq(students.numEtud, numEtud));
}); });
@@ -11,6 +11,5 @@ async function Students(_request: Request, _context: FreshContext<State>) {
return <ConsultStudents />; return <ConsultStudents />;
} }
export { Students as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Students); export default makePartials(Students);
@@ -16,6 +16,5 @@ async function Students(_request: Request, _context: FreshContext<State>) {
); );
} }
export { Students as Page };
export const config = getPartialsConfig(); export const config = getPartialsConfig();
export default makePartials(Students); export default makePartials(Students);
-1
View File
@@ -29,7 +29,6 @@ export default async function App(
<link rel="stylesheet" href="/styles/app-cards.css" /> <link rel="stylesheet" href="/styles/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" />
<script src="/theme.js"></script>
</head> </head>
<body f-client-nav> <body f-client-nav>
<Header link={link} /> <Header link={link} />
+1 -12
View File
@@ -44,20 +44,9 @@ export default async function Apps(
_request: Request, _request: Request,
context: FreshContext<State, Record<string, AppProperties>>, context: FreshContext<State, Record<string, AppProperties>>,
) { ) {
let visibleApps = context.data;
if (
context.state.isAuthenticated &&
context.state.session.eduPersonPrimaryAffiliation === "student"
) {
visibleApps = Object.fromEntries(
Object.entries(context.data).filter(([_, app]) => !app.employeeOnly),
);
}
return ( return (
<> <>
<AppNavigator apps={visibleApps} /> <AppNavigator apps={context.data} />
</> </>
); );
} }
+3 -18
View File
@@ -1,28 +1,13 @@
import { FreshContext, Handlers } from "$fresh/server.ts"; import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
export const handler: Handlers<unknown, State> = { // deno-lint-ignore require-await
GET(_request: Request, context: FreshContext<State>) { export default async function Home(_request: Request, _context: FreshContext) {
if (context.state.isAuthenticated) {
return new Response(null, {
status: 302,
headers: { Location: "/apps" },
});
}
return context.render();
},
};
export default function Home() {
return ( return (
<> <>
<h2>PolyMPR</h2> <h2>PolyMPR</h2>
<h3> <h3>
The <em>ultimate</em> HR platform The <em>ultimate</em> HR platform
</h3> </h3>
<p>
<a href="/login">Se connecter</a>
</p>
</> </>
); );
} }
+3 -26
View File
@@ -5,12 +5,7 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
{ {
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([ const ws = XLSX.utils.aoa_to_sheet([
[ [null, null, null, "Promotion peut etre vide mais doit prealablement Exister"],
null,
null,
null,
"Promotion peut etre vide mais doit prealablement Exister",
],
["Nom", "Prenom", "Numero-etudiant", "Promotion"], ["Nom", "Prenom", "Numero-etudiant", "Promotion"],
["NOM", "PRENOM", 12345678, "3AFISE24-25"], ["NOM", "PRENOM", 12345678, "3AFISE24-25"],
]); ]);
@@ -43,26 +38,8 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
{ {
const data = [ const data = [
["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."], ["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."],
[ ["Description des UE du diplome", null, null, null, null, null, "Nombre d'heures"],
"Description des UE du diplome", ["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\n ECTS", "Coeff.", "CM", "TD", "TP"],
null,
null,
null,
null,
null,
"Nombre d'heures",
],
[
"Annee\nSemestres",
"Codes APOGEE",
null,
null,
"Credits\n ECTS",
"Coeff.",
"CM",
"TD",
"TP",
],
["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"], ["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"],
["SEM 5", null, null, null, 30], ["SEM 5", null, null, null, 30],
["UE", "CODE_UE1", "Nom de l'UE 1", null, 6], ["UE", "CODE_UE1", "Nom de l'UE 1", null, 6],
+2 -6
View File
@@ -9,9 +9,7 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) {
for (const sheetName of wb.SheetNames) { for (const sheetName of wb.SheetNames) {
console.log(`\n--- Sheet: ${sheetName} ---`); console.log(`\n--- Sheet: ${sheetName} ---`);
const sheet = wb.Sheets[sheetName]; const sheet = wb.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { header: 1 });
header: 1,
});
// Print first 5 cols of each row, mark rows that look like year/semester headers // Print first 5 cols of each row, mark rows that look like year/semester headers
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
@@ -19,9 +17,7 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) {
const col0 = row[0] != null ? String(row[0]).trim() : ""; const col0 = row[0] != null ? String(row[0]).trim() : "";
// Show rows that are structural (year, semester, UE headers) // Show rows that are structural (year, semester, UE headers)
if (col0 || (row[1] != null && String(row[1]).trim())) { if (col0 || (row[1] != null && String(row[1]).trim())) {
const preview = row.slice(0, 6).map((c) => const preview = row.slice(0, 6).map(c => c != null ? String(c).substring(0, 25) : "").join(" | ");
c != null ? String(c).substring(0, 25) : ""
).join(" | ");
console.log(` [${i}] ${preview}`); console.log(` [${i}] ${preview}`);
} }
} }
+2 -10
View File
@@ -40,12 +40,6 @@
font-size: 0.8rem; font-size: 0.8rem;
font-family: inherit; font-family: inherit;
min-width: 8rem; min-width: 8rem;
box-sizing: border-box;
}
.form-field .filter-select {
width: 100%;
min-width: 0;
} }
.filter-input:focus, .filter-input:focus,
@@ -374,9 +368,7 @@
color: light-dark(var(--light-foreground), var(--dark-foreground)); color: light-dark(var(--light-foreground), var(--dark-foreground));
font-size: 0.82rem; font-size: 0.82rem;
font-family: inherit; font-family: inherit;
min-width: 0; min-width: 12rem;
width: 100%;
box-sizing: border-box;
} }
.form-input:focus { .form-input:focus {
@@ -807,7 +799,7 @@
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.75rem 1rem; gap: 0.75rem 1rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
-29
View File
@@ -1,29 +0,0 @@
(function () {
const t = localStorage.getItem("theme");
if (t) document.documentElement.style.colorScheme = t;
document.addEventListener("click", function (e) {
const btn = e.target.closest("#theme-toggle");
if (!btn) return;
const cs = getComputedStyle(document.documentElement).colorScheme;
const isDark = cs === "dark" ||
(!cs || cs === "light dark") &&
matchMedia("(prefers-color-scheme:dark)").matches;
const next = isDark ? "light" : "dark";
document.documentElement.style.colorScheme = next;
localStorage.setItem("theme", next);
btn.querySelector("span").textContent = next === "dark"
? "light_mode"
: "dark_mode";
});
document.addEventListener("DOMContentLoaded", function () {
const btn = document.getElementById("theme-toggle");
if (!btn) return;
const cs = getComputedStyle(document.documentElement).colorScheme;
const isDark = cs === "dark" ||
(!cs || cs === "light dark") &&
matchMedia("(prefers-color-scheme:dark)").matches;
btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode";
});
})();
+2 -2
View File
@@ -34,7 +34,7 @@ Deno.test({
}); });
Deno.test({ Deno.test({
name: "e2e modules: GET /modules returns all for non-employee", name: "e2e modules: GET /modules returns empty for non-employee",
async fn() { async fn() {
await truncateAll(); await truncateAll();
await seedModules([{ id: "MATH101", nom: "Mathématiques" }]); await seedModules([{ id: "MATH101", nom: "Mathématiques" }]);
@@ -44,7 +44,7 @@ Deno.test({
); );
assertEquals(res.status, 200); assertEquals(res.status, 200);
const body = await res.json(); const body = await res.json();
assertEquals(body.length, 1); assertEquals(body.length, 0);
}, },
sanitizeResources: false, sanitizeResources: false,
sanitizeOps: false, sanitizeOps: false,
+1 -1
View File
@@ -26,7 +26,7 @@ export const testPool = createTestPool();
export const testDb = drizzle(testPool, { schema }); export const testDb = drizzle(testPool, { schema });
const ALL_TABLES = const ALL_TABLES =
'"mobilites","stages","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; '"mobility","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"';
/** /**
* Vide toutes les tables dans le bon ordre. * Vide toutes les tables dans le bon ordre.