951c9c1fea
Check Deno code / Check Deno code (pull_request) Has been cancelled
Tests / Unit tests (pull_request) Has been cancelled
Tests / Integration tests (pull_request) Has been cancelled
Check Deno code / Check Deno code (push) Has been cancelled
Tests / Unit tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
569 lines
20 KiB
TypeScript
569 lines
20 KiB
TypeScript
import { useEffect, useState } from "preact/hooks";
|
||
import {
|
||
applyAjustement,
|
||
calculateWeightedAverage,
|
||
getEffectiveNote,
|
||
} from "$root/logic/grades.ts";
|
||
|
||
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";
|
||
}
|
||
|
||
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, 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; field: "note" | "noteSession2"; value: string } | null
|
||
>(null);
|
||
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("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<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) {
|
||
setError(e instanceof Error ? e.message : "Erreur");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [numEtud]);
|
||
|
||
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<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(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 (
|
||
<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 a la liste
|
||
</a>
|
||
|
||
<h2
|
||
class="page-title"
|
||
style="border-bottom: none; margin-bottom: 0.5rem"
|
||
>
|
||
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 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 configuree pour cette promotion.
|
||
</p>
|
||
)
|
||
: ueList.map((ue) => {
|
||
const ueMods = ueList.length > 0 ? ueModules.filter((um) => um.idUE === ue.id) : [];
|
||
const notesRecord = Object.fromEntries(noteMap);
|
||
const avg = calculateWeightedAverage(ueMods, notesRecord);
|
||
const ajust = ajustements.find((a) => a.idUE === ue.id) ?? null;
|
||
const finalAvg = applyAjustement(avg, ajust);
|
||
|
||
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. calculee : {fmt(avg)}
|
||
</span>
|
||
)}
|
||
{ajust && (
|
||
<span
|
||
class="note-chip note-chip--ajust"
|
||
style="font-size: 0.78rem"
|
||
>
|
||
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>
|
||
|
||
{/* ECUE rows */}
|
||
{ueMods.length === 0
|
||
? (
|
||
<p
|
||
class="col-dim"
|
||
style="font-size: 0.8rem; padding: 0.25rem 0; margin-bottom: 0.75rem"
|
||
>
|
||
Aucun ECUE associe a cette UE pour cette promotion.
|
||
</p>
|
||
)
|
||
: (
|
||
<div style="margin-bottom: 0.75rem">
|
||
{ueMods.map((um) => {
|
||
const noteObj = noteMap.get(um.idModule);
|
||
const noteVal = noteObj?.note;
|
||
const noteS2 = noteObj?.noteSession2;
|
||
const effective = noteObj
|
||
? getEffectiveNote(noteObj)
|
||
: undefined;
|
||
const nomMod = moduleMap.get(um.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>
|
||
|
||
{/* 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}
|
||
autoFocus
|
||
onInput={(e) =>
|
||
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,
|
||
)}
|
||
/>
|
||
<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="S1 — Cliquer pour modifier"
|
||
onClick={() =>
|
||
setEditingNote({
|
||
idModule: um.idModule,
|
||
field: "note",
|
||
value: noteVal !== undefined
|
||
? String(noteVal)
|
||
: "",
|
||
})}
|
||
>
|
||
S1:{" "}
|
||
{noteVal !== undefined ? fmt(noteVal) : "—/20"}
|
||
</span>
|
||
)}
|
||
|
||
{/* 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"
|
||
>
|
||
→ {fmt(effective!)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Ajustement + Malus */}
|
||
<div class="ajust-section">
|
||
<p class="ajust-title">Ajustement de la moyenne UE</p>
|
||
<p class="ajust-hint">
|
||
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]?.valeur ?? ""}
|
||
onInput={(e) =>
|
||
setAjustInputs((prev) => ({
|
||
...prev,
|
||
[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
|
||
</button>
|
||
{ajust && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
class="btn btn-sm btn-secondary"
|
||
onClick={() => resetAjust(ue.id)}
|
||
>
|
||
Reinitialiser
|
||
</button>
|
||
<span
|
||
class="col-dim"
|
||
style="font-size: 0.75rem; font-family: monospace"
|
||
>
|
||
Affiche : {fmt(ajust.valeur)}
|
||
{ajust.malus > 0
|
||
? ` - ${ajust.malus} = ${
|
||
fmt(ajust.valeur - ajust.malus)
|
||
}`
|
||
: ""}
|
||
{avg !== null ? ` (calculee : ${fmt(avg)})` : ""}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|