feat : fix a lot of stuff
This commit is contained in:
@@ -1,446 +0,0 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type UE = { id: number; nom: string };
|
||||
type UEModule = {
|
||||
idModule: string;
|
||||
idUE: number;
|
||||
idPromo: string;
|
||||
coeff: number;
|
||||
};
|
||||
type Module = { id: string; nom: string };
|
||||
type Promo = { id: string; annee: string };
|
||||
|
||||
export default function AdminUEs() {
|
||||
const [ues, setUes] = useState<UE[]>([]);
|
||||
const [ueModules, setUeModules] = useState<UEModule[]>([]);
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [promos, setPromos] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedUe, setSelectedUe] = useState<UE | null>(null);
|
||||
|
||||
// New UE form
|
||||
const [newUeNom, setNewUeNom] = useState("");
|
||||
const [creatingUe, setCreatingUe] = useState(false);
|
||||
|
||||
// Add UE-module form
|
||||
const [addModuleId, setAddModuleId] = useState("");
|
||||
const [addPromoId, setAddPromoId] = useState("");
|
||||
const [addCoeff, setAddCoeff] = useState("1");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
// Inline coeff editing
|
||||
const [editingCoeff, setEditingCoeff] = useState<string | null>(null);
|
||||
const [editCoeffValue, setEditCoeffValue] = useState("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [uRes, umRes, mRes, pRes] = await Promise.all([
|
||||
fetch("/notes/api/ues"),
|
||||
fetch("/notes/api/ue-modules"),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!uRes.ok) throw new Error("Impossible de charger les UEs");
|
||||
const uesData: UE[] = await uRes.json();
|
||||
setUes(uesData);
|
||||
if (umRes.ok) setUeModules(await umRes.json());
|
||||
if (mRes.ok) setModules(await mRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
// Keep selection in sync
|
||||
setSelectedUe((prev) =>
|
||||
prev ? uesData.find((u) => u.id === prev.id) ?? null : null
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createUE() {
|
||||
if (!newUeNom.trim()) return;
|
||||
setCreatingUe(true);
|
||||
try {
|
||||
const res = await fetch("/notes/api/ues", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nom: newUeNom.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Création échouée");
|
||||
setNewUeNom("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setCreatingUe(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUeModule(
|
||||
idModule: string,
|
||||
idUE: number,
|
||||
idPromo: string,
|
||||
) {
|
||||
if (!confirm("Supprimer ce module de la UE ?")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
|
||||
encodeURIComponent(idPromo)
|
||||
}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function addUeModule() {
|
||||
if (!selectedUe || !addModuleId || !addPromoId) {
|
||||
setAddError("Module et Promo sont requis");
|
||||
return;
|
||||
}
|
||||
const coeff = parseFloat(addCoeff);
|
||||
if (isNaN(coeff) || coeff <= 0) {
|
||||
setAddError("Coefficient invalide");
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
const res = await fetch("/notes/api/ue-modules", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idModule: addModuleId,
|
||||
idUE: selectedUe.id,
|
||||
idPromo: addPromoId,
|
||||
coeff,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAddModuleId("");
|
||||
setAddPromoId("");
|
||||
setAddCoeff("1");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCoeff(
|
||||
idModule: string,
|
||||
idUE: number,
|
||||
idPromo: string,
|
||||
coeff: number,
|
||||
) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
|
||||
encodeURIComponent(idPromo)
|
||||
}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ coeff }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Modification échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setEditingCoeff(null);
|
||||
}
|
||||
}
|
||||
|
||||
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
|
||||
|
||||
const selectedUeModules = selectedUe
|
||||
? ueModules.filter((um) => um.idUE === selectedUe.id)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des UEs</h2>
|
||||
<p
|
||||
class="col-dim"
|
||||
style="font-size: 0.78rem; margin: -0.5rem 0 1rem"
|
||||
>
|
||||
UE = Unité d'Enseignement regroupant plusieurs modules
|
||||
</p>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="ue-split">
|
||||
{/* Left panel – UE list */}
|
||||
<div class="ue-panel-left">
|
||||
<div class="panel-box">
|
||||
<p class="panel-box-title">UEs existantes</p>
|
||||
<div class="form-row" style="margin-bottom: 0.75rem">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom de la nouvelle UE…"
|
||||
value={newUeNom}
|
||||
onInput={(e) =>
|
||||
setNewUeNom((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createUE()}
|
||||
style="min-width: 0; flex: 1"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createUE}
|
||||
disabled={creatingUe}
|
||||
style="width: 100%; justify-content: center; margin-bottom: 0.5rem"
|
||||
>
|
||||
+ Nouvelle UE
|
||||
</button>
|
||||
<div>
|
||||
{ues.map((ue) => (
|
||||
<div
|
||||
key={ue.id}
|
||||
class={`ue-list-item${
|
||||
selectedUe?.id === ue.id ? " active" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedUe(ue);
|
||||
setAddError(null);
|
||||
}}
|
||||
>
|
||||
{ue.nom}
|
||||
</div>
|
||||
))}
|
||||
{ues.length === 0 && (
|
||||
<p class="state-empty" style="padding: 1rem 0">
|
||||
Aucune UE
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel – UE detail */}
|
||||
<div class="ue-panel-right">
|
||||
{selectedUe
|
||||
? (
|
||||
<div class="panel-box">
|
||||
<p class="panel-box-title">{selectedUe.nom}</p>
|
||||
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
|
||||
Modules assignés (UE_Module)
|
||||
</p>
|
||||
<div class="data-table-wrap" style="margin-bottom: 1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
<th>Promo</th>
|
||||
<th>Coeff</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedUeModules.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={4} class="state-empty">
|
||||
Aucun module assigné
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: selectedUeModules.map((um) => {
|
||||
const mod = moduleMap[um.idModule];
|
||||
return (
|
||||
<tr
|
||||
key={`${um.idModule}-${um.idPromo}`}
|
||||
>
|
||||
<td class="col-promo">
|
||||
{mod
|
||||
? `${mod.id} – ${mod.nom}`
|
||||
: um.idModule}
|
||||
</td>
|
||||
<td>
|
||||
<span class="promo-chip">{um.idPromo}</span>
|
||||
</td>
|
||||
<td
|
||||
onClick={() => {
|
||||
const key =
|
||||
`${um.idModule}-${um.idUE}-${um.idPromo}`;
|
||||
setEditingCoeff(key);
|
||||
setEditCoeffValue(String(um.coeff));
|
||||
}}
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{editingCoeff ===
|
||||
`${um.idModule}-${um.idUE}-${um.idPromo}`
|
||||
? (
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
value={editCoeffValue}
|
||||
min="0.1"
|
||||
step="0.5"
|
||||
style="width: 5rem; padding: 0.2rem 0.4rem; font-size: 0.82rem"
|
||||
autoFocus
|
||||
onInput={(e) =>
|
||||
setEditCoeffValue(
|
||||
(e.target as HTMLInputElement)
|
||||
.value,
|
||||
)}
|
||||
onBlur={() => {
|
||||
const v = parseFloat(
|
||||
editCoeffValue,
|
||||
);
|
||||
if (!isNaN(v) && v > 0) {
|
||||
updateCoeff(
|
||||
um.idModule,
|
||||
um.idUE,
|
||||
um.idPromo,
|
||||
v,
|
||||
);
|
||||
} else {
|
||||
setEditingCoeff(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
(e.target as HTMLInputElement)
|
||||
.blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingCoeff(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: um.coeff}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
deleteUeModule(
|
||||
um.idModule,
|
||||
um.idUE,
|
||||
um.idPromo,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect
|
||||
x="5"
|
||||
y="6"
|
||||
width="14"
|
||||
height="16"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
|
||||
Ajouter un module à cette UE
|
||||
</p>
|
||||
{addError && (
|
||||
<p class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
<div class="form-row">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addModuleId}
|
||||
onChange={(e) =>
|
||||
setAddModuleId(
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
style="min-width: 12rem"
|
||||
>
|
||||
<option value="">Module ▾</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id} – {m.nom}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addPromoId}
|
||||
onChange={(e) =>
|
||||
setAddPromoId(
|
||||
(e.target as HTMLSelectElement).value,
|
||||
)}
|
||||
style="min-width: 9rem"
|
||||
>
|
||||
<option value="">Promo ▾</option>
|
||||
{promos.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="Coeff"
|
||||
value={addCoeff}
|
||||
min="0.1"
|
||||
step="0.5"
|
||||
onInput={(e) =>
|
||||
setAddCoeff((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 5rem; max-width: 6rem"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addUeModule}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "…" : "+ Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div class="panel-box">
|
||||
<p class="state-empty" style="padding: 2rem 0">
|
||||
Sélectionnez une UE pour voir ses modules
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,61 @@
|
||||
// @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts"
|
||||
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
|
||||
import { useRef } from "preact/hooks";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { useSignal } from "@preact/signals";
|
||||
import ImportResultPopup, {
|
||||
type ImportDetail,
|
||||
type ImportResult,
|
||||
} from "$root/defaults/ImportResultPopup.tsx";
|
||||
|
||||
type Student = { numEtud: number; nom: string; prenom: string };
|
||||
type ColumnInfo = {
|
||||
index: number;
|
||||
code: string;
|
||||
name: string;
|
||||
coeff: number | null;
|
||||
type: "module" | "malus" | "ue" | "semester" | "unknown";
|
||||
};
|
||||
|
||||
function parseHeader(header: string): { code: string; name: string } {
|
||||
const parts = header.split(" - ");
|
||||
if (parts.length >= 2) {
|
||||
return { code: parts[0].trim(), name: parts.slice(1).join(" - ").trim() };
|
||||
}
|
||||
return { code: header.trim(), name: header.trim() };
|
||||
}
|
||||
|
||||
function detectColumnType(
|
||||
header: string,
|
||||
_coeff: number | null,
|
||||
): ColumnInfo["type"] {
|
||||
const h = header.trim();
|
||||
if (/^MALUS/i.test(h)) return "malus";
|
||||
if (/^S\d+$/i.test(h)) return "semester";
|
||||
// UE codes typically contain "U" followed by digits (e.g., JIN5U05, JTR5U01)
|
||||
const { code } = parseHeader(h);
|
||||
if (/U\d/i.test(code) && !/^MALUS/i.test(code)) return "ue";
|
||||
return "module";
|
||||
}
|
||||
|
||||
export default function ImportNotes() {
|
||||
const file = useSignal<File | null>(null);
|
||||
const dragging = useSignal(false);
|
||||
const uploading = useSignal(false);
|
||||
const error = useSignal<string | null>(null);
|
||||
const success = useSignal<string | null>(null);
|
||||
const importResult = useSignal<ImportResult | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const students = useSignal<Student[]>([]);
|
||||
const columns = useSignal<ColumnInfo[]>([]);
|
||||
const sheetNames = useSignal<string[]>([]);
|
||||
const selectedSheet = useSignal("");
|
||||
const session = useSignal<"1" | "2">("1");
|
||||
const workbookRef = useRef<XLSX.WorkBook | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/students/api/students")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data) => (students.value = data));
|
||||
}, []);
|
||||
|
||||
function pickFile(f: File) {
|
||||
if (!f.name.match(/\.xlsx?$/i)) {
|
||||
@@ -18,76 +64,404 @@ export default function ImportNotes() {
|
||||
}
|
||||
file.value = f;
|
||||
error.value = null;
|
||||
success.value = null;
|
||||
importResult.value = null;
|
||||
columns.value = [];
|
||||
|
||||
f.arrayBuffer().then((buf) => {
|
||||
try {
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
workbookRef.current = wb;
|
||||
sheetNames.value = wb.SheetNames;
|
||||
if (wb.SheetNames.length > 0) {
|
||||
selectedSheet.value = wb.SheetNames[0];
|
||||
parseSheet(wb, wb.SheetNames[0]);
|
||||
}
|
||||
} catch {
|
||||
error.value = "Impossible de lire le fichier.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragging.value = true;
|
||||
function parseSheet(wb: XLSX.WorkBook, sheetName: string) {
|
||||
const sheet = wb.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
|
||||
header: 1,
|
||||
});
|
||||
if (rows.length < 2) {
|
||||
columns.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRow = rows[0];
|
||||
const coeffRow = rows[1];
|
||||
|
||||
const cols: ColumnInfo[] = [];
|
||||
// First 2 columns are nom/prenom, skip them
|
||||
for (let i = 2; i < headerRow.length; i++) {
|
||||
const h = headerRow[i];
|
||||
if (h == null || String(h).trim() === "") continue;
|
||||
const header = String(h).trim();
|
||||
const coeff = typeof coeffRow[i] === "number" ? coeffRow[i] : null;
|
||||
const { code, name } = parseHeader(header);
|
||||
const type = detectColumnType(header, coeff as number | null);
|
||||
cols.push({ index: i, code, name, coeff: coeff as number | null, type });
|
||||
}
|
||||
columns.value = cols;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragging.value = false;
|
||||
function onSheetChange(name: string) {
|
||||
selectedSheet.value = name;
|
||||
if (workbookRef.current) {
|
||||
parseSheet(workbookRef.current, name);
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragging.value = false;
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}
|
||||
|
||||
function onInputChange(e: Event) {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) pickFile(f);
|
||||
function findStudent(
|
||||
nom: string,
|
||||
prenom: string,
|
||||
): Student | undefined {
|
||||
const normNom = nom.toUpperCase().trim();
|
||||
const normPrenom = prenom.toUpperCase().trim();
|
||||
return students.value.find(
|
||||
(s) =>
|
||||
s.nom.toUpperCase().trim() === normNom &&
|
||||
s.prenom.toUpperCase().trim() === normPrenom,
|
||||
);
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!file.value) return;
|
||||
if (!workbookRef.current || !selectedSheet.value) return;
|
||||
uploading.value = true;
|
||||
error.value = null;
|
||||
success.value = null;
|
||||
importResult.value = null;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.value.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
const sheet = workbookRef.current.Sheets[selectedSheet.value];
|
||||
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, {
|
||||
header: 1,
|
||||
});
|
||||
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json<{
|
||||
numEtud: number;
|
||||
idModule: string;
|
||||
note: number;
|
||||
}>(sheet, { header: ["numEtud", "idModule", "note"], range: 1 });
|
||||
const moduleCols = columns.value.filter((c) => c.type === "module");
|
||||
|
||||
for (const row of rows) {
|
||||
const res = await fetch("/notes/api/notes", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(row),
|
||||
let added = 0;
|
||||
let modified = 0;
|
||||
let ignored = 0;
|
||||
let errors = 0;
|
||||
const details: ImportDetail[] = [];
|
||||
|
||||
// Process data rows (skip header + coeff rows)
|
||||
for (let r = 2; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (!row || row.length < 3) continue;
|
||||
|
||||
const nom = row[0] != null ? String(row[0]).trim() : "";
|
||||
const prenom = row[1] != null ? String(row[1]).trim() : "";
|
||||
if (!nom || !prenom) continue;
|
||||
|
||||
const student = findStudent(nom, prenom);
|
||||
if (!student) {
|
||||
ignored++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `${nom} ${prenom} : Etudiant non trouve`,
|
||||
});
|
||||
if (res.ok) imported++;
|
||||
else failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Import module notes
|
||||
for (const col of moduleCols) {
|
||||
const val = row[col.index];
|
||||
if (val == null || typeof val !== "number") {
|
||||
if (val != null && typeof val !== "number") {
|
||||
errors++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message:
|
||||
`${student.numEtud} : ${col.code} : Note "${val}" invalide`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (val < 0 || val > 20) {
|
||||
errors++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message:
|
||||
`${student.numEtud} : ${col.code} : Note ${val} hors limites`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const noteField = session.value === "2" ? "noteSession2" : "note";
|
||||
|
||||
// Try PUT first (update), then POST (create)
|
||||
const putRes = await fetch(
|
||||
`/notes/api/notes/${student.numEtud}/${
|
||||
encodeURIComponent(col.code)
|
||||
}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ [noteField]: val }),
|
||||
},
|
||||
);
|
||||
|
||||
if (putRes.ok) {
|
||||
const prev = await putRes.json();
|
||||
const oldVal = session.value === "2"
|
||||
? prev.noteSession2
|
||||
: prev.note;
|
||||
modified++;
|
||||
details.push({
|
||||
type: "change",
|
||||
message: `${student.numEtud} : ${col.code} : ${
|
||||
oldVal ?? "null"
|
||||
} -> ${val}`,
|
||||
});
|
||||
} else if (putRes.status === 404) {
|
||||
// Note doesn't exist yet, create it
|
||||
const body: Record<string, unknown> = {
|
||||
numEtud: student.numEtud,
|
||||
idModule: col.code,
|
||||
note: session.value === "1" ? val : 0,
|
||||
};
|
||||
if (session.value === "2") body.noteSession2 = val;
|
||||
|
||||
const postRes = await fetch("/notes/api/notes", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (postRes.ok) {
|
||||
added++;
|
||||
details.push({
|
||||
type: "change",
|
||||
message: `${student.numEtud} : ${col.code} : null -> ${val}`,
|
||||
});
|
||||
} else {
|
||||
errors++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message:
|
||||
`${student.numEtud} : ${col.code} : Matiere non trouvee`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors++;
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `${student.numEtud} : ${col.code} : Erreur serveur`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
success.value = `Import terminé — ${imported} ajouté${
|
||||
imported !== 1 ? "s" : ""
|
||||
}${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`;
|
||||
importResult.value = { added, modified, ignored, errors, details };
|
||||
} catch {
|
||||
error.value = "Erreur lors de la lecture du fichier.";
|
||||
error.value = "Erreur lors de l'import.";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet([["numEtud", "idModule", "note"]]);
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Notes");
|
||||
XLSX.writeFile(wb, "modele_notes.xlsx");
|
||||
globalThis.open("/templates/modele_notes.xlsx", "_blank");
|
||||
}
|
||||
|
||||
function downloadExport() {
|
||||
// Export notes from the API in the same format
|
||||
Promise.all([
|
||||
fetch("/students/api/students").then((r) => r.json()),
|
||||
fetch("/notes/api/notes").then((r) => r.json()),
|
||||
fetch("/admin/api/modules").then((r) => r.json()),
|
||||
fetch("/admin/api/ue-modules").then((r) => r.json()),
|
||||
fetch("/admin/api/ues").then((r) => r.json()),
|
||||
]).then(
|
||||
([
|
||||
studentsData,
|
||||
notesData,
|
||||
modulesData,
|
||||
ueModulesData,
|
||||
uesData,
|
||||
]) => {
|
||||
// Build module map
|
||||
const modMap = new Map<string, string>(
|
||||
modulesData.map((m: { id: string; nom: string }) => [m.id, m.nom]),
|
||||
);
|
||||
|
||||
// Get unique module IDs from notes
|
||||
const moduleIds = [
|
||||
...new Set(
|
||||
notesData.map((n: { idModule: string }) => n.idModule),
|
||||
),
|
||||
] as string[];
|
||||
|
||||
// Group ue-modules by UE
|
||||
const ueMap = new Map<number, string>(
|
||||
uesData.map((u: { id: number; nom: string }) => [u.id, u.nom]),
|
||||
);
|
||||
const umByUE = new Map<number, typeof ueModulesData>();
|
||||
for (const um of ueModulesData) {
|
||||
if (!umByUE.has(um.idUE)) umByUE.set(um.idUE, []);
|
||||
umByUE.get(um.idUE)!.push(um);
|
||||
}
|
||||
|
||||
// Build column order: group modules by UE, add UE avg columns
|
||||
const orderedCols: {
|
||||
id: string;
|
||||
header: string;
|
||||
coeff: number | null;
|
||||
type: "module" | "ue";
|
||||
ueId?: number;
|
||||
}[] = [];
|
||||
|
||||
const usedModules = new Set<string>();
|
||||
for (const [ueId, ums] of umByUE) {
|
||||
for (const um of ums) {
|
||||
if (!moduleIds.includes(um.idModule)) continue;
|
||||
orderedCols.push({
|
||||
id: um.idModule,
|
||||
header: `${um.idModule} - ${
|
||||
modMap.get(um.idModule) || um.idModule
|
||||
}`,
|
||||
coeff: um.coeff,
|
||||
type: "module",
|
||||
ueId,
|
||||
});
|
||||
usedModules.add(um.idModule);
|
||||
}
|
||||
const ueName = ueMap.get(ueId) || `UE ${ueId}`;
|
||||
orderedCols.push({
|
||||
id: `ue_${ueId}`,
|
||||
header: ueName,
|
||||
coeff: ums.reduce(
|
||||
(s: number, um: { coeff: number }) => s + um.coeff,
|
||||
0,
|
||||
),
|
||||
type: "ue",
|
||||
ueId,
|
||||
});
|
||||
}
|
||||
// Add modules not linked to any UE
|
||||
for (const mId of moduleIds) {
|
||||
if (usedModules.has(mId)) continue;
|
||||
orderedCols.push({
|
||||
id: mId,
|
||||
header: `${mId} - ${modMap.get(mId) || mId}`,
|
||||
coeff: null,
|
||||
type: "module",
|
||||
});
|
||||
}
|
||||
|
||||
// Build note lookup: numEtud -> idModule -> note
|
||||
const noteLookup = new Map<
|
||||
number,
|
||||
Map<string, { note: number; noteSession2: number | null }>
|
||||
>();
|
||||
for (const n of notesData) {
|
||||
if (!noteLookup.has(n.numEtud)) noteLookup.set(n.numEtud, new Map());
|
||||
noteLookup.get(n.numEtud)!.set(n.idModule, {
|
||||
note: n.note,
|
||||
noteSession2: n.noteSession2,
|
||||
});
|
||||
}
|
||||
|
||||
// Get students who have notes
|
||||
const studentsWithNotes = studentsData.filter(
|
||||
(s: Student) => noteLookup.has(s.numEtud),
|
||||
);
|
||||
|
||||
// Build header rows
|
||||
const headerRow: (string | null)[] = [null, null];
|
||||
const coeffRow: (number | null)[] = [null, null];
|
||||
for (const col of orderedCols) {
|
||||
headerRow.push(col.header);
|
||||
coeffRow.push(col.coeff);
|
||||
}
|
||||
|
||||
// Build session 1 data rows
|
||||
const s1Rows: (string | number | null)[][] = [];
|
||||
for (const s of studentsWithNotes) {
|
||||
const row: (string | number | null)[] = [s.nom, s.prenom];
|
||||
const sNotes = noteLookup.get(s.numEtud) || new Map();
|
||||
for (const col of orderedCols) {
|
||||
if (col.type === "module") {
|
||||
const n = sNotes.get(col.id);
|
||||
row.push(n ? n.note : null);
|
||||
} else {
|
||||
// UE average - calculate
|
||||
const ueMods = orderedCols.filter(
|
||||
(c) => c.type === "module" && c.ueId === col.ueId,
|
||||
);
|
||||
let total = 0, coeffSum = 0;
|
||||
for (const um of ueMods) {
|
||||
const n = sNotes.get(um.id);
|
||||
if (n && um.coeff) {
|
||||
total += n.note * um.coeff;
|
||||
coeffSum += um.coeff;
|
||||
}
|
||||
}
|
||||
row.push(
|
||||
coeffSum > 0
|
||||
? Math.round((total / coeffSum) * 100) / 100
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
s1Rows.push(row);
|
||||
}
|
||||
|
||||
// Build session 2 data rows
|
||||
const s2Rows: (string | number | null)[][] = [];
|
||||
for (const s of studentsWithNotes) {
|
||||
const row: (string | number | null)[] = [s.nom, s.prenom];
|
||||
const sNotes = noteLookup.get(s.numEtud) || new Map();
|
||||
for (const col of orderedCols) {
|
||||
if (col.type === "module") {
|
||||
const n = sNotes.get(col.id);
|
||||
// Use session 2 note if available, else session 1
|
||||
row.push(n ? (n.noteSession2 ?? n.note) : null);
|
||||
} else {
|
||||
const ueMods = orderedCols.filter(
|
||||
(c) => c.type === "module" && c.ueId === col.ueId,
|
||||
);
|
||||
let total = 0, coeffSum = 0;
|
||||
for (const um of ueMods) {
|
||||
const n = sNotes.get(um.id);
|
||||
if (n && um.coeff) {
|
||||
const noteVal = n.noteSession2 ?? n.note;
|
||||
total += noteVal * um.coeff;
|
||||
coeffSum += um.coeff;
|
||||
}
|
||||
}
|
||||
row.push(
|
||||
coeffSum > 0
|
||||
? Math.round((total / coeffSum) * 100) / 100
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
s2Rows.push(row);
|
||||
}
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws1 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s1Rows]);
|
||||
XLSX.utils.book_append_sheet(wb, ws1, "Session 1");
|
||||
const ws2 = XLSX.utils.aoa_to_sheet([headerRow, coeffRow, ...s2Rows]);
|
||||
XLSX.utils.book_append_sheet(wb, ws2, "Session 2");
|
||||
const buf = XLSX.write(wb, { bookType: "xlsx", type: "array" });
|
||||
const blob = new Blob([buf], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "export_notes.xlsx";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,14 +471,25 @@ export default function ImportNotes() {
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style="display:none"
|
||||
onChange={onInputChange}
|
||||
onChange={(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class={`drop-zone${dragging.value ? " dragging" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
dragging.value = true;
|
||||
}}
|
||||
onDragLeave={() => (dragging.value = false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragging.value = false;
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) pickFile(f);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<span class="drop-zone-icon">⬇</span>
|
||||
@@ -117,10 +502,85 @@ export default function ImportNotes() {
|
||||
</div>
|
||||
|
||||
{error.value && <p class="state-error">{error.value}</p>}
|
||||
{success.value && (
|
||||
<p style="font-size:0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.75rem">
|
||||
{success.value}
|
||||
</p>
|
||||
|
||||
{importResult.value && (
|
||||
<ImportResultPopup
|
||||
result={importResult.value}
|
||||
onClose={() => (importResult.value = null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sheet + session selector */}
|
||||
{sheetNames.value.length > 0 && (
|
||||
<div style="display: flex; gap: 1rem; margin-bottom: 0.75rem; flex-wrap: wrap">
|
||||
<div>
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
|
||||
Feuille
|
||||
</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedSheet.value}
|
||||
onChange={(e) =>
|
||||
onSheetChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{sheetNames.value.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 0.25rem">
|
||||
Importer en tant que
|
||||
</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={session.value}
|
||||
onChange={(e) => (session.value = (e.target as HTMLSelectElement)
|
||||
.value as "1" | "2")}
|
||||
>
|
||||
<option value="1">Session 1 (note)</option>
|
||||
<option value="2">Session 2 (noteSession2)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column preview */}
|
||||
{columns.value.length > 0 && (
|
||||
<div style="margin-bottom: 1rem">
|
||||
<p style="font-size: 0.82rem; font-weight: 600; margin-bottom: 0.5rem">
|
||||
Colonnes detectees :
|
||||
</p>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.35rem">
|
||||
{columns.value.map((col) => (
|
||||
<span
|
||||
key={col.index}
|
||||
class={`numEtud-chip${
|
||||
col.type === "module"
|
||||
? ""
|
||||
: col.type === "malus"
|
||||
? " note-chip--fail"
|
||||
: " note-chip--promo"
|
||||
}`}
|
||||
style="font-size: 0.72rem"
|
||||
title={`${col.type} — ${col.name}${
|
||||
col.coeff != null ? ` (coef ${col.coeff})` : ""
|
||||
}`}
|
||||
>
|
||||
{col.type === "module"
|
||||
? "M"
|
||||
: col.type === "ue"
|
||||
? "UE"
|
||||
: col.type === "malus"
|
||||
? "X"
|
||||
: "?"} {col.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p class="col-dim" style="font-size: 0.72rem; margin-top: 0.35rem">
|
||||
M = module (importe) | UE = moyenne UE (ignore) | X = malus
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="upload-actions">
|
||||
@@ -128,22 +588,31 @@ export default function ImportNotes() {
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={doImport}
|
||||
disabled={!file.value || uploading.value}
|
||||
disabled={!file.value || uploading.value ||
|
||||
columns.value.filter((c) => c.type === "module").length === 0}
|
||||
>
|
||||
{uploading.value ? "…" : "⊕ Importer"}
|
||||
{uploading.value ? "..." : "+ Importer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={downloadTemplate}
|
||||
>
|
||||
⊕ Télécharger Modèle
|
||||
Telecharger Modele
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={downloadExport}
|
||||
>
|
||||
Exporter Notes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="upload-format">
|
||||
Format : <strong>numEtud</strong> | <strong>idModule</strong> |{" "}
|
||||
<strong>note</strong>
|
||||
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
|
||||
<strong>CODE - Module</strong> (colonnes notes){" "}
|
||||
— les colonnes UE et MALUS sont auto-detectees
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Note = { numEtud: number; idModule: string; note: number };
|
||||
type Note = {
|
||||
numEtud: number;
|
||||
idModule: string;
|
||||
note: number;
|
||||
noteSession2: number | null;
|
||||
};
|
||||
type UE = { id: number; nom: string };
|
||||
type UEModule = {
|
||||
idModule: string;
|
||||
@@ -9,7 +14,12 @@ type UEModule = {
|
||||
coeff: number;
|
||||
};
|
||||
type Module = { id: string; nom: string };
|
||||
type Ajustement = { numEtud: number; idUE: number; valeur: number };
|
||||
type Ajustement = {
|
||||
numEtud: number;
|
||||
idUE: number;
|
||||
valeur: number;
|
||||
malus: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
numEtud: number | null;
|
||||
@@ -26,6 +36,11 @@ function avgClass(avg: number | null): string {
|
||||
return avg >= 10 ? "avg-good" : "avg-warn";
|
||||
}
|
||||
|
||||
/** Returns the effective note (session 2 if exists, otherwise session 1). */
|
||||
function effectiveNote(n: Note): number {
|
||||
return n.noteSession2 ?? n.note;
|
||||
}
|
||||
|
||||
export default function NotesView({ numEtud, prenom }: Props) {
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [ues, setUes] = useState<UE[]>([]);
|
||||
@@ -47,8 +62,8 @@ export default function NotesView({ numEtud, prenom }: Props) {
|
||||
try {
|
||||
const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([
|
||||
fetch(`/notes/api/notes?numEtud=${numEtud}`),
|
||||
fetch("/notes/api/ues"),
|
||||
fetch("/notes/api/ue-modules"),
|
||||
fetch("/admin/api/ues"),
|
||||
fetch("/admin/api/ue-modules"),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch(`/notes/api/ajustements?numEtud=${numEtud}`),
|
||||
]);
|
||||
@@ -72,7 +87,6 @@ export default function NotesView({ numEtud, prenom }: Props) {
|
||||
setModules(modData);
|
||||
setAjustements(ajData);
|
||||
|
||||
// Derive promos from UE-modules for this student's notes
|
||||
const noteModuleIds = new Set(notesData.map((n: Note) => n.idModule));
|
||||
const relevantPromos = [
|
||||
...new Set(
|
||||
@@ -99,7 +113,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
|
||||
<div class="page-content">
|
||||
<p class="state-empty">
|
||||
Bonjour {prenom}{" "}
|
||||
— aucun dossier étudiant n'est associé à votre compte.
|
||||
— aucun dossier etudiant n'est associe a votre compte.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -108,7 +122,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement…</p>
|
||||
<p class="state-loading">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,20 +135,18 @@ export default function NotesView({ numEtud, prenom }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Filter UE-modules by active promo
|
||||
const filteredUeModules = activePromo
|
||||
? ueModules.filter((um) => um.idPromo === activePromo)
|
||||
: ueModules;
|
||||
|
||||
// Group UE-modules by UE
|
||||
const ueIds = [...new Set(filteredUeModules.map((um) => um.idUE))];
|
||||
|
||||
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
|
||||
const noteMap = Object.fromEntries(
|
||||
notes.map((n) => [n.idModule, n.note]),
|
||||
notes.map((n) => [n.idModule, n]),
|
||||
);
|
||||
const ajMap = Object.fromEntries(
|
||||
ajustements.map((a) => [a.idUE, a.valeur]),
|
||||
ajustements.map((a) => [a.idUE, a]),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -155,7 +167,7 @@ export default function NotesView({ numEtud, prenom }: Props) {
|
||||
)}
|
||||
|
||||
{ueIds.length === 0 && (
|
||||
<p class="state-empty">Aucune note disponible pour cette période.</p>
|
||||
<p class="state-empty">Aucune note disponible pour cette periode.</p>
|
||||
)}
|
||||
|
||||
{ueIds.map((ueId) => {
|
||||
@@ -166,51 +178,65 @@ export default function NotesView({ numEtud, prenom }: Props) {
|
||||
let weightedSum = 0;
|
||||
let coveredCoeff = 0;
|
||||
ueModsForUE.forEach((um) => {
|
||||
const note = noteMap[um.idModule];
|
||||
if (note !== undefined) {
|
||||
weightedSum += note * um.coeff;
|
||||
const noteObj = noteMap[um.idModule];
|
||||
if (noteObj) {
|
||||
const val = effectiveNote(noteObj);
|
||||
weightedSum += val * um.coeff;
|
||||
coveredCoeff += um.coeff;
|
||||
}
|
||||
});
|
||||
|
||||
const avg = coveredCoeff > 0 ? weightedSum / coveredCoeff : null;
|
||||
const ajustement = ajMap[ueId] ?? null;
|
||||
const finalAvg = avg !== null && ajustement !== null
|
||||
? avg + ajustement
|
||||
: avg;
|
||||
const ajust = ajMap[ueId] ?? null;
|
||||
|
||||
// If ajust.valeur exists, it replaces the calculated average
|
||||
// Then malus is subtracted
|
||||
let finalAvg: number | null = avg;
|
||||
if (ajust) {
|
||||
finalAvg = ajust.valeur;
|
||||
if (ajust.malus > 0) {
|
||||
finalAvg = (finalAvg ?? 0) - ajust.malus;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={ueId} class="ue-card">
|
||||
<div class="ue-card-header">
|
||||
<p class="ue-card-title">UE : {ue.nom}</p>
|
||||
{finalAvg !== null && (
|
||||
<p class={`ue-card-avg ${avgClass(finalAvg)}`}>
|
||||
Moyenne : {finalAvg.toFixed(2)}/20
|
||||
{ajustement !== null && ajustement !== 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
(ajustement : {ajustement > 0 ? "+" : ""}
|
||||
{ajustement})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{finalAvg === null && (
|
||||
<p class="ue-card-avg avg-warn">Notes non disponibles</p>
|
||||
)}
|
||||
{finalAvg !== null
|
||||
? (
|
||||
<p class={`ue-card-avg ${avgClass(finalAvg)}`}>
|
||||
Moyenne : {finalAvg.toFixed(2)}/20
|
||||
{ajust && ajust.malus > 0 && (
|
||||
<span>(malus : -{ajust.malus})</span>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
: <p class="ue-card-avg avg-warn">Notes non disponibles</p>}
|
||||
</div>
|
||||
|
||||
{ueModsForUE.map((um) => {
|
||||
const mod = moduleMap[um.idModule];
|
||||
const note = noteMap[um.idModule] ?? null;
|
||||
const noteObj = noteMap[um.idModule] ?? null;
|
||||
const effective = noteObj ? effectiveNote(noteObj) : null;
|
||||
const hasS2 = noteObj?.noteSession2 != null;
|
||||
|
||||
return (
|
||||
<div key={um.idModule} class="ue-module-row">
|
||||
<span class="ue-module-name">
|
||||
{mod ? mod.id : um.idModule} —{" "}
|
||||
{mod ? mod.nom : "Module inconnu"} (coef {um.coeff})
|
||||
</span>
|
||||
<span class={`score-chip ${scoreClass(note)}`}>
|
||||
{note !== null ? `${note}/20` : "—"}
|
||||
<span class={`score-chip ${scoreClass(effective)}`}>
|
||||
{effective !== null ? `${effective}/20` : "—"}
|
||||
{hasS2 && (
|
||||
<span
|
||||
style="font-size: 0.7rem; opacity: 0.7; margin-left: 0.35rem"
|
||||
title={`Session 1 : ${noteObj!.note}/20`}
|
||||
>
|
||||
(S1: {noteObj!.note})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user