631 lines
20 KiB
TypeScript
631 lines
20 KiB
TypeScript
// @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 {
|
|
calculateWeightedAverage,
|
|
getEffectiveNote,
|
|
roundGrade,
|
|
} from "$root/logic/grades.ts";
|
|
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 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)) {
|
|
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<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`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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<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,
|
|
);
|
|
|
|
const notesRecord: Record<string, { note: number; noteSession2: number | null }> = {};
|
|
ueMods.forEach(um => {
|
|
const n = sNotes.get(um.id);
|
|
if (n) notesRecord[um.id] = n;
|
|
});
|
|
|
|
const avg = calculateWeightedAverage(
|
|
ueMods.map(m => ({ idModule: m.id, coeff: m.coeff ?? 0 })),
|
|
notesRecord
|
|
);
|
|
|
|
row.push(avg !== null ? roundGrade(avg) : 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,
|
|
);
|
|
|
|
const notesRecord: Record<string, { note: number; noteSession2: number | null }> = {};
|
|
ueMods.forEach(um => {
|
|
const n = sNotes.get(um.id);
|
|
if (n) notesRecord[um.id] = n;
|
|
});
|
|
|
|
const avg = calculateWeightedAverage(
|
|
ueMods.map(m => ({ idModule: m.id, coeff: m.coeff ?? 0 })),
|
|
notesRecord
|
|
);
|
|
|
|
row.push(avg !== null ? roundGrade(avg) : 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 (
|
|
<div>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept=".xlsx,.xls"
|
|
style="display:none"
|
|
onChange={(e) => {
|
|
const f = (e.target as HTMLInputElement).files?.[0];
|
|
if (f) pickFile(f);
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
class={`drop-zone${dragging.value ? " dragging" : ""}`}
|
|
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>
|
|
{file.value ? <span class="drop-zone-file">{file.value.name}</span> : (
|
|
<>
|
|
<span class="drop-zone-text">Glisser le fichier .xlsx ici</span>
|
|
<span class="drop-zone-hint">ou cliquer pour parcourir</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{error.value && <p class="state-error">{error.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 = ECUE (importe) | UE = moyenne UE (ignore) | X = malus
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div class="upload-actions">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
onClick={doImport}
|
|
disabled={!file.value || uploading.value ||
|
|
columns.value.filter((c) => c.type === "module").length === 0}
|
|
>
|
|
{uploading.value ? "..." : "+ Importer"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary"
|
|
onClick={downloadTemplate}
|
|
>
|
|
Telecharger Modele
|
|
</button>
|
|
{
|
|
/* TODO: fix blob download in Fresh
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary"
|
|
onClick={downloadExport}
|
|
>
|
|
Exporter Notes
|
|
</button>
|
|
*/
|
|
}
|
|
</div>
|
|
|
|
<p class="upload-format">
|
|
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
|
|
<strong>CODE - ECUE</strong> (colonnes notes){" "}
|
|
— les colonnes UE et MALUS sont auto-detectees
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|