,
+) {
+ return ;
}
+export { Overview as Page };
export const config = getPartialsConfig();
-export default makePartials(Mobility);
+export default makePartials(Overview);
diff --git a/routes/(apps)/notes/(_islands)/ImportNotes.tsx b/routes/(apps)/notes/(_islands)/ImportNotes.tsx
index 2490029..a582797 100644
--- a/routes/(apps)/notes/(_islands)/ImportNotes.tsx
+++ b/routes/(apps)/notes/(_islands)/ImportNotes.tsx
@@ -273,9 +273,9 @@ export default function ImportNotes() {
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()),
+ fetch("/notes/api/modules").then((r) => r.json()),
+ fetch("/notes/api/ue-modules").then((r) => r.json()),
+ fetch("/notes/api/ues").then((r) => r.json()),
]).then(
([
studentsData,
@@ -450,7 +450,10 @@ export default function ImportNotes() {
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 blob = new Blob([buf], {
+ type:
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -600,6 +603,8 @@ export default function ImportNotes() {
>
Telecharger Modele
+ {
+ /* TODO: fix blob download in Fresh
+ */
+ }
diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx
index af24da8..de9ec39 100644
--- a/routes/(apps)/notes/(_islands)/NoteRecap.tsx
+++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx
@@ -66,11 +66,11 @@ export default function NoteRecap({ numEtud }: Props) {
setStudent(s);
const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([
- fetch("/admin/api/ues"),
+ fetch("/notes/api/ues"),
fetch(
- `/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
+ `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
),
- fetch("/admin/api/modules"),
+ fetch("/notes/api/modules"),
fetch(`/notes/api/notes?numEtud=${numEtud}`),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]);
diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx
index 6dcbf7e..326d6e7 100644
--- a/routes/(apps)/notes/(_islands)/NotesView.tsx
+++ b/routes/(apps)/notes/(_islands)/NotesView.tsx
@@ -62,9 +62,9 @@ export default function NotesView({ numEtud, prenom }: Props) {
try {
const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([
fetch(`/notes/api/notes?numEtud=${numEtud}`),
- fetch("/admin/api/ues"),
- fetch("/admin/api/ue-modules"),
- fetch("/admin/api/modules"),
+ fetch("/notes/api/ues"),
+ fetch("/notes/api/ue-modules"),
+ fetch("/notes/api/modules"),
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
]);
diff --git a/routes/(apps)/notes/[slug].tsx b/routes/(apps)/notes/[slug].tsx
new file mode 100644
index 0000000..9b29f17
--- /dev/null
+++ b/routes/(apps)/notes/[slug].tsx
@@ -0,0 +1,2 @@
+import makeSlug from "$root/defaults/makeSlug.ts";
+export default makeSlug(import.meta.dirname!);
diff --git a/routes/(apps)/notes/api/modules.ts b/routes/(apps)/notes/api/modules.ts
new file mode 100644
index 0000000..3333369
--- /dev/null
+++ b/routes/(apps)/notes/api/modules.ts
@@ -0,0 +1,12 @@
+import { Handlers } from "$fresh/server.ts";
+import { db } from "$root/databases/db.ts";
+import { modules } from "$root/databases/schema.ts";
+
+export const handler: Handlers = {
+ async GET() {
+ const rows = await db.select().from(modules);
+ return new Response(JSON.stringify(rows), {
+ headers: { "content-type": "application/json" },
+ });
+ },
+};
diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts
new file mode 100644
index 0000000..08e3a11
--- /dev/null
+++ b/routes/(apps)/notes/api/ue-modules.ts
@@ -0,0 +1,28 @@
+import { Handlers } from "$fresh/server.ts";
+import { db } from "$root/databases/db.ts";
+import { ueModules } from "$root/databases/schema.ts";
+import { and, eq } from "npm:drizzle-orm@0.45.2";
+
+export const handler: Handlers = {
+ async GET(request) {
+ const url = new URL(request.url);
+ const idPromo = url.searchParams.get("idPromo");
+ const idUEParam = url.searchParams.get("idUE");
+ const idUE = idUEParam ? parseInt(idUEParam) : null;
+
+ if (idUEParam && isNaN(idUE!)) {
+ return new Response("Paramètre idUE invalide", { status: 400 });
+ }
+
+ const rows = await db.select().from(ueModules).where(
+ and(
+ idPromo ? eq(ueModules.idPromo, idPromo) : undefined,
+ idUE ? eq(ueModules.idUE, idUE) : undefined,
+ ),
+ );
+
+ return new Response(JSON.stringify(rows), {
+ headers: { "content-type": "application/json" },
+ });
+ },
+};
diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts
new file mode 100644
index 0000000..09230a9
--- /dev/null
+++ b/routes/(apps)/notes/api/ues.ts
@@ -0,0 +1,12 @@
+import { Handlers } from "$fresh/server.ts";
+import { db } from "$root/databases/db.ts";
+import { ues } from "$root/databases/schema.ts";
+
+export const handler: Handlers = {
+ async GET() {
+ const rows = await db.select().from(ues);
+ return new Response(JSON.stringify(rows), {
+ headers: { "content-type": "application/json" },
+ });
+ },
+};
diff --git a/routes/(apps)/notes/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx
index 0ec8ebe..6f9f8ba 100644
--- a/routes/(apps)/notes/partials/(admin)/courses.tsx
+++ b/routes/(apps)/notes/partials/(admin)/courses.tsx
@@ -11,5 +11,6 @@ async function Courses(_request: Request, _context: FreshContext) {
return ;
}
+export { Courses as Page };
export const config = getPartialsConfig();
export default makePartials(Courses);
diff --git a/routes/(apps)/notes/partials/(admin)/import.tsx b/routes/(apps)/notes/partials/(admin)/import.tsx
index 111edf0..3f56e2d 100644
--- a/routes/(apps)/notes/partials/(admin)/import.tsx
+++ b/routes/(apps)/notes/partials/(admin)/import.tsx
@@ -19,5 +19,6 @@ async function ImportNotesPage(
);
}
+export { ImportNotesPage as Page };
export const config = getPartialsConfig();
export default makePartials(ImportNotesPage);
diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx
index ec2e5d8..de9e686 100644
--- a/routes/(apps)/notes/partials/notes.tsx
+++ b/routes/(apps)/notes/partials/notes.tsx
@@ -54,5 +54,6 @@ async function Notes(
);
}
+export { Notes as Page };
export const config = getPartialsConfig();
export default makePartials(Notes);
diff --git a/routes/(apps)/stages/(_islands)/StagesOverview.tsx b/routes/(apps)/stages/(_islands)/StagesOverview.tsx
new file mode 100644
index 0000000..60b0a7e
--- /dev/null
+++ b/routes/(apps)/stages/(_islands)/StagesOverview.tsx
@@ -0,0 +1,542 @@
+import { useEffect, useState } from "preact/hooks";
+
+type Student = {
+ numEtud: number;
+ nom: string;
+ prenom: string;
+ idPromo: string;
+};
+type Promotion = { id: string; annee: string };
+type Stage = {
+ id: number;
+ numEtud: number;
+ duree: number;
+ nomEntreprise: string;
+ mission: string | null;
+};
+
+const REQUIRED_WEEKS = 40;
+
+export default function StagesOverview() {
+ const [students, setStudents] = useState([]);
+ const [promos, setPromos] = useState([]);
+ const [stagesList, setStagesList] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const [filterPromo, setFilterPromo] = useState("");
+ const [filterNom, setFilterNom] = useState("");
+
+ // Detail view state
+ const [detailStudent, setDetailStudent] = useState(null);
+ const [editingStage, setEditingStage] = useState(null);
+ const [showAddForm, setShowAddForm] = useState(false);
+
+ async function load() {
+ try {
+ const [sRes, pRes, stRes] = await Promise.all([
+ fetch("/students/api/students"),
+ fetch("/students/api/promotions"),
+ fetch("/stages/api/stages"),
+ ]);
+ if (!sRes.ok) throw new Error("Impossible de charger les données");
+ const [sData, pData, stData] = await Promise.all([
+ sRes.json(),
+ pRes.ok ? pRes.json() : [],
+ stRes.ok ? stRes.json() : [],
+ ]);
+ setStudents(sData);
+ setPromos(pData);
+ setStagesList(stData);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Erreur");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ if (detailStudent) {
+ return (
+ s.numEtud === detailStudent.numEtud)}
+ allStages={stagesList}
+ editingStage={editingStage}
+ setEditingStage={setEditingStage}
+ showAddForm={showAddForm}
+ setShowAddForm={setShowAddForm}
+ onBack={() => {
+ setDetailStudent(null);
+ setEditingStage(null);
+ setShowAddForm(false);
+ }}
+ onReload={load}
+ />
+ );
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+ if (error) {
+ return (
+
+ );
+ }
+
+ const filtered = students.filter((s) => {
+ const matchPromo = !filterPromo || s.idPromo === filterPromo;
+ const matchNom = !filterNom ||
+ `${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase());
+ return matchPromo && matchNom;
+ });
+
+ const stagesByStudent = (numEtud: number) =>
+ stagesList.filter((s) => s.numEtud === numEtud);
+
+ return (
+
+
Suivi des stages
+
+
+
+ setFilterNom((e.target as HTMLInputElement).value)}
+ />
+
+
+
setDetailStudent(s)}
+ />
+
+ );
+}
+
+// ─── Liste View ─────────────────────────────────────────────
+
+function ListView(
+ { students, stagesByStudent, onConsult }: {
+ students: Student[];
+ stagesByStudent: (n: number) => Stage[];
+ onConsult: (s: Student) => void;
+ },
+) {
+ return (
+
+
+
+
+ | N° étud. |
+ Nom |
+ Prénom |
+ Semaines |
+ Actions |
+
+
+
+ {students.length === 0
+ ? (
+
+ | Aucun élève trouvé |
+
+ )
+ : students.map((s) => {
+ const stages = stagesByStudent(s.numEtud);
+ const weeks = stages.reduce((sum, st) => sum + st.duree, 0);
+ const ok = weeks >= REQUIRED_WEEKS;
+ return (
+
+ | {s.numEtud} |
+ {s.nom} |
+ {s.prenom} |
+
+
+ {weeks}/{REQUIRED_WEEKS}
+
+ |
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
+
+// ─── Detail View ────────────────────────────────────────────
+
+function DetailView(
+ {
+ student,
+ stages,
+ allStages,
+ editingStage,
+ setEditingStage,
+ showAddForm,
+ setShowAddForm,
+ onBack,
+ onReload,
+ }: {
+ student: Student;
+ stages: Stage[];
+ allStages: Stage[];
+ editingStage: Stage | null;
+ setEditingStage: (s: Stage | null) => void;
+ showAddForm: boolean;
+ setShowAddForm: (v: boolean) => void;
+ onBack: () => void;
+ onReload: () => Promise;
+ },
+) {
+ const weeks = stages.reduce((sum, s) => sum + s.duree, 0);
+ const entreprises = [
+ ...new Set(allStages.map((s) => s.nomEntreprise).filter(Boolean)),
+ ];
+
+ async function deleteStage(id: number) {
+ if (!confirm("Supprimer ce stage ?")) return;
+ await fetch(`/stages/api/stages/${id}`, { method: "DELETE" });
+ await onReload();
+ }
+
+ return (
+
+
+
+ Consulter : {student.prenom} {student.nom}
+ = REQUIRED_WEEKS ? "#22c55e" : "#dc2626",
+ fontFamily: "monospace",
+ }}
+ >
+ {weeks}/{REQUIRED_WEEKS} semaines
+
+
+
+ {stages.length === 0 && (
+
+ Aucun stage enregistré.
+
+ )}
+
+ {stages.map((stage, i) => {
+ const isEditing = editingStage?.id === stage.id;
+
+ if (isEditing) {
+ return (
+
setEditingStage(null)}
+ onSave={async () => {
+ setEditingStage(null);
+ await onReload();
+ }}
+ />
+ );
+ }
+
+ return (
+
+
+
+
+ Entreprise : {stage.nomEntreprise}
+ {stage.mission && — {stage.mission}}
+
+
+
+
+
+
+
+ );
+ })}
+
+ {showAddForm
+ ? (
+ setShowAddForm(false)}
+ onSave={async () => {
+ setShowAddForm(false);
+ await onReload();
+ }}
+ />
+ )
+ : (
+
+ )}
+
+ );
+}
+
+// ─── Inline forms ───────────────────────────────────────────
+
+function StageEditForm(
+ { stage, entreprises, onCancel, onSave }: {
+ stage: Stage;
+ entreprises: string[];
+ onCancel: () => void;
+ onSave: () => Promise;
+ },
+) {
+ const [duree, setDuree] = useState(String(stage.duree));
+ const [nomEntreprise, setNomEntreprise] = useState(stage.nomEntreprise);
+ const [mission, setMission] = useState(stage.mission ?? "");
+ const [busy, setBusy] = useState(false);
+
+ async function submit() {
+ setBusy(true);
+ try {
+ const res = await fetch(`/stages/api/stages/${stage.id}`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ duree: parseInt(duree),
+ nomEntreprise,
+ mission: mission || null,
+ }),
+ });
+ if (!res.ok) throw new Error("Erreur");
+ await onSave();
+ } catch {
+ alert("Erreur lors de la modification");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
Modifier le stage #{stage.id}
+
+
+
+
+
+
+ );
+}
+
+function StageAddForm(
+ { numEtud, entreprises, onCancel, onSave }: {
+ numEtud: number;
+ entreprises: string[];
+ onCancel: () => void;
+ onSave: () => Promise;
+ },
+) {
+ const [duree, setDuree] = useState("4");
+ const [nomEntreprise, setNomEntreprise] = useState("");
+ const [mission, setMission] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ async function submit() {
+ setBusy(true);
+ try {
+ const res = await fetch("/stages/api/stages", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ numEtud,
+ duree: parseInt(duree),
+ nomEntreprise,
+ mission: mission || null,
+ }),
+ });
+ if (!res.ok) throw new Error("Erreur");
+ await onSave();
+ } catch {
+ alert("Erreur lors de la création");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
Nouveau stage
+
+
+
+
+
+
+ );
+}
diff --git a/routes/(apps)/stages/(_props)/props.ts b/routes/(apps)/stages/(_props)/props.ts
new file mode 100644
index 0000000..feffd2b
--- /dev/null
+++ b/routes/(apps)/stages/(_props)/props.ts
@@ -0,0 +1,15 @@
+import { AppProperties } from "$root/defaults/interfaces.ts";
+
+const properties: AppProperties = {
+ name: "Stages",
+ icon: "work",
+ pages: {
+ index: "Accueil",
+ overview: "Suivi des stages",
+ },
+ adminOnly: ["overview"],
+ employeeOnly: true,
+ hint: "Suivi des stages et semaines",
+};
+
+export default properties;
diff --git a/routes/(apps)/stages/[slug].tsx b/routes/(apps)/stages/[slug].tsx
new file mode 100644
index 0000000..9b29f17
--- /dev/null
+++ b/routes/(apps)/stages/[slug].tsx
@@ -0,0 +1,2 @@
+import makeSlug from "$root/defaults/makeSlug.ts";
+export default makeSlug(import.meta.dirname!);
diff --git a/routes/(apps)/stages/api/stages.ts b/routes/(apps)/stages/api/stages.ts
new file mode 100644
index 0000000..602381c
--- /dev/null
+++ b/routes/(apps)/stages/api/stages.ts
@@ -0,0 +1,84 @@
+import { FreshContext, Handlers } from "$fresh/server.ts";
+import { db } from "$root/databases/db.ts";
+import { stages } from "$root/databases/schema.ts";
+import { AuthenticatedState } from "$root/defaults/interfaces.ts";
+import { eq } from "npm:drizzle-orm@0.45.2";
+
+export const handler: Handlers = {
+ // GET /stages — list all, optional ?numEtud filter
+ async GET(request) {
+ try {
+ const url = new URL(request.url);
+ const numEtudParam = url.searchParams.get("numEtud");
+
+ let query = db.select().from(stages).$dynamic();
+
+ if (numEtudParam) {
+ const numEtud = parseInt(numEtudParam);
+ if (isNaN(numEtud)) {
+ return new Response("Paramètre numEtud invalide", { status: 400 });
+ }
+ query = query.where(eq(stages.numEtud, numEtud));
+ }
+
+ const result = await query;
+
+ return new Response(JSON.stringify(result), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ } catch (error) {
+ console.error("Error fetching stages:", error);
+ return new Response("Failed to fetch data", { status: 500 });
+ }
+ },
+
+ // POST /stages — create stage (employee only)
+ async POST(
+ request: Request,
+ context: FreshContext,
+ ): Promise {
+ if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
+ return new Response(null, { status: 403 });
+ }
+
+ try {
+ const body = await request.json();
+ const { numEtud, duree, nomEntreprise, mission } = body;
+
+ if (!numEtud || duree === undefined || !nomEntreprise) {
+ return new Response(
+ JSON.stringify({
+ error: "Champs requis: numEtud, duree, nomEntreprise",
+ }),
+ { status: 400, headers: { "content-type": "application/json" } },
+ );
+ }
+
+ if (!Number.isInteger(duree) || duree < 1) {
+ return new Response(
+ JSON.stringify({ error: "duree doit être un entier >= 1" }),
+ { status: 400, headers: { "content-type": "application/json" } },
+ );
+ }
+
+ const [created] = await db
+ .insert(stages)
+ .values({
+ numEtud,
+ duree,
+ nomEntreprise,
+ mission: mission ?? null,
+ })
+ .returning();
+
+ return new Response(JSON.stringify(created), {
+ status: 201,
+ headers: { "content-type": "application/json" },
+ });
+ } catch (error) {
+ console.error("Error creating stage:", error);
+ return new Response("Failed to create stage", { status: 500 });
+ }
+ },
+};
diff --git a/routes/(apps)/stages/api/stages/[idStage].ts b/routes/(apps)/stages/api/stages/[idStage].ts
new file mode 100644
index 0000000..2fea148
--- /dev/null
+++ b/routes/(apps)/stages/api/stages/[idStage].ts
@@ -0,0 +1,122 @@
+import { FreshContext, Handlers } from "$fresh/server.ts";
+import { db } from "$root/databases/db.ts";
+import { mobilites, stages } from "$root/databases/schema.ts";
+import { AuthenticatedState } from "$root/defaults/interfaces.ts";
+import { eq } from "npm:drizzle-orm@0.45.2";
+
+const NOT_FOUND = () =>
+ new Response(
+ JSON.stringify({ error: "Stage introuvable" }),
+ { status: 404, headers: { "content-type": "application/json" } },
+ );
+
+const FORBIDDEN = () => new Response(null, { status: 403 });
+
+export const handler: Handlers = {
+ // GET /stages/:idStage
+ async GET(
+ _request: Request,
+ context: FreshContext,
+ ): Promise {
+ const idStage = Number(context.params.idStage);
+ if (isNaN(idStage)) {
+ return new Response("Paramètre idStage invalide", { status: 400 });
+ }
+
+ const row = await db
+ .select()
+ .from(stages)
+ .where(eq(stages.id, idStage))
+ .then((rows) => rows[0] ?? null);
+
+ if (!row) return NOT_FOUND();
+
+ return new Response(JSON.stringify(row), {
+ headers: { "content-type": "application/json" },
+ });
+ },
+
+ // PUT /stages/:idStage (employee only)
+ async PUT(
+ request: Request,
+ context: FreshContext,
+ ): Promise {
+ if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
+ return FORBIDDEN();
+ }
+
+ const idStage = Number(context.params.idStage);
+ if (isNaN(idStage)) {
+ return new Response("Paramètre idStage invalide", { status: 400 });
+ }
+
+ const body = await request.json();
+ const { duree, nomEntreprise, mission } = body;
+
+ if (duree !== undefined && (!Number.isInteger(duree) || duree < 1)) {
+ return new Response(
+ JSON.stringify({ error: "duree doit être un entier >= 1" }),
+ { status: 400, headers: { "content-type": "application/json" } },
+ );
+ }
+
+ const set: Record = {};
+ if (duree !== undefined) set.duree = duree;
+ if (nomEntreprise !== undefined) set.nomEntreprise = nomEntreprise;
+ if (mission !== undefined) set.mission = mission;
+
+ if (Object.keys(set).length === 0) {
+ return new Response(
+ JSON.stringify({ error: "Au moins un champ à modifier requis" }),
+ { status: 400, headers: { "content-type": "application/json" } },
+ );
+ }
+
+ const [updated] = await db
+ .update(stages)
+ .set(set)
+ .where(eq(stages.id, idStage))
+ .returning();
+
+ if (!updated) return NOT_FOUND();
+
+ // If duration changed and this stage is linked as a mobility, update the mobility too
+ if (duree !== undefined) {
+ await db
+ .update(mobilites)
+ .set({ duree })
+ .where(eq(mobilites.idStage, idStage));
+ }
+
+ return new Response(JSON.stringify(updated), {
+ headers: { "content-type": "application/json" },
+ });
+ },
+
+ // DELETE /stages/:idStage (employee only)
+ async DELETE(
+ _request: Request,
+ context: FreshContext,
+ ): Promise {
+ if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
+ return FORBIDDEN();
+ }
+
+ const idStage = Number(context.params.idStage);
+ if (isNaN(idStage)) {
+ return new Response("Paramètre idStage invalide", { status: 400 });
+ }
+
+ // Remove linked mobilites first (FK constraint)
+ await db.delete(mobilites).where(eq(mobilites.idStage, idStage));
+
+ const [deleted] = await db
+ .delete(stages)
+ .where(eq(stages.id, idStage))
+ .returning();
+
+ if (!deleted) return NOT_FOUND();
+
+ return new Response(null, { status: 204 });
+ },
+};
diff --git a/routes/(apps)/stages/index.tsx b/routes/(apps)/stages/index.tsx
new file mode 100644
index 0000000..1d82f7f
--- /dev/null
+++ b/routes/(apps)/stages/index.tsx
@@ -0,0 +1,2 @@
+import makeIndex from "$root/defaults/makeIndex.ts";
+export default makeIndex(import.meta.dirname!);
diff --git a/routes/(apps)/stages/partials/index.tsx b/routes/(apps)/stages/partials/index.tsx
new file mode 100644
index 0000000..cfbf369
--- /dev/null
+++ b/routes/(apps)/stages/partials/index.tsx
@@ -0,0 +1,30 @@
+import {
+ getPartialsConfig,
+ makePartials,
+} from "$root/defaults/makePartials.tsx";
+import { FreshContext } from "$fresh/server.ts";
+import { State } from "$root/defaults/interfaces.ts";
+
+// deno-lint-ignore require-await
+export async function Index(
+ _request: Request,
+ context: FreshContext,
+) {
+ return (
+
+
Stages
+
+ Bienvenue{" "}
+
+ {(context.state as unknown as { session: Record })
+ .session.displayName}
+
+ .
+
+
Suivi des stages : 40 semaines requises par élève.
+
+ );
+}
+
+export const config = getPartialsConfig();
+export default makePartials(Index);
diff --git a/routes/(apps)/stages/partials/overview.tsx b/routes/(apps)/stages/partials/overview.tsx
new file mode 100644
index 0000000..d0d496c
--- /dev/null
+++ b/routes/(apps)/stages/partials/overview.tsx
@@ -0,0 +1,19 @@
+import {
+ getPartialsConfig,
+ makePartials,
+} from "$root/defaults/makePartials.tsx";
+import { FreshContext } from "$fresh/server.ts";
+import { State } from "$root/defaults/interfaces.ts";
+import StagesOverview from "../(_islands)/StagesOverview.tsx";
+
+// deno-lint-ignore require-await
+async function Overview(
+ _request: Request,
+ _context: FreshContext,
+) {
+ return ;
+}
+
+export { Overview as Page };
+export const config = getPartialsConfig();
+export default makePartials(Overview);
diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts
index d6b498c..9a503f6 100644
--- a/routes/(apps)/students/(_props)/props.ts
+++ b/routes/(apps)/students/(_props)/props.ts
@@ -9,6 +9,7 @@ const properties: AppProperties = {
upload: "Import xlsx",
},
adminOnly: ["consult", "upload"],
+ employeeOnly: true,
hint: "Create students promotion and see informations",
};
diff --git a/routes/(apps)/students/[slug].tsx b/routes/(apps)/students/[slug].tsx
new file mode 100644
index 0000000..9b29f17
--- /dev/null
+++ b/routes/(apps)/students/[slug].tsx
@@ -0,0 +1,2 @@
+import makeSlug from "$root/defaults/makeSlug.ts";
+export default makeSlug(import.meta.dirname!);
diff --git a/routes/(apps)/students/api/students/[numEtud].ts b/routes/(apps)/students/api/students/[numEtud].ts
index ce0f2d3..6d2c0e6 100644
--- a/routes/(apps)/students/api/students/[numEtud].ts
+++ b/routes/(apps)/students/api/students/[numEtud].ts
@@ -2,8 +2,9 @@ import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import {
ajustements,
- mobility,
+ mobilites,
notes,
+ stages,
students,
} from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
@@ -80,7 +81,7 @@ export const handler: Handlers = {
},
// #12 DELETE /students/{numEtud}
- // Cascade: deletes notes, ajustements, mobility for this student.
+ // Cascade: deletes notes, ajustements, mobilites, stages for this student.
async DELETE(
_request: Request,
context: FreshContext,
@@ -102,7 +103,8 @@ export const handler: Handlers = {
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(mobilites).where(eq(mobilites.numEtud, numEtud));
+ await tx.delete(stages).where(eq(stages.numEtud, numEtud));
await tx.delete(students).where(eq(students.numEtud, numEtud));
});
diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx
index 4c81c71..2adaaa4 100644
--- a/routes/(apps)/students/partials/(admin)/consult.tsx
+++ b/routes/(apps)/students/partials/(admin)/consult.tsx
@@ -11,5 +11,6 @@ async function Students(_request: Request, _context: FreshContext) {
return ;
}
+export { Students as Page };
export const config = getPartialsConfig();
export default makePartials(Students);
diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx
index 578d830..ca1b847 100644
--- a/routes/(apps)/students/partials/(admin)/upload.tsx
+++ b/routes/(apps)/students/partials/(admin)/upload.tsx
@@ -16,5 +16,6 @@ async function Students(_request: Request, _context: FreshContext) {
);
}
+export { Students as Page };
export const config = getPartialsConfig();
export default makePartials(Students);
diff --git a/routes/_app.tsx b/routes/_app.tsx
index 81187c3..77ba7c3 100644
--- a/routes/_app.tsx
+++ b/routes/_app.tsx
@@ -29,6 +29,7 @@ export default async function App(
+
diff --git a/routes/apps.tsx b/routes/apps.tsx
index d64cabb..067798d 100644
--- a/routes/apps.tsx
+++ b/routes/apps.tsx
@@ -44,9 +44,20 @@ export default async function Apps(
_request: Request,
context: FreshContext>,
) {
+ let visibleApps = context.data;
+
+ if (
+ context.state.isAuthenticated &&
+ context.state.session.eduPersonPrimaryAffiliation === "student"
+ ) {
+ visibleApps = Object.fromEntries(
+ Object.entries(context.data).filter(([_, app]) => !app.employeeOnly),
+ );
+ }
+
return (
<>
-
+
>
);
}
diff --git a/routes/index.tsx b/routes/index.tsx
index f92dc1b..b16caea 100644
--- a/routes/index.tsx
+++ b/routes/index.tsx
@@ -1,13 +1,28 @@
-import { FreshContext } from "$fresh/server.ts";
+import { FreshContext, Handlers } from "$fresh/server.ts";
+import { State } from "$root/defaults/interfaces.ts";
-// deno-lint-ignore require-await
-export default async function Home(_request: Request, _context: FreshContext) {
+export const handler: Handlers = {
+ GET(_request: Request, context: FreshContext) {
+ if (context.state.isAuthenticated) {
+ return new Response(null, {
+ status: 302,
+ headers: { Location: "/apps" },
+ });
+ }
+ return context.render();
+ },
+};
+
+export default function Home() {
return (
<>
PolyMPR
The ultimate HR platform
+
+ Se connecter
+
>
);
}
diff --git a/scripts/generate-templates.ts b/scripts/generate-templates.ts
index ab2f3bc..5245a7c 100644
--- a/scripts/generate-templates.ts
+++ b/scripts/generate-templates.ts
@@ -5,7 +5,12 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
{
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([
- [null, null, null, "Promotion peut etre vide mais doit prealablement Exister"],
+ [
+ null,
+ null,
+ null,
+ "Promotion peut etre vide mais doit prealablement Exister",
+ ],
["Nom", "Prenom", "Numero-etudiant", "Promotion"],
["NOM", "PRENOM", 12345678, "3AFISE24-25"],
]);
@@ -38,8 +43,26 @@ import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
{
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"],
+ [
+ "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],
diff --git a/scripts/inspect-maquette.ts b/scripts/inspect-maquette.ts
index 0dd3dce..b96865f 100644
--- a/scripts/inspect-maquette.ts
+++ b/scripts/inspect-maquette.ts
@@ -9,7 +9,9 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) {
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 });
+ 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];
@@ -17,7 +19,9 @@ for (const file of ["FISE-INFO-2025.xlsx", "FISA-INFO-2025.xlsx"]) {
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(" | ");
+ const preview = row.slice(0, 6).map((c) =>
+ c != null ? String(c).substring(0, 25) : ""
+ ).join(" | ");
console.log(` [${i}] ${preview}`);
}
}
diff --git a/static/theme.js b/static/theme.js
new file mode 100644
index 0000000..af947af
--- /dev/null
+++ b/static/theme.js
@@ -0,0 +1,29 @@
+(function () {
+ var t = localStorage.getItem("theme");
+ if (t) document.documentElement.style.colorScheme = t;
+
+ document.addEventListener("click", function (e) {
+ var btn = e.target.closest("#theme-toggle");
+ if (!btn) return;
+ var cs = getComputedStyle(document.documentElement).colorScheme;
+ var isDark = cs === "dark" ||
+ (!cs || cs === "light dark") &&
+ matchMedia("(prefers-color-scheme:dark)").matches;
+ var next = isDark ? "light" : "dark";
+ document.documentElement.style.colorScheme = next;
+ localStorage.setItem("theme", next);
+ btn.querySelector("span").textContent = next === "dark"
+ ? "light_mode"
+ : "dark_mode";
+ });
+
+ document.addEventListener("DOMContentLoaded", function () {
+ var btn = document.getElementById("theme-toggle");
+ if (!btn) return;
+ var cs = getComputedStyle(document.documentElement).colorScheme;
+ var isDark = cs === "dark" ||
+ (!cs || cs === "light dark") &&
+ matchMedia("(prefers-color-scheme:dark)").matches;
+ btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode";
+ });
+})();
diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts
index 2a571bf..be102db 100644
--- a/tests/helpers/db_integration.ts
+++ b/tests/helpers/db_integration.ts
@@ -26,7 +26,7 @@ export const testPool = createTestPool();
export const testDb = drizzle(testPool, { schema });
const ALL_TABLES =
- '"mobility","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"';
+ '"mobilites","stages","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"';
/**
* Vide toutes les tables dans le bon ordre.