feat : fix a lot of stuff
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user