feat : fix a lot of stuff
Check Deno code / Check Deno code (pull_request) Failing after 8s
Tests / Unit tests (pull_request) Successful in 13s
Tests / Integration tests (pull_request) Failing after 1m0s

This commit is contained in:
2026-04-30 13:49:47 +02:00
parent 04be659d6b
commit df3957741d
50 changed files with 2664 additions and 437 deletions
+267 -81
View File
@@ -14,8 +14,18 @@ type UEModule = {
coeff: number;
};
type Module = { id: string; nom: string };
type Note = { numEtud: number; idModule: string; note: number };
type Ajustement = { numEtud: number; idUE: number; valeur: number };
type Note = {
numEtud: number;
idModule: string;
note: number;
noteSession2: number | null;
};
type Ajustement = {
numEtud: number;
idUE: number;
valeur: number;
malus: number;
};
type Props = { numEtud: number };
@@ -27,31 +37,38 @@ function noteClass(n: number): string {
return n >= 10 ? "note-chip note-chip--ok" : "note-chip note-chip--fail";
}
/** Returns the effective note (session 2 if exists, otherwise session 1). */
function effectiveNote(n: Note): number {
return n.noteSession2 ?? n.note;
}
export default function NoteRecap({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null);
const [ueList, setUeList] = useState<UE[]>([]);
const [ueModules, setUeModules] = useState<UEModule[]>([]);
const [moduleMap, setModuleMap] = useState<Map<string, string>>(new Map());
const [noteMap, setNoteMap] = useState<Map<string, number>>(new Map());
const [noteMap, setNoteMap] = useState<Map<string, Note>>(new Map());
const [ajustements, setAjustements] = useState<Ajustement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingNote, setEditingNote] = useState<
{ idModule: string; value: string } | null
{ idModule: string; field: "note" | "noteSession2"; value: string } | null
>(null);
const [ajustInputs, setAjustInputs] = useState<Record<number, string>>({});
const [ajustInputs, setAjustInputs] = useState<
Record<number, { valeur: string; malus: string }>
>({});
async function load() {
try {
const sRes = await fetch(`/students/api/students/${numEtud}`);
if (!sRes.ok) throw new Error("Élève introuvable");
if (!sRes.ok) throw new Error("Eleve introuvable");
const s: Student = await sRes.json();
setStudent(s);
const [uesRes, umRes, mRes, notesRes, ajustRes] = await Promise.all([
fetch("/notes/api/ues"),
fetch("/admin/api/ues"),
fetch(
`/notes/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
`/admin/api/ue-modules?idPromo=${encodeURIComponent(s.idPromo)}`,
),
fetch("/admin/api/modules"),
fetch(`/notes/api/notes?numEtud=${numEtud}`),
@@ -66,13 +83,18 @@ export default function NoteRecap({ numEtud }: Props) {
}
if (notesRes.ok) {
const ns: Note[] = await notesRes.json();
setNoteMap(new Map(ns.map((n) => [n.idModule, n.note])));
setNoteMap(new Map(ns.map((n) => [n.idModule, n])));
}
if (ajustRes.ok) {
const aj: Ajustement[] = await ajustRes.json();
setAjustements(aj);
const inputs: Record<number, string> = {};
for (const a of aj) inputs[a.idUE] = String(a.valeur);
const inputs: Record<number, { valeur: string; malus: string }> = {};
for (const a of aj) {
inputs[a.idUE] = {
valeur: String(a.valeur),
malus: String(a.malus),
};
}
setAjustInputs(inputs);
}
} catch (e) {
@@ -87,57 +109,108 @@ export default function NoteRecap({ numEtud }: Props) {
}, [numEtud]);
function calcAvg(ueMods: UEModule[]): number | null {
let total = 0, coeff = 0;
let total = 0,
coeff = 0;
for (const um of ueMods) {
const n = noteMap.get(um.idModule);
if (n === undefined) return null;
total += n * um.coeff;
const val = effectiveNote(n);
total += val * um.coeff;
coeff += um.coeff;
}
return coeff > 0 ? total / coeff : null;
}
async function saveNote(idModule: string, value: string) {
async function saveNote(
idModule: string,
field: "note" | "noteSession2",
value: string,
) {
if (value.trim() === "" && field === "noteSession2") {
// Clear session 2 note
const res = await fetch(
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ noteSession2: null }),
},
);
if (res.ok) {
const updated: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, updated));
}
setEditingNote(null);
return;
}
const note = parseFloat(value.replace(",", "."));
if (isNaN(note) || note < 0 || note > 20) {
setEditingNote(null);
return;
}
const res = await fetch(
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
{
method: "PUT",
const existing = noteMap.get(idModule);
if (existing) {
// Update
const res = await fetch(
`/notes/api/notes/${numEtud}/${encodeURIComponent(idModule)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ [field]: note }),
},
);
if (res.ok) {
const updated: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, updated));
}
} else {
// Create
const body: Record<string, unknown> = {
numEtud,
idModule,
note: field === "note" ? note : 0,
};
if (field === "noteSession2") body.noteSession2 = note;
const res = await fetch("/notes/api/notes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ note }),
},
);
if (res.ok) {
const updated: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, updated.note));
body: JSON.stringify(body),
});
if (res.ok) {
const created: Note = await res.json();
setNoteMap((prev) => new Map(prev).set(idModule, created));
}
}
setEditingNote(null);
}
async function applyAjust(idUE: number) {
const val = parseFloat((ajustInputs[idUE] ?? "").replace(",", "."));
const inputs = ajustInputs[idUE];
const val = parseFloat((inputs?.valeur ?? "").replace(",", "."));
const malus = parseInt(inputs?.malus ?? "0");
if (isNaN(val) || val < 0 || val > 20) return;
if (isNaN(malus) || malus < 0) return;
const existing = ajustements.find((a) => a.idUE === idUE);
const res = existing
? await fetch(`/notes/api/ajustements/${numEtud}/${idUE}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ valeur: val }),
body: JSON.stringify({ valeur: val, malus }),
})
: await fetch("/notes/api/ajustements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ numEtud, idUE, valeur: val }),
body: JSON.stringify({ numEtud, idUE, valeur: val, malus }),
});
if (res.ok) {
const updated: Ajustement = await res.json();
setAjustements((prev) =>
existing
? prev.map((a) => a.idUE === idUE ? updated : a)
? prev.map((a) => (a.idUE === idUE ? updated : a))
: [...prev, updated]
);
}
@@ -160,7 +233,7 @@ export default function NoteRecap({ numEtud }: Props) {
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement</p>
<p class="state-loading">Chargement...</p>
</div>
);
}
@@ -180,19 +253,21 @@ export default function NoteRecap({ numEtud }: Props) {
href="/notes/courses"
f-partial="/notes/partials/courses"
>
Retour à la liste
Retour a la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Récap notes {student.prenom} {student.nom}
Recap notes {student.prenom} {student.nom}
</h2>
<div class="info-bar" style="margin-bottom: 1.25rem">
<span class="numEtud-chip">{student.numEtud}</span>
<span style="font-weight: 600">{student.prenom} {student.nom}</span>
<span style="font-weight: 600">
{student.prenom} {student.nom}
</span>
<span class="note-chip note-chip--promo">{student.idPromo}</span>
</div>
@@ -201,7 +276,7 @@ export default function NoteRecap({ numEtud }: Props) {
{ueList.length === 0
? (
<p class="state-empty">
Aucune UE configurée pour cette promotion.
Aucune UE configuree pour cette promotion.
</p>
)
: ueList.map((ue) => {
@@ -209,14 +284,26 @@ export default function NoteRecap({ numEtud }: Props) {
const avg = calcAvg(ueMods);
const ajust = ajustements.find((a) => a.idUE === ue.id);
// Final displayed average: if ajust.valeur exists it replaces avg, then subtract malus
let finalAvg = avg;
if (ajust) {
finalAvg = ajust.valeur;
if (ajust.malus > 0) {
finalAvg = (finalAvg ?? 0) - ajust.malus;
}
}
return (
<div key={ue.id} class="edit-section">
{/* UE header */}
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap">
<p class="edit-section-title" style="margin: 0">{ue.nom}</p>
{avg !== null && (
<span class={noteClass(avg)} style="font-size: 0.78rem">
Moy. calculée : {fmt(avg)}
<span
class={noteClass(avg)}
style="font-size: 0.78rem"
>
Moy. calculee : {fmt(avg)}
</span>
)}
{ajust && (
@@ -224,7 +311,15 @@ export default function NoteRecap({ numEtud }: Props) {
class="note-chip note-chip--ajust"
style="font-size: 0.78rem"
>
Ajust. actif : {fmt(ajust.valeur)}
Ajust. actif : {fmt(ajust.valeur)}
</span>
)}
{ajust && ajust.malus > 0 && (
<span
class="note-chip note-chip--fail"
style="font-size: 0.78rem"
>
Malus : -{ajust.malus}
</span>
)}
</div>
@@ -236,21 +331,22 @@ export default function NoteRecap({ numEtud }: Props) {
class="col-dim"
style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem"
>
Aucun module associé à cette UE pour cette promotion.
Aucun module associe a cette UE pour cette promotion.
</p>
)
: (
<div style="margin-bottom: 0.75rem">
{ueMods.map((um) => {
const noteVal = noteMap.get(um.idModule);
const noteObj = noteMap.get(um.idModule);
const noteVal = noteObj?.note;
const noteS2 = noteObj?.noteSession2;
const effective = noteObj
? effectiveNote(noteObj)
: undefined;
const nomMod = moduleMap.get(um.idModule) ?? um.idModule;
const isEditing = editingNote?.idModule === um.idModule;
return (
<div
key={um.idModule}
class="note-row"
>
<div key={um.idModule} class="note-row">
<span class="note-row-label">
<span class="numEtud-chip note-row-chip">
{um.idModule}
@@ -260,17 +356,20 @@ export default function NoteRecap({ numEtud }: Props) {
<span class="col-dim note-row-coef">
coef {um.coeff}
</span>
{isEditing
{/* Session 1 note */}
{editingNote?.idModule === um.idModule &&
editingNote.field === "note"
? (
<div style="display: flex; align-items: center; gap: 0.25rem">
<input
class="form-input"
style="width: 5rem; text-align: center; font-size: 0.85rem"
value={editingNote!.value}
value={editingNote.value}
autoFocus
onInput={(e) =>
setEditingNote({
idModule: um.idModule,
...editingNote,
value:
(e.target as HTMLInputElement).value,
})}
@@ -278,7 +377,8 @@ export default function NoteRecap({ numEtud }: Props) {
if (e.key === "Enter") {
saveNote(
um.idModule,
editingNote!.value,
"note",
editingNote.value,
);
}
if (e.key === "Escape") {
@@ -286,7 +386,11 @@ export default function NoteRecap({ numEtud }: Props) {
}
}}
onBlur={() =>
saveNote(um.idModule, editingNote!.value)}
saveNote(
um.idModule,
"note",
editingNote.value,
)}
/>
<span
class="col-dim"
@@ -302,76 +406,153 @@ export default function NoteRecap({ numEtud }: Props) {
? noteClass(noteVal)
: "note-chip note-chip--none"}
style="font-size: 0.78rem; cursor: pointer"
title="Cliquer pour modifier"
title="S1 — Cliquer pour modifier"
onClick={() =>
setEditingNote({
idModule: um.idModule,
field: "note",
value: noteVal !== undefined
? String(noteVal)
: "",
})}
>
S1:{" "}
{noteVal !== undefined ? fmt(noteVal) : "—/20"}
</span>
)}
<button
type="button"
class="btn btn-sm btn-secondary"
style="font-size: 0.75rem"
onClick={() =>
setEditingNote({
idModule: um.idModule,
value: noteVal !== undefined
? String(noteVal)
: "",
})}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
{/* Session 2 note */}
{editingNote?.idModule === um.idModule &&
editingNote.field === "noteSession2"
? (
<div style="display: flex; align-items: center; gap: 0.25rem">
<input
class="form-input"
style="width: 5rem; text-align: center; font-size: 0.85rem"
value={editingNote.value}
autoFocus
placeholder="vide = suppr"
onInput={(e) =>
setEditingNote({
...editingNote,
value:
(e.target as HTMLInputElement).value,
})}
onKeyDown={(e) => {
if (e.key === "Enter") {
saveNote(
um.idModule,
"noteSession2",
editingNote.value,
);
}
if (e.key === "Escape") {
setEditingNote(null);
}
}}
onBlur={() =>
saveNote(
um.idModule,
"noteSession2",
editingNote.value,
)}
/>
<span
class="col-dim"
style="font-size: 0.75rem"
>
/20
</span>
</div>
)
: (
<span
class={noteS2 != null
? noteClass(noteS2)
: "note-chip note-chip--none"}
style="font-size: 0.78rem; cursor: pointer"
title="S2 — Cliquer pour modifier (vide = pas de session 2)"
onClick={() =>
setEditingNote({
idModule: um.idModule,
field: "noteSession2",
value: noteS2 != null ? String(noteS2) : "",
})}
>
S2: {noteS2 != null ? fmt(noteS2) : "—"}
</span>
)}
{/* Effective note indicator */}
{noteS2 != null && (
<span
class="col-dim"
style="font-size: 0.72rem; font-style: italic"
>
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>{" "}
note
</button>
{fmt(effective!)}
</span>
)}
</div>
);
})}
</div>
)}
{/* Ajustement */}
{/* Ajustement + Malus */}
<div class="ajust-section">
<p class="ajust-title">Ajustement de la moyenne UE</p>
<p class="ajust-hint">
Override ponctuel laisser vide pour utiliser la moy.
calculée
La valeur remplace la moyenne calculee. Le malus est
soustrait.
</p>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap">
<div style="display: flex; align-items: center; gap: 0.25rem">
<span class="col-dim" style="font-size: 0.8rem">
Val:
</span>
<input
class="form-input"
style="width: 4.5rem; text-align: center"
placeholder="—"
value={ajustInputs[ue.id] ?? ""}
value={ajustInputs[ue.id]?.valeur ?? ""}
onInput={(e) =>
setAjustInputs((prev) => ({
...prev,
[ue.id]: (e.target as HTMLInputElement).value,
[ue.id]: {
valeur: (e.target as HTMLInputElement).value,
malus: prev[ue.id]?.malus ?? "0",
},
}))}
/>
<span class="col-dim" style="font-size: 0.8rem">/20</span>
</div>
<div style="display: flex; align-items: center; gap: 0.25rem">
<span class="col-dim" style="font-size: 0.8rem">
Malus:
</span>
<input
type="number"
class="form-input"
style="width: 4rem; text-align: center"
placeholder="0"
min="0"
value={ajustInputs[ue.id]?.malus ?? ""}
onInput={(e) =>
setAjustInputs((prev) => ({
...prev,
[ue.id]: {
valeur: prev[ue.id]?.valeur ?? "",
malus: (e.target as HTMLInputElement).value,
},
}))}
/>
</div>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => applyAjust(ue.id)}
>
Appliquer
Appliquer
</button>
{ajust && (
<>
@@ -380,14 +561,19 @@ export default function NoteRecap({ numEtud }: Props) {
class="btn btn-sm btn-secondary"
onClick={() => resetAjust(ue.id)}
>
Réinitialiser
Reinitialiser
</button>
<span
class="col-dim"
style="font-size: 0.75rem; font-family: monospace"
>
Affiché à l'élève : {fmt(ajust.valeur)}
{avg !== null ? ` (calculée : ${fmt(avg)})` : ""}
Affiche : {fmt(ajust.valeur)}
{ajust.malus > 0
? ` - ${ajust.malus} = ${
fmt(ajust.valeur - ajust.malus)
}`
: ""}
{avg !== null ? ` (calculee : ${fmt(avg)})` : ""}
</span>
</>
)}