// @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 { 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(null); const dragging = useSignal(false); const uploading = useSignal(false); const error = useSignal(null); const importResult = useSignal(null); const inputRef = useRef(null); const students = useSignal([]); const columns = useSignal([]); const sheetNames = useSignal([]); const selectedSheet = useSignal(""); const session = useSignal<"1" | "2">("1"); const workbookRef = useRef(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)) { error.value = "Fichier invalide — format attendu : .xlsx"; return; } file.value = f; error.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 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 onSheetChange(name: string) { selectedSheet.value = name; if (workbookRef.current) { parseSheet(workbookRef.current, name); } } 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 (!workbookRef.current || !selectedSheet.value) return; uploading.value = true; error.value = null; importResult.value = null; try { const sheet = workbookRef.current.Sheets[selectedSheet.value]; const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(sheet, { header: 1, }); const moduleCols = columns.value.filter((c) => c.type === "module"); 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`, }); 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 = { 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`, }); } } } importResult.value = { added, modified, ignored, errors, details }; } catch { error.value = "Erreur lors de l'import."; } finally { uploading.value = false; } } function downloadTemplate() { 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("/notes/api/modules").then((r) => r.json()), fetch("/notes/api/ue-modules").then((r) => r.json()), fetch("/notes/api/ues").then((r) => r.json()), ]).then( ([ studentsData, notesData, modulesData, ueModulesData, uesData, ]) => { // Build module map const modMap = new Map( 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( uesData.map((u: { id: number; nom: string }) => [u.id, u.nom]), ); const umByUE = new Map(); 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(); 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 >(); 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 (
{ const f = (e.target as HTMLInputElement).files?.[0]; if (f) pickFile(f); }} />
{ 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()} > {file.value ? {file.value.name} : ( <> Glisser le fichier .xlsx ici ou cliquer pour parcourir )}
{error.value &&

{error.value}

} {importResult.value && ( (importResult.value = null)} /> )} {/* Sheet + session selector */} {sheetNames.value.length > 0 && (
)} {/* Column preview */} {columns.value.length > 0 && (

Colonnes detectees :

{columns.value.map((col) => ( {col.type === "module" ? "M" : col.type === "ue" ? "UE" : col.type === "malus" ? "X" : "?"} {col.code} ))}

M = ECUE (importe) | UE = moyenne UE (ignore) | X = malus

)}
{ /* TODO: fix blob download in Fresh */ }

Format : Nom | Prenom |{" "} CODE - ECUE (colonnes notes){" "} — les colonnes UE et MALUS sont auto-detectees

); }