Files
PolyMPR/routes/(apps)/notes/(_islands)/NoteRecap.tsx
T
djalim 2c5e4ebf11
Check Deno code / Check Deno code (pull_request) Failing after 5s
Tests / Unit tests (pull_request) Successful in 12s
Tests / Integration tests (pull_request) Failing after 1m17s
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
2026-04-27 18:22:23 +02:00

386 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 [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
>(null);
const [ajustInputs, setAjustInputs] = useState<Record<number, string>>({});
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<number, string> = {};
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 (
<div class="page-content">
<p class="state-loading">Chargement</p>
</div>
);
}
if (error && !student) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
if (!student) return null;
return (
<div class="page-content">
<a
class="back-link"
href="/notes/courses"
f-partial="/notes/partials/courses"
>
Retour à la liste
</a>
<h2
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Récap 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 class="note-chip note-chip--promo">{student.idPromo}</span>
</div>
{error && <p class="state-error">{error}</p>}
{ueList.length === 0
? (
<p class="state-empty">
Aucune UE configurée pour cette promotion.
</p>
)
: 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 (
<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>
)}
{ajust && (
<span
class="note-chip note-chip--ajust"
style="font-size: 0.78rem"
>
Ajust. actif : {fmt(ajust.valeur)}
</span>
)}
</div>
{/* Module rows */}
{ueMods.length === 0
? (
<p
class="col-dim"
style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem"
>
Aucun module associé à cette UE pour cette promotion.
</p>
)
: (
<div style="margin-bottom: 0.75rem">
{ueMods.map((um) => {
const noteVal = noteMap.get(um.idModule);
const nomMod = moduleMap.get(um.idModule) ?? um.idModule;
const isEditing = editingNote?.idModule === um.idModule;
return (
<div
key={um.idModule}
class="note-row"
>
<span class="note-row-label">
<span class="numEtud-chip note-row-chip">
{um.idModule}
</span>
{nomMod}
</span>
<span class="col-dim note-row-coef">
coef {um.coeff}
</span>
{isEditing
? (
<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
onInput={(e) =>
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)}
/>
<span
class="col-dim"
style="font-size: 0.75rem"
>
/20
</span>
</div>
)
: (
<span
class={noteVal !== undefined
? noteClass(noteVal)
: "note-chip note-chip--none"}
style="font-size: 0.78rem; cursor: pointer"
title="Cliquer pour modifier"
onClick={() =>
setEditingNote({
idModule: um.idModule,
value: noteVal !== undefined
? String(noteVal)
: "",
})}
>
{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)
: "",
})}
>
note
</button>
</div>
);
})}
</div>
)}
{/* Ajustement */}
<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
</p>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap">
<div style="display: flex; align-items: center; gap: 0.25rem">
<input
class="form-input"
style="width: 4.5rem; text-align: center"
placeholder="—"
value={ajustInputs[ue.id] ?? ""}
onInput={(e) =>
setAjustInputs((prev) => ({
...prev,
[ue.id]: (e.target as HTMLInputElement).value,
}))}
/>
<span class="col-dim" style="font-size: 0.8rem">/20</span>
</div>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => applyAjust(ue.id)}
>
Appliquer
</button>
{ajust && (
<>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() => resetAjust(ue.id)}
>
Réinitialiser
</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)})` : ""}
</span>
</>
)}
</div>
</div>
</div>
);
})}
</div>
);
}