feat: cascade deletes, student notes, import popups, module reorganization

- Cascade delete on all entities (student, module, UE, user, role, promotion)
- Fix Response body reuse bug (factory functions instead of constants)
- Student note viewing via CAS uid (strip non-digit prefix)
- Fix middleware page visibility for students in LOCAL mode
- Import result popup component (shared across all import pages)
- Fix student import to use numEtud from Excel
- Bulk student selection with promo change and delete
- Move UE/UE-Module API and pages from notes to admin module
- Move promotions page from students to admin module
- Multi-year maquette import with per-year promo selection
- Inline promo creation in maquette import
- Static Excel templates (students, notes, maquette)
- Fix XLSX export using blob download instead of writeFile
- Allow students to read modules list (GET /modules)
This commit is contained in:
2026-04-30 13:47:16 +02:00
parent 04be659d6b
commit 6c38cd0019
51 changed files with 3022 additions and 437 deletions
@@ -1,246 +0,0 @@
import { useEffect, useState } from "preact/hooks";
type Promotion = { id: string; annee: string | null };
type Student = { numEtud: number; idPromo: string };
function parsePromo(id: string) {
const m = id.match(/^(\d+A)(FISE|FISA)(.+)$/);
if (!m) return { annee: id, filiere: "?", anneeSco: "?" };
return { annee: m[1], filiere: m[2], anneeSco: m[3] };
}
const ANNEES = ["3A", "4A", "5A"];
const FILIERES = ["FISE", "FISA"];
export default function AdminPromotions() {
const [promos, setPromos] = useState<Promotion[]>([]);
const [students, setStudents] = useState<Student[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
// PromoBuilder state
const [selectedAnnee, setSelectedAnnee] = useState("4A");
const [selectedFiliere, setSelectedFiliere] = useState("FISE");
const [anneeSco, setAnneeSco] = useState("");
const generatedId = anneeSco.trim()
? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}`
: "";
async function load() {
try {
const [pRes, sRes] = await Promise.all([
fetch("/students/api/promotions"),
fetch("/students/api/students"),
]);
if (!pRes.ok) throw new Error("Impossible de charger les promotions");
setPromos(await pRes.json());
if (sRes.ok) setStudents(await sRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createPromo() {
if (!generatedId) return;
setCreating(true);
try {
const res = await fetch("/students/api/promotions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idPromo: generatedId,
annee: selectedAnnee,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setAnneeSco("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreating(false);
}
}
async function deletePromo(id: string) {
if (!confirm(`Supprimer la promotion ${id} ?`)) return;
try {
const res = await fetch(
`/students/api/promotions/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
function studentCount(idPromo: string) {
return students.filter((s) => s.idPromo === idPromo).length;
}
return (
<div class="page-content">
<h2 class="page-title">Gestion des Promotions</h2>
{error && <p class="state-error">{error}</p>}
{/* PromoBuilder */}
<div class="promo-builder">
<p class="promo-builder-title">Créer une promotion</p>
<p class="promo-builder-subtitle">
idPromo est généré automatiquement
</p>
<div class="promo-builder-row">
<div class="promo-builder-field">
<label>Année</label>
<div class="pill-group">
{ANNEES.map((a) => (
<button
key={a}
type="button"
class={`pill-btn${selectedAnnee === a ? " active" : ""}`}
onClick={() => setSelectedAnnee(a)}
>
{a}
</button>
))}
</div>
</div>
<div class="promo-builder-field">
<label>Filière</label>
<div class="pill-group">
{FILIERES.map((f) => (
<button
key={f}
type="button"
class={`pill-btn${selectedFiliere === f ? " active" : ""}`}
onClick={() => setSelectedFiliere(f)}
>
{f}
</button>
))}
</div>
</div>
<div class="promo-builder-field">
<label>Année scolaire</label>
<input
class="form-input"
placeholder="ex: 25-26, 24-27…"
value={anneeSco}
onInput={(e) => setAnneeSco((e.target as HTMLInputElement).value)}
style="min-width: 9rem"
/>
</div>
</div>
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap">
<div style="display: flex; align-items: center; gap: 0.5rem">
<span style="font-size: 0.78rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
idPromo généré :
</span>
<span class="promo-id-preview">
{generatedId || "—"}
</span>
</div>
<button
type="button"
class="btn btn-primary"
onClick={createPromo}
disabled={creating || !generatedId}
>
{creating ? "…" : "+ Créer la promo"}
</button>
</div>
</div>
{/* Existing promotions table */}
<p style="font-size: 0.82rem; font-weight: var(--font-weight-bold); margin-bottom: 0.5rem">
Promotions existantes
</p>
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>idPromo</th>
<th>Année</th>
<th>Filière</th>
<th>Année sco.</th>
<th>Nb étudiants</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{promos.length === 0
? (
<tr>
<td colspan={6} class="state-empty">
Aucune promotion enregistrée
</td>
</tr>
)
: promos.map((p) => {
const parsed = parsePromo(p.id);
const count = studentCount(p.id);
return (
<tr key={p.id}>
<td>
<span class="promo-chip">{p.id}</span>
</td>
<td>{parsed.annee}</td>
<td>
<span class="filiere-chip">{parsed.filiere}</span>
</td>
<td>{parsed.anneeSco}</td>
<td class="col-dim">
{count} étudiant{count !== 1 ? "s" : ""}
</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deletePromo(p.id)}
>
<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>
)}
</div>
);
}
@@ -15,6 +15,9 @@ export default function ConsultStudents() {
const [error, setError] = useState<string | null>(null);
const [filterPromo, setFilterPromo] = useState("");
const [filterNom, setFilterNom] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkPromo, setBulkPromo] = useState("");
const [bulkBusy, setBulkBusy] = useState(false);
async function load() {
try {
@@ -44,6 +47,11 @@ export default function ConsultStudents() {
});
if (!res.ok) throw new Error("Suppression échouée");
await load();
setSelected((prev) => {
const next = new Set(prev);
next.delete(numEtud);
return next;
});
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
@@ -56,6 +64,85 @@ export default function ConsultStudents() {
return matchPromo && matchNom;
});
const filteredIds = new Set(filtered.map((s) => s.numEtud));
const selectedInView = [...selected].filter((id) => filteredIds.has(id));
const allFilteredSelected = filtered.length > 0 &&
filtered.every((s) => selected.has(s.numEtud));
function toggleOne(numEtud: number) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(numEtud)) next.delete(numEtud);
else next.add(numEtud);
return next;
});
}
function toggleAll() {
if (allFilteredSelected) {
setSelected((prev) => {
const next = new Set(prev);
for (const s of filtered) next.delete(s.numEtud);
return next;
});
} else {
setSelected((prev) => {
const next = new Set(prev);
for (const s of filtered) next.add(s.numEtud);
return next;
});
}
}
async function bulkDelete() {
const count = selectedInView.length;
if (count === 0) return;
if (
!confirm(`Supprimer définitivement ${count} élève(s) sélectionné(s) ?`)
) return;
setBulkBusy(true);
try {
const results = await Promise.all(
selectedInView.map((id) =>
fetch(`/students/api/students/${id}`, { method: "DELETE" })
),
);
const failed = results.filter((r) => !r.ok).length;
if (failed > 0) setError(`${failed} suppression(s) échouée(s)`);
setSelected(new Set());
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setBulkBusy(false);
}
}
async function bulkChangePromo() {
if (!bulkPromo || selectedInView.length === 0) return;
setBulkBusy(true);
try {
const results = await Promise.all(
selectedInView.map((id) =>
fetch(`/students/api/students/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idPromo: bulkPromo }),
})
),
);
const failed = results.filter((r) => !r.ok).length;
if (failed > 0) setError(`${failed} modification(s) échouée(s)`);
setSelected(new Set());
setBulkPromo("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setBulkBusy(false);
}
}
return (
<div class="page-content">
<h2 class="page-title">Gestion des Élèves</h2>
@@ -93,6 +180,44 @@ export default function ConsultStudents() {
/>
</div>
{/* Bulk actions bar */}
{selectedInView.length > 0 && (
<div class="bulk-bar">
<span class="bulk-count">
{selectedInView.length} sélectionné(s)
</span>
<div class="bulk-actions">
<select
class="filter-select"
value={bulkPromo}
onChange={(e) =>
setBulkPromo((e.target as HTMLSelectElement).value)}
>
<option value="">Changer de promo</option>
{promos.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={!bulkPromo || bulkBusy}
onClick={bulkChangePromo}
>
Appliquer
</button>
<button
type="button"
class="btn btn-sm btn-danger"
disabled={bulkBusy}
onClick={bulkDelete}
>
Supprimer
</button>
</div>
</div>
)}
{loading
? <p class="state-loading">Chargement</p>
: (
@@ -100,6 +225,13 @@ export default function ConsultStudents() {
<table class="data-table">
<thead>
<tr>
<th style="width: 2.5rem">
<input
type="checkbox"
checked={allFilteredSelected && filtered.length > 0}
onChange={toggleAll}
/>
</th>
<th>N° étud.</th>
<th>Nom</th>
<th>Prénom</th>
@@ -111,13 +243,23 @@ export default function ConsultStudents() {
{filtered.length === 0
? (
<tr>
<td colspan={5} class="state-empty">
<td colspan={6} class="state-empty">
Aucun élève trouvé
</td>
</tr>
)
: filtered.map((s) => (
<tr key={s.numEtud}>
<tr
key={s.numEtud}
class={selected.has(s.numEtud) ? "row-selected" : ""}
>
<td>
<input
type="checkbox"
checked={selected.has(s.numEtud)}
onChange={() => toggleOne(s.numEtud)}
/>
</td>
<td class="col-dim">{s.numEtud}</td>
<td>{s.nom}</td>
<td>{s.prenom}</td>
@@ -2,13 +2,17 @@
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
import { useRef } from "preact/hooks";
import { useSignal } from "@preact/signals";
import ImportResultPopup, {
type ImportDetail,
type ImportResult,
} from "$root/defaults/ImportResultPopup.tsx";
export default function UploadStudents() {
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);
function pickFile(f: File) {
@@ -18,7 +22,7 @@ export default function UploadStudents() {
}
file.value = f;
error.value = null;
success.value = null;
importResult.value = null;
}
function onDragOver(e: DragEvent) {
@@ -46,36 +50,58 @@ export default function UploadStudents() {
if (!file.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;
let added = 0;
let errors = 0;
const details: ImportDetail[] = [];
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<{
numEtud: number;
nom: string;
prenom: string;
}>(sheet, { header: ["numEtud", "nom", "prenom"], range: 1 });
numEtud: number;
idPromo: string;
}>(sheet, {
header: ["nom", "prenom", "numEtud", "idPromo"],
range: 2,
});
for (const row of rows) {
const res = await fetch("/students/api/students", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...row, idPromo: sheetName }),
body: JSON.stringify(row),
});
if (res.ok) imported++;
else failed++;
if (res.ok) {
added++;
details.push({
type: "change",
message:
`${row.numEtud} : ${row.nom} ${row.prenom} -> ${row.idPromo}`,
});
} else {
errors++;
const body = await res.json().catch(() => ({}));
details.push({
type: "error",
message: `${row.numEtud} : ${body.error ?? "Erreur creation"}`,
});
}
}
}
success.value = `Import terminé — ${imported} ajouté${
imported !== 1 ? "s" : ""
}${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`;
importResult.value = {
added,
modified: 0,
ignored: 0,
errors,
details,
};
} catch {
error.value = "Erreur lors de la lecture du fichier.";
} finally {
@@ -84,10 +110,7 @@ export default function UploadStudents() {
}
function downloadTemplate() {
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet([["numEtud", "nom", "prenom"]]);
XLSX.utils.book_append_sheet(wb, ws, "4A22");
XLSX.writeFile(wb, "modele_etudiants.xlsx");
globalThis.open("/templates/modele_etudiants.xlsx", "_blank");
}
return (
@@ -117,10 +140,12 @@ export default function UploadStudents() {
</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)}
/>
)}
<div class="upload-actions">
@@ -142,9 +167,8 @@ export default function UploadStudents() {
</div>
<p class="upload-format">
Format : <strong>promo</strong> (nom de la feuille) |{" "}
<strong>numEtud</strong> | <strong>nom</strong> |{" "}
<strong>prénom</strong>
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
<strong>Numero-etudiant</strong> | <strong>Promotion</strong>
</p>
</div>
);