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; noteSession2: number | null; }; type Ajustement = { numEtud: number; idUE: number; valeur: number; malus: 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"; } /** 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(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; field: "note" | "noteSession2"; value: string } | null >(null); const [ajustInputs, setAjustInputs] = useState< Record >({}); async function load() { try { const sRes = await fetch(`/students/api/students/${numEtud}`); 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( `/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`, ), fetch("/notes/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]))); } if (ajustRes.ok) { const aj: Ajustement[] = await ajustRes.json(); setAjustements(aj); const inputs: Record = {}; for (const a of aj) { inputs[a.idUE] = { valeur: String(a.valeur), malus: String(a.malus), }; } 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; const val = effectiveNote(n); total += val * um.coeff; coeff += um.coeff; } return coeff > 0 ? total / coeff : null; } 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 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 = { 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(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 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, malus }), }) : await fetch("/notes/api/ajustements", { method: "POST", headers: { "content-type": "application/json" }, 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, 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 a la liste

Recap notes – {student.prenom} {student.nom}

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

{error}

} {ueList.length === 0 ? (

Aucune UE configuree 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); // 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 (
{/* UE header */}

{ue.nom}

{avg !== null && ( Moy. calculee : {fmt(avg)} )} {ajust && ( Ajust. actif : {fmt(ajust.valeur)} )} {ajust && ajust.malus > 0 && ( Malus : -{ajust.malus} )}
{/* Module rows */} {ueMods.length === 0 ? (

Aucun module associe a cette UE pour cette promotion.

) : (
{ueMods.map((um) => { 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; return (
{um.idModule} {nomMod} coef {um.coeff} {/* Session 1 note */} {editingNote?.idModule === um.idModule && editingNote.field === "note" ? (
setEditingNote({ ...editingNote, value: (e.target as HTMLInputElement).value, })} onKeyDown={(e) => { if (e.key === "Enter") { saveNote( um.idModule, "note", editingNote.value, ); } if (e.key === "Escape") { setEditingNote(null); } }} onBlur={() => saveNote( um.idModule, "note", editingNote.value, )} /> /20
) : ( setEditingNote({ idModule: um.idModule, field: "note", value: noteVal !== undefined ? String(noteVal) : "", })} > S1:{" "} {noteVal !== undefined ? fmt(noteVal) : "—/20"} )} {/* Session 2 note */} {editingNote?.idModule === um.idModule && editingNote.field === "noteSession2" ? (
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, )} /> /20
) : ( setEditingNote({ idModule: um.idModule, field: "noteSession2", value: noteS2 != null ? String(noteS2) : "", })} > S2: {noteS2 != null ? fmt(noteS2) : "—"} )} {/* Effective note indicator */} {noteS2 != null && ( → {fmt(effective!)} )}
); })}
)} {/* Ajustement + Malus */}

Ajustement de la moyenne UE

La valeur remplace la moyenne calculee. Le malus est soustrait.

Val: setAjustInputs((prev) => ({ ...prev, [ue.id]: { valeur: (e.target as HTMLInputElement).value, malus: prev[ue.id]?.malus ?? "0", }, }))} /> /20
Malus: setAjustInputs((prev) => ({ ...prev, [ue.id]: { valeur: prev[ue.id]?.valeur ?? "", malus: (e.target as HTMLInputElement).value, }, }))} />
{ajust && ( <> Affiche : {fmt(ajust.valeur)} {ajust.malus > 0 ? ` - ${ajust.malus} = ${ fmt(ajust.valeur - ajust.malus) }` : ""} {avg !== null ? ` (calculee : ${fmt(avg)})` : ""} )}
); })}
); }