Compare commits

...

5 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
djalim 04be659d6b feat(app): add studentOnly pages and new routes
Add routes for modules, users, notes import, recap, and islands edit.
Update middleware to filter pages based on user role.

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

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

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

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

feat(admin): add EditModule component for module editing

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

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

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

refactor: UI – icons, modal overlay, grid, subtitles, import margin
2026-04-29 09:12:55 +02:00
Clément Oudelet f71128a7f3 PMPR-44 : fix missing newline
Check Deno code / Check Deno code (pull_request) Successful in 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Successful in 1m14s
Check Deno code / Check Deno code (push) Successful in 5s
Tests / Unit tests (push) Successful in 12s
Tests / Integration tests (push) Successful in 1m13s
2026-04-27 17:19:57 +00:00
Clément Oudelet 720a380be8 PMPR-44 : fix formatting 2026-04-27 17:19:57 +00:00
Clément Oudelet 6c602cb10a PMPR-44 : POST /notes/import-xlsx - importer des notes via Excel 2026-04-27 17:19:57 +00:00
65 changed files with 4514 additions and 667 deletions
@@ -0,0 +1,3 @@
ALTER TABLE "notes" ADD COLUMN "noteSession2" double precision;
--> statement-breakpoint
ALTER TABLE "ajustements" ADD COLUMN "malus" integer NOT NULL DEFAULT 0;
+7
View File
@@ -22,6 +22,13 @@
"when": 1777155028710,
"tag": "0002_update_permission_names",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1777155028711,
"tag": "0003_add_session2_and_malus",
"breakpoints": true
}
]
}
+2
View File
@@ -75,6 +75,7 @@ export const notes = pgTable("notes", {
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
idModule: text("idModule").notNull().references(() => modules.id),
note: doublePrecision("note").notNull(),
noteSession2: doublePrecision("noteSession2"),
}, (t) => ({
pk: primaryKey({ columns: [t.numEtud, t.idModule] }),
}));
@@ -83,6 +84,7 @@ export const ajustements = pgTable("ajustements", {
numEtud: integer("numEtud").notNull().references(() => students.numEtud),
idUE: integer("idUE").notNull().references(() => ues.id),
valeur: doublePrecision("valeur").notNull(),
malus: integer("malus").notNull().default(0),
}, (t) => ({
pk: primaryKey({ columns: [t.numEtud, t.idUE] }),
}));
+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;
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "preact/hooks";
export type ImportResult = {
added: number;
modified: number;
ignored: number;
errors: number;
details: ImportDetail[];
};
export type ImportDetail = {
type: "change" | "error";
message: string;
};
type Props = {
result: ImportResult;
onClose: () => void;
};
export default function ImportResultPopup({ result, onClose }: Props) {
const [showDetails, setShowDetails] = useState(false);
const hasErrors = result.errors > 0;
const changes = result.details.filter((d) => d.type === "change");
const errors = result.details.filter((d) => d.type === "error");
return (
<div class="import-popup-overlay" onClick={onClose}>
<div class="import-popup" onClick={(e) => e.stopPropagation()}>
<div class="import-popup-header">
<h3 class="import-popup-title">Resultats de l'import</h3>
<span
class={`import-popup-badge ${
hasErrors ? "badge-error" : "badge-success"
}`}
>
{hasErrors ? "Erreur" : "Succes"}
</span>
</div>
<div class="import-popup-stats">
<div class="import-stat-row">
<span class="import-stat-label">Ajoutes</span>
<span class="import-stat-value stat-added">
{result.added} note{result.added !== 1 ? "s" : ""}
</span>
</div>
<div class="import-stat-row">
<span class="import-stat-label">Modifies</span>
<span class="import-stat-value stat-modified">
{result.modified} note{result.modified !== 1 ? "s" : ""}
</span>
</div>
<div class="import-stat-row">
<span class="import-stat-label">Ignores</span>
<span class="import-stat-value stat-ignored">
{result.ignored} note{result.ignored !== 1 ? "s" : ""}
</span>
</div>
<div class="import-stat-row">
<span class="import-stat-label">Erreurs</span>
<span class="import-stat-value stat-errors">
{result.errors} note{result.errors !== 1 ? "s" : ""}
</span>
</div>
</div>
<div class="import-popup-actions">
{result.details.length > 0 && (
<button
type="button"
class="btn btn-secondary"
onClick={() => setShowDetails(!showDetails)}
>
Details {showDetails ? "\u25B3" : "\u25BD"}
</button>
)}
<button
type="button"
class="btn btn-primary"
onClick={onClose}
>
Ok
</button>
</div>
{showDetails && result.details.length > 0 && (
<div class="import-popup-details">
{changes.length > 0 &&
changes.map((d, i) => (
<p key={`c-${i}`} class="import-detail-change">{d.message}</p>
))}
{errors.length > 0 &&
errors.map((d, i) => (
<p key={`e-${i}`} class="import-detail-error">{d.message}</p>
))}
</div>
)}
</div>
</div>
);
}
+1
View File
@@ -19,6 +19,7 @@ export interface AppProperties {
icon: string;
pages: Record<string, string>;
adminOnly: string[];
studentOnly?: string[];
hint: string;
}
+42 -23
View File
@@ -12,15 +12,24 @@ import * as $_apps_admin_api_modules_idModule_ from "./routes/(apps)/admin/api/m
import * as $_apps_admin_api_permissions from "./routes/(apps)/admin/api/permissions.ts";
import * as $_apps_admin_api_roles from "./routes/(apps)/admin/api/roles.ts";
import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles/[idRole].ts";
import * as $_apps_admin_api_ue_modules from "./routes/(apps)/admin/api/ue-modules.ts";
import * as $_apps_admin_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
import * as $_apps_admin_api_ues from "./routes/(apps)/admin/api/ues.ts";
import * as $_apps_admin_api_ues_idUE_ from "./routes/(apps)/admin/api/ues/[idUE].ts";
import * as $_apps_admin_api_users from "./routes/(apps)/admin/api/users.ts";
import * as $_apps_admin_api_users_id_ from "./routes/(apps)/admin/api/users/[id].ts";
import * as $_apps_admin_index from "./routes/(apps)/admin/index.tsx";
import * as $_apps_admin_modules_idModule_ from "./routes/(apps)/admin/modules/[idModule].tsx";
import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx";
import * as $_apps_admin_partials_import_maquette from "./routes/(apps)/admin/partials/import-maquette.tsx";
import * as $_apps_admin_partials_index from "./routes/(apps)/admin/partials/index.tsx";
import * as $_apps_admin_partials_modules from "./routes/(apps)/admin/partials/modules.tsx";
import * as $_apps_admin_partials_permissions from "./routes/(apps)/admin/partials/permissions.tsx";
import * as $_apps_admin_partials_promotions from "./routes/(apps)/admin/partials/promotions.tsx";
import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx";
import * as $_apps_admin_partials_ues from "./routes/(apps)/admin/partials/ues.tsx";
import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx";
import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx";
import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts";
import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx";
import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx";
@@ -30,18 +39,14 @@ import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustem
import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts";
import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts";
import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts";
import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts";
import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts";
import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts";
import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts";
import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx";
import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx";
import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx";
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx";
import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx";
import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx";
import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx";
import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx";
import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx";
import * as $_apps_students_api_promotions from "./routes/(apps)/students/api/promotions.ts";
import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts";
import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts";
@@ -50,7 +55,6 @@ import * as $_apps_students_api_students_import_csv from "./routes/(apps)/studen
import * as $_apps_students_edit_numEtud_ from "./routes/(apps)/students/edit/[numEtud].tsx";
import * as $_apps_students_index from "./routes/(apps)/students/index.tsx";
import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx";
import * as $_apps_students_partials_admin_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx";
import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx";
import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx";
import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts";
@@ -68,17 +72,20 @@ import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx";
import * as $_apps_admin_islands_AdminEnseignements from "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx";
import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_islands)/AdminModules.tsx";
import * as $_apps_admin_islands_AdminPermissions from "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx";
import * as $_apps_admin_islands_AdminPromotions from "./routes/(apps)/admin/(_islands)/AdminPromotions.tsx";
import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx";
import * as $_apps_admin_islands_AdminUEs from "./routes/(apps)/admin/(_islands)/AdminUEs.tsx";
import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx";
import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx";
import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx";
import * as $_apps_admin_islands_ImportMaquette from "./routes/(apps)/admin/(_islands)/ImportMaquette.tsx";
import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx";
import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx";
import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx";
import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx";
import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx";
import * as $_apps_notes_islands_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.tsx";
import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx";
import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx";
import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx";
import * as $_apps_students_islands_EditStudents from "./routes/(apps)/students/(_islands)/EditStudents.tsx";
import * as $_apps_students_islands_UploadStudents from "./routes/(apps)/students/(_islands)/UploadStudents.tsx";
@@ -100,17 +107,30 @@ const manifest = {
"./routes/(apps)/admin/api/roles.ts": $_apps_admin_api_roles,
"./routes/(apps)/admin/api/roles/[idRole].ts":
$_apps_admin_api_roles_idRole_,
"./routes/(apps)/admin/api/ue-modules.ts": $_apps_admin_api_ue_modules,
"./routes/(apps)/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts":
$_apps_admin_api_ue_modules_idModule_idUE_idPromo_,
"./routes/(apps)/admin/api/ues.ts": $_apps_admin_api_ues,
"./routes/(apps)/admin/api/ues/[idUE].ts": $_apps_admin_api_ues_idUE_,
"./routes/(apps)/admin/api/users.ts": $_apps_admin_api_users,
"./routes/(apps)/admin/api/users/[id].ts": $_apps_admin_api_users_id_,
"./routes/(apps)/admin/index.tsx": $_apps_admin_index,
"./routes/(apps)/admin/modules/[idModule].tsx":
$_apps_admin_modules_idModule_,
"./routes/(apps)/admin/partials/enseignements.tsx":
$_apps_admin_partials_enseignements,
"./routes/(apps)/admin/partials/import-maquette.tsx":
$_apps_admin_partials_import_maquette,
"./routes/(apps)/admin/partials/index.tsx": $_apps_admin_partials_index,
"./routes/(apps)/admin/partials/modules.tsx": $_apps_admin_partials_modules,
"./routes/(apps)/admin/partials/permissions.tsx":
$_apps_admin_partials_permissions,
"./routes/(apps)/admin/partials/promotions.tsx":
$_apps_admin_partials_promotions,
"./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles,
"./routes/(apps)/admin/partials/ues.tsx": $_apps_admin_partials_ues,
"./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users,
"./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_,
"./routes/(apps)/mobility/api/insert_mobility.ts":
$_apps_mobility_api_insert_mobility,
"./routes/(apps)/mobility/index.tsx": $_apps_mobility_index,
@@ -126,23 +146,18 @@ const manifest = {
"./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes,
"./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts":
$_apps_notes_api_notes_numEtud_idModule_,
"./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules,
"./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts":
$_apps_notes_api_ue_modules_idModule_idUE_idPromo_,
"./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues,
"./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_,
"./routes/(apps)/notes/api/notes/import-xlsx.ts":
$_apps_notes_api_notes_import_xlsx,
"./routes/(apps)/notes/edition/[numEtud].tsx":
$_apps_notes_edition_numEtud_,
"./routes/(apps)/notes/index.tsx": $_apps_notes_index,
"./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_,
"./routes/(apps)/notes/partials/(admin)/courses.tsx":
$_apps_notes_partials_admin_courses,
"./routes/(apps)/notes/partials/(admin)/import.tsx":
$_apps_notes_partials_admin_import,
"./routes/(apps)/notes/partials/(admin)/ues.tsx":
$_apps_notes_partials_admin_ues,
"./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index,
"./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes,
"./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_,
"./routes/(apps)/students/api/promotions.ts":
$_apps_students_api_promotions,
"./routes/(apps)/students/api/promotions/[idPromo].ts":
@@ -157,8 +172,6 @@ const manifest = {
"./routes/(apps)/students/index.tsx": $_apps_students_index,
"./routes/(apps)/students/partials/(admin)/consult.tsx":
$_apps_students_partials_admin_consult,
"./routes/(apps)/students/partials/(admin)/promotions.tsx":
$_apps_students_partials_admin_promotions,
"./routes/(apps)/students/partials/(admin)/upload.tsx":
$_apps_students_partials_admin_upload,
"./routes/(apps)/students/partials/index.tsx":
@@ -183,10 +196,20 @@ const manifest = {
$_apps_admin_islands_AdminModules,
"./routes/(apps)/admin/(_islands)/AdminPermissions.tsx":
$_apps_admin_islands_AdminPermissions,
"./routes/(apps)/admin/(_islands)/AdminPromotions.tsx":
$_apps_admin_islands_AdminPromotions,
"./routes/(apps)/admin/(_islands)/AdminRoles.tsx":
$_apps_admin_islands_AdminRoles,
"./routes/(apps)/admin/(_islands)/AdminUEs.tsx":
$_apps_admin_islands_AdminUEs,
"./routes/(apps)/admin/(_islands)/AdminUsers.tsx":
$_apps_admin_islands_AdminUsers,
"./routes/(apps)/admin/(_islands)/EditModule.tsx":
$_apps_admin_islands_EditModule,
"./routes/(apps)/admin/(_islands)/EditUser.tsx":
$_apps_admin_islands_EditUser,
"./routes/(apps)/admin/(_islands)/ImportMaquette.tsx":
$_apps_admin_islands_ImportMaquette,
"./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx":
$_apps_mobility_islands_ConsultMobility,
"./routes/(apps)/mobility/(_islands)/EditMobility.tsx":
@@ -195,16 +218,12 @@ const manifest = {
$_apps_mobility_islands_ImportFile,
"./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx":
$_apps_notes_islands_AdminConsultNotes,
"./routes/(apps)/notes/(_islands)/AdminUEs.tsx":
$_apps_notes_islands_AdminUEs,
"./routes/(apps)/notes/(_islands)/ImportNotes.tsx":
$_apps_notes_islands_ImportNotes,
"./routes/(apps)/notes/(_islands)/NoteRecap.tsx":
$_apps_notes_islands_NoteRecap,
"./routes/(apps)/notes/(_islands)/NotesView.tsx":
$_apps_notes_islands_NotesView,
"./routes/(apps)/students/(_islands)/AdminPromotions.tsx":
$_apps_students_islands_AdminPromotions,
"./routes/(apps)/students/(_islands)/ConsultStudents.tsx":
$_apps_students_islands_ConsultStudents,
"./routes/(apps)/students/(_islands)/EditStudents.tsx":
+14 -5
View File
@@ -21,14 +21,23 @@ export const handler: MiddlewareHandler<AuthenticatedState>[] = [
`./${currentApp}/(_props)/props.ts`
)).default;
context.state.availablePages = properties.pages;
if (
context.state.session.eduPersonPrimaryAffiliation == "student" &&
Deno.env.get("LOCAL") != "true"
) {
context.state.availablePages = { ...properties.pages };
const isStudent =
context.state.session.eduPersonPrimaryAffiliation === "student";
const isLocal = Deno.env.get("LOCAL") === "true";
if (isStudent) {
// Students only see studentOnly pages (+ non-restricted pages)
properties.adminOnly.forEach((page) =>
delete context.state.availablePages[page]
);
} else if (isLocal) {
// In local mode, employees see all pages (admin + student)
} else {
// In prod, employees don't see studentOnly pages
properties.studentOnly?.forEach((page) =>
delete context.state.availablePages[page]
);
}
return await context.next();
@@ -169,55 +169,77 @@ export default function AdminEnseignements() {
</div>
{showAdd && (
<div class="form-row" style="margin-bottom: 1.25rem">
{addError && (
<span class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</span>
)}
<select
class="filter-select"
value={addPromo}
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
style="min-width: 10rem"
>
<option value="">Promo</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<select
class="filter-select"
value={addModule}
onChange={(e) =>
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 14rem"
>
<option value="">Module</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>{m.id} {m.nom}</option>
))}
</select>
<input
class="form-input"
placeholder="User ID enseignant…"
value={addProf}
onInput={(e) => setAddProf((e.target as HTMLInputElement).value)}
style="min-width: 10rem"
/>
<button
type="button"
class="btn btn-primary"
onClick={addEnseignement}
disabled={adding}
>
{adding ? "…" : "Créer"}
</button>
<button
type="button"
class="btn btn-secondary"
onClick={() => setShowAdd(false)}
>
Annuler
</button>
<div class="modal-overlay" onClick={() => setShowAdd(false)}>
<div class="modal-box" onClick={(e) => e.stopPropagation()}>
<p class="modal-title">Assigner un enseignement</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</p>
)}
<div class="modal-form">
<div class="form-field">
<label>Promo</label>
<select
class="filter-select"
value={addPromo}
onChange={(e) =>
setAddPromo((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%"
>
<option value="">Promo...</option>
{promos.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
</div>
<div class="form-field">
<label>Module</label>
<select
class="filter-select"
value={addModule}
onChange={(e) =>
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%"
>
<option value="">Module...</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} -- {m.nom}
</option>
))}
</select>
</div>
<div class="form-field">
<label>User ID enseignant</label>
<input
class="form-input"
placeholder="User ID enseignant..."
value={addProf}
onInput={(e) =>
setAddProf((e.target as HTMLInputElement).value)}
style="min-width: 0; width: 100%"
/>
</div>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
onClick={() => setShowAdd(false)}
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
onClick={addEnseignement}
disabled={adding}
>
{adding ? "..." : "+ Assigner"}
</button>
</div>
</div>
</div>
)}
@@ -266,7 +288,24 @@ export default function AdminEnseignements() {
e.idPromo,
)}
>
🗑
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</div>
</td>
+152 -102
View File
@@ -1,22 +1,31 @@
import { useEffect, useState } from "preact/hooks";
type Module = { id: string; nom: string };
type Enseignement = { idProf: string; idModule: string; idPromo: string };
type User = { id: string; nom: string; prenom: string };
export default function AdminModules() {
const [modules, setModules] = useState<Module[]>([]);
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newId, setNewId] = useState("");
const [newNom, setNewNom] = useState("");
const [creating, setCreating] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editNom, setEditNom] = useState("");
const [filterNom, setFilterNom] = useState("");
async function load() {
try {
const res = await fetch("/admin/api/modules");
if (!res.ok) throw new Error("Impossible de charger les modules");
setModules(await res.json());
const [mRes, eRes, uRes] = await Promise.all([
fetch("/admin/api/modules"),
fetch("/admin/api/enseignements"),
fetch("/admin/api/users"),
]);
if (!mRes.ok) throw new Error("Impossible de charger les modules");
setModules(await mRes.json());
if (eRes.ok) setEnseignements(await eRes.json());
if (uRes.ok) setUsers(await uRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
@@ -51,21 +60,6 @@ export default function AdminModules() {
}
}
async function saveEdit(id: string) {
try {
const res = await fetch(`/admin/api/modules/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: editNom.trim() }),
});
if (!res.ok) throw new Error("Modification échouée");
setEditId(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function deleteModule(id: string) {
if (!confirm(`Supprimer le module ${id} ?`)) return;
try {
@@ -80,125 +74,181 @@ export default function AdminModules() {
}
}
const userMap = Object.fromEntries(
users.map((u) => [u.id, u]),
);
function enseignantsForModule(moduleId: string): string {
const profs = [
...new Set(
enseignements
.filter((e) => e.idModule === moduleId)
.map((e) => e.idProf),
),
];
if (profs.length === 0) return "";
return profs
.map((id) => {
const u = userMap[id];
return u ? `${u.nom} ${u.prenom.charAt(0)}.` : id;
})
.join(", ");
}
const filtered = modules.filter((m) =>
!filterNom ||
`${m.id} ${m.nom}`.toLowerCase().includes(filterNom.toLowerCase())
);
return (
<div class="page-content">
<h2 class="page-title">Gestion des Modules</h2>
{error && <p class="state-error">{error}</p>}
<div class="form-row">
<div class="filters">
<input
class="form-input"
placeholder="Identifiant (ex: JIA3)"
value={newId}
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
style="min-width: 10rem"
/>
<input
class="form-input"
placeholder="Nom du module"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
class="filter-input"
placeholder="Rechercher..."
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="btn btn-primary"
onClick={createModule}
disabled={creating}
onClick={() => {
const el = document.getElementById("new-module-section");
if (el) el.scrollIntoView({ behavior: "smooth" });
}}
style="margin-left: auto"
>
+ Ajouter
+ Ajouter module
</button>
</div>
{loading
? <p class="state-loading">Chargement</p>
? <p class="state-loading">Chargement...</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Identifiant</th>
<th>Nom</th>
<th>id (code)</th>
<th>Nom du module</th>
<th>Enseignants assignes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{modules.length === 0
{filtered.length === 0
? (
<tr>
<td colspan={3} class="state-empty">
<td colspan={4} class="state-empty">
Aucun module enregistré
</td>
</tr>
)
: modules.map((m) => (
<tr key={m.id}>
<td class="col-dim">{m.id}</td>
<td>
{editId === m.id
? (
<input
class="form-input"
value={editNom}
onInput={(e) =>
setEditNom(
(e.target as HTMLInputElement).value,
)}
style="min-width: 0; width: 100%"
/>
)
: m.nom}
</td>
<td>
<div class="col-actions">
{editId === m.id
: filtered.map((m) => {
const profs = enseignantsForModule(m.id);
return (
<tr key={m.id}>
<td class="col-dim">{m.id}</td>
<td>{m.nom}</td>
<td>
{profs
? (
<>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => saveEdit(m.id)}
>
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => setEditId(null)}
>
</button>
</>
<span style="font-size: 0.78rem">
{profs}
</span>
)
: (
<>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => {
setEditId(m.id);
setEditNom(m.nom);
}}
>
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteModule(m.id)}
>
🗑
</button>
</>
)}
</div>
</td>
</tr>
))}
: <span class="col-dim">--</span>}
</td>
<td>
<div class="col-actions">
<a
class="btn btn-sm btn-secondary"
href={`/admin/modules/${
encodeURIComponent(m.id)
}`}
f-client-nav={false}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>{" "}
edit
</a>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteModule(m.id)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Nouveau module */}
<div
id="new-module-section"
class="edit-section"
style="margin-top: 1.5rem"
>
<p class="edit-section-title">Nouveau module</p>
<div class="form-row">
<input
class="form-input"
placeholder="Code"
value={newId}
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
style="min-width: 8rem; max-width: 10rem"
/>
<input
class="form-input"
placeholder="Nom du module"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="btn btn-primary"
onClick={createModule}
disabled={creating}
>
{creating ? "..." : "+ Créer"}
</button>
</div>
</div>
</div>
);
}
@@ -3,6 +3,19 @@ import { useEffect, useState } from "preact/hooks";
type Perm = { id: string; nom: string };
type Role = { id: number; nom: string; permissions: string[] };
const ROLE_COLORS = [
"#22c55e",
"#d4a017",
"#e07020",
"#8b5cf6",
"#06b6d4",
"#ec4899",
];
function roleColor(roleId: number): string {
return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length];
}
export default function AdminPermissions() {
const [permissions, setPermissions] = useState<Perm[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
@@ -80,7 +93,15 @@ export default function AdminPermissions() {
<td>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
{shown.map((r) => (
<span key={r.id} class="role-chip">{r.nom}</span>
<span
key={r.id}
class="role-chip"
style={`border-color: ${
roleColor(r.id)
}; color: ${roleColor(r.id)}`}
>
{r.nom}
</span>
))}
{overflow > 0 && (
<span
@@ -25,7 +25,7 @@ export default function AdminPromotions() {
const [anneeSco, setAnneeSco] = useState("");
const generatedId = anneeSco.trim()
? `${selectedAnnee}${selectedFiliere}${anneeSco.trim()}`
? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}`
: "";
async function load() {
@@ -74,13 +74,26 @@ export default function AdminPromotions() {
}
async function deletePromo(id: string) {
if (!confirm(`Supprimer la promotion ${id} ?`)) return;
if (studentCount(id) > 0) {
setError(
`Impossible de supprimer ${id} : des étudiants y sont encore assignés. Réassignez-les d'abord.`,
);
return;
}
if (
!confirm(`Supprimer la promotion ${id} et toutes ses données liées ?`)
) {
return;
}
try {
const res = await fetch(
`/students/api/promotions/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Suppression échouée");
}
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
@@ -101,7 +114,7 @@ export default function AdminPromotions() {
<div class="promo-builder">
<p class="promo-builder-title">Créer une promotion</p>
<p class="promo-builder-subtitle">
POST /promotions idPromo est généré automatiquement
idPromo est généré automatiquement
</p>
<div class="promo-builder-row">
@@ -141,7 +154,7 @@ export default function AdminPromotions() {
<label>Année scolaire</label>
<input
class="form-input"
placeholder="ex: 25/26, 24/27…"
placeholder="ex: 25-26, 24-27…"
value={anneeSco}
onInput={(e) => setAnneeSco((e.target as HTMLInputElement).value)}
style="min-width: 9rem"
@@ -218,9 +231,24 @@ export default function AdminPromotions() {
<button
type="button"
class="btn btn-sm btn-danger"
disabled={count > 0}
title={count > 0
? "Réassignez les étudiants avant de supprimer"
: "Supprimer la promotion"}
onClick={() => deletePromo(p.id)}
>
🗑
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect x="5" y="6" width="14" height="16" rx="1" />
</svg>
</button>
</td>
</tr>
+41 -12
View File
@@ -131,16 +131,13 @@ export default function AdminRoles() {
{saveError && <p class="state-error">{saveError}</p>}
<div
class="toolbar"
style="margin-bottom: 1.25rem; align-items: center"
>
<div class="perm-header-bar">
<div style="display: flex; align-items: center; gap: 0.6rem">
<span class="numEtud-chip">idRole : {managingRole.id}</span>
<span style="font-weight: var(--font-weight-bold); font-size: 0.9rem">
{managingRole.nom}
</span>
<span style="font-size: 0.8rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
<span style="font-size: 0.8rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color))">
{activeCount} permission{activeCount !== 1 ? "s" : ""} active
{activeCount !== 1 ? "s" : ""}
</span>
@@ -151,7 +148,7 @@ export default function AdminRoles() {
onClick={savePerms}
disabled={saving}
>
{saving ? "" : "Enregistrer"}
{saving ? "..." : "Enregistrer"}
</button>
</div>
@@ -192,6 +189,8 @@ export default function AdminRoles() {
);
}
const permMap = Object.fromEntries(permissions.map((p) => [p.id, p.nom]));
// ---- Main list view ----
return (
<div class="page-content">
@@ -202,7 +201,7 @@ export default function AdminRoles() {
<div class="toolbar">
<input
class="form-input"
placeholder="Nom du rôle"
placeholder="Nom du rôle..."
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && createRole()}
@@ -219,7 +218,7 @@ export default function AdminRoles() {
</div>
{loading
? <p class="state-loading">Chargement</p>
? <p class="state-loading">Chargement...</p>
: (
<div class="data-table-wrap">
<table class="data-table">
@@ -252,7 +251,9 @@ export default function AdminRoles() {
<td>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
{shown.map((p) => (
<span key={p} class="perm-chip">{p}</span>
<span key={p} class="perm-chip">
{permMap[p] ?? p}
</span>
))}
{overflow > 0 && (
<span
@@ -268,17 +269,45 @@ export default function AdminRoles() {
<div class="col-actions">
<button
type="button"
class="btn btn-sm btn-secondary"
class="btn btn-sm btn-primary"
onClick={() => openManage(r)}
>
Gérer perms
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</svg>{" "}
Gérer perms
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteRole(r.id)}
>
🗑
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</div>
</td>
@@ -19,6 +19,7 @@ export default function AdminUEs() {
const [error, setError] = useState<string | null>(null);
const [selectedUe, setSelectedUe] = useState<UE | null>(null);
const [filterPromo, setFilterPromo] = useState("");
// New UE form
const [newUeNom, setNewUeNom] = useState("");
@@ -31,11 +32,15 @@ export default function AdminUEs() {
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
// Inline coeff editing
const [editingCoeff, setEditingCoeff] = useState<string | null>(null);
const [editCoeffValue, setEditCoeffValue] = useState("");
async function load() {
try {
const [uRes, umRes, mRes, pRes] = await Promise.all([
fetch("/notes/api/ues"),
fetch("/notes/api/ue-modules"),
fetch("/admin/api/ues"),
fetch("/admin/api/ue-modules"),
fetch("/admin/api/modules"),
fetch("/students/api/promotions"),
]);
@@ -64,7 +69,7 @@ export default function AdminUEs() {
if (!newUeNom.trim()) return;
setCreatingUe(true);
try {
const res = await fetch("/notes/api/ues", {
const res = await fetch("/admin/api/ues", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: newUeNom.trim() }),
@@ -79,6 +84,21 @@ export default function AdminUEs() {
}
}
async function deleteUE(ue: UE) {
if (!confirm(`Supprimer la UE "${ue.nom}" et tous ses liens ?`)) return;
try {
const res = await fetch(`/admin/api/ues/${ue.id}`, { method: "DELETE" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Suppression échouée");
}
if (selectedUe?.id === ue.id) setSelectedUe(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function deleteUeModule(
idModule: string,
idUE: number,
@@ -87,7 +107,7 @@ export default function AdminUEs() {
if (!confirm("Supprimer ce module de la UE ?")) return;
try {
const res = await fetch(
`/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
encodeURIComponent(idPromo)
}`,
{ method: "DELETE" },
@@ -112,7 +132,7 @@ export default function AdminUEs() {
setAdding(true);
setAddError(null);
try {
const res = await fetch("/notes/api/ue-modules", {
const res = await fetch("/admin/api/ue-modules", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -137,8 +157,41 @@ export default function AdminUEs() {
}
}
async function updateCoeff(
idModule: string,
idUE: number,
idPromo: string,
coeff: number,
) {
try {
const res = await fetch(
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
encodeURIComponent(idPromo)
}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ coeff }),
},
);
if (!res.ok) throw new Error("Modification échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setEditingCoeff(null);
}
}
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
// Filter UEs by promo: keep UEs that have at least one ue_module for that promo
const filteredUes = filterPromo
? ues.filter((ue) =>
ueModules.some((um) => um.idUE === ue.id && um.idPromo === filterPromo)
)
: ues;
const selectedUeModules = selectedUe
? ueModules.filter((um) => um.idUE === selectedUe.id)
: [];
@@ -163,6 +216,20 @@ export default function AdminUEs() {
<div class="ue-panel-left">
<div class="panel-box">
<p class="panel-box-title">UEs existantes</p>
<select
class="filter-select"
value={filterPromo}
onChange={(e) =>
setFilterPromo(
(e.target as HTMLSelectElement).value,
)}
style="width: 100%; margin-bottom: 0.5rem"
>
<option value="">Toutes les promos</option>
{promos.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
<div class="form-row" style="margin-bottom: 0.75rem">
<input
class="form-input"
@@ -184,23 +251,56 @@ export default function AdminUEs() {
+ Nouvelle UE
</button>
<div>
{ues.map((ue) => (
{filteredUes.map((ue) => (
<div
key={ue.id}
class={`ue-list-item${
selectedUe?.id === ue.id ? " active" : ""
}`}
onClick={() => {
setSelectedUe(ue);
setAddError(null);
}}
style="display: flex; align-items: center; justify-content: space-between"
>
{ue.nom}
<span
style="flex: 1; cursor: pointer"
onClick={() => {
setSelectedUe(ue);
setAddError(null);
}}
>
{ue.nom}
</span>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={(e) => {
e.stopPropagation();
deleteUE(ue);
}}
title="Supprimer cette UE"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</div>
))}
{ues.length === 0 && (
{filteredUes.length === 0 && (
<p class="state-empty" style="padding: 1rem 0">
Aucune UE
{filterPromo ? "Aucune UE pour cette promo" : "Aucune UE"}
</p>
)}
</div>
@@ -249,7 +349,59 @@ export default function AdminUEs() {
<td>
<span class="promo-chip">{um.idPromo}</span>
</td>
<td>{um.coeff}</td>
<td
onClick={() => {
const key =
`${um.idModule}-${um.idUE}-${um.idPromo}`;
setEditingCoeff(key);
setEditCoeffValue(String(um.coeff));
}}
style="cursor: pointer"
>
{editingCoeff ===
`${um.idModule}-${um.idUE}-${um.idPromo}`
? (
<input
type="number"
class="form-input"
value={editCoeffValue}
min="0.1"
step="0.5"
style="width: 5rem; padding: 0.2rem 0.4rem; font-size: 0.82rem"
autoFocus
onInput={(e) =>
setEditCoeffValue(
(e.target as HTMLInputElement)
.value,
)}
onBlur={() => {
const v = parseFloat(
editCoeffValue,
);
if (!isNaN(v) && v > 0) {
updateCoeff(
um.idModule,
um.idUE,
um.idPromo,
v,
);
} else {
setEditingCoeff(null);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
(e.target as HTMLInputElement)
.blur();
}
if (e.key === "Escape") {
setEditingCoeff(null);
}
}}
/>
)
: um.coeff}
</td>
<td>
<button
type="button"
@@ -261,7 +413,24 @@ export default function AdminUEs() {
um.idPromo,
)}
>
🗑
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</td>
</tr>
+151 -44
View File
@@ -3,11 +3,25 @@ import { useEffect, useState } from "preact/hooks";
type User = { id: string; nom: string; prenom: string; idRole: number | null };
type Role = { id: number; nom: string };
const ROLE_COLORS = [
"#22c55e",
"#d4a017",
"#e07020",
"#8b5cf6",
"#06b6d4",
"#ec4899",
];
function roleColor(roleId: number): string {
return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length];
}
export default function AdminUsers() {
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newId, setNewId] = useState("");
const [newNom, setNewNom] = useState("");
const [newPrenom, setNewPrenom] = useState("");
@@ -15,6 +29,7 @@ export default function AdminUsers() {
const [creating, setCreating] = useState(false);
const [filterNom, setFilterNom] = useState("");
const [filterRole, setFilterRole] = useState("");
async function load() {
try {
@@ -58,6 +73,7 @@ export default function AdminUsers() {
setNewNom("");
setNewPrenom("");
setNewIdRole("");
setShowCreate(false);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
@@ -81,12 +97,14 @@ export default function AdminUsers() {
const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom]));
const filtered = users.filter((u) =>
!filterNom ||
`${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes(
filterNom.toLowerCase(),
)
);
const filtered = users.filter((u) => {
const matchNom = !filterNom ||
`${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes(
filterNom.toLowerCase(),
);
const matchRole = !filterRole || String(u.idRole) === filterRole;
return matchNom && matchRole;
});
return (
<div class="page-content">
@@ -94,64 +112,121 @@ export default function AdminUsers() {
{error && <p class="state-error">{error}</p>}
<div class="form-row">
<div class="filters">
<input
class="form-input"
placeholder="Login (uid)"
value={newId}
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
style="min-width: 9rem"
/>
<input
class="form-input"
placeholder="Nom"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/>
<input
class="form-input"
placeholder="Prénom"
value={newPrenom}
onInput={(e) => setNewPrenom((e.target as HTMLInputElement).value)}
class="filter-input"
placeholder="Rechercher..."
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
<select
class="filter-select"
value={newIdRole}
onChange={(e) => setNewIdRole((e.target as HTMLSelectElement).value)}
value={filterRole}
onChange={(e) => setFilterRole((e.target as HTMLSelectElement).value)}
>
<option value="">Aucun rôle</option>
<option value="">Role</option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}</option>)}
</select>
<button
type="button"
class="btn btn-primary"
onClick={createUser}
disabled={creating}
onClick={() => setShowCreate(true)}
style="margin-left: auto"
>
+ Ajouter
+ Créer utilisateur
</button>
</div>
<div class="filters">
<input
class="filter-input"
placeholder="Rechercher…"
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
</div>
{/* Creation modal */}
{showCreate && (
<div
class="modal-overlay"
onClick={() => setShowCreate(false)}
>
<div class="modal-box" onClick={(e) => e.stopPropagation()}>
<p class="modal-title">Créer un utilisateur</p>
<div class="modal-form">
<div class="form-field">
<label>Login (uid)</label>
<input
class="form-input"
placeholder="Login (uid)"
value={newId}
onInput={(e) =>
setNewId((e.target as HTMLInputElement).value)}
style="min-width: 0; width: 100%"
/>
</div>
<div class="form-field">
<label>Nom</label>
<input
class="form-input"
placeholder="Nom"
value={newNom}
onInput={(e) =>
setNewNom((e.target as HTMLInputElement).value)}
style="min-width: 0; width: 100%"
/>
</div>
<div class="form-field">
<label>Prénom</label>
<input
class="form-input"
placeholder="Prénom"
value={newPrenom}
onInput={(e) =>
setNewPrenom((e.target as HTMLInputElement).value)}
style="min-width: 0; width: 100%"
/>
</div>
<div class="form-field">
<label>Rôle</label>
<select
class="filter-select"
value={newIdRole}
onChange={(e) =>
setNewIdRole((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%"
>
<option value="">Aucun rôle</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>{r.nom}</option>
))}
</select>
</div>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
onClick={() => setShowCreate(false)}
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
onClick={createUser}
disabled={creating}
>
{creating ? "..." : "+ Créer"}
</button>
</div>
</div>
</div>
)}
{loading
? <p class="state-loading">Chargement</p>
? <p class="state-loading">Chargement...</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Login</th>
<th>id (login)</th>
<th>Nom</th>
<th>Prénom</th>
<th>Rôle</th>
<th>Rôle(s)</th>
<th>Actions</th>
</tr>
</thead>
@@ -170,7 +245,18 @@ export default function AdminUsers() {
<td>{u.nom}</td>
<td>{u.prenom}</td>
<td>
{u.idRole ? (roleMap[u.idRole] ?? `#${u.idRole}`) : "—"}
{u.idRole
? (
<span
class="role-chip"
style={`border-color: ${
roleColor(u.idRole)
}; color: ${roleColor(u.idRole)}`}
>
{roleMap[u.idRole] ?? `#${u.idRole}`}
</span>
)
: <span class="col-dim">--</span>}
</td>
<td>
<div class="col-actions">
@@ -179,14 +265,35 @@ export default function AdminUsers() {
href={`/admin/users/${encodeURIComponent(u.id)}`}
f-client-nav={false}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>{" "}
edit
</a>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteUser(u.id)}
>
🗑
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect x="5" y="6" width="14" height="16" rx="1" />
</svg>
</button>
</div>
</td>
@@ -0,0 +1,344 @@
import { useEffect, useState } from "preact/hooks";
type Module = { id: string; nom: string };
type Enseignement = { idProf: string; idModule: string; idPromo: string };
type User = { id: string; nom: string; prenom: string };
type Promo = { id: string; annee: string };
type Props = { moduleId: string };
export default function EditModule({ moduleId }: Props) {
const [mod, setMod] = useState<Module | null>(null);
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [promos, setPromos] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [nom, setNom] = useState("");
// Add enseignement
const [addProf, setAddProf] = useState("");
const [addPromo, setAddPromo] = useState("");
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
async function load() {
try {
const [mRes, eRes, uRes, pRes] = await Promise.all([
fetch(`/admin/api/modules/${encodeURIComponent(moduleId)}`),
fetch("/admin/api/enseignements"),
fetch("/admin/api/users"),
fetch("/students/api/promotions"),
]);
if (!mRes.ok) throw new Error("Module introuvable");
const m: Module = await mRes.json();
setMod(m);
setNom(m.nom);
if (eRes.ok) {
const all: Enseignement[] = await eRes.json();
setEnseignements(all.filter((e) => e.idModule === moduleId));
}
if (uRes.ok) setUsers(await uRes.json());
if (pRes.ok) setPromos(await pRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, [moduleId]);
async function saveInfos() {
if (!mod) return;
setSaving(true);
setSaveMsg(null);
try {
const res = await fetch(
`/admin/api/modules/${encodeURIComponent(moduleId)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: nom.trim() }),
},
);
if (!res.ok) throw new Error("Modification échouée");
const updated: Module = await res.json();
setMod(updated);
setSaveMsg("Module enregistré.");
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setSaving(false);
}
}
async function deleteModule() {
if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return;
try {
const res = await fetch(
`/admin/api/modules/${encodeURIComponent(moduleId)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
globalThis.location.href = "/admin/modules";
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function addEnseignement() {
if (!addProf || !addPromo) {
setAddError("Enseignant et Promo sont requis");
return;
}
setAdding(true);
setAddError(null);
try {
const res = await fetch("/admin/api/enseignements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idProf: addProf,
idModule: moduleId,
idPromo: addPromo,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setAddProf("");
setAddPromo("");
await load();
} catch (e) {
setAddError(e instanceof Error ? e.message : "Erreur");
} finally {
setAdding(false);
}
}
async function removeEnseignement(idProf: string, idPromo: string) {
if (!confirm("Retirer cet enseignement ?")) return;
try {
const res = await fetch(
`/admin/api/enseignements/${encodeURIComponent(idProf)}/${
encodeURIComponent(moduleId)
}/${encodeURIComponent(idPromo)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error && !mod) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
if (!mod) return null;
return (
<div class="page-content">
<a
class="back-link"
href="/admin/modules"
f-partial="/admin/partials/modules"
>
&larr; Retour a la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Module -- {mod.id}
</h2>
<div class="info-bar">
<span class="module-chip">{mod.id}</span>
<span>{mod.nom}</span>
</div>
{error && <p class="state-error">{error}</p>}
{saveMsg && (
<p style="font-size: 0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.5rem">
{saveMsg}
</p>
)}
{/* Section 1: Infos */}
<div class="edit-section">
<p class="edit-section-title">Informations</p>
<div class="form-grid">
<div class="form-field">
<label>Code</label>
<input
class="form-input"
value={mod.id}
disabled
style="opacity: 0.6"
/>
</div>
<div class="form-field">
<label>Nom du module</label>
<input
class="form-input"
value={nom}
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
/>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: space-between; flex-wrap: wrap">
<button
type="button"
class="btn btn-primary"
onClick={saveInfos}
disabled={saving}
>
{saving ? "..." : "Enregistrer"}
</button>
<button
type="button"
class="btn btn-danger"
onClick={deleteModule}
>
Supprimer le module
</button>
</div>
</div>
{/* Section 2: Enseignements */}
<div class="edit-section">
<p class="edit-section-title">Enseignants assignes</p>
{enseignements.length > 0
? (
<div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table">
<thead>
<tr>
<th>Enseignant</th>
<th>Promo</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{enseignements.map((e) => {
const u = userMap[e.idProf];
return (
<tr key={`${e.idProf}-${e.idPromo}`}>
<td>
{u ? `${u.nom} ${u.prenom.charAt(0)}.` : e.idProf}
</td>
<td>
<span class="promo-chip">{e.idPromo}</span>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
removeEnseignement(e.idProf, e.idPromo)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)
: (
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
>
Aucun enseignant assigne.
</p>
)}
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un enseignant
</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</p>
)}
<div class="form-row">
<select
class="filter-select"
value={addProf}
onChange={(e) => setAddProf((e.target as HTMLSelectElement).value)}
style="min-width: 12rem"
>
<option value="">Enseignant</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.nom} {u.prenom} ({u.id})
</option>
))}
</select>
<select
class="filter-select"
value={addPromo}
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
style="min-width: 9rem"
>
<option value="">Promo</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<button
type="button"
class="btn btn-primary"
onClick={addEnseignement}
disabled={adding}
>
{adding ? "..." : "+ Ajouter"}
</button>
</div>
</div>
</div>
);
}
+391
View File
@@ -0,0 +1,391 @@
import { useEffect, useState } from "preact/hooks";
type User = { id: string; nom: string; prenom: string; idRole: number | null };
type Role = { id: number; nom: string };
type Enseignement = { idProf: string; idModule: string; idPromo: string };
type Module = { id: string; nom: string };
type Promo = { id: string; annee: string };
type Props = { userId: string };
export default function EditUser({ userId }: Props) {
const [user, setUser] = useState<User | null>(null);
const [roles, setRoles] = useState<Role[]>([]);
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
const [modules, setModules] = useState<Module[]>([]);
const [promos, setPromos] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [nom, setNom] = useState("");
const [prenom, setPrenom] = useState("");
const [idRole, setIdRole] = useState("");
// Add enseignement form
const [addModule, setAddModule] = useState("");
const [addPromo, setAddPromo] = useState("");
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
async function load() {
try {
const [uRes, rRes, eRes, mRes, pRes] = await Promise.all([
fetch(`/admin/api/users/${encodeURIComponent(userId)}`),
fetch("/admin/api/roles"),
fetch("/admin/api/enseignements"),
fetch("/admin/api/modules"),
fetch("/students/api/promotions"),
]);
if (!uRes.ok) throw new Error("Utilisateur introuvable");
const u: User = await uRes.json();
setUser(u);
setNom(u.nom);
setPrenom(u.prenom);
setIdRole(u.idRole !== null ? String(u.idRole) : "");
if (rRes.ok) setRoles(await rRes.json());
if (eRes.ok) {
const allEns: Enseignement[] = await eRes.json();
setEnseignements(allEns.filter((e) => e.idProf === userId));
}
if (mRes.ok) setModules(await mRes.json());
if (pRes.ok) setPromos(await pRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, [userId]);
async function saveInfos() {
if (!user) return;
setSaving(true);
setSaveMsg(null);
try {
const res = await fetch(
`/admin/api/users/${encodeURIComponent(userId)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
nom: nom.trim(),
prenom: prenom.trim(),
idRole: idRole ? Number(idRole) : null,
}),
},
);
if (!res.ok) throw new Error("Modification échouée");
const updated: User = await res.json();
setUser(updated);
setSaveMsg("Informations enregistrées.");
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setSaving(false);
}
}
async function deleteUser() {
if (!confirm(`Supprimer définitivement l'utilisateur ${userId} ?`)) return;
try {
const res = await fetch(
`/admin/api/users/${encodeURIComponent(userId)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
globalThis.location.href = "/admin/users";
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function addEnseignement() {
if (!addModule || !addPromo) {
setAddError("Module et Promo sont requis");
return;
}
setAdding(true);
setAddError(null);
try {
const res = await fetch("/admin/api/enseignements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idProf: userId,
idModule: addModule,
idPromo: addPromo,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setAddModule("");
setAddPromo("");
await load();
} catch (e) {
setAddError(e instanceof Error ? e.message : "Erreur");
} finally {
setAdding(false);
}
}
async function removeEnseignement(idModule: string, idPromo: string) {
if (!confirm("Retirer cet enseignement ?")) return;
try {
const res = await fetch(
`/admin/api/enseignements/${encodeURIComponent(userId)}/${
encodeURIComponent(idModule)
}/${encodeURIComponent(idPromo)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom]));
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement...</p>
</div>
);
}
if (error && !user) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
if (!user) return null;
return (
<div class="page-content">
<a
class="back-link"
href="/admin/users"
f-partial="/admin/partials/users"
>
&larr; Retour a la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Edition -- {user.prenom} {user.nom}
</h2>
<div class="info-bar">
<span class="numEtud-chip">{user.id}</span>
<span>
{user.idRole
? (roleMap[user.idRole] ?? `Role #${user.idRole}`)
: "Aucun role"}
</span>
</div>
{error && <p class="state-error">{error}</p>}
{saveMsg && (
<p style="font-size: 0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.5rem">
{saveMsg}
</p>
)}
{/* Section 1: Informations generales */}
<div class="edit-section">
<p class="edit-section-title">Informations generales</p>
<div class="form-grid">
<div class="form-field">
<label>Nom</label>
<input
class="form-input"
value={nom}
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>Prenom</label>
<input
class="form-input"
value={prenom}
onInput={(e) => setPrenom((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>Login</label>
<input
class="form-input"
value={user.id}
disabled
style="opacity: 0.6"
/>
</div>
<div class="form-field">
<label>Role</label>
<select
class="filter-select"
value={idRole}
onChange={(e) => setIdRole((e.target as HTMLSelectElement).value)}
style="min-width: 0"
>
<option value="">Aucun role</option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}
</option>)}
</select>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: space-between; flex-wrap: wrap">
<button
type="button"
class="btn btn-primary"
onClick={saveInfos}
disabled={saving}
>
{saving ? "..." : "Enregistrer"}
</button>
<button
type="button"
class="btn btn-danger"
onClick={deleteUser}
>
Supprimer l'utilisateur
</button>
</div>
</div>
{/* Section 2: Enseignements */}
<div class="edit-section">
<p class="edit-section-title">Enseignements</p>
<p
class="col-dim"
style="font-size: 0.75rem; margin: 0 0 0.75rem"
>
Modules enseignes par cet utilisateur
</p>
{enseignements.length > 0
? (
<div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table">
<thead>
<tr>
<th>Module</th>
<th>Promo</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{enseignements.map((e) => {
const mod = moduleMap[e.idModule];
return (
<tr key={`${e.idModule}-${e.idPromo}`}>
<td class="col-promo">
{mod ? `${mod.id} -- ${mod.nom}` : e.idModule}
</td>
<td>
<span class="promo-chip">{e.idPromo}</span>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
removeEnseignement(e.idModule, e.idPromo)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect
x="5"
y="6"
width="14"
height="16"
rx="1"
/>
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)
: (
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
>
Aucun enseignement assigne.
</p>
)}
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un enseignement
</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</p>
)}
<div class="form-row">
<select
class="filter-select"
value={addModule}
onChange={(e) =>
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 12rem"
>
<option value="">Module</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} -- {m.nom}
</option>
))}
</select>
<select
class="filter-select"
value={addPromo}
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
style="min-width: 9rem"
>
<option value="">Promo</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<button
type="button"
class="btn btn-primary"
onClick={addEnseignement}
disabled={adding}
>
{adding ? "..." : "+ Ajouter"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,531 @@
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
import { useEffect, useRef } from "preact/hooks";
import { useSignal } from "@preact/signals";
import ImportResultPopup, {
type ImportDetail,
type ImportResult,
} from "$root/defaults/ImportResultPopup.tsx";
type ParsedUE = {
code: string | null;
name: string;
ects: number | null;
modules: ParsedModule[];
};
type ParsedModule = {
code: string;
name: string;
coeff: number;
};
type ParsedYear = {
label: string;
ues: ParsedUE[];
};
type Promo = { id: string; annee: string | null };
function parseMaquette(workbook: XLSX.WorkBook): ParsedYear[] {
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
header: 1,
});
const years: ParsedYear[] = [];
let currentYear: ParsedYear | null = null;
let currentUE: ParsedUE | null = null;
let moduleIndex = 0;
for (const row of rows) {
if (!row || row.length === 0) continue;
const col0 = row[0] != null ? String(row[0]).trim() : "";
// Detect year row: INFO3A, INFO4A, INFO5A, INFOAPP3A, INFOAPP4A, etc.
if (/^(INFO|INFOAPP)\s*\d+A$/i.test(col0)) {
currentYear = { label: col0, ues: [] };
years.push(currentYear);
currentUE = null;
continue;
}
// Detect UE row: col[0] === "UE" or starts with "UE" (e.g., "UE51")
if (col0 === "UE" || (col0.startsWith("UE") && /^UE\d/.test(col0))) {
const ueCode = row[1] != null ? String(row[1]).trim() : null;
const ueName = row[2] != null ? String(row[2]).trim() : "UE sans nom";
const ects = typeof row[4] === "number" ? row[4] : null;
currentUE = { code: ueCode, name: ueName, ects, modules: [] };
if (currentYear) {
currentYear.ues.push(currentUE);
} else {
// No year detected yet — create a default one
currentYear = { label: "Maquette", ues: [currentUE] };
years.push(currentYear);
}
moduleIndex = 0;
continue;
}
// Detect semester header rows — just skip, don't reset UE
if (/^SEM\s*\d/i.test(col0)) {
currentUE = null;
continue;
}
// Detect module row: inside a UE, col[3] has a name, col[5] is a number (coeff)
if (currentUE && row[3] != null && typeof row[5] === "number") {
const modName = String(row[3]).trim();
if (!modName) continue;
let modCode = row[1] != null ? String(row[1]).trim() : "";
if (!modCode) {
const uePrefix = (currentUE.code || "MOD").replace(/[^A-Z0-9]/gi, "");
modCode = `${uePrefix}_${String(moduleIndex).padStart(2, "0")}`;
}
currentUE.modules.push({ code: modCode, name: modName, coeff: row[5] });
moduleIndex++;
}
}
return years;
}
export default function ImportMaquette() {
const file = useSignal<File | null>(null);
const dragging = useSignal(false);
const uploading = useSignal(false);
const error = useSignal<string | null>(null);
const importResult = useSignal<ImportResult | null>(null);
const preview = useSignal<ParsedYear[] | null>(null);
const promos = useSignal<Promo[]>([]);
// Map: year label -> selected promo id
const yearPromos = useSignal<Record<string, string>>({});
// Inline promo creation
const newPromoId = useSignal("");
const newPromoAnnee = useSignal("");
const creatingPromo = useSignal(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch("/students/api/promotions")
.then((r) => (r.ok ? r.json() : []))
.then((data) => (promos.value = data));
}, []);
function pickFile(f: File) {
if (!f.name.match(/\.xlsx?$/i)) {
error.value = "Fichier invalide — format attendu : .xlsx";
return;
}
file.value = f;
error.value = null;
importResult.value = null;
preview.value = null;
yearPromos.value = {};
f.arrayBuffer().then((buf) => {
try {
const wb = XLSX.read(buf, { type: "array" });
preview.value = parseMaquette(wb);
} catch {
error.value = "Impossible de lire le fichier.";
}
});
}
async function createPromo() {
if (!newPromoId.value.trim() || !newPromoAnnee.value.trim()) return;
creatingPromo.value = true;
try {
const res = await fetch("/students/api/promotions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idPromo: newPromoId.value.trim(),
annee: newPromoAnnee.value.trim(),
}),
});
if (res.ok) {
const created = await res.json();
promos.value = [...promos.value, { id: created.id, annee: created.annee }];
newPromoId.value = "";
newPromoAnnee.value = "";
} else {
error.value = "Erreur lors de la creation de la promotion.";
}
} finally {
creatingPromo.value = false;
}
}
function setYearPromo(yearLabel: string, promoId: string) {
yearPromos.value = { ...yearPromos.value, [yearLabel]: promoId };
}
// Check that at least one year has a promo assigned
function canImport(): boolean {
if (!preview.value || uploading.value) return false;
return preview.value.some((y) => yearPromos.value[y.label]);
}
async function doImport() {
if (!preview.value) return;
uploading.value = true;
error.value = null;
importResult.value = null;
let added = 0;
let ignored = 0;
let errCount = 0;
const details: ImportDetail[] = [];
try {
for (const year of preview.value) {
const promoId = yearPromos.value[year.label];
if (!promoId) {
ignored += year.ues.reduce((s, ue) => s + ue.modules.length + 1, 0);
details.push({
type: "error",
message: `${year.label} : ignoree (pas de promo selectionnee)`,
});
continue;
}
for (const ue of year.ues) {
const ueRes = await fetch("/admin/api/ues", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: ue.name }),
});
if (!ueRes.ok) {
errCount++;
details.push({
type: "error",
message: `UE "${ue.name}" : creation echouee`,
});
continue;
}
const createdUE = await ueRes.json();
added++;
details.push({
type: "change",
message: `UE "${ue.name}" creee (id: ${createdUE.id})`,
});
for (const mod of ue.modules) {
const modRes = await fetch("/admin/api/modules", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: mod.code, nom: mod.name }),
});
if (modRes.ok) {
added++;
details.push({
type: "change",
message: `Module ${mod.code} "${mod.name}" cree`,
});
} else if (modRes.status !== 409) {
errCount++;
details.push({
type: "error",
message: `Module "${mod.code}" : creation echouee`,
});
continue;
}
const linkRes = await fetch("/admin/api/ue-modules", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idModule: mod.code,
idUE: createdUE.id,
idPromo: promoId,
coeff: mod.coeff,
}),
});
if (linkRes.ok) {
added++;
} else {
errCount++;
details.push({
type: "error",
message: `Lien ${mod.code} -> UE ${ue.name} : echoue`,
});
}
}
}
}
importResult.value = {
added,
modified: 0,
ignored,
errors: errCount,
details,
};
} catch {
error.value = "Erreur lors de l'import.";
} finally {
uploading.value = false;
}
}
function downloadTemplate() {
globalThis.open("/templates/modele_maquette.xlsx", "_blank");
}
function downloadExport() {
Promise.all([
fetch("/admin/api/ues").then((r) => r.json()),
fetch("/admin/api/ue-modules").then((r) => r.json()),
fetch("/admin/api/modules").then((r) => r.json()),
]).then(([uesData, ueModulesData, modulesData]) => {
const modMap = Object.fromEntries(
modulesData.map((m: { id: string; nom: string }) => [m.id, m]),
);
const data: (string | number | null)[][] = [
["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\nECTS", "Coeff."],
];
for (const ue of uesData) {
const mods = ueModulesData.filter(
(um: { idUE: number }) => um.idUE === ue.id,
);
const totalCoeff = mods.reduce(
(s: number, um: { coeff: number }) => s + um.coeff,
0,
);
data.push(["UE", null, ue.nom, null, totalCoeff]);
for (const um of mods) {
const mod = modMap[um.idModule];
data.push([null, um.idModule, null, mod ? mod.nom : um.idModule, null, um.coeff]);
}
data.push([]);
}
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet(data);
XLSX.utils.book_append_sheet(wb, ws, "Maquette");
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "export_maquette.xlsx";
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
});
}
return (
<div>
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls"
style="display:none"
onChange={(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) pickFile(f);
}}
/>
<div
class={`drop-zone${dragging.value ? " dragging" : ""}`}
onDragOver={(e) => {
e.preventDefault();
dragging.value = true;
}}
onDragLeave={() => (dragging.value = false)}
onDrop={(e) => {
e.preventDefault();
dragging.value = false;
const f = e.dataTransfer?.files?.[0];
if (f) pickFile(f);
}}
onClick={() => inputRef.current?.click()}
>
<span class="drop-zone-icon"></span>
{file.value ? <span class="drop-zone-file">{file.value.name}</span> : (
<>
<span class="drop-zone-text">
Glisser le fichier maquette .xlsx ici
</span>
<span class="drop-zone-hint">ou cliquer pour parcourir</span>
</>
)}
</div>
{error.value && <p class="state-error">{error.value}</p>}
{importResult.value && (
<ImportResultPopup
result={importResult.value}
onClose={() => (importResult.value = null)}
/>
)}
{/* Create promo inline */}
<div class="create-promo-inline">
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
Creer une promotion
</label>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap">
<input
type="text"
class="filter-select"
placeholder="ID (ex: 3AFISE24-25)"
value={newPromoId.value}
onInput={(e) =>
(newPromoId.value = (e.target as HTMLInputElement).value)}
style="min-width: 10rem"
/>
<input
type="text"
class="filter-select"
placeholder="Annee (ex: 2024-2025)"
value={newPromoAnnee.value}
onInput={(e) =>
(newPromoAnnee.value = (e.target as HTMLInputElement).value)}
style="min-width: 8rem"
/>
<button
type="button"
class="btn btn-secondary"
onClick={createPromo}
disabled={creatingPromo.value || !newPromoId.value.trim() ||
!newPromoAnnee.value.trim()}
style="white-space: nowrap"
>
{creatingPromo.value ? "..." : "+ Creer"}
</button>
</div>
</div>
{/* Preview grouped by year */}
{preview.value && preview.value.length > 0 && (
<div style="margin-bottom: 1rem">
{preview.value.map((year) => {
const totalMods = year.ues.reduce(
(s, ue) => s + ue.modules.length,
0,
);
return (
<div key={year.label} style="margin-bottom: 1.25rem">
<div style="display: flex; gap: 1rem; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap">
<p style="font-size: 0.85rem; font-weight: 700; margin: 0">
{year.label}
<span class="col-dim" style="font-weight: 400">
{" "} {year.ues.length} UE, {totalMods} modules
</span>
</p>
<select
class="filter-select"
value={yearPromos.value[year.label] || ""}
onChange={(e) =>
setYearPromo(
year.label,
(e.target as HTMLSelectElement).value,
)}
>
<option value=""> Ignorer </option>
{promos.value.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
</div>
<div
class="data-table-wrap"
style="max-height: 15rem; overflow-y: auto"
>
<table class="data-table">
<thead>
<tr>
<th>UE</th>
<th>Module</th>
<th>Code</th>
<th>Coeff</th>
</tr>
</thead>
<tbody>
{year.ues.map((ue, i) =>
ue.modules.length === 0
? (
<tr key={`ue-${i}`}>
<td style="font-weight: 600">{ue.name}</td>
<td class="col-dim" colspan={3}>
Aucun module
</td>
</tr>
)
: ue.modules.map((mod, j) => (
<tr key={`${i}-${j}`}>
{j === 0 && (
<td
rowSpan={ue.modules.length}
style="font-weight: 600; vertical-align: top"
>
{ue.name}
{ue.ects != null && (
<span class="col-dim">
{" "}({ue.ects} ECTS)
</span>
)}
</td>
)}
<td>{mod.name}</td>
<td class="col-dim">{mod.code}</td>
<td>{mod.coeff}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
})}
</div>
)}
<div class="upload-actions">
<button
type="button"
class="btn btn-primary"
onClick={doImport}
disabled={!canImport()}
>
{uploading.value ? "..." : "+ Importer"}
</button>
<button
type="button"
class="btn btn-secondary"
onClick={downloadTemplate}
>
Telecharger Modele
</button>
<button
type="button"
class="btn btn-secondary"
onClick={downloadExport}
>
Exporter Maquette
</button>
</div>
<p class="upload-format">
Format : fichier maquette FISE / FISA avec lignes <strong>UE</strong>
{" "}et <strong>modules</strong> (colonnes code, nom, coefficient)
</p>
</div>
);
}
+4 -1
View File
@@ -10,8 +10,11 @@ const properties: AppProperties = {
permissions: "Permissions",
modules: "Modules",
enseignements: "Enseignements",
promotions: "Promotions",
ues: "UEs",
"import-maquette": "Import Maquette",
},
adminOnly: ["users", "roles", "permissions", "modules", "enseignements"],
adminOnly: ["users", "roles", "permissions", "modules", "enseignements", "promotions", "ues", "import-maquette"],
hint: "PolyMPR module",
};
+13 -11
View File
@@ -4,17 +4,19 @@ import { enseignements } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
const _NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const _NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
const CONFLICT = new Response(
JSON.stringify({ error: "Cet enseignement existe déjà." }),
{ status: 409, headers: { "content-type": "application/json" } },
);
const CONFLICT = () =>
new Response(
JSON.stringify({ error: "Cet enseignement existe déjà." }),
{ status: 409, headers: { "content-type": "application/json" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// GET /enseignements
@@ -39,7 +41,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
let body: { idProf: string; idModule: string; idPromo: string };
@@ -67,7 +69,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.then((rows) => rows[0] ?? null);
if (existing) {
return CONFLICT;
return CONFLICT();
}
const [created] = await db
@@ -4,12 +4,13 @@ import { enseignements } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #30 GET /enseignements/{idProf}/{idModule}/{idPromo}
@@ -18,7 +19,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const idProf = context.params.idProf;
@@ -37,7 +38,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
)
.then((rows) => rows[0] ?? null);
if (!enseignement) return NOT_FOUND;
if (!enseignement) return NOT_FOUND();
return new Response(JSON.stringify(enseignement), {
headers: { "content-type": "application/json" },
@@ -50,7 +51,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const idProf = context.params.idProf;
@@ -68,7 +69,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
)
.returning();
if (!deleted) return NOT_FOUND;
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
+1 -7
View File
@@ -8,14 +8,8 @@ export const handler: Handlers<null, AuthenticatedState> = {
// #23 GET /modules
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
_context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return new Response(JSON.stringify([]), {
headers: { "content-type": "application/json" },
});
}
const rows = await db.select().from(modules);
return new Response(JSON.stringify(rows), {
headers: { "content-type": "application/json" },
+31 -12
View File
@@ -1,13 +1,19 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { modules } from "$root/databases/schema.ts";
import {
enseignements,
modules,
notes,
ueModules,
} from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// #25 GET /modules/{idModule}
@@ -21,7 +27,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(modules.id, context.params.idModule))
.then((rows) => rows[0] ?? null);
if (!module) return NOT_FOUND;
if (!module) return NOT_FOUND();
return new Response(JSON.stringify(module), {
headers: { "content-type": "application/json" },
@@ -50,7 +56,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(modules.id, context.params.idModule))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
@@ -58,16 +64,29 @@ export const handler: Handlers<null, AuthenticatedState> = {
},
// #27 DELETE /modules/{idModule}
// Cascade: deletes notes, ue_modules, enseignements for this module.
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const [deleted] = await db
.delete(modules)
.where(eq(modules.id, context.params.idModule))
.returning();
const idModule = context.params.idModule;
if (!deleted) return NOT_FOUND;
const mod = await db
.select()
.from(modules)
.where(eq(modules.id, idModule))
.then((r) => r[0] ?? null);
if (!mod) return NOT_FOUND();
await db.transaction(async (tx) => {
await tx.delete(notes).where(eq(notes.idModule, idModule));
await tx.delete(ueModules).where(eq(ueModules.idModule, idModule));
await tx.delete(enseignements).where(
eq(enseignements.idModule, idModule),
);
await tx.delete(modules).where(eq(modules.id, idModule));
});
return new Response(null, { status: 204 });
},
+23 -14
View File
@@ -1,13 +1,14 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { rolePermissions, roles } from "$root/databases/schema.ts";
import { rolePermissions, roles, users } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
async function getRoleWithPermissions(
id: number,
@@ -41,7 +42,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
const id = Number(context.params.idRole);
const role = await getRoleWithPermissions(id);
if (!role) return NOT_FOUND;
if (!role) return NOT_FOUND();
return new Response(JSON.stringify(role), {
headers: { "content-type": "application/json" },
@@ -62,7 +63,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(roles.id, id))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
// Reset permissions
await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id));
@@ -80,21 +81,29 @@ export const handler: Handlers<null, AuthenticatedState> = {
},
// #69 DELETE /roles/{idRole}
// Cascade: deletes role_permissions, detaches users (idRole set to null).
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const id = Number(context.params.idRole);
// Cascade delete role_permissions first
await db.delete(rolePermissions).where(eq(rolePermissions.idRole, id));
const [deleted] = await db
.delete(roles)
const role = await db
.select()
.from(roles)
.where(eq(roles.id, id))
.returning();
.then((r) => r[0] ?? null);
if (!deleted) return NOT_FOUND;
if (!role) return NOT_FOUND();
await db.transaction(async (tx) => {
await tx.delete(rolePermissions).where(eq(rolePermissions.idRole, id));
await tx
.update(users)
.set({ idRole: null })
.where(eq(users.idRole, id));
await tx.delete(roles).where(eq(roles.id, id));
});
return new Response(null, { status: 204 });
},
@@ -4,17 +4,19 @@ import { ueModules } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Association UE-Module introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Association UE-Module introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
const BAD_REQUEST = new Response(
JSON.stringify({ error: "Paramètres invalides" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
const BAD_REQUEST = () =>
new Response(
JSON.stringify({ error: "Paramètres invalides" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// #39 GET /ue-modules/{idModule}/{idUE}/{idPromo}
@@ -23,7 +25,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const idModule = context.params.idModule;
@@ -31,7 +33,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST;
return BAD_REQUEST();
}
const ueModuleAssociation = await db
@@ -44,7 +46,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
)
.then((rows) => rows[0] ?? null);
if (!ueModuleAssociation) return NOT_FOUND;
if (!ueModuleAssociation) return NOT_FOUND();
return new Response(JSON.stringify(ueModuleAssociation), {
headers: { "content-type": "application/json" },
@@ -57,7 +59,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const idModule = context.params.idModule;
@@ -65,7 +67,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST;
return BAD_REQUEST();
}
const body: { coeff: number } = await request.json();
@@ -89,7 +91,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
)
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(
JSON.stringify({
@@ -110,7 +112,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const idModule = context.params.idModule;
@@ -118,7 +120,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST;
return BAD_REQUEST();
}
const [deleted] = await db
@@ -132,7 +134,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
)
.returning();
if (!deleted) return NOT_FOUND;
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
@@ -1,6 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "../../../../../databases/db.ts";
import { ues } from "../../../../../databases/schema.ts";
import {
ajustements,
ueModules,
ues,
} from "../../../../../databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers = {
@@ -87,6 +91,7 @@ export const handler: Handlers = {
},
// #36 DELETE /ues/:idUE
// Cascade: deletes ajustements, ue_modules for this UE.
async DELETE(_request, context) {
try {
const idUE = parseInt(context.params.idUE);
@@ -101,9 +106,9 @@ export const handler: Handlers = {
);
}
const result = await db.delete(ues).where(eq(ues.id, idUE)).returning();
const existing = await db.select().from(ues).where(eq(ues.id, idUE));
if (result.length === 0) {
if (existing.length === 0) {
return new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{
@@ -113,6 +118,12 @@ export const handler: Handlers = {
);
}
await db.transaction(async (tx) => {
await tx.delete(ajustements).where(eq(ajustements.idUE, idUE));
await tx.delete(ueModules).where(eq(ueModules.idUE, idUE));
await tx.delete(ues).where(eq(ues.id, idUE));
});
return new Response(null, { status: 204 });
} catch (error) {
console.error("Error deleting UE:", error);
+22 -12
View File
@@ -1,13 +1,14 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { users } from "$root/databases/schema.ts";
import { enseignements, users } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// #62 GET /users/{id}
@@ -21,7 +22,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(users.id, context.params.id))
.then((rows) => rows[0] ?? null);
if (!user) return NOT_FOUND;
if (!user) return NOT_FOUND();
return new Response(JSON.stringify(user), {
headers: { "content-type": "application/json" },
@@ -42,7 +43,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(users.id, context.params.id))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
@@ -50,16 +51,25 @@ export const handler: Handlers<null, AuthenticatedState> = {
},
// #64 DELETE /users/{id}
// Cascade: deletes enseignements for this user.
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
const [deleted] = await db
.delete(users)
.where(eq(users.id, context.params.id))
.returning();
const id = context.params.id;
if (!deleted) return NOT_FOUND;
const user = await db
.select()
.from(users)
.where(eq(users.id, id))
.then((r) => r[0] ?? null);
if (!user) return NOT_FOUND();
await db.transaction(async (tx) => {
await tx.delete(enseignements).where(eq(enseignements.idProf, id));
await tx.delete(users).where(eq(users.id, id));
});
return new Response(null, { status: 204 });
},
@@ -0,0 +1,11 @@
import { FreshContext } from "$fresh/server.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import EditModule from "../(_islands)/EditModule.tsx";
// deno-lint-ignore require-await
export default async function EditModulePage(
_request: Request,
context: FreshContext<AuthenticatedState>,
) {
return <EditModule moduleId={context.params.idModule} />;
}
@@ -0,0 +1,23 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import ImportMaquette from "../(_islands)/ImportMaquette.tsx";
// deno-lint-ignore require-await
async function ImportMaquettePage(
_request: Request,
_context: FreshContext<State>,
) {
return (
<div class="page-content">
<h2 class="page-title">Importer une Maquette (UE & Modules)</h2>
<ImportMaquette />
</div>
);
}
export const config = getPartialsConfig();
export default makePartials(ImportMaquettePage);
@@ -4,7 +4,7 @@ import {
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import AdminPromotions from "../../(_islands)/AdminPromotions.tsx";
import AdminPromotions from "../(_islands)/AdminPromotions.tsx";
// deno-lint-ignore require-await
async function Promotions(
@@ -4,7 +4,7 @@ import {
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import AdminUEs from "../../(_islands)/AdminUEs.tsx";
import AdminUEs from "../(_islands)/AdminUEs.tsx";
// deno-lint-ignore require-await
async function UEs(
+11
View File
@@ -0,0 +1,11 @@
import { FreshContext } from "$fresh/server.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import EditUser from "../(_islands)/EditUser.tsx";
// deno-lint-ignore require-await
export default async function EditUserPage(
_request: Request,
context: FreshContext<AuthenticatedState>,
) {
return <EditUser userId={context.params.id} />;
}
@@ -130,7 +130,17 @@ export default function AdminConsultNotes() {
href={`/notes/edition/${s.numEtud}`}
f-client-nav={false}
>
édit
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>{" "}
édit
</a>
</div>
</td>
+528 -59
View File
@@ -1,15 +1,61 @@
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
import { useRef } from "preact/hooks";
import { useEffect, useRef } from "preact/hooks";
import { useSignal } from "@preact/signals";
import ImportResultPopup, {
type ImportDetail,
type ImportResult,
} from "$root/defaults/ImportResultPopup.tsx";
type Student = { numEtud: number; nom: string; prenom: string };
type ColumnInfo = {
index: number;
code: string;
name: string;
coeff: number | null;
type: "module" | "malus" | "ue" | "semester" | "unknown";
};
function parseHeader(header: string): { code: string; name: string } {
const parts = header.split(" - ");
if (parts.length >= 2) {
return { code: parts[0].trim(), name: parts.slice(1).join(" - ").trim() };
}
return { code: header.trim(), name: header.trim() };
}
function detectColumnType(
header: string,
_coeff: number | null,
): ColumnInfo["type"] {
const h = header.trim();
if (/^MALUS/i.test(h)) return "malus";
if (/^S\d+$/i.test(h)) return "semester";
// UE codes typically contain "U" followed by digits (e.g., JIN5U05, JTR5U01)
const { code } = parseHeader(h);
if (/U\d/i.test(code) && !/^MALUS/i.test(code)) return "ue";
return "module";
}
export default function ImportNotes() {
const file = useSignal<File | null>(null);
const dragging = useSignal(false);
const uploading = useSignal(false);
const error = useSignal<string | null>(null);
const success = useSignal<string | null>(null);
const importResult = useSignal<ImportResult | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const students = useSignal<Student[]>([]);
const columns = useSignal<ColumnInfo[]>([]);
const sheetNames = useSignal<string[]>([]);
const selectedSheet = useSignal("");
const session = useSignal<"1" | "2">("1");
const workbookRef = useRef<XLSX.WorkBook | null>(null);
useEffect(() => {
fetch("/students/api/students")
.then((r) => (r.ok ? r.json() : []))
.then((data) => (students.value = data));
}, []);
function pickFile(f: File) {
if (!f.name.match(/\.xlsx?$/i)) {
@@ -18,76 +64,404 @@ export default function ImportNotes() {
}
file.value = f;
error.value = null;
success.value = null;
importResult.value = null;
columns.value = [];
f.arrayBuffer().then((buf) => {
try {
const wb = XLSX.read(buf, { type: "array" });
workbookRef.current = wb;
sheetNames.value = wb.SheetNames;
if (wb.SheetNames.length > 0) {
selectedSheet.value = wb.SheetNames[0];
parseSheet(wb, wb.SheetNames[0]);
}
} catch {
error.value = "Impossible de lire le fichier.";
}
});
}
function onDragOver(e: DragEvent) {
e.preventDefault();
dragging.value = true;
function parseSheet(wb: XLSX.WorkBook, sheetName: string) {
const sheet = wb.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
header: 1,
});
if (rows.length < 2) {
columns.value = [];
return;
}
const headerRow = rows[0];
const coeffRow = rows[1];
const cols: ColumnInfo[] = [];
// First 2 columns are nom/prenom, skip them
for (let i = 2; i < headerRow.length; i++) {
const h = headerRow[i];
if (h == null || String(h).trim() === "") continue;
const header = String(h).trim();
const coeff = typeof coeffRow[i] === "number" ? coeffRow[i] : null;
const { code, name } = parseHeader(header);
const type = detectColumnType(header, coeff as number | null);
cols.push({ index: i, code, name, coeff: coeff as number | null, type });
}
columns.value = cols;
}
function onDragLeave() {
dragging.value = false;
function onSheetChange(name: string) {
selectedSheet.value = name;
if (workbookRef.current) {
parseSheet(workbookRef.current, name);
}
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragging.value = false;
const f = e.dataTransfer?.files?.[0];
if (f) pickFile(f);
}
function onInputChange(e: Event) {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) pickFile(f);
function findStudent(
nom: string,
prenom: string,
): Student | undefined {
const normNom = nom.toUpperCase().trim();
const normPrenom = prenom.toUpperCase().trim();
return students.value.find(
(s) =>
s.nom.toUpperCase().trim() === normNom &&
s.prenom.toUpperCase().trim() === normPrenom,
);
}
async function doImport() {
if (!file.value) return;
if (!workbookRef.current || !selectedSheet.value) return;
uploading.value = true;
error.value = null;
success.value = null;
importResult.value = null;
try {
const arrayBuffer = await file.value.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: "array" });
let imported = 0;
let failed = 0;
const sheet = workbookRef.current.Sheets[selectedSheet.value];
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
header: 1,
});
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<{
numEtud: number;
idModule: string;
note: number;
}>(sheet, { header: ["numEtud", "idModule", "note"], range: 1 });
const moduleCols = columns.value.filter((c) => c.type === "module");
for (const row of rows) {
const res = await fetch("/notes/api/notes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(row),
let added = 0;
let modified = 0;
let ignored = 0;
let errors = 0;
const details: ImportDetail[] = [];
// Process data rows (skip header + coeff rows)
for (let r = 2; r < rows.length; r++) {
const row = rows[r];
if (!row || row.length < 3) continue;
const nom = row[0] != null ? String(row[0]).trim() : "";
const prenom = row[1] != null ? String(row[1]).trim() : "";
if (!nom || !prenom) continue;
const student = findStudent(nom, prenom);
if (!student) {
ignored++;
details.push({
type: "error",
message: `${nom} ${prenom} : Etudiant non trouve`,
});
if (res.ok) imported++;
else failed++;
continue;
}
// Import module notes
for (const col of moduleCols) {
const val = row[col.index];
if (val == null || typeof val !== "number") {
if (val != null && typeof val !== "number") {
errors++;
details.push({
type: "error",
message:
`${student.numEtud} : ${col.code} : Note "${val}" invalide`,
});
}
continue;
}
if (val < 0 || val > 20) {
errors++;
details.push({
type: "error",
message:
`${student.numEtud} : ${col.code} : Note ${val} hors limites`,
});
continue;
}
const noteField = session.value === "2" ? "noteSession2" : "note";
// Try PUT first (update), then POST (create)
const putRes = await fetch(
`/notes/api/notes/${student.numEtud}/${
encodeURIComponent(col.code)
}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ [noteField]: val }),
},
);
if (putRes.ok) {
const prev = await putRes.json();
const oldVal = session.value === "2"
? prev.noteSession2
: prev.note;
modified++;
details.push({
type: "change",
message: `${student.numEtud} : ${col.code} : ${
oldVal ?? "null"
} -> ${val}`,
});
} else if (putRes.status === 404) {
// Note doesn't exist yet, create it
const body: Record<string, unknown> = {
numEtud: student.numEtud,
idModule: col.code,
note: session.value === "1" ? val : 0,
};
if (session.value === "2") body.noteSession2 = val;
const postRes = await fetch("/notes/api/notes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (postRes.ok) {
added++;
details.push({
type: "change",
message: `${student.numEtud} : ${col.code} : null -> ${val}`,
});
} else {
errors++;
details.push({
type: "error",
message:
`${student.numEtud} : ${col.code} : Matiere non trouvee`,
});
}
} else {
errors++;
details.push({
type: "error",
message: `${student.numEtud} : ${col.code} : Erreur serveur`,
});
}
}
}
success.value = `Import terminé — ${imported} ajouté${
imported !== 1 ? "s" : ""
}${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`;
importResult.value = { added, modified, ignored, errors, details };
} catch {
error.value = "Erreur lors de la lecture du fichier.";
error.value = "Erreur lors de l'import.";
} finally {
uploading.value = false;
}
}
function downloadTemplate() {
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([["numEtud", "idModule", "note"]]);
XLSX.utils.book_append_sheet(wb, ws, "Notes");
XLSX.writeFile(wb, "modele_notes.xlsx");
globalThis.open("/templates/modele_notes.xlsx", "_blank");
}
function downloadExport() {
// Export notes from the API in the same format
Promise.all([
fetch("/students/api/students").then((r) => r.json()),
fetch("/notes/api/notes").then((r) => r.json()),
fetch("/admin/api/modules").then((r) => r.json()),
fetch("/admin/api/ue-modules").then((r) => r.json()),
fetch("/admin/api/ues").then((r) => r.json()),
]).then(
([
studentsData,
notesData,
modulesData,
ueModulesData,
uesData,
]) => {
// Build module map
const modMap = new Map<string, string>(
modulesData.map((m: { id: string; nom: string }) => [m.id, m.nom]),
);
// Get unique module IDs from notes
const moduleIds = [
...new Set(
notesData.map((n: { idModule: string }) => n.idModule),
),
] as string[];
// Group ue-modules by UE
const ueMap = new Map<number, string>(
uesData.map((u: { id: number; nom: string }) => [u.id, u.nom]),
);
const umByUE = new Map<number, typeof ueModulesData>();
for (const um of ueModulesData) {
if (!umByUE.has(um.idUE)) umByUE.set(um.idUE, []);
umByUE.get(um.idUE)!.push(um);
}
// Build column order: group modules by UE, add UE avg columns
const orderedCols: {
id: string;
header: string;
coeff: number | null;
type: "module" | "ue";
ueId?: number;
}[] = [];
const usedModules = new Set<string>();
for (const [ueId, ums] of umByUE) {
for (const um of ums) {
if (!moduleIds.includes(um.idModule)) continue;
orderedCols.push({
id: um.idModule,
header: `${um.idModule} - ${
modMap.get(um.idModule) || um.idModule
}`,
coeff: um.coeff,
type: "module",
ueId,
});
usedModules.add(um.idModule);
}
const ueName = ueMap.get(ueId) || `UE ${ueId}`;
orderedCols.push({
id: `ue_${ueId}`,
header: ueName,
coeff: ums.reduce(
(s: number, um: { coeff: number }) => s + um.coeff,
0,
),
type: "ue",
ueId,
});
}
// Add modules not linked to any UE
for (const mId of moduleIds) {
if (usedModules.has(mId)) continue;
orderedCols.push({
id: mId,
header: `${mId} - ${modMap.get(mId) || mId}`,
coeff: null,
type: "module",
});
}
// Build note lookup: numEtud -> idModule -> note
const noteLookup = new Map<
number,
Map<string, { note: number; noteSession2: number | null }>
>();
for (const n of notesData) {
if (!noteLookup.has(n.numEtud)) noteLookup.set(n.numEtud, new Map());
noteLookup.get(n.numEtud)!.set(n.idModule, {
note: n.note,
noteSession2: n.noteSession2,
});
}
// Get students who have notes
const studentsWithNotes = studentsData.filter(
(s: Student) => noteLookup.has(s.numEtud),
);
// Build header rows
const headerRow: (string | null)[] = [null, null];
const coeffRow: (number | null)[] = [null, null];
for (const col of orderedCols) {
headerRow.push(col.header);
coeffRow.push(col.coeff);
}
// Build session 1 data rows
const s1Rows: (string | number | null)[][] = [];
for (const s of studentsWithNotes) {
const row: (string | number | null)[] = [s.nom, s.prenom];
const sNotes = noteLookup.get(s.numEtud) || new Map();
for (const col of orderedCols) {
if (col.type === "module") {
const n = sNotes.get(col.id);
row.push(n ? n.note : null);
} else {
// UE average - calculate
const ueMods = orderedCols.filter(
(c) => c.type === "module" && c.ueId === col.ueId,
);
let total = 0, coeffSum = 0;
for (const um of ueMods) {
const n = sNotes.get(um.id);
if (n && um.coeff) {
total += n.note * um.coeff;
coeffSum += um.coeff;
}
}
row.push(
coeffSum > 0
? Math.round((total / coeffSum) * 100) / 100
: null,
);
}
}
s1Rows.push(row);
}
// Build session 2 data rows
const s2Rows: (string | number | null)[][] = [];
for (const s of studentsWithNotes) {
const row: (string | number | null)[] = [s.nom, s.prenom];
const sNotes = noteLookup.get(s.numEtud) || new Map();
for (const col of orderedCols) {
if (col.type === "module") {
const n = sNotes.get(col.id);
// Use session 2 note if available, else session 1
row.push(n ? (n.noteSession2 ?? n.note) : null);
} else {
const ueMods = orderedCols.filter(
(c) => c.type === "module" && c.ueId === col.ueId,
);
let total = 0, coeffSum = 0;
for (const um of ueMods) {
const n = sNotes.get(um.id);
if (n && um.coeff) {
const noteVal = n.noteSession2 ?? n.note;
total += noteVal * um.coeff;
coeffSum += um.coeff;
}
}
row.push(
coeffSum > 0
? Math.round((total / coeffSum) * 100) / 100
: null,
);
}
}
s2Rows.push(row);
}
const wb = XLSX.utils.book_new();
const ws1 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s1Rows]);
XLSX.utils.book_append_sheet(wb, ws1, "Session 1");
const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]);
XLSX.utils.book_append_sheet(wb, ws2, "Session 2");
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "export_notes.xlsx";
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
},
);
}
return (
@@ -97,14 +471,25 @@ export default function ImportNotes() {
type="file"
accept=".xlsx,.xls"
style="display:none"
onChange={onInputChange}
onChange={(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) pickFile(f);
}}
/>
<div
class={`drop-zone${dragging.value ? " dragging" : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragOver={(e) => {
e.preventDefault();
dragging.value = true;
}}
onDragLeave={() => (dragging.value = false)}
onDrop={(e) => {
e.preventDefault();
dragging.value = false;
const f = e.dataTransfer?.files?.[0];
if (f) pickFile(f);
}}
onClick={() => inputRef.current?.click()}
>
<span class="drop-zone-icon"></span>
@@ -117,10 +502,85 @@ export default function ImportNotes() {
</div>
{error.value && <p class="state-error">{error.value}</p>}
{success.value && (
<p style="font-size:0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.75rem">
{success.value}
</p>
{importResult.value && (
<ImportResultPopup
result={importResult.value}
onClose={() => (importResult.value = null)}
/>
)}
{/* Sheet + session selector */}
{sheetNames.value.length > 0 && (
<div style="display: flex; gap: 1rem; margin-bottom: 0.75rem; flex-wrap: wrap">
<div>
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
Feuille
</label>
<select
class="filter-select"
value={selectedSheet.value}
onChange={(e) =>
onSheetChange((e.target as HTMLSelectElement).value)}
>
{sheetNames.value.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div>
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
Importer en tant que
</label>
<select
class="filter-select"
value={session.value}
onChange={(e) => (session.value = (e.target as HTMLSelectElement)
.value as "1" | "2")}
>
<option value="1">Session 1 (note)</option>
<option value="2">Session 2 (noteSession2)</option>
</select>
</div>
</div>
)}
{/* Column preview */}
{columns.value.length > 0 && (
<div style="margin-bottom: 1rem">
<p style="font-size: 0.82rem; font-weight: 600; margin-bottom: 0.5rem">
Colonnes detectees :
</p>
<div style="display: flex; flex-wrap: wrap; gap: 0.35rem">
{columns.value.map((col) => (
<span
key={col.index}
class={`numEtud-chip${
col.type === "module"
? ""
: col.type === "malus"
? " note-chip--fail"
: " note-chip--promo"
}`}
style="font-size: 0.72rem"
title={`${col.type}${col.name}${
col.coeff != null ? ` (coef ${col.coeff})` : ""
}`}
>
{col.type === "module"
? "M"
: col.type === "ue"
? "UE"
: col.type === "malus"
? "X"
: "?"} {col.code}
</span>
))}
</div>
<p class="col-dim" style="font-size: 0.72rem; margin-top: 0.35rem">
M = module (importe) | UE = moyenne UE (ignore) | X = malus
</p>
</div>
)}
<div class="upload-actions">
@@ -128,22 +588,31 @@ export default function ImportNotes() {
type="button"
class="btn btn-primary"
onClick={doImport}
disabled={!file.value || uploading.value}
disabled={!file.value || uploading.value ||
columns.value.filter((c) => c.type === "module").length === 0}
>
{uploading.value ? "" : " Importer"}
{uploading.value ? "..." : "+ Importer"}
</button>
<button
type="button"
class="btn btn-secondary"
onClick={downloadTemplate}
>
Télécharger Modèle
Telecharger Modele
</button>
<button
type="button"
class="btn btn-secondary"
onClick={downloadExport}
>
Exporter Notes
</button>
</div>
<p class="upload-format">
Format : <strong>numEtud</strong> | <strong>idModule</strong> |{" "}
<strong>note</strong>
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
<strong>CODE - Module</strong> (colonnes notes){" "}
les colonnes UE et MALUS sont auto-detectees
</p>
</div>
);
+268 -72
View File
@@ -14,8 +14,18 @@ type UEModule = {
coeff: number;
};
type Module = { id: string; nom: string };
type Note = { numEtud: number; idModule: string; note: number };
type Ajustement = { numEtud: number; idUE: number; valeur: number };
type Note = {
numEtud: number;
idModule: string;
note: number;
noteSession2: number | null;
};
type Ajustement = {
numEtud: number;
idUE: number;
valeur: number;
malus: number;
};
type Props = { numEtud: number };
@@ -27,31 +37,38 @@ function noteClass(n: number): string {
return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail";
}
/** Returns the effective note (session 2 if exists, otherwise session 1). */
function effectiveNote(n: Note): number {
return n.noteSession2 ?? n.note;
}
export default function NoteRecap({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null);
const [ueList, setUeList] = useState<UE[]>([]);
const [ueModules, setUeModules] = useState<UEModule[]>([]);
const [moduleMap, setModuleMap] = useState<Map<string, string>>(new Map());
const [noteMap, setNoteMap] = useState<Map<string, number>>(new Map());
const [noteMap, setNoteMap] = useState<Map<string, Note>>(new Map());
const [ajustements, setAjustements] = useState<Ajustement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingNote, setEditingNote] = useState<
{ idModule: string; value: string } | null
{ idModule: string; field: "note" | "noteSession2"; value: string } | null
>(null);
const [ajustInputs, setAjustInputs] = useState<Record<number, string>>({});
const [ajustInputs, setAjustInputs] = useState<
Record<number, { valeur: string; malus: string }>
>({});
async function load() {
try {
const sRes = await fetch(`/students/api/students/${numEtud}`);
if (!sRes.ok) throw new Error("Élève introuvable");
if (!sRes.ok) throw new Error("Eleve introuvable");
const s: Student = await sRes.json();
setStudent(s);
const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([
fetch("/notes/api/ues"),
fetch("/admin/api/ues"),
fetch(
`/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
`/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
),
fetch("/admin/api/modules"),
fetch(`/notes/api/notes?numEtud=${numEtud}`),
@@ -66,13 +83,18 @@ export default function NoteRecap({ numEtud }: Props) {
}
if (notesRes.ok) {
const ns: Note[] = await notesRes.json();
setNoteMap(new Map(ns.map((n) => [n.idModule, n.note])));
setNoteMap(new Map(ns.map((n) => [n.idModule, n])));
}
if (ajustRes.ok) {
const aj: Ajustement[] = await ajustRes.json();
setAjustements(aj);
const inputs: Record<number, string> = {};
for (const a of aj) inputs[a.idUE] = String(a.valeur);
const inputs: Record<number, { valeur: string; malus: string }> = {};
for (const a of aj) {
inputs[a.idUE] = {
valeur: String(a.valeur),
malus: String(a.malus),
};
}
setAjustInputs(inputs);
}
} catch (e) {
@@ -87,57 +109,108 @@ export default function NoteRecap({ numEtud }: Props) {
}, [numEtud]);
function calcAvg(ueMods: UEModule[]): number | null {
let total = 0, coeff = 0;
let total = 0,
coeff = 0;
for (const um of ueMods) {
const n = noteMap.get(um.idModule);
if (n === undefined) return null;
total += n * um.coeff;
const val = effectiveNote(n);
total += val * um.coeff;
coeff += um.coeff;
}
return coeff > 0 ? total / coeff : null;
}
async function saveNote(idModule: string, value: string) {
async function saveNote(
idModule: string,
field: "note" | "noteSession2",
value: string,
) {
if (value.trim() === "" && field === "noteSession2") {
// Clear session 2 note
const res = await fetch(
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ noteSession2: null }),
},
);
if (res.ok) {
const updated: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, updated));
}
setEditingNote(null);
return;
}
const note = parseFloat(value.replace(",", "."));
if (isNaN(note) || note < 0 || note > 20) {
setEditingNote(null);
return;
}
const res = await fetch(
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
{
method: "PUT",
const existing = noteMap.get(idModule);
if (existing) {
// Update
const res = await fetch(
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ [field]: note }),
},
);
if (res.ok) {
const updated: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, updated));
}
} else {
// Create
const body: Record<string, unknown> = {
numEtud,
idModule,
note: field === "note" ? note : 0,
};
if (field === "noteSession2") body.noteSession2 = note;
const res = await fetch("/notes/api/notes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ note }),
},
);
if (res.ok) {
const updated: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, updated.note));
body: JSON.stringify(body),
});
if (res.ok) {
const created: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, created));
}
}
setEditingNote(null);
}
async function applyAjust(idUE: number) {
const val = parseFloat((ajustInputs[idUE] ?? "").replace(",", "."));
const inputs = ajustInputs[idUE];
const val = parseFloat((inputs?.valeur ?? "").replace(",", "."));
const malus = parseInt(inputs?.malus ?? "0");
if (isNaN(val) || val < 0 || val > 20) return;
if (isNaN(malus) || malus < 0) return;
const existing = ajustements.find((a) => a.idUE === idUE);
const res = existing
? await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ valeur: val }),
body: JSON.stringify({ valeur: val, malus }),
})
: await fetch("/notes/api/ajustements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ numEtud, idUE, valeur: val }),
body: JSON.stringify({ numEtud, idUE, valeur: val, malus }),
});
if (res.ok) {
const updated: Ajustement = await res.json();
setAjustements((prev) =>
existing
? prev.map((a) => a.idUE === idUE ? updated : a)
? prev.map((a) => (a.idUE === idUE ? updated : a))
: [...prev, updated]
);
}
@@ -160,7 +233,7 @@ export default function NoteRecap({ numEtud }: Props) {
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement</p>
<p class="state-loading">Chargement...</p>
</div>
);
}
@@ -180,19 +253,21 @@ export default function NoteRecap({ numEtud }: Props) {
href="/notes/courses"
f-partial="/notes/partials/courses"
>
Retour à la liste
Retour a la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Récap notes {student.prenom} {student.nom}
Recap notes {student.prenom} {student.nom}
</h2>
<div class="info-bar" style="margin-bottom: 1.25rem">
<span class="numEtud-chip">{student.numEtud}</span>
<span style="font-weight: 600">{student.prenom} {student.nom}</span>
<span style="font-weight: 600">
{student.prenom} {student.nom}
</span>
<span class="note-chip note-chip--promo">{student.idPromo}</span>
</div>
@@ -201,7 +276,7 @@ export default function NoteRecap({ numEtud }: Props) {
{ueList.length === 0
? (
<p class="state-empty">
Aucune UE configurée pour cette promotion.
Aucune UE configuree pour cette promotion.
</p>
)
: ueList.map((ue) => {
@@ -209,14 +284,26 @@ export default function NoteRecap({ numEtud }: Props) {
const avg = calcAvg(ueMods);
const ajust = ajustements.find((a) => a.idUE === ue.id);
// Final displayed average: if ajust.valeur exists it replaces avg, then subtract malus
let finalAvg = avg;
if (ajust) {
finalAvg = ajust.valeur;
if (ajust.malus > 0) {
finalAvg = (finalAvg ?? 0) - ajust.malus;
}
}
return (
<div key={ue.id} class="edit-section">
{/* UE header */}
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap">
<p class="edit-section-title" style="margin: 0">{ue.nom}</p>
{avg !== null && (
<span class={noteClass(avg)} style="font-size: 0.78rem">
Moy. calculée : {fmt(avg)}
<span
class={noteClass(avg)}
style="font-size: 0.78rem"
>
Moy. calculee : {fmt(avg)}
</span>
)}
{ajust && (
@@ -224,7 +311,15 @@ export default function NoteRecap({ numEtud }: Props) {
class="note-chip note-chip--ajust"
style="font-size: 0.78rem"
>
Ajust. actif : {fmt(ajust.valeur)}
Ajust. actif : {fmt(ajust.valeur)}
</span>
)}
{ajust && ajust.malus > 0 && (
<span
class="note-chip note-chip--fail"
style="font-size: 0.78rem"
>
Malus : -{ajust.malus}
</span>
)}
</div>
@@ -236,21 +331,22 @@ export default function NoteRecap({ numEtud }: Props) {
class="col-dim"
style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem"
>
Aucun module associé à cette UE pour cette promotion.
Aucun module associe a cette UE pour cette promotion.
</p>
)
: (
<div style="margin-bottom: 0.75rem">
{ueMods.map((um) => {
const noteVal = noteMap.get(um.idModule);
const noteObj = noteMap.get(um.idModule);
const noteVal = noteObj?.note;
const noteS2 = noteObj?.noteSession2;
const effective = noteObj
? effectiveNote(noteObj)
: undefined;
const nomMod = moduleMap.get(um.idModule) ?? um.idModule;
const isEditing = editingNote?.idModule === um.idModule;
return (
<div
key={um.idModule}
class="note-row"
>
<div key={um.idModule} class="note-row">
<span class="note-row-label">
<span class="numEtud-chip note-row-chip">
{um.idModule}
@@ -260,17 +356,20 @@ export default function NoteRecap({ numEtud }: Props) {
<span class="col-dim note-row-coef">
coef {um.coeff}
</span>
{isEditing
{/* Session 1 note */}
{editingNote?.idModule === um.idModule &&
editingNote.field === "note"
? (
<div style="display: flex; align-items: center; gap: 0.25rem">
<input
class="form-input"
style="width: 5rem; text-align: center; font-size: 0.85rem"
value={editingNote!.value}
value={editingNote.value}
autoFocus
onInput={(e) =>
setEditingNote({
idModule: um.idModule,
...editingNote,
value:
(e.target as HTMLInputElement).value,
})}
@@ -278,7 +377,8 @@ export default function NoteRecap({ numEtud }: Props) {
if (e.key === "Enter") {
saveNote(
um.idModule,
editingNote!.value,
"note",
editingNote.value,
);
}
if (e.key === "Escape") {
@@ -286,7 +386,11 @@ export default function NoteRecap({ numEtud }: Props) {
}
}}
onBlur={() =>
saveNote(um.idModule, editingNote!.value)}
saveNote(
um.idModule,
"note",
editingNote.value,
)}
/>
<span
class="col-dim"
@@ -302,66 +406,153 @@ export default function NoteRecap({ numEtud }: Props) {
? noteClass(noteVal)
: "note-chip note-chip--none"}
style="font-size: 0.78rem; cursor: pointer"
title="Cliquer pour modifier"
title="S1 — Cliquer pour modifier"
onClick={() =>
setEditingNote({
idModule: um.idModule,
field: "note",
value: noteVal !== undefined
? String(noteVal)
: "",
})}
>
S1:{" "}
{noteVal !== undefined ? fmt(noteVal) : "—/20"}
</span>
)}
<button
type="button"
class="btn btn-sm btn-secondary"
style="font-size: 0.75rem"
onClick={() =>
setEditingNote({
idModule: um.idModule,
value: noteVal !== undefined
? String(noteVal)
: "",
})}
>
note
</button>
{/* Session 2 note */}
{editingNote?.idModule === um.idModule &&
editingNote.field === "noteSession2"
? (
<div style="display: flex; align-items: center; gap: 0.25rem">
<input
class="form-input"
style="width: 5rem; text-align: center; font-size: 0.85rem"
value={editingNote.value}
autoFocus
placeholder="vide = suppr"
onInput={(e) =>
setEditingNote({
...editingNote,
value:
(e.target as HTMLInputElement).value,
})}
onKeyDown={(e) => {
if (e.key === "Enter") {
saveNote(
um.idModule,
"noteSession2",
editingNote.value,
);
}
if (e.key === "Escape") {
setEditingNote(null);
}
}}
onBlur={() =>
saveNote(
um.idModule,
"noteSession2",
editingNote.value,
)}
/>
<span
class="col-dim"
style="font-size: 0.75rem"
>
/20
</span>
</div>
)
: (
<span
class={noteS2 != null
? noteClass(noteS2)
: "note-chip note-chip--none"}
style="font-size: 0.78rem; cursor: pointer"
title="S2 — Cliquer pour modifier (vide = pas de session 2)"
onClick={() =>
setEditingNote({
idModule: um.idModule,
field: "noteSession2",
value: noteS2 != null ? String(noteS2) : "",
})}
>
S2: {noteS2 != null ? fmt(noteS2) : "—"}
</span>
)}
{/* Effective note indicator */}
{noteS2 != null && (
<span
class="col-dim"
style="font-size: 0.72rem; font-style: italic"
>
{fmt(effective!)}
</span>
)}
</div>
);
})}
</div>
)}
{/* Ajustement */}
{/* Ajustement + Malus */}
<div class="ajust-section">
<p class="ajust-title">Ajustement de la moyenne UE</p>
<p class="ajust-hint">
Override ponctuel laisser vide pour utiliser la moy.
calculée
La valeur remplace la moyenne calculee. Le malus est
soustrait.
</p>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap">
<div style="display: flex; align-items: center; gap: 0.25rem">
<span class="col-dim" style="font-size: 0.8rem">
Val:
</span>
<input
class="form-input"
style="width: 4.5rem; text-align: center"
placeholder="—"
value={ajustInputs[ue.id] ?? ""}
value={ajustInputs[ue.id]?.valeur ?? ""}
onInput={(e) =>
setAjustInputs((prev) => ({
...prev,
[ue.id]: (e.target as HTMLInputElement).value,
[ue.id]: {
valeur: (e.target as HTMLInputElement).value,
malus: prev[ue.id]?.malus ?? "0",
},
}))}
/>
<span class="col-dim" style="font-size: 0.8rem">/20</span>
</div>
<div style="display: flex; align-items: center; gap: 0.25rem">
<span class="col-dim" style="font-size: 0.8rem">
Malus:
</span>
<input
type="number"
class="form-input"
style="width: 4rem; text-align: center"
placeholder="0"
min="0"
value={ajustInputs[ue.id]?.malus ?? ""}
onInput={(e) =>
setAjustInputs((prev) => ({
...prev,
[ue.id]: {
valeur: prev[ue.id]?.valeur ?? "",
malus: (e.target as HTMLInputElement).value,
},
}))}
/>
</div>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => applyAjust(ue.id)}
>
Appliquer
Appliquer
</button>
{ajust && (
<>
@@ -370,14 +561,19 @@ export default function NoteRecap({ numEtud }: Props) {
class="btn btn-sm btn-secondary"
onClick={() => resetAjust(ue.id)}
>
Réinitialiser
Reinitialiser
</button>
<span
class="col-dim"
style="font-size: 0.75rem; font-family: monospace"
>
Affiché à l'élève : {fmt(ajust.valeur)}
{avg !== null ? ` (calculée : ${fmt(avg)})` : ""}
Affiche : {fmt(ajust.valeur)}
{ajust.malus > 0
? ` - ${ajust.malus} = ${
fmt(ajust.valeur - ajust.malus)
}`
: ""}
{avg !== null ? ` (calculee : ${fmt(avg)})` : ""}
</span>
</>
)}
+63 -37
View File
@@ -1,6 +1,11 @@
import { useEffect, useState } from "preact/hooks";
type Note = { numEtud: number; idModule: string; note: number };
type Note = {
numEtud: number;
idModule: string;
note: number;
noteSession2: number | null;
};
type UE = { id: number; nom: string };
type UEModule = {
idModule: string;
@@ -9,7 +14,12 @@ type UEModule = {
coeff: number;
};
type Module = { id: string; nom: string };
type Ajustement = { numEtud: number; idUE: number; valeur: number };
type Ajustement = {
numEtud: number;
idUE: number;
valeur: number;
malus: number;
};
type Props = {
numEtud: number | null;
@@ -26,6 +36,11 @@ function avgClass(avg: number | null): string {
return avg >= 10 ? "avg-good" : "avg-warn";
}
/** Returns the effective note (session 2 if exists, otherwise session 1). */
function effectiveNote(n: Note): number {
return n.noteSession2 ?? n.note;
}
export default function NotesView({ numEtud, prenom }: Props) {
const [notes, setNotes] = useState<Note[]>([]);
const [ues, setUes] = useState<UE[]>([]);
@@ -47,8 +62,8 @@ export default function NotesView({ numEtud, prenom }: Props) {
try {
const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([
fetch(`/notes/api/notes?numEtud=${numEtud}`),
fetch("/notes/api/ues"),
fetch("/notes/api/ue-modules"),
fetch("/admin/api/ues"),
fetch("/admin/api/ue-modules"),
fetch("/admin/api/modules"),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]);
@@ -72,7 +87,6 @@ export default function NotesView({ numEtud, prenom }: Props) {
setModules(modData);
setAjustements(ajData);
// Derive promos from UE-modules for this student's notes
const noteModuleIds = new Set(notesData.map((n: Note) => n.idModule));
const relevantPromos = [
...new Set(
@@ -99,7 +113,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
<div class="page-content">
<p class="state-empty">
Bonjour {prenom}{" "}
aucun dossier étudiant n'est associé à votre compte.
aucun dossier etudiant n'est associe a votre compte.
</p>
</div>
);
@@ -108,7 +122,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement</p>
<p class="state-loading">Chargement...</p>
</div>
);
}
@@ -121,20 +135,18 @@ export default function NotesView({ numEtud, prenom }: Props) {
);
}
// Filter UE-modules by active promo
const filteredUeModules = activePromo
? ueModules.filter((um) => um.idPromo === activePromo)
: ueModules;
// Group UE-modules by UE
const ueIds = [...new Set(filteredUeModules.map((um) => um.idUE))];
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
const noteMap = Object.fromEntries(
notes.map((n) => [n.idModule, n.note]),
notes.map((n) => [n.idModule, n]),
);
const ajMap = Object.fromEntries(
ajustements.map((a) => [a.idUE, a.valeur]),
ajustements.map((a) => [a.idUE, a]),
);
return (
@@ -155,7 +167,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
)}
{ueIds.length === 0 && (
<p class="state-empty">Aucune note disponible pour cette période.</p>
<p class="state-empty">Aucune note disponible pour cette periode.</p>
)}
{ueIds.map((ueId) => {
@@ -166,51 +178,65 @@ export default function NotesView({ numEtud, prenom }: Props) {
let weightedSum = 0;
let coveredCoeff = 0;
ueModsForUE.forEach((um) => {
const note = noteMap[um.idModule];
if (note !== undefined) {
weightedSum += note * um.coeff;
const noteObj = noteMap[um.idModule];
if (noteObj) {
const val = effectiveNote(noteObj);
weightedSum += val * um.coeff;
coveredCoeff += um.coeff;
}
});
const avg = coveredCoeff > 0 ? weightedSum / coveredCoeff : null;
const ajustement = ajMap[ueId] ?? null;
const finalAvg = avg !== null && ajustement !== null
? avg + ajustement
: avg;
const ajust = ajMap[ueId] ?? null;
// If ajust.valeur exists, it replaces the calculated average
// Then malus is subtracted
let finalAvg: number | null = avg;
if (ajust) {
finalAvg = ajust.valeur;
if (ajust.malus > 0) {
finalAvg = (finalAvg ?? 0) - ajust.malus;
}
}
return (
<div key={ueId} class="ue-card">
<div class="ue-card-header">
<p class="ue-card-title">UE : {ue.nom}</p>
{finalAvg !== null && (
<p class={`ue-card-avg ${avgClass(finalAvg)}`}>
Moyenne : {finalAvg.toFixed(2)}/20
{ajustement !== null && ajustement !== 0 && (
<span>
{" "}
(ajustement : {ajustement > 0 ? "+" : ""}
{ajustement})
</span>
)}
</p>
)}
{finalAvg === null && (
<p class="ue-card-avg avg-warn">Notes non disponibles</p>
)}
{finalAvg !== null
? (
<p class={`ue-card-avg ${avgClass(finalAvg)}`}>
Moyenne : {finalAvg.toFixed(2)}/20
{ajust && ajust.malus > 0 && (
<span>(malus : -{ajust.malus})</span>
)}
</p>
)
: <p class="ue-card-avg avg-warn">Notes non disponibles</p>}
</div>
{ueModsForUE.map((um) => {
const mod = moduleMap[um.idModule];
const note = noteMap[um.idModule] ?? null;
const noteObj = noteMap[um.idModule] ?? null;
const effective = noteObj ? effectiveNote(noteObj) : null;
const hasS2 = noteObj?.noteSession2 != null;
return (
<div key={um.idModule} class="ue-module-row">
<span class="ue-module-name">
{mod ? mod.id : um.idModule} {" "}
{mod ? mod.nom : "Module inconnu"} (coef {um.coeff})
</span>
<span class={`score-chip ${scoreClass(note)}`}>
{note !== null ? `${note}/20` : "—"}
<span class={`score-chip ${scoreClass(effective)}`}>
{effective !== null ? `${effective}/20` : "—"}
{hasS2 && (
<span
style="font-size: 0.7rem; opacity: 0.7; margin-left: 0.35rem"
title={`Session 1 : ${noteObj!.note}/20`}
>
(S1: {noteObj!.note})
</span>
)}
</span>
</div>
);
+3 -3
View File
@@ -7,10 +7,10 @@ const properties: AppProperties = {
index: "Accueil",
notes: "Mes notes",
courses: "Consulter",
ues: "UEs",
import: "Import xlsx",
import: "Import Notes",
},
adminOnly: ["courses", "ues", "import"],
adminOnly: ["courses", "import"],
studentOnly: ["notes"],
hint: "Student grading management",
};
+17 -2
View File
@@ -52,8 +52,12 @@ export const handler: Handlers<null, AuthenticatedState> = {
}
try {
const body: { numEtud: number; idUE: number; valeur: number } =
await request.json();
const body: {
numEtud: number;
idUE: number;
valeur: number;
malus?: number;
} = await request.json();
if (!body.numEtud || !body.idUE || body.valeur === undefined) {
return new Response(
@@ -62,12 +66,23 @@ export const handler: Handlers<null, AuthenticatedState> = {
);
}
if (
body.malus !== undefined &&
(!Number.isInteger(body.malus) || body.malus < 0)
) {
return new Response(
JSON.stringify({ error: "malus doit être un entier >= 0" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [created] = await db
.insert(ajustements)
.values({
numEtud: body.numEtud,
idUE: body.idUE,
valeur: body.valeur,
malus: body.malus ?? 0,
})
.returning();
@@ -4,12 +4,13 @@ import { ajustements } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ajustement introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ajustement introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #50 GET /ajustements/{numEtud}/{idUE}
@@ -18,7 +19,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
@@ -34,7 +35,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.then((rows) => rows[0] ?? null);
if (!ajustement) return NOT_FOUND;
if (!ajustement) return NOT_FOUND();
return new Response(JSON.stringify(ajustement), {
headers: { "content-type": "application/json" },
@@ -47,7 +48,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
@@ -57,7 +58,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
return new Response("Paramètres invalides", { status: 400 });
}
const body: { valeur: number } = await request.json();
const body: { valeur: number; malus?: number } = await request.json();
if (body.valeur === undefined) {
return new Response(JSON.stringify({ error: "Champ requis: valeur" }), {
@@ -66,13 +67,28 @@ export const handler: Handlers<null, AuthenticatedState> = {
});
}
if (
body.malus !== undefined &&
(!Number.isInteger(body.malus) || body.malus < 0)
) {
return new Response(
JSON.stringify({ error: "malus doit être un entier >= 0" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const set: { valeur: number; malus?: number } = { valeur: body.valeur };
if (body.malus !== undefined) {
set.malus = body.malus;
}
const [updated] = await db
.update(ajustements)
.set({ valeur: body.valeur })
.set(set)
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
@@ -85,7 +101,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
@@ -100,7 +116,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.returning();
if (!deleted) return NOT_FOUND;
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
+27 -2
View File
@@ -41,7 +41,7 @@ export const handler: Handlers = {
async POST(request) {
try {
const body = await request.json();
const { note, numEtud, idModule } = body;
const { note, numEtud, idModule, noteSession2 } = body;
if (note === undefined || !numEtud || !idModule) {
return new Response("Champs 'note', 'numEtud' et 'idModule' requis", {
@@ -55,7 +55,32 @@ export const handler: Handlers = {
});
}
const result = await db.insert(notes).values({ note, numEtud, idModule })
if (
noteSession2 !== undefined && noteSession2 !== null &&
(typeof noteSession2 !== "number" || noteSession2 < 0 ||
noteSession2 > 20)
) {
return new Response(
"Champ 'noteSession2' doit être un nombre entre 0 et 20",
{ status: 400 },
);
}
const values: {
note: number;
numEtud: number;
idModule: string;
noteSession2?: number | null;
} = {
note,
numEtud,
idModule,
};
if (noteSession2 !== undefined) {
values.noteSession2 = noteSession2;
}
const result = await db.insert(notes).values(values)
.returning();
return new Response(JSON.stringify(result[0]), {
@@ -64,13 +64,39 @@ export const handler: Handlers = {
}
const body = await request.json();
const { note } = body;
const { note, noteSession2 } = body;
if (note === undefined) {
return new Response("Champ 'note' manquant", { status: 400 });
if (note === undefined && noteSession2 === undefined) {
return new Response("Au moins 'note' ou 'noteSession2' requis", {
status: 400,
});
}
const result = await db.update(notes).set({ note }).where(
if (
note !== undefined &&
(typeof note !== "number" || note < 0 || note > 20)
) {
return new Response("Champ 'note' doit être un nombre entre 0 et 20", {
status: 400,
});
}
if (
noteSession2 !== undefined && noteSession2 !== null &&
(typeof noteSession2 !== "number" || noteSession2 < 0 ||
noteSession2 > 20)
) {
return new Response(
"Champ 'noteSession2' doit être un nombre entre 0 et 20",
{ status: 400 },
);
}
const set: { note?: number; noteSession2?: number | null } = {};
if (note !== undefined) set.note = note;
if (noteSession2 !== undefined) set.noteSession2 = noteSession2;
const result = await db.update(notes).set(set).where(
and(
eq(notes.numEtud, numEtud),
eq(notes.idModule, idModule),
@@ -0,0 +1,70 @@
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
import { Handlers } from "$fresh/server.ts";
import { db } from "../../../../../databases/db.ts";
import { notes } from "../../../../../databases/schema.ts";
export const handler: Handlers = {
//# 44 POST /notes/import-xlsx
async POST(request) {
try {
const formData = await request.formData();
const file = formData.get("file");
const idModule = formData.get("idModule");
if (!file || !(file instanceof File)) {
return new Response("Champ 'file' manquant", { status: 400 });
}
if (!idModule || typeof idModule !== "string") {
return new Response("Champ 'idModule' manquant", { status: 400 });
}
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet) as {
numEtud: number;
note: number;
noteSession2?: number;
}[];
for (const row of rows) {
const { numEtud, note, noteSession2 } = row;
if (!numEtud || note === undefined) {
continue;
}
const values: {
numEtud: number;
idModule: string;
note: number;
noteSession2?: number | null;
} = {
numEtud,
idModule,
note,
};
const set: { note: number; noteSession2?: number | null } = { note };
if (noteSession2 !== undefined) {
values.noteSession2 = noteSession2;
set.noteSession2 = noteSession2;
}
await db.insert(notes)
.values(values)
.onConflictDoUpdate({
target: [notes.numEtud, notes.idModule],
set,
});
}
return new Response(null, { status: 204 });
} catch (error) {
console.error("Error importing notes:", error);
return new Response("Failed to import notes", { status: 500 });
}
},
};
@@ -14,12 +14,6 @@ async function ImportNotesPage(
return (
<div class="page-content">
<h2 class="page-title">Importer des Notes</h2>
<p
class="upload-format"
style="margin-bottom: 1.25rem"
>
POST /notes/api/notes
</p>
<ImportNotes />
</div>
);
+33 -12
View File
@@ -6,31 +6,52 @@ import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { State } from "$root/defaults/interfaces.ts";
import { CasContent, State } from "$root/defaults/interfaces.ts";
import NotesView from "../(_islands)/NotesView.tsx";
async function Notes(
_request: Request,
context: FreshContext<State>,
) {
const session =
(context.state as unknown as { session: { sn: string; givenName: string } })
.session;
const { sn, givenName } = session;
const session = (context.state as unknown as { session: CasContent }).session;
let numEtud: number | null = null;
try {
const student = await db
.select()
.from(students)
.where(and(eq(students.nom, sn), eq(students.prenom, givenName)))
.then((rows) => rows[0] ?? null);
numEtud = student?.numEtud ?? null;
if (session.eduPersonPrimaryAffiliation === "student") {
// Students: uid is "<letter>21212006" in AMU CAS — strip non-digit prefix
const etudId = parseInt(session.uid.replace(/^\D+/, ""), 10);
if (!isNaN(etudId)) {
const student = await db
.select()
.from(students)
.where(eq(students.numEtud, etudId))
.then((rows) => rows[0] ?? null);
numEtud = student?.numEtud ?? null;
}
} else {
// Employees: look up by nom/prenom
const student = await db
.select()
.from(students)
.where(
and(
eq(students.nom, session.sn),
eq(students.prenom, session.givenName),
),
)
.then((rows) => rows[0] ?? null);
numEtud = student?.numEtud ?? null;
}
} catch {
// DB lookup failed — island will show fallback message
}
return <NotesView numEtud={numEtud} prenom={session.givenName} />;
return (
<NotesView
numEtud={numEtud}
prenom={session.givenName || session.displayName}
/>
);
}
export const config = getPartialsConfig();
@@ -15,6 +15,9 @@ export default function ConsultStudents() {
const [error, setError] = useState<string | null>(null);
const [filterPromo, setFilterPromo] = useState("");
const [filterNom, setFilterNom] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkPromo, setBulkPromo] = useState("");
const [bulkBusy, setBulkBusy] = useState(false);
async function load() {
try {
@@ -44,6 +47,11 @@ export default function ConsultStudents() {
});
if (!res.ok) throw new Error("Suppression échouée");
await load();
setSelected((prev) => {
const next = new Set(prev);
next.delete(numEtud);
return next;
});
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
@@ -56,6 +64,85 @@ export default function ConsultStudents() {
return matchPromo && matchNom;
});
const filteredIds = new Set(filtered.map((s) => s.numEtud));
const selectedInView = [...selected].filter((id) => filteredIds.has(id));
const allFilteredSelected = filtered.length > 0 &&
filtered.every((s) => selected.has(s.numEtud));
function toggleOne(numEtud: number) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(numEtud)) next.delete(numEtud);
else next.add(numEtud);
return next;
});
}
function toggleAll() {
if (allFilteredSelected) {
setSelected((prev) => {
const next = new Set(prev);
for (const s of filtered) next.delete(s.numEtud);
return next;
});
} else {
setSelected((prev) => {
const next = new Set(prev);
for (const s of filtered) next.add(s.numEtud);
return next;
});
}
}
async function bulkDelete() {
const count = selectedInView.length;
if (count === 0) return;
if (
!confirm(`Supprimer définitivement ${count} élève(s) sélectionné(s) ?`)
) return;
setBulkBusy(true);
try {
const results = await Promise.all(
selectedInView.map((id) =>
fetch(`/students/api/students/${id}`, { method: "DELETE" })
),
);
const failed = results.filter((r) => !r.ok).length;
if (failed > 0) setError(`${failed} suppression(s) échouée(s)`);
setSelected(new Set());
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setBulkBusy(false);
}
}
async function bulkChangePromo() {
if (!bulkPromo || selectedInView.length === 0) return;
setBulkBusy(true);
try {
const results = await Promise.all(
selectedInView.map((id) =>
fetch(`/students/api/students/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idPromo: bulkPromo }),
})
),
);
const failed = results.filter((r) => !r.ok).length;
if (failed > 0) setError(`${failed} modification(s) échouée(s)`);
setSelected(new Set());
setBulkPromo("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setBulkBusy(false);
}
}
return (
<div class="page-content">
<h2 class="page-title">Gestion des Élèves</h2>
@@ -67,6 +154,7 @@ export default function ConsultStudents() {
class="btn btn-primary"
href="/students/upload"
f-partial="/students/partials/upload"
style="margin-left: auto"
>
Importer xlsx
</a>
@@ -92,6 +180,44 @@ export default function ConsultStudents() {
/>
</div>
{/* Bulk actions bar */}
{selectedInView.length > 0 && (
<div class="bulk-bar">
<span class="bulk-count">
{selectedInView.length} sélectionné(s)
</span>
<div class="bulk-actions">
<select
class="filter-select"
value={bulkPromo}
onChange={(e) =>
setBulkPromo((e.target as HTMLSelectElement).value)}
>
<option value="">Changer de promo</option>
{promos.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={!bulkPromo || bulkBusy}
onClick={bulkChangePromo}
>
Appliquer
</button>
<button
type="button"
class="btn btn-sm btn-danger"
disabled={bulkBusy}
onClick={bulkDelete}
>
Supprimer
</button>
</div>
</div>
)}
{loading
? <p class="state-loading">Chargement</p>
: (
@@ -99,6 +225,13 @@ export default function ConsultStudents() {
<table class="data-table">
<thead>
<tr>
<th style="width: 2.5rem">
<input
type="checkbox"
checked={allFilteredSelected && filtered.length > 0}
onChange={toggleAll}
/>
</th>
<th>N° étud.</th>
<th>Nom</th>
<th>Prénom</th>
@@ -110,13 +243,23 @@ export default function ConsultStudents() {
{filtered.length === 0
? (
<tr>
<td colspan={5} class="state-empty">
<td colspan={6} class="state-empty">
Aucun élève trouvé
</td>
</tr>
)
: filtered.map((s) => (
<tr key={s.numEtud}>
<tr
key={s.numEtud}
class={selected.has(s.numEtud) ? "row-selected" : ""}
>
<td>
<input
type="checkbox"
checked={selected.has(s.numEtud)}
onChange={() => toggleOne(s.numEtud)}
/>
</td>
<td class="col-dim">{s.numEtud}</td>
<td>{s.nom}</td>
<td>{s.prenom}</td>
@@ -128,14 +271,34 @@ export default function ConsultStudents() {
href={`/students/edit/${s.numEtud}`}
f-client-nav={false}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</a>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deleteStudent(s.numEtud)}
>
🗑
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<rect x="5" y="6" width="14" height="16" rx="1" />
</svg>
</button>
</div>
</td>
@@ -147,8 +147,6 @@ export default function EditStudents({ numEtud }: Props) {
{/* Section 1: Informations générales */}
<div class="edit-section">
<p class="edit-section-title">Informations générales</p>
<p class="edit-section-subtitle">PUT /students/{"{numEtud}"}</p>
<div class="form-grid">
<div class="form-field">
<label>Nom</label>
@@ -212,9 +210,6 @@ export default function EditStudents({ numEtud }: Props) {
{/* Section 2: Spécialisations */}
<div class="edit-section">
<p class="edit-section-title">Spécialisations</p>
<p class="edit-section-subtitle">
GET·POST·DELETE /spe5a plusieurs modules possibles
</p>
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
@@ -226,9 +221,6 @@ export default function EditStudents({ numEtud }: Props) {
{/* Section 3: Notes lecture seule */}
<div class="edit-section">
<p class="edit-section-title">Notes (lecture seule)</p>
<p class="edit-section-subtitle">
GET /students/{"{numEtud}"}/notes voir récap complet
</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span class="col-dim" style="font-size: 0.82rem">
Voir le récap complet des notes et moyennes de cet étudiant
@@ -2,13 +2,17 @@
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
import { useRef } from "preact/hooks";
import { useSignal } from "@preact/signals";
import ImportResultPopup, {
type ImportDetail,
type ImportResult,
} from "$root/defaults/ImportResultPopup.tsx";
export default function UploadStudents() {
const file = useSignal<File | null>(null);
const dragging = useSignal(false);
const uploading = useSignal(false);
const error = useSignal<string | null>(null);
const success = useSignal<string | null>(null);
const importResult = useSignal<ImportResult | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
function pickFile(f: File) {
@@ -18,7 +22,7 @@ export default function UploadStudents() {
}
file.value = f;
error.value = null;
success.value = null;
importResult.value = null;
}
function onDragOver(e: DragEvent) {
@@ -46,36 +50,58 @@ export default function UploadStudents() {
if (!file.value) return;
uploading.value = true;
error.value = null;
success.value = null;
importResult.value = null;
try {
const arrayBuffer = await file.value.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: "array" });
let imported = 0;
let failed = 0;
let added = 0;
let errors = 0;
const details: ImportDetail[] = [];
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<{
numEtud: number;
nom: string;
prenom: string;
}>(sheet, { header: ["numEtud", "nom", "prenom"], range: 1 });
numEtud: number;
idPromo: string;
}>(sheet, {
header: ["nom", "prenom", "numEtud", "idPromo"],
range: 2,
});
for (const row of rows) {
const res = await fetch("/students/api/students", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...row, idPromo: sheetName }),
body: JSON.stringify(row),
});
if (res.ok) imported++;
else failed++;
if (res.ok) {
added++;
details.push({
type: "change",
message:
`${row.numEtud} : ${row.nom} ${row.prenom} -> ${row.idPromo}`,
});
} else {
errors++;
const body = await res.json().catch(() => ({}));
details.push({
type: "error",
message: `${row.numEtud} : ${body.error ?? "Erreur creation"}`,
});
}
}
}
success.value = `Import terminé — ${imported} ajouté${
imported !== 1 ? "s" : ""
}${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`;
importResult.value = {
added,
modified: 0,
ignored: 0,
errors,
details,
};
} catch {
error.value = "Erreur lors de la lecture du fichier.";
} finally {
@@ -84,10 +110,7 @@ export default function UploadStudents() {
}
function downloadTemplate() {
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([["numEtud", "nom", "prenom"]]);
XLSX.utils.book_append_sheet(wb, ws, "4A22");
XLSX.writeFile(wb, "modele_etudiants.xlsx");
globalThis.open("/templates/modele_etudiants.xlsx", "_blank");
}
return (
@@ -117,10 +140,12 @@ export default function UploadStudents() {
</div>
{error.value && <p class="state-error">{error.value}</p>}
{success.value && (
<p style="font-size:0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.75rem">
{success.value}
</p>
{importResult.value && (
<ImportResultPopup
result={importResult.value}
onClose={() => (importResult.value = null)}
/>
)}
<div class="upload-actions">
@@ -142,9 +167,8 @@ export default function UploadStudents() {
</div>
<p class="upload-format">
Format : <strong>promo</strong> (nom de la feuille) |{" "}
<strong>numEtud</strong> | <strong>nom</strong> |{" "}
<strong>prénom</strong>
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
<strong>Numero-etudiant</strong> | <strong>Promotion</strong>
</p>
</div>
);
+1 -2
View File
@@ -6,10 +6,9 @@ const properties: AppProperties = {
pages: {
index: "Accueil",
consult: "Élèves",
promotions: "Promotions",
upload: "Import xlsx",
},
adminOnly: ["consult", "promotions", "upload"],
adminOnly: ["consult", "upload"],
hint: "Create students promotion and see informations",
};
@@ -1,15 +1,25 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { promotions } from "$root/databases/schema.ts";
import {
ajustements,
enseignements,
modules,
notes,
promotions,
students,
ueModules,
ues,
} from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #15 GET /promotions/{idPromo}
@@ -18,7 +28,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const promo = await db
@@ -27,7 +37,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(promotions.id, context.params.idPromo))
.then((rows) => rows[0] ?? null);
if (!promo) return NOT_FOUND;
if (!promo) return NOT_FOUND();
return new Response(JSON.stringify(promo), {
headers: { "content-type": "application/json" },
@@ -40,7 +50,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const body: { annee: string } = await request.json();
@@ -51,7 +61,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(promotions.id, context.params.idPromo))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
@@ -59,20 +69,104 @@ export const handler: Handlers<null, AuthenticatedState> = {
},
// #17 DELETE /promotions/{idPromo}
// Blocked if students are still assigned (409).
// Cascade: deletes linked ue_modules, enseignements, and orphaned
// modules (+ their notes) & UEs (+ their ajustements).
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const [deleted] = await db
.delete(promotions)
.where(eq(promotions.id, context.params.idPromo))
.returning();
const idPromo = context.params.idPromo;
if (!deleted) return NOT_FOUND;
const promo = await db
.select()
.from(promotions)
.where(eq(promotions.id, idPromo))
.then((r) => r[0] ?? null);
if (!promo) return NOT_FOUND();
// Block deletion if students are still assigned
const assignedStudents = await db
.select()
.from(students)
.where(eq(students.idPromo, idPromo))
.then((r) => r.length);
if (assignedStudents > 0) {
return new Response(
JSON.stringify({
error:
`Impossible de supprimer : ${assignedStudents} étudiant(s) encore assigné(s) à cette promotion`,
}),
{ status: 409, headers: { "content-type": "application/json" } },
);
}
await db.transaction(async (tx) => {
// Collect linked module IDs and UE IDs before deleting junction rows
const linkedUeModules = await tx
.select({ idModule: ueModules.idModule, idUE: ueModules.idUE })
.from(ueModules)
.where(eq(ueModules.idPromo, idPromo));
const linkedEns = await tx
.select({ idModule: enseignements.idModule })
.from(enseignements)
.where(eq(enseignements.idPromo, idPromo));
const moduleIds = [
...new Set([
...linkedUeModules.map((um) => um.idModule),
...linkedEns.map((e) => e.idModule),
]),
];
const ueIds = [...new Set(linkedUeModules.map((um) => um.idUE))];
// Delete junction rows that directly reference this promo
await tx.delete(ueModules).where(eq(ueModules.idPromo, idPromo));
await tx.delete(enseignements).where(eq(enseignements.idPromo, idPromo));
// Delete orphaned modules (not used by another promo) and their notes
for (const modId of moduleIds) {
const stillInUeModules = await tx
.select()
.from(ueModules)
.where(eq(ueModules.idModule, modId))
.then((r) => r.length > 0);
const stillInEns = await tx
.select()
.from(enseignements)
.where(eq(enseignements.idModule, modId))
.then((r) => r.length > 0);
if (!stillInUeModules && !stillInEns) {
await tx.delete(notes).where(eq(notes.idModule, modId));
await tx.delete(modules).where(eq(modules.id, modId));
}
}
// Delete orphaned UEs (not used by another promo) and their ajustements
for (const ueId of ueIds) {
const stillUsed = await tx
.select()
.from(ueModules)
.where(eq(ueModules.idUE, ueId))
.then((r) => r.length > 0);
if (!stillUsed) {
await tx.delete(ajustements).where(eq(ajustements.idUE, ueId));
await tx.delete(ues).where(eq(ues.id, ueId));
}
}
// Delete the promotion
await tx.delete(promotions).where(eq(promotions.id, idPromo));
});
return new Response(null, { status: 204 });
},
+14 -2
View File
@@ -44,13 +44,25 @@ export const handler: Handlers<null, AuthenticatedState> = {
idPromo: string;
} = await request.json();
if (!body.nom || !body.prenom || !body.idPromo) {
if (!body.nom || !body.prenom) {
return new Response(null, { status: 400 });
}
const values: {
numEtud?: number;
nom: string;
prenom: string;
idPromo?: string;
} = {
nom: body.nom,
prenom: body.prenom,
};
if (body.numEtud) values.numEtud = body.numEtud;
if (body.idPromo) values.idPromo = body.idPromo;
const [created] = await db
.insert(students)
.values({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo })
.values(values)
.returning();
return new Response(JSON.stringify(created), {
@@ -1,15 +1,21 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { students } from "$root/databases/schema.ts";
import {
ajustements,
mobility,
notes,
students,
} from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #10 GET /students/{numEtud}
@@ -18,7 +24,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
@@ -28,7 +34,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(students.numEtud, numEtud))
.then((rows) => rows[0] ?? null);
if (!student) return NOT_FOUND;
if (!student) return NOT_FOUND();
return new Response(JSON.stringify(student), {
headers: { "content-type": "application/json" },
@@ -41,20 +47,32 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
const body: { nom: string; prenom: string; idPromo: string } = await request
.json();
const body: { nom?: string; prenom?: string; idPromo?: string } =
await request.json();
const set: { nom?: string; prenom?: string; idPromo?: string } = {};
if (body.nom !== undefined) set.nom = body.nom;
if (body.prenom !== undefined) set.prenom = body.prenom;
if (body.idPromo !== undefined) set.idPromo = body.idPromo;
if (Object.keys(set).length === 0) {
return new Response(
JSON.stringify({ error: "Au moins un champ requis" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [updated] = await db
.update(students)
.set({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo })
.set(set)
.where(eq(students.numEtud, numEtud))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
@@ -62,21 +80,31 @@ export const handler: Handlers<null, AuthenticatedState> = {
},
// #12 DELETE /students/{numEtud}
// Cascade: deletes notes, ajustements, mobility for this student.
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
const [deleted] = await db
.delete(students)
.where(eq(students.numEtud, numEtud))
.returning();
if (!deleted) return NOT_FOUND;
const student = await db
.select()
.from(students)
.where(eq(students.numEtud, numEtud))
.then((r) => r[0] ?? null);
if (!student) return NOT_FOUND();
await db.transaction(async (tx) => {
await tx.delete(notes).where(eq(notes.numEtud, numEtud));
await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud));
await tx.delete(mobility).where(eq(mobility.studentId, numEtud));
await tx.delete(students).where(eq(students.numEtud, numEtud));
});
return new Response(null, { status: 204 });
},
@@ -11,12 +11,6 @@ async function Students(_request: Request, _context: FreshContext<State>) {
return (
<div class="page-content">
<h2 class="page-title">Importer des Élèves</h2>
<p
class="upload-format"
style="margin-bottom: 1.25rem"
>
POST /students/api/students/import-csv
</p>
<UploadStudents />
</div>
);
+53 -21
View File
@@ -4,41 +4,73 @@ import { createJwt } from "@popov/jwt";
import { setCookie } from "$std/http/cookie.ts";
import { getKey } from "$root/routes/_middleware.ts";
const FAKE_ADMIN: CasContent = {
amuCampus: "local",
amuComposante: "local",
amuDateValidation: "",
coGroup: "",
eduPersonPrimaryAffiliation: "employee",
eduPersonPrincipalName: "admin@local",
mail: "admin@local",
displayName: "Admin Local",
givenName: "Admin",
memberOf: [],
sn: "Local",
supannCivilite: "",
supannEntiteAffectation: "",
supannEtuAnneeInscription: "",
supannEtuEtape: "",
uid: "admin-local",
};
function makeFakeUser(
role: "employee" | "student",
numEtud?: string,
): CasContent {
if (role === "student" && numEtud) {
return {
amuCampus: "local",
amuComposante: "local",
amuDateValidation: "",
coGroup: "",
eduPersonPrimaryAffiliation: "student",
eduPersonPrincipalName: `${numEtud}@local`,
mail: `${numEtud}@local`,
displayName: `Etudiant ${numEtud}`,
givenName: "",
memberOf: [],
sn: "",
supannCivilite: "",
supannEntiteAffectation: "",
supannEtuAnneeInscription: "",
supannEtuEtape: "",
uid: `e${numEtud}`,
};
}
return {
amuCampus: "local",
amuComposante: "local",
amuDateValidation: "",
coGroup: "",
eduPersonPrimaryAffiliation: "employee",
eduPersonPrincipalName: "admin@local",
mail: "admin@local",
displayName: "Admin Local",
givenName: "Admin",
memberOf: [],
sn: "Local",
supannCivilite: "",
supannEntiteAffectation: "",
supannEtuAnneeInscription: "",
supannEtuEtape: "",
uid: "admin-local",
};
}
export const handler: Handlers<null, State> = {
async GET(_request: Request, _context: FreshContext<State, null>) {
async GET(request: Request, _context: FreshContext<State, null>) {
if (Deno.env.get("LOCAL") !== "true") {
return new Response("Not available outside LOCAL mode.", { status: 403 });
}
const url = new URL(request.url);
const role = url.searchParams.get("role") === "student"
? "student"
: "employee";
const numEtud = url.searchParams.get("numEtud") ?? undefined;
const user = makeFakeUser(role, numEtud);
const now = Math.floor(Date.now() / 1000);
const payload: LoginJWT = {
iss: "PolyMPR",
iat: now,
exp: now + 0xe10,
aud: "PolyMPR",
user: FAKE_ADMIN,
user,
};
const token = await createJwt(payload, getKey(FAKE_ADMIN.uid));
const token = await createJwt(payload, getKey(user.uid));
const headers = new Headers();
setCookie(headers, { name: "sessionToken", value: token });
headers.set("Location", "/apps");
+2
View File
@@ -45,6 +45,8 @@ function createUserJWT(casResponse: CasResponse): Promise<string> {
}
});
console.log(fullUserInfos);
const now = Math.floor(Date.now() / 1000);
const payload: LoginJWT = {
iss: "PolyMPR",
+60
View File
@@ -0,0 +1,60 @@
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
// --- Template 1: Students ---
{
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([
[null, null, null, "Promotion peut etre vide mais doit prealablement Exister"],
["Nom", "Prenom", "Numero-etudiant", "Promotion"],
["NOM", "PRENOM", 12345678, "3AFISE24-25"],
]);
XLSX.utils.book_append_sheet(wb, ws, "Eleves");
XLSX.writeFile(wb, "static/templates/modele_etudiants.xlsx");
console.log("Created static/templates/modele_etudiants.xlsx");
}
// --- Template 2: Notes ---
{
const headers = [
null,
null,
"MOD01 - Module 1",
"MOD02 - Module 2",
"MOD03 - Module 3",
];
const coeffs = [null, null, 2, 3, 2];
const row1 = ["NOM", "PRENOM", 12, 15.5, 14];
const row2 = ["DUPONT", "JEAN", 8, 10, 16.5];
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([headers, coeffs, row1, row2]);
XLSX.utils.book_append_sheet(wb, ws, "Session 1");
XLSX.writeFile(wb, "static/templates/modele_notes.xlsx");
console.log("Created static/templates/modele_notes.xlsx");
}
// --- Template 3: Maquette ---
{
const data = [
["Intitule du diplome", null, "Informatique - Annee 20.. - 20.."],
["Description des UE du diplome", null, null, null, null, null, "Nombre d'heures"],
["Annee\nSemestres", "Codes APOGEE", null, null, "Credits\n ECTS", "Coeff.", "CM", "TD", "TP"],
["INFO3A", null, null, null, "ECTS", "Coef", "CM", "TD", "TP"],
["SEM 5", null, null, null, 30],
["UE", "CODE_UE1", "Nom de l'UE 1", null, 6],
[null, "MOD01", null, "Module 1", null, 2, 10, 10, 10],
[null, "MOD02", null, "Module 2", null, 2, 10, 10, 10],
[null, "MOD03", null, "Module 3", null, 2, 10, 10, 10],
[],
["UE", "CODE_UE2", "Nom de l'UE 2", null, 4],
[null, "MOD04", null, "Module 4", null, 2, 10, 10, 10],
[null, "MOD05", null, "Module 5", null, 2, 10, 10, 10],
];
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet(data);
XLSX.utils.book_append_sheet(wb, ws, "Maquette");
XLSX.writeFile(wb, "static/templates/modele_maquette.xlsx");
console.log("Created static/templates/modele_maquette.xlsx");
}
+25
View File
@@ -0,0 +1,25 @@
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) {
console.log(`\n=== ${file} ===`);
const wb = XLSX.read(Deno.readFileSync(`Excels/${file}`), { type: "array" });
console.log(`Sheets: ${wb.SheetNames.join(", ")}`);
for (const sheetName of wb.SheetNames) {
console.log(`\n--- Sheet: ${sheetName} ---`);
const sheet = wb.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { header: 1 });
// Print first 5 cols of each row, mark rows that look like year/semester headers
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || row.length === 0) continue;
const col0 = row[0] != null ? String(row[0]).trim() : "";
// Show rows that are structural (year, semester, UE headers)
if (col0 || (row[1] != null && String(row[1]).trim())) {
const preview = row.slice(0, 6).map(c => c != null ? String(c).substring(0, 25) : "").join(" | ");
console.log(` [${i}] ${preview}`);
}
}
}
}
+249 -1
View File
@@ -391,6 +391,54 @@
gap: 1rem;
}
/* -------------------------------------------------------
Bulk actions bar
------------------------------------------------------- */
.bulk-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border-radius: 6px;
background: light-dark(var(--light-accent-color), var(--dark-accent-color));
color: light-dark(
var(--light-background-color),
var(--dark-background-color)
);
font-size: 0.82rem;
flex-wrap: wrap;
}
.bulk-count {
font-weight: var(--font-weight-bold);
white-space: nowrap;
}
.bulk-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-wrap: wrap;
}
.bulk-bar .filter-select {
color: light-dark(
var(--light-foreground-color),
var(--dark-foreground-color)
);
font-size: 0.78rem;
}
.row-selected {
background: light-dark(
color-mix(in srgb, var(--light-accent-color) 8%, transparent),
color-mix(in srgb, var(--dark-accent-color) 12%, transparent)
);
}
/* -------------------------------------------------------
Chips: perm, role, promo, module
------------------------------------------------------- */
@@ -470,6 +518,18 @@
Permission toggle cards (role management)
------------------------------------------------------- */
.perm-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.85rem;
margin-bottom: 1.25rem;
background: light-dark(#f5f4ff, #141228);
border: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
border-radius: 4px;
}
.perm-toggle-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -740,7 +800,7 @@
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.5rem;
gap: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
@@ -840,6 +900,14 @@
margin-bottom: 0.75rem;
}
.create-promo-inline {
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px dashed
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
border-radius: 6px;
}
.upload-format {
font-size: 0.72rem;
font-family: monospace;
@@ -947,9 +1015,189 @@
(end note recap)
------------------------------------------------------- */
/* -------------------------------------------------------
Modal overlay
------------------------------------------------------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-box {
background: light-dark(white, #1a172d);
border: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
border-radius: 6px;
padding: 1.25rem;
min-width: 22rem;
max-width: 90vw;
}
.modal-title {
font-size: 0.95rem;
font-weight: var(--font-weight-bold);
margin: 0 0 1rem;
}
.modal-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.info-note-dim {
font-size: 0.7rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-family: monospace;
margin-top: 0.25rem;
}
/* -------------------------------------------------------
Import result popup
------------------------------------------------------- */
.import-popup-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.import-popup {
background: light-dark(
var(--light-background-color),
var(--dark-background-color)
);
border: 1px solid
light-dark(var(--light-border-color), var(--dark-border-color));
border-radius: 10px;
padding: 1.5rem 2rem;
min-width: 28rem;
max-width: 40rem;
max-height: 80vh;
overflow-y: auto;
}
.import-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.import-popup-title {
font-size: 1.1rem;
font-weight: var(--font-weight-bold);
margin: 0;
}
.import-popup-badge {
font-size: 0.78rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 4px;
border: 1px solid;
}
.badge-error {
color: #f5a623;
border-color: #f5a623;
}
.badge-success {
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
border-color: light-dark(var(--light-accent-color), var(--dark-accent-color));
}
.import-popup-stats {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1.25rem;
}
.import-stat-row {
display: flex;
align-items: center;
gap: 1.5rem;
}
.import-stat-label {
min-width: 6rem;
font-size: 0.85rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
.import-stat-value {
font-size: 0.85rem;
font-family: monospace;
padding: 0.2rem 0.6rem;
border-radius: 4px;
border: 1px solid;
min-width: 8rem;
}
.stat-added {
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
border-color: light-dark(var(--light-accent-color), var(--dark-accent-color));
}
.stat-modified {
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
border-color: light-dark(var(--light-accent-color), var(--dark-accent-color));
}
.stat-ignored {
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
border-color: light-dark(var(--light-border-color), var(--dark-border-color));
}
.stat-errors {
color: #f5a623;
border-color: #f5a623;
}
.import-popup-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.import-popup-details {
border-top: 1px solid
light-dark(var(--light-border-color), var(--dark-border-color));
padding-top: 0.75rem;
font-family: monospace;
font-size: 0.75rem;
max-height: 12rem;
overflow-y: auto;
}
.import-detail-change {
margin: 0.15rem 0;
color: light-dark(
var(--light-foreground-color),
var(--dark-foreground-color)
);
}
.import-detail-error {
margin: 0.15rem 0;
color: #f5a623;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -21,8 +21,8 @@ import {
import { handler as modulesHandler } from "$apps/admin/api/modules.ts";
import { handler as moduleHandler } from "$apps/admin/api/modules/[idModule].ts";
import { handler as notesHandler } from "$apps/notes/api/notes.ts";
import { handler as uesHandler } from "$apps/notes/api/ues.ts";
import { handler as ueModulesHandler } from "$apps/notes/api/ue-modules.ts";
import { handler as uesHandler } from "$apps/admin/api/ues.ts";
import { handler as ueModulesHandler } from "$apps/admin/api/ue-modules.ts";
import { handler as ajustementsHandler } from "$apps/notes/api/ajustements.ts";
import { handler as enseignementsHandler } from "$apps/admin/api/enseignements.ts";
import { handler as usersHandler } from "$apps/admin/api/users.ts";
+2 -2
View File
@@ -14,8 +14,8 @@ import {
seedUes,
truncateAll,
} from "../helpers/db_integration.ts";
import { handler as ueModulesHandler } from "$apps/notes/api/ue-modules.ts";
import { handler as ueModuleHandler } from "$apps/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
import { handler as ueModulesHandler } from "$apps/admin/api/ue-modules.ts";
import { handler as ueModuleHandler } from "$apps/admin/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
import { ueModules as ueModulesTable } from "$root/databases/schema.ts";
import { testDb } from "../helpers/db_integration.ts";
+2 -2
View File
@@ -7,8 +7,8 @@ import {
makeJsonRequest,
} from "../helpers/handler.ts";
import { seedUes, truncateAll } from "../helpers/db_integration.ts";
import { handler as uesHandler } from "$apps/notes/api/ues.ts";
import { handler as ueHandler } from "$apps/notes/api/ues/[idUE].ts";
import { handler as uesHandler } from "$apps/admin/api/ues.ts";
import { handler as ueHandler } from "$apps/admin/api/ues/[idUE].ts";
// --- GET /ues ---