feat(fresh.gen.ts): add routes for notes edition, recap and island recap
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(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
This commit is contained in:
2026-04-27 18:22:23 +02:00
parent 757e364af0
commit 2c5e4ebf11
6 changed files with 510 additions and 3 deletions
+8
View File
@@ -34,7 +34,9 @@ import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modul
import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts"; import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts";
import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts"; import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts";
import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx";
import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx";
import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx";
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx";
import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx"; import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx";
import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx"; import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx";
@@ -74,6 +76,7 @@ import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_
import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx"; import * as $_apps_notes_islands_AdminConsultNotes from "./routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx";
import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx"; import * as $_apps_notes_islands_AdminUEs from "./routes/(apps)/notes/(_islands)/AdminUEs.tsx";
import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx"; import * as $_apps_notes_islands_ImportNotes from "./routes/(apps)/notes/(_islands)/ImportNotes.tsx";
import * as $_apps_notes_islands_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.tsx";
import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx"; import * as $_apps_notes_islands_NotesView from "./routes/(apps)/notes/(_islands)/NotesView.tsx";
import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx"; import * as $_apps_students_islands_AdminPromotions from "./routes/(apps)/students/(_islands)/AdminPromotions.tsx";
import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx"; import * as $_apps_students_islands_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx";
@@ -128,7 +131,10 @@ const manifest = {
$_apps_notes_api_ue_modules_idModule_idUE_idPromo_, $_apps_notes_api_ue_modules_idModule_idUE_idPromo_,
"./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues, "./routes/(apps)/notes/api/ues.ts": $_apps_notes_api_ues,
"./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_, "./routes/(apps)/notes/api/ues/[idUE].ts": $_apps_notes_api_ues_idUE_,
"./routes/(apps)/notes/edition/[numEtud].tsx":
$_apps_notes_edition_numEtud_,
"./routes/(apps)/notes/index.tsx": $_apps_notes_index, "./routes/(apps)/notes/index.tsx": $_apps_notes_index,
"./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_,
"./routes/(apps)/notes/partials/(admin)/courses.tsx": "./routes/(apps)/notes/partials/(admin)/courses.tsx":
$_apps_notes_partials_admin_courses, $_apps_notes_partials_admin_courses,
"./routes/(apps)/notes/partials/(admin)/import.tsx": "./routes/(apps)/notes/partials/(admin)/import.tsx":
@@ -193,6 +199,8 @@ const manifest = {
$_apps_notes_islands_AdminUEs, $_apps_notes_islands_AdminUEs,
"./routes/(apps)/notes/(_islands)/ImportNotes.tsx": "./routes/(apps)/notes/(_islands)/ImportNotes.tsx":
$_apps_notes_islands_ImportNotes, $_apps_notes_islands_ImportNotes,
"./routes/(apps)/notes/(_islands)/NoteRecap.tsx":
$_apps_notes_islands_NoteRecap,
"./routes/(apps)/notes/(_islands)/NotesView.tsx": "./routes/(apps)/notes/(_islands)/NotesView.tsx":
$_apps_notes_islands_NotesView, $_apps_notes_islands_NotesView,
"./routes/(apps)/students/(_islands)/AdminPromotions.tsx": "./routes/(apps)/students/(_islands)/AdminPromotions.tsx":
@@ -0,0 +1,385 @@
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>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { FreshContext } from "$fresh/server.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import NoteRecap from "../(_islands)/NoteRecap.tsx";
// deno-lint-ignore require-await
export default async function EditionPage(
_request: Request,
context: FreshContext<AuthenticatedState>,
) {
const numEtud = Number(context.params.numEtud);
return <NoteRecap numEtud={numEtud} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { FreshContext } from "$fresh/server.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import NoteRecap from "../(_islands)/NoteRecap.tsx";
// deno-lint-ignore require-await
export default async function RecapPage(
_request: Request,
context: FreshContext<AuthenticatedState>,
) {
const numEtud = Number(context.params.numEtud);
return <NoteRecap numEtud={numEtud} />;
}
+3 -3
View File
@@ -26,9 +26,9 @@ export default async function App(
/> />
<link rel="stylesheet" href="/styles/main.css" /> <link rel="stylesheet" href="/styles/main.css" />
<link rel="stylesheet" href="/styles/app.css" /> <link rel="stylesheet" href="/styles/app.css" />
<link rel="stylesheet" href="styles/app-cards.css" /> <link rel="stylesheet" href="/styles/app-cards.css" />
<link rel="stylesheet" href="styles/students.css" /> <link rel="stylesheet" href="/styles/students.css" />
<link rel="stylesheet" href="styles/ui.css" /> <link rel="stylesheet" href="/styles/ui.css" />
</head> </head>
<body f-client-nav> <body f-client-nav>
<Header link={link} /> <Header link={link} />
+90
View File
@@ -857,6 +857,96 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
/* -------------------------------------------------------
Note recap chips & rows
------------------------------------------------------- */
.note-chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 10px;
border: 1px solid currentColor;
font-size: 0.78rem;
font-weight: var(--font-weight-bold);
font-family: monospace;
white-space: nowrap;
}
.note-chip--ok {
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
}
.note-chip--fail {
color: light-dark(#dc2626, #f87171);
}
.note-chip--none {
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
}
.note-chip--promo {
color: light-dark(var(--light-accent-color), var(--dark-accent-color));
background: transparent;
}
.note-chip--ajust {
color: #f59e0b;
}
.note-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
flex-wrap: wrap;
}
.note-row-label {
flex: 1;
min-width: 10rem;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.note-row-chip {
font-size: 0.68rem;
padding: 0.1rem 0.4rem;
}
.note-row-coef {
font-size: 0.75rem;
white-space: nowrap;
}
.ajust-section {
margin-top: 0.75rem;
padding-top: 0.65rem;
border-top: 1px solid
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
}
.ajust-title {
font-size: 0.78rem;
font-weight: var(--font-weight-bold);
margin: 0 0 0.15rem;
}
.ajust-hint {
font-size: 0.7rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
font-family: monospace;
margin: 0 0 0.5rem;
}
/* -------------------------------------------------------
(end note recap)
------------------------------------------------------- */
.info-note-dim { .info-note-dim {
font-size: 0.7rem; font-size: 0.7rem;
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));