2c5e4ebf11
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
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|