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
This commit is contained in:
@@ -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_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_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].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_import from "./routes/(apps)/notes/partials/(admin)/import.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_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_NoteRecap from "./routes/(apps)/notes/(_islands)/NoteRecap.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_ConsultStudents from "./routes/(apps)/students/(_islands)/ConsultStudents.tsx";
|
||||
@@ -128,7 +131,10 @@ const manifest = {
|
||||
$_apps_notes_api_ue_modules_idModule_idUE_idPromo_,
|
||||
"./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/edition/[numEtud].tsx":
|
||||
$_apps_notes_edition_numEtud_,
|
||||
"./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":
|
||||
$_apps_notes_partials_admin_courses,
|
||||
"./routes/(apps)/notes/partials/(admin)/import.tsx":
|
||||
@@ -193,6 +199,8 @@ const manifest = {
|
||||
$_apps_notes_islands_AdminUEs,
|
||||
"./routes/(apps)/notes/(_islands)/ImportNotes.tsx":
|
||||
$_apps_notes_islands_ImportNotes,
|
||||
"./routes/(apps)/notes/(_islands)/NoteRecap.tsx":
|
||||
$_apps_notes_islands_NoteRecap,
|
||||
"./routes/(apps)/notes/(_islands)/NotesView.tsx":
|
||||
$_apps_notes_islands_NotesView,
|
||||
"./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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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
@@ -26,9 +26,9 @@ export default async function App(
|
||||
/>
|
||||
<link rel="stylesheet" href="/styles/main.css" />
|
||||
<link rel="stylesheet" href="/styles/app.css" />
|
||||
<link rel="stylesheet" href="styles/app-cards.css" />
|
||||
<link rel="stylesheet" href="styles/students.css" />
|
||||
<link rel="stylesheet" href="styles/ui.css" />
|
||||
<link rel="stylesheet" href="/styles/app-cards.css" />
|
||||
<link rel="stylesheet" href="/styles/students.css" />
|
||||
<link rel="stylesheet" href="/styles/ui.css" />
|
||||
</head>
|
||||
<body f-client-nav>
|
||||
<Header link={link} />
|
||||
|
||||
@@ -857,6 +857,96 @@
|
||||
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 {
|
||||
font-size: 0.7rem;
|
||||
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
|
||||
|
||||
Reference in New Issue
Block a user