diff --git a/fresh.gen.ts b/fresh.gen.ts index 2309f78..a4a95f9 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -15,8 +15,10 @@ import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles import * as $_apps_admin_api_users from "./routes/(apps)/admin/api/users.ts"; import * as $_apps_admin_api_users_id_ from "./routes/(apps)/admin/api/users/[id].ts"; import * as $_apps_admin_index from "./routes/(apps)/admin/index.tsx"; +import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx"; import * as $_apps_admin_partials_index from "./routes/(apps)/admin/partials/index.tsx"; import * as $_apps_admin_partials_modules from "./routes/(apps)/admin/partials/modules.tsx"; +import * as $_apps_admin_partials_permissions from "./routes/(apps)/admin/partials/permissions.tsx"; import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx"; import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; @@ -42,6 +44,7 @@ import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/studen import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; import * as $_apps_students_api_students_numEtud_ from "./routes/(apps)/students/api/students/[numEtud].ts"; import * as $_apps_students_api_students_import_csv from "./routes/(apps)/students/api/students/import-csv.ts"; +import * as $_apps_students_edit_numEtud_ from "./routes/(apps)/students/edit/[numEtud].tsx"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; import * as $_apps_students_partials_admin_promotions from "./routes/(apps)/students/partials/(admin)/promotions.tsx"; @@ -59,7 +62,9 @@ import * as $login from "./routes/login.tsx"; import * as $logout from "./routes/logout.tsx"; import * as $_islands_AppNavigator from "./routes/(_islands)/AppNavigator.tsx"; import * as $_islands_Navbar from "./routes/(_islands)/Navbar.tsx"; +import * as $_apps_admin_islands_AdminEnseignements from "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx"; import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_islands)/AdminModules.tsx"; +import * as $_apps_admin_islands_AdminPermissions from "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx"; import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx"; import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx"; import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx"; @@ -93,8 +98,12 @@ const manifest = { "./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/partials/enseignements.tsx": + $_apps_admin_partials_enseignements, "./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/roles.tsx": $_apps_admin_partials_roles, "./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users, "./routes/(apps)/mobility/api/insert_mobility.ts": @@ -133,6 +142,8 @@ const manifest = { $_apps_students_api_students_numEtud_, "./routes/(apps)/students/api/students/import-csv.ts": $_apps_students_api_students_import_csv, + "./routes/(apps)/students/edit/[numEtud].tsx": + $_apps_students_edit_numEtud_, "./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, @@ -156,8 +167,12 @@ const manifest = { islands: { "./routes/(_islands)/AppNavigator.tsx": $_islands_AppNavigator, "./routes/(_islands)/Navbar.tsx": $_islands_Navbar, + "./routes/(apps)/admin/(_islands)/AdminEnseignements.tsx": + $_apps_admin_islands_AdminEnseignements, "./routes/(apps)/admin/(_islands)/AdminModules.tsx": $_apps_admin_islands_AdminModules, + "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx": + $_apps_admin_islands_AdminPermissions, "./routes/(apps)/admin/(_islands)/AdminRoles.tsx": $_apps_admin_islands_AdminRoles, "./routes/(apps)/admin/(_islands)/AdminUsers.tsx": diff --git a/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx new file mode 100644 index 0000000..7b158d2 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminEnseignements.tsx @@ -0,0 +1,292 @@ +import { useEffect, useState } from "preact/hooks"; + +type Enseignement = { idProf: string; idModule: string; idPromo: string }; +type Module = { id: string; nom: string }; +type Promo = { id: string; annee: string }; + +export default function AdminEnseignements() { + const [enseignements, setEnseignements] = useState([]); + const [modules, setModules] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Filters + const [filterPromo, setFilterPromo] = useState(""); + const [filterModule, setFilterModule] = useState(""); + const [filterEnseignant, setFilterEnseignant] = useState(""); + + // Add form + const [showAdd, setShowAdd] = useState(false); + const [addPromo, setAddPromo] = useState(""); + const [addModule, setAddModule] = useState(""); + const [addProf, setAddProf] = useState(""); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); + + async function load() { + try { + const [eRes, mRes, pRes] = await Promise.all([ + fetch("/admin/api/enseignements"), + fetch("/admin/api/modules"), + fetch("/students/api/promotions"), + ]); + if (!eRes.ok) throw new Error("Impossible de charger les enseignements"); + setEnseignements(await eRes.json()); + if (mRes.ok) setModules(await mRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function deleteEnseignement( + idProf: string, + idModule: string, + idPromo: string, + ) { + if ( + !confirm( + `Supprimer l'assignation ${idProf} → ${idModule} / ${idPromo} ?`, + ) + ) return; + try { + const res = await fetch( + `/admin/api/enseignements/${encodeURIComponent(idProf)}/${ + encodeURIComponent(idModule) + }/${encodeURIComponent(idPromo)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + async function addEnseignement() { + if (!addProf.trim() || !addModule || !addPromo) { + setAddError("Tous les champs sont requis"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/admin/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idProf: addProf.trim(), + idModule: addModule, + idPromo: addPromo, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddProf(""); + setAddModule(""); + setAddPromo(""); + setShowAdd(false); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + + const filtered = enseignements.filter((e) => { + const matchPromo = !filterPromo || e.idPromo === filterPromo; + const matchModule = !filterModule || e.idModule === filterModule; + const matchEns = !filterEnseignant || + e.idProf.toLowerCase().includes(filterEnseignant.toLowerCase()); + return matchPromo && matchModule && matchEns; + }); + + return ( +
+

Assignations Enseignant → Module / Promo

+ + {error &&

{error}

} + +
+ + + + setFilterEnseignant((e.target as HTMLInputElement).value)} + /> + + +
+ + {showAdd && ( +
+ {addError && ( + + {addError} + + )} + + + setAddProf((e.target as HTMLInputElement).value)} + style="min-width: 10rem" + /> + + +
+ )} + + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((e) => { + const mod = moduleMap[e.idModule]; + return ( + + + + + + + ); + })} + +
PromoModuleEnseignant (User.id)Actions
+ Aucun enseignement trouvé +
+ {e.idPromo} + + {mod ? `${mod.id} – ${mod.nom}` : e.idModule} + {e.idProf} +
+ +
+
+
+ )} + +
+

+ Un même module peut être enseigné par plusieurs utilisateurs sur une + même promo. +

+

+ Clé composite = idProf (User.Id) + idModule + idPromo +

+
+
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminPermissions.tsx b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx new file mode 100644 index 0000000..57c600b --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminPermissions.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "preact/hooks"; + +type Perm = { id: string; nom: string }; +type Role = { id: number; nom: string; permissions: string[] }; + +export default function AdminPermissions() { + const [permissions, setPermissions] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + const [pRes, rRes] = await Promise.all([ + fetch("/admin/api/permissions"), + fetch("/admin/api/roles"), + ]); + if (!pRes.ok) throw new Error("Impossible de charger les permissions"); + setPermissions(await pRes.json()); + if (rRes.ok) setRoles(await rRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + function rolesForPerm(permId: string): Role[] { + return roles.filter((r) => r.permissions.includes(permId)); + } + + const MAX_ROLE_CHIPS = 2; + + return ( +
+

Permissions

+ +
+

+ Les permissions sont définies statiquement par le serveur. +

+

+ Elles ne peuvent pas être créées ou supprimées via l'API. +

+
+ + {error &&

{error}

} + + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {permissions.map((p) => { + const associated = rolesForPerm(p.id); + const shown = associated.slice(0, MAX_ROLE_CHIPS); + const overflow = associated.length - MAX_ROLE_CHIPS; + return ( + + + + + + ); + })} + +
idPermissionnomPermissionRôles associés
+ + {p.id} + + {p.nom} +
+ {shown.map((r) => ( + {r.nom} + ))} + {overflow > 0 && ( + + +{overflow} + + )} + {associated.length === 0 && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx index 9e97fad..448e334 100644 --- a/routes/(apps)/admin/(_islands)/AdminRoles.tsx +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -1,17 +1,23 @@ import { useEffect, useState } from "preact/hooks"; -type Role = { id: number; nom: string }; -type Permission = { id: string; nom: string }; +type Role = { id: number; nom: string; permissions: string[] }; +type Perm = { id: string; nom: string }; + +const MAX_CHIPS = 3; export default function AdminRoles() { const [roles, setRoles] = useState([]); - const [permissions, setPermissions] = useState([]); + const [permissions, setPermissions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [newNom, setNewNom] = useState(""); const [creating, setCreating] = useState(false); - const [editId, setEditId] = useState(null); - const [editNom, setEditNom] = useState(""); + + // Manage-perms sub-view + const [managingRole, setManagingRole] = useState(null); + const [editPerms, setEditPerms] = useState>(new Set()); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); async function load() { try { @@ -55,21 +61,6 @@ export default function AdminRoles() { } } - async function saveEdit(id: number) { - try { - const res = await fetch(`/admin/api/roles/${id}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ nom: editNom.trim() }), - }); - if (!res.ok) throw new Error("Modification échouée"); - setEditId(null); - await load(); - } catch (e) { - setError(e instanceof Error ? e.message : "Erreur"); - } - } - async function deleteRole(id: number) { if (!confirm("Supprimer ce rôle ?")) return; try { @@ -81,19 +72,143 @@ export default function AdminRoles() { } } + function openManage(role: Role) { + setManagingRole(role); + setEditPerms(new Set(role.permissions)); + setSaveError(null); + } + + function togglePerm(permId: string) { + setEditPerms((prev) => { + const next = new Set(prev); + if (next.has(permId)) next.delete(permId); + else next.add(permId); + return next; + }); + } + + async function savePerms() { + if (!managingRole) return; + setSaving(true); + setSaveError(null); + try { + const res = await fetch(`/admin/api/roles/${managingRole.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: managingRole.nom, + permissions: [...editPerms], + }), + }); + if (!res.ok) throw new Error("Enregistrement échoué"); + await load(); + setManagingRole(null); + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + // ---- Manage-perms view ---- + if (managingRole) { + const activeCount = editPerms.size; + return ( +
+ { + e.preventDefault(); + setManagingRole(null); + }} + > + ← Retour à la liste des rôles + +

+ Permissions du rôle – {managingRole.nom} +

+ + {saveError &&

{saveError}

} + +
+
+ idRole : {managingRole.id} + + {managingRole.nom} + + + {activeCount} permission{activeCount !== 1 ? "s" : ""} active + {activeCount !== 1 ? "s" : ""} + +
+ +
+ +
+ + Permissions disponibles + + + Activer = inclure dans le rôle + +
+ +
+ {permissions.map((p) => { + const active = editPerms.has(p.id); + return ( + + ); + })} +
+
+ ); + } + + // ---- Main list view ---- return (

Gestion des Rôles

{error &&

{error}

} -
+
setNewNom((e.target as HTMLInputElement).value)} onKeyDown={(e) => e.key === "Enter" && createRole()} + style="min-width: 14rem" />
@@ -112,8 +227,9 @@ export default function AdminRoles() { - - + + + @@ -121,105 +237,60 @@ export default function AdminRoles() { {roles.length === 0 ? ( - ) - : roles.map((r) => ( - - - - + + + - - ))} + + + + + ); + })}
IDNomidRoleNom du rôlePermissions Actions
+ Aucun rôle enregistré
{r.id} - {editId === r.id - ? ( - - setEditNom( - (e.target as HTMLInputElement).value, - )} - style="min-width: 0; width: 100%" - /> - ) - : r.nom} - -
- {editId === r.id - ? ( - <> - - - - ) - : ( - <> - - - + : roles.map((r) => { + const shown = r.permissions.slice(0, MAX_CHIPS); + const overflow = r.permissions.length - MAX_CHIPS; + return ( +
{r.id} + {r.nom} + +
+ {shown.map((p) => ( + {p} + ))} + {overflow > 0 && ( + + +{overflow} + )} -
-
+
+ + +
+
)} - - {permissions.length > 0 && ( -
-

- Permissions disponibles -

-
- - - - - - - - - {permissions.map((p) => ( - - - - - ))} - -
IDNom
{p.id}{p.nom}
-
-
- )}
); } diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index a95fb54..5563bed 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -5,11 +5,13 @@ const properties: AppProperties = { icon: "school", pages: { index: "Accueil", - modules: "Modules", users: "Utilisateurs", roles: "Rôles", + permissions: "Permissions", + modules: "Modules", + enseignements: "Enseignements", }, - adminOnly: ["modules", "users", "roles"], + adminOnly: ["users", "roles", "permissions", "modules", "enseignements"], hint: "PolyMPR module", }; diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index cb2ab47..fd5fee8 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -17,6 +17,22 @@ const CONFLICT = new Response( ); export const handler: Handlers = { + // GET /enseignements + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return new Response(JSON.stringify([]), { + headers: { "content-type": "application/json" }, + }); + } + const rows = await db.select().from(enseignements); + return new Response(JSON.stringify(rows), { + headers: { "content-type": "application/json" }, + }); + }, + // #29 POST /enseignements async POST( request: Request, diff --git a/routes/(apps)/admin/partials/enseignements.tsx b/routes/(apps)/admin/partials/enseignements.tsx new file mode 100644 index 0000000..9b0127e --- /dev/null +++ b/routes/(apps)/admin/partials/enseignements.tsx @@ -0,0 +1,18 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminEnseignements from "../(_islands)/AdminEnseignements.tsx"; + +// deno-lint-ignore require-await +async function Enseignements( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Enseignements); diff --git a/routes/(apps)/admin/partials/permissions.tsx b/routes/(apps)/admin/partials/permissions.tsx new file mode 100644 index 0000000..f9359e5 --- /dev/null +++ b/routes/(apps)/admin/partials/permissions.tsx @@ -0,0 +1,18 @@ +import { + getPartialsConfig, + makePartials, +} from "$root/defaults/makePartials.tsx"; +import { FreshContext } from "$fresh/server.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminPermissions from "../(_islands)/AdminPermissions.tsx"; + +// deno-lint-ignore require-await +async function Permissions( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Permissions); diff --git a/routes/(apps)/notes/(_islands)/AdminUEs.tsx b/routes/(apps)/notes/(_islands)/AdminUEs.tsx index d698c34..8c2ea22 100644 --- a/routes/(apps)/notes/(_islands)/AdminUEs.tsx +++ b/routes/(apps)/notes/(_islands)/AdminUEs.tsx @@ -1,19 +1,54 @@ import { useEffect, useState } from "preact/hooks"; type UE = { id: number; nom: string }; +type UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; +type Module = { id: string; nom: string }; +type Promo = { id: string; annee: string }; export default function AdminUEs() { const [ues, setUes] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [modules, setModules] = useState([]); + const [promos, setPromos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [newNom, setNewNom] = useState(""); - const [creating, setCreating] = useState(false); + + const [selectedUe, setSelectedUe] = useState(null); + + // New UE form + const [newUeNom, setNewUeNom] = useState(""); + const [creatingUe, setCreatingUe] = useState(false); + + // Add UE-module form + const [addModuleId, setAddModuleId] = useState(""); + const [addPromoId, setAddPromoId] = useState(""); + const [addCoeff, setAddCoeff] = useState("1"); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); async function load() { try { - const res = await fetch("/notes/api/ues"); - if (!res.ok) throw new Error("Impossible de charger les UEs"); - setUes(await res.json()); + const [uRes, umRes, mRes, pRes] = await Promise.all([ + fetch("/notes/api/ues"), + fetch("/notes/api/ue-modules"), + fetch("/admin/api/modules"), + fetch("/students/api/promotions"), + ]); + if (!uRes.ok) throw new Error("Impossible de charger les UEs"); + const uesData: UE[] = await uRes.json(); + setUes(uesData); + if (umRes.ok) setUeModules(await umRes.json()); + if (mRes.ok) setModules(await mRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + // Keep selection in sync + setSelectedUe((prev) => + prev ? uesData.find((u) => u.id === prev.id) ?? null : null + ); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -26,28 +61,37 @@ export default function AdminUEs() { }, []); async function createUE() { - if (!newNom.trim()) return; - setCreating(true); + if (!newUeNom.trim()) return; + setCreatingUe(true); try { const res = await fetch("/notes/api/ues", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ nom: newNom.trim() }), + body: JSON.stringify({ nom: newUeNom.trim() }), }); if (!res.ok) throw new Error("Création échouée"); - setNewNom(""); + setNewUeNom(""); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { - setCreating(false); + setCreatingUe(false); } } - async function deleteUE(id: number) { - if (!confirm("Supprimer cette UE ?")) return; + async function deleteUeModule( + idModule: string, + idUE: number, + idPromo: string, + ) { + if (!confirm("Supprimer ce module de la UE ?")) return; try { - const res = await fetch(`/notes/api/ues/${id}`, { method: "DELETE" }); + const res = await fetch( + `/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ + encodeURIComponent(idPromo) + }`, + { method: "DELETE" }, + ); if (!res.ok) throw new Error("Suppression échouée"); await load(); } catch (e) { @@ -55,68 +99,247 @@ export default function AdminUEs() { } } + async function addUeModule() { + if (!selectedUe || !addModuleId || !addPromoId) { + setAddError("Module et Promo sont requis"); + return; + } + const coeff = parseFloat(addCoeff); + if (isNaN(coeff) || coeff <= 0) { + setAddError("Coefficient invalide"); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await fetch("/notes/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + idModule: addModuleId, + idUE: selectedUe.id, + idPromo: addPromoId, + coeff, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setAddModuleId(""); + setAddPromoId(""); + setAddCoeff("1"); + await load(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Erreur"); + } finally { + setAdding(false); + } + } + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + + const selectedUeModules = selectedUe + ? ueModules.filter((um) => um.idUE === selectedUe.id) + : []; + return (

Gestion des UEs

+

+ UE = Unité d'Enseignement regroupant plusieurs modules +

{error &&

{error}

} -
- setNewNom((e.target as HTMLInputElement).value)} - onKeyDown={(e) => e.key === "Enter" && createUE()} - /> - -
- {loading ?

Chargement…

: ( -
- - - - - - - - - - {ues.length === 0 - ? ( - - - - ) - : ues.map((ue) => ( - - - - - +
+ {/* Left panel – UE list */} +
+
+

UEs existantes

+
+ + setNewUeNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createUE()} + style="min-width: 0; flex: 1" + /> +
+ +
+ {ues.map((ue) => ( +
{ + setSelectedUe(ue); + setAddError(null); + }} + > + {ue.nom} +
))} -
-
IDNomAction
- Aucune UE enregistrée -
{ue.id}{ue.nom} - -
+ {ues.length === 0 && ( +

+ Aucune UE +

+ )} +
+
+ + + {/* Right panel – UE detail */} +
+ {selectedUe + ? ( +
+

{selectedUe.nom}

+

+ Modules assignés (UE_Module) +

+
+ + + + + + + + + + + {selectedUeModules.length === 0 + ? ( + + + + ) + : selectedUeModules.map((um) => { + const mod = moduleMap[um.idModule]; + return ( + + + + + + + ); + })} + +
ModulePromoCoeffActions
+ Aucun module assigné +
+ {mod + ? `${mod.id} – ${mod.nom}` + : um.idModule} + + {um.idPromo} + {um.coeff} + +
+
+ +

+ Ajouter un module à cette UE +

+ {addError && ( +

+ {addError} +

+ )} +
+ + + + setAddCoeff((e.target as HTMLInputElement).value)} + style="min-width: 5rem; max-width: 6rem" + /> + +
+
+ ) + : ( +
+

+ Sélectionnez une UE pour voir ses modules +

+
+ )} +
)} diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/students/(_islands)/AdminPromotions.tsx index 5461d9e..6143972 100644 --- a/routes/(apps)/students/(_islands)/AdminPromotions.tsx +++ b/routes/(apps)/students/(_islands)/AdminPromotions.tsx @@ -1,20 +1,42 @@ import { useEffect, useState } from "preact/hooks"; type Promotion = { id: string; annee: string | null }; +type Student = { numEtud: number; idPromo: string }; + +function parsePromo(id: string) { + const m = id.match(/^(\d+A)(FISE|FISA)(.+)$/); + if (!m) return { annee: id, filiere: "?", anneeSco: "?" }; + return { annee: m[1], filiere: m[2], anneeSco: m[3] }; +} + +const ANNEES = ["3A", "4A", "5A"]; +const FILIERES = ["FISE", "FISA"]; export default function AdminPromotions() { const [promos, setPromos] = useState([]); + const [students, setStudents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [newId, setNewId] = useState(""); - const [newAnnee, setNewAnnee] = useState(""); const [creating, setCreating] = useState(false); + // PromoBuilder state + const [selectedAnnee, setSelectedAnnee] = useState("4A"); + const [selectedFiliere, setSelectedFiliere] = useState("FISE"); + const [anneeSco, setAnneeSco] = useState(""); + + const generatedId = anneeSco.trim() + ? `${selectedAnnee}${selectedFiliere}${anneeSco.trim()}` + : ""; + async function load() { try { - const res = await fetch("/students/api/promotions"); - if (!res.ok) throw new Error("Impossible de charger les promotions"); - setPromos(await res.json()); + const [pRes, sRes] = await Promise.all([ + fetch("/students/api/promotions"), + fetch("/students/api/students"), + ]); + if (!pRes.ok) throw new Error("Impossible de charger les promotions"); + setPromos(await pRes.json()); + if (sRes.ok) setStudents(await sRes.json()); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); } finally { @@ -27,23 +49,22 @@ export default function AdminPromotions() { }, []); async function createPromo() { - if (!newId.trim()) return; + if (!generatedId) return; setCreating(true); try { const res = await fetch("/students/api/promotions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - idPromo: newId.trim(), - annee: newAnnee.trim() || null, + idPromo: generatedId, + annee: selectedAnnee, }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? "Création échouée"); } - setNewId(""); - setNewAnnee(""); + setAnneeSco(""); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Erreur"); @@ -57,9 +78,7 @@ export default function AdminPromotions() { try { const res = await fetch( `/students/api/promotions/${encodeURIComponent(id)}`, - { - method: "DELETE", - }, + { method: "DELETE" }, ); if (!res.ok) throw new Error("Suppression échouée"); await load(); @@ -68,36 +87,93 @@ export default function AdminPromotions() { } } + function studentCount(idPromo: string) { + return students.filter((s) => s.idPromo === idPromo).length; + } + return (

Gestion des Promotions

{error &&

{error}

} -
- setNewId((e.target as HTMLInputElement).value)} - /> - setNewAnnee((e.target as HTMLInputElement).value)} - style="min-width: 14rem" - /> - + {/* PromoBuilder */} +
+

Créer une promotion

+

+ POST /promotions – idPromo est généré automatiquement +

+ +
+
+ +
+ {ANNEES.map((a) => ( + + ))} +
+
+ +
+ +
+ {FILIERES.map((f) => ( + + ))} +
+
+ +
+ + setAnneeSco((e.target as HTMLInputElement).value)} + style="min-width: 9rem" + /> +
+
+ +
+
+ + idPromo généré : + + + {generatedId || "—"} + +
+ +
+ {/* Existing promotions table */} +

+ Promotions existantes +

+ {loading ?

Chargement…

: ( @@ -105,35 +181,51 @@ export default function AdminPromotions() { - + - + + + + {promos.length === 0 ? ( - ) - : promos.map((p) => ( - - - - - - ))} + : promos.map((p) => { + const parsed = parsePromo(p.id); + const count = studentCount(p.id); + return ( + + + + + + + + + ); + })}
IdentifiantidPromo AnnéeActionFilièreAnnée sco.Nb étudiantsActions
+ Aucune promotion enregistrée
{p.id}{p.annee ?? "—"} - -
+ {p.id} + {parsed.annee} + {parsed.filiere} + {parsed.anneeSco} + {count} étudiant{count !== 1 ? "s" : ""} + + +
diff --git a/routes/(apps)/students/(_islands)/EditStudents.tsx b/routes/(apps)/students/(_islands)/EditStudents.tsx index e69de29..f5728c4 100644 --- a/routes/(apps)/students/(_islands)/EditStudents.tsx +++ b/routes/(apps)/students/(_islands)/EditStudents.tsx @@ -0,0 +1,247 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promo = { id: string; annee: string }; +type Module = { id: string; nom: string }; + +type Props = { numEtud: number }; + +function anneeLabel(idPromo: string): string { + const m = idPromo.match(/^(\d+)A/); + if (!m) return ""; + const n = m[1]; + if (n === "3") return "3ème année"; + if (n === "4") return "4ème année"; + if (n === "5") return "5ème année"; + return `${n}ème année`; +} + +export default function EditStudents({ numEtud }: Props) { + const [student, setStudent] = useState(null); + const [promos, setPromos] = useState([]); + const [_modules, setModules] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saveMsg, setSaveMsg] = useState(null); + const [saving, setSaving] = useState(false); + + // Edit form state + const [nom, setNom] = useState(""); + const [prenom, setPrenom] = useState(""); + const [idPromo, setIdPromo] = useState(""); + + useEffect(() => { + async function load() { + try { + const [sRes, pRes, mRes] = await Promise.all([ + fetch(`/students/api/students/${numEtud}`), + fetch("/students/api/promotions"), + fetch("/admin/api/modules"), + ]); + if (!sRes.ok) throw new Error("Élève introuvable"); + const s: Student = await sRes.json(); + setStudent(s); + setNom(s.nom); + setPrenom(s.prenom); + setIdPromo(s.idPromo); + if (pRes.ok) setPromos(await pRes.json()); + if (mRes.ok) setModules(await mRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, [numEtud]); + + async function saveInfos() { + if (!student) return; + setSaving(true); + setSaveMsg(null); + try { + const res = await fetch(`/students/api/students/${numEtud}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + nom: nom.trim(), + prenom: prenom.trim(), + idPromo, + }), + }); + if (!res.ok) throw new Error("Modification échouée"); + const updated: Student = await res.json(); + setStudent(updated); + setSaveMsg("Informations enregistrées."); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setSaving(false); + } + } + + async function deleteStudent() { + if (!confirm(`Supprimer définitivement l'élève #${numEtud} ?`)) return; + try { + const res = await fetch(`/students/api/students/${numEtud}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Suppression échouée"); + globalThis.location.href = "/students/consult"; + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + if (loading) { + return ( +
+

Chargement…

+
+ ); + } + + if (error && !student) { + return ( +
+

{error}

+
+ ); + } + + if (!student) return null; + + return ( +
+ + ← Retour à la liste + + +

+ Édition – {student.prenom} {student.nom} +

+ + {/* Info bar */} +
+ {student.numEtud} + {student.idPromo} + {anneeLabel(student.idPromo)} +
+ + {error &&

{error}

} + {saveMsg && ( +

+ {saveMsg} +

+ )} + + {/* Section 1: Informations générales */} +
+

Informations générales

+

PUT /students/{"{numEtud}"}

+ +
+
+ + setNom((e.target as HTMLInputElement).value)} + /> +
+
+ + setPrenom((e.target as HTMLInputElement).value)} + /> +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {/* Section 2: Spécialisations */} +
+

Spécialisations

+

+ GET·POST·DELETE /spe5a – plusieurs modules possibles +

+

+ Fonctionnalité non disponible (endpoint non implémenté). +

+
+ + {/* Section 3: Notes lecture seule */} +
+

Notes (lecture seule)

+

+ GET /students/{"{numEtud}"}/notes – voir récap complet +

+
+ + Voir le récap complet des notes et moyennes de cet étudiant → + + + Récap notes + +
+
+
+ ); +} diff --git a/routes/(apps)/students/edit/[numEtud].tsx b/routes/(apps)/students/edit/[numEtud].tsx new file mode 100644 index 0000000..e88ff1b --- /dev/null +++ b/routes/(apps)/students/edit/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import EditStudents from "../(_islands)/EditStudents.tsx"; + +// deno-lint-ignore require-await +export default async function EditPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/static/styles/ui.css b/static/styles/ui.css index e56daa2..12132eb 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -391,3 +391,412 @@ margin-bottom: 1rem; gap: 1rem; } + +/* ------------------------------------------------------- + Chips: perm, role, promo, module +------------------------------------------------------- */ + +.perm-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.68rem; + font-family: monospace; + margin: 0.1rem; +} + +.role-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.72rem; + margin: 0.1rem; +} + +.promo-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid #b8820a; + color: #d4a017; + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +.filiere-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +.module-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-size: 0.7rem; + font-family: monospace; +} + +.numEtud-chip { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 10px; + border: 1px solid + light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.72rem; + font-weight: var(--font-weight-bold); +} + +/* ------------------------------------------------------- + Permission toggle cards (role management) +------------------------------------------------------- */ + +.perm-toggle-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem; +} + +.perm-toggle-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.55rem 0.75rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + cursor: pointer; + gap: 0.5rem; +} + +.perm-toggle-card.active { + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + background: light-dark(#f0fff4, #0d1f12); +} + +.perm-toggle-label { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.perm-toggle-id { + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + font-family: monospace; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.perm-toggle-nom { + font-size: 0.78rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* Simple toggle switch */ +.toggle-switch { + position: relative; + width: 2.4rem; + height: 1.3rem; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: light-dark(#ccc, #444); + border-radius: 1rem; + transition: background 150ms; +} + +.toggle-slider::before { + content: ""; + position: absolute; + width: 1rem; + height: 1rem; + left: 0.15rem; + top: 0.15rem; + background: white; + border-radius: 50%; + transition: transform 150ms; +} + +.toggle-switch input:checked + .toggle-slider { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(1.1rem); +} + +/* ------------------------------------------------------- + UE split layout +------------------------------------------------------- */ + +.ue-split { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.ue-panel-left { + width: 270px; + flex-shrink: 0; +} + +.ue-panel-right { + flex: 1; + min-width: 0; +} + +.panel-box { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + padding: 0.75rem; +} + +.panel-box-title { + font-size: 0.8rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.6rem; +} + +.ue-list-item { + padding: 0.45rem 0.6rem; + cursor: pointer; + border-radius: 3px; + font-size: 0.82rem; + border: 1px solid transparent; + margin-bottom: 0.2rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.ue-list-item:hover { + background: light-dark(#f0efff, #1a172d); + color: light-dark(var(--light-foreground), var(--dark-foreground)); +} + +.ue-list-item.active { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + font-weight: var(--font-weight-bold); +} + +/* ------------------------------------------------------- + Pill buttons (PromoBuilder) +------------------------------------------------------- */ + +.pill-group { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.pill-btn { + padding: 0.3rem 0.8rem; + border-radius: 20px; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + background: transparent; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.82rem; + font-family: inherit; + cursor: pointer; +} + +.pill-btn::before { + all: unset; +} + +.pill-btn.active { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + background: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); + font-weight: var(--font-weight-bold); +} + +/* PromoBuilder box */ +.promo-builder { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 4px; + padding: 1rem; + margin-bottom: 1.5rem; +} + +.promo-builder-title { + font-size: 0.85rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.promo-builder-subtitle { + font-size: 0.72rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.85rem; +} + +.promo-builder-row { + display: flex; + gap: 1.5rem; + align-items: flex-start; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.promo-builder-field { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.promo-builder-field label { + font-size: 0.72rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.promo-id-preview { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.75rem; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 3px; + font-size: 0.85rem; + font-weight: var(--font-weight-bold); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + min-width: 10rem; + font-family: monospace; +} + +/* ------------------------------------------------------- + Edit student sections +------------------------------------------------------- */ + +.edit-section { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; +} + +.edit-section-title { + font-size: 0.88rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.edit-section-subtitle { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.8rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.form-field label { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.info-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + margin-bottom: 1rem; + font-size: 0.82rem; +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + text-decoration: none; + margin-bottom: 0.5rem; +} + +.back-link:hover { + text-decoration: underline; +} + +/* Info note box */ +.info-note { + padding: 0.75rem 1rem; + border: 1px solid + light-dark(var(--light-accent-color), var(--dark-accent-color)); + border-radius: 4px; + background: light-dark(#f0fff4, #0a1a10); + margin-top: 1.5rem; + font-size: 0.82rem; +} + +.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; +}