From 2c5e4ebf112d7d26d31b1e02fc9f5decac86cd5c Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Mon, 27 Apr 2026 18:22:23 +0200 Subject: [PATCH] feat(fresh.gen.ts): add routes for notes edition, recap and island recap feat(notes): add NoteRecap island component for student grade recap feat: add adjust controls to UI component Add placeholder, value binding, onInput handler, apply/reset buttons, and display of adjusted value. feat(notes): add edition and recap pages, update styles and links --- fresh.gen.ts | 8 + routes/(apps)/notes/(_islands)/NoteRecap.tsx | 385 +++++++++++++++++++ routes/(apps)/notes/edition/[numEtud].tsx | 12 + routes/(apps)/notes/recap/[numEtud].tsx | 12 + routes/_app.tsx | 6 +- static/styles/ui.css | 90 +++++ 6 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 routes/(apps)/notes/(_islands)/NoteRecap.tsx create mode 100644 routes/(apps)/notes/edition/[numEtud].tsx create mode 100644 routes/(apps)/notes/recap/[numEtud].tsx diff --git a/fresh.gen.ts b/fresh.gen.ts index f19d57b..22cab59 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -34,7 +34,9 @@ import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modul import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; import * as $_apps_notes_api_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_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"; @@ -74,6 +76,7 @@ import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_ 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"; @@ -128,7 +131,10 @@ const manifest = { $_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/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": @@ -193,6 +199,8 @@ const manifest = { $_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": diff --git a/routes/(apps)/notes/(_islands)/NoteRecap.tsx b/routes/(apps)/notes/(_islands)/NoteRecap.tsx new file mode 100644 index 0000000..5ee4618 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/NoteRecap.tsx @@ -0,0 +1,385 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { numEtud: number; nom: string; prenom: string; idPromo: string }; +type UE = { id: number; nom: string }; +type UEModule = { idModule: string; idUE: number; idPromo: string; coeff: number }; +type Module = { id: string; nom: string }; +type Note = { numEtud: number; idModule: string; note: number }; +type Ajustement = { numEtud: number; idUE: number; valeur: number }; + +type Props = { numEtud: number }; + +function fmt(n: number): string { + return `${Math.round(n * 10) / 10}/20`; +} + +function noteClass(n: number): string { + return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail"; +} + +export default function NoteRecap({ numEtud }: Props) { + const [student, setStudent] = useState(null); + const [ueList, setUeList] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [moduleMap, setModuleMap] = useState>(new Map()); + const [noteMap, setNoteMap] = useState>(new Map()); + const [ajustements, setAjustements] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingNote, setEditingNote] = useState< + { idModule: string; value: string } | null + >(null); + const [ajustInputs, setAjustInputs] = useState>({}); + + async function load() { + try { + const sRes = await fetch(`/students/api/students/${numEtud}`); + if (!sRes.ok) throw new Error("Élève introuvable"); + const s: Student = await sRes.json(); + setStudent(s); + + const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([ + fetch("/notes/api/ues"), + fetch( + `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, + ), + fetch("/admin/api/modules"), + fetch(`/notes/api/notes?numEtud=${numEtud}`), + fetch(`/notes/api/ajustements?numEtud=${numEtud}`), + ]); + + if (uesRes.ok) setUeList(await uesRes.json()); + if (umRes.ok) setUeModules(await umRes.json()); + if (mRes.ok) { + const mods: Module[] = await mRes.json(); + setModuleMap(new Map(mods.map((m) => [m.id, m.nom]))); + } + if (notesRes.ok) { + const ns: Note[] = await notesRes.json(); + setNoteMap(new Map(ns.map((n) => [n.idModule, n.note]))); + } + if (ajustRes.ok) { + const aj: Ajustement[] = await ajustRes.json(); + setAjustements(aj); + const inputs: Record = {}; + for (const a of aj) inputs[a.idUE] = String(a.valeur); + setAjustInputs(inputs); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, [numEtud]); + + function calcAvg(ueMods: UEModule[]): number | null { + let total = 0, coeff = 0; + for (const um of ueMods) { + const n = noteMap.get(um.idModule); + if (n === undefined) return null; + total += n * um.coeff; + coeff += um.coeff; + } + return coeff > 0 ? total / coeff : null; + } + + async function saveNote(idModule: string, value: string) { + const note = parseFloat(value.replace(",", ".")); + if (isNaN(note) || note < 0 || note > 20) { + setEditingNote(null); + return; + } + const res = await fetch( + `/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ note }), + }, + ); + if (res.ok) { + const updated: Note = await res.json(); + setNoteMap((prev) => new Map(prev).set(idModule, updated.note)); + } + setEditingNote(null); + } + + async function applyAjust(idUE: number) { + const val = parseFloat((ajustInputs[idUE] ?? "").replace(",", ".")); + if (isNaN(val) || val < 0 || val > 20) return; + const existing = ajustements.find((a) => a.idUE === idUE); + const res = existing + ? await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ valeur: val }), + }) + : await fetch("/notes/api/ajustements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ numEtud, idUE, valeur: val }), + }); + if (res.ok) { + const updated: Ajustement = await res.json(); + setAjustements((prev) => + existing + ? prev.map((a) => a.idUE === idUE ? updated : a) + : [...prev, updated] + ); + } + } + + async function resetAjust(idUE: number) { + const res = await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, { + method: "DELETE", + }); + if (res.ok) { + setAjustements((prev) => prev.filter((a) => a.idUE !== idUE)); + setAjustInputs((prev) => { + const c = { ...prev }; + delete c[idUE]; + return c; + }); + } + } + + if (loading) { + return ( +
+

Chargement…

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

{error}

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

+ Récap notes – {student.prenom} {student.nom} +

+ +
+ {student.numEtud} + {student.prenom} {student.nom} + {student.idPromo} +
+ + {error &&

{error}

} + + {ueList.length === 0 + ? ( +

+ Aucune UE configurée pour cette promotion. +

+ ) + : ueList.map((ue) => { + const ueMods = ueModules.filter((um) => um.idUE === ue.id); + const avg = calcAvg(ueMods); + const ajust = ajustements.find((a) => a.idUE === ue.id); + + return ( +
+ {/* UE header */} +
+

{ue.nom}

+ {avg !== null && ( + + Moy. calculée : {fmt(avg)} + + )} + {ajust && ( + + ⚡ Ajust. actif : {fmt(ajust.valeur)} + + )} +
+ + {/* Module rows */} + {ueMods.length === 0 + ? ( +

+ Aucun module associé à cette UE pour cette promotion. +

+ ) + : ( +
+ {ueMods.map((um) => { + const noteVal = noteMap.get(um.idModule); + const nomMod = moduleMap.get(um.idModule) ?? um.idModule; + const isEditing = editingNote?.idModule === um.idModule; + + return ( +
+ + + {um.idModule} + + {nomMod} + + + coef {um.coeff} + + {isEditing + ? ( +
+ + setEditingNote({ + idModule: um.idModule, + value: + (e.target as HTMLInputElement).value, + })} + onKeyDown={(e) => { + if (e.key === "Enter") { + saveNote( + um.idModule, + editingNote!.value, + ); + } + if (e.key === "Escape") { + setEditingNote(null); + } + }} + onBlur={() => + saveNote(um.idModule, editingNote!.value)} + /> + + /20 + +
+ ) + : ( + + setEditingNote({ + idModule: um.idModule, + value: noteVal !== undefined + ? String(noteVal) + : "", + })} + > + {noteVal !== undefined ? fmt(noteVal) : "—/20"} + + )} + +
+ ); + })} +
+ )} + + {/* Ajustement */} +
+

Ajustement de la moyenne UE

+

+ Override ponctuel – laisser vide pour utiliser la moy. + calculée +

+
+
+ + setAjustInputs((prev) => ({ + ...prev, + [ue.id]: (e.target as HTMLInputElement).value, + }))} + /> + /20 +
+ + {ajust && ( + <> + + + Affiché à l'élève : {fmt(ajust.valeur)} + {avg !== null ? ` (calculée : ${fmt(avg)})` : ""} + + + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/routes/(apps)/notes/edition/[numEtud].tsx b/routes/(apps)/notes/edition/[numEtud].tsx new file mode 100644 index 0000000..437d4c4 --- /dev/null +++ b/routes/(apps)/notes/edition/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import NoteRecap from "../(_islands)/NoteRecap.tsx"; + +// deno-lint-ignore require-await +export default async function EditionPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/routes/(apps)/notes/recap/[numEtud].tsx b/routes/(apps)/notes/recap/[numEtud].tsx new file mode 100644 index 0000000..208da0f --- /dev/null +++ b/routes/(apps)/notes/recap/[numEtud].tsx @@ -0,0 +1,12 @@ +import { FreshContext } from "$fresh/server.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import NoteRecap from "../(_islands)/NoteRecap.tsx"; + +// deno-lint-ignore require-await +export default async function RecapPage( + _request: Request, + context: FreshContext, +) { + const numEtud = Number(context.params.numEtud); + return ; +} diff --git a/routes/_app.tsx b/routes/_app.tsx index 8162820..81187c3 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -26,9 +26,9 @@ export default async function App( /> - - - + + +
diff --git a/static/styles/ui.css b/static/styles/ui.css index 88d3080..f43bfc8 100644 --- a/static/styles/ui.css +++ b/static/styles/ui.css @@ -857,6 +857,96 @@ font-size: 0.82rem; } +/* ------------------------------------------------------- + Note recap chips & rows +------------------------------------------------------- */ +.note-chip { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.55rem; + border-radius: 10px; + border: 1px solid currentColor; + font-size: 0.78rem; + font-weight: var(--font-weight-bold); + font-family: monospace; + white-space: nowrap; +} + +.note-chip--ok { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.note-chip--fail { + color: light-dark(#dc2626, #f87171); +} + +.note-chip--none { + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.note-chip--promo { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + background: transparent; +} + +.note-chip--ajust { + color: #f59e0b; +} + +.note-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + flex-wrap: wrap; +} + +.note-row-label { + flex: 1; + min-width: 10rem; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.note-row-chip { + font-size: 0.68rem; + padding: 0.1rem 0.4rem; +} + +.note-row-coef { + font-size: 0.75rem; + white-space: nowrap; +} + +.ajust-section { + margin-top: 0.75rem; + padding-top: 0.65rem; + border-top: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ajust-title { + font-size: 0.78rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.15rem; +} + +.ajust-hint { + font-size: 0.7rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-family: monospace; + margin: 0 0 0.5rem; +} + +/* ------------------------------------------------------- + (end note recap) +------------------------------------------------------- */ + .info-note-dim { font-size: 0.7rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));