feat : fixed some page not being as described in the figma

This commit is contained in:
2026-04-27 11:21:32 +02:00
parent 56019ad372
commit 733259e317
13 changed files with 1757 additions and 235 deletions
@@ -1,20 +1,42 @@
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 [newId, setNewId] = useState("");
const [newAnnee, setNewAnnee] = useState("");
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()}`
: "";
async function load() {
try {
const res = await fetch("/students/api/promotions");
if (!res.ok) throw new Error("Impossible de charger les promotions");
setPromos(await res.json());
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 {
@@ -27,23 +49,22 @@ export default function AdminPromotions() {
}, []);
async function createPromo() {
if (!newId.trim()) return;
if (!generatedId) return;
setCreating(true);
try {
const res = await fetch("/students/api/promotions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idPromo: newId.trim(),
annee: newAnnee.trim() || null,
idPromo: generatedId,
annee: selectedAnnee,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setNewId("");
setNewAnnee("");
setAnneeSco("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
@@ -57,9 +78,7 @@ export default function AdminPromotions() {
try {
const res = await fetch(
`/students/api/promotions/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
@@ -68,36 +87,93 @@ export default function AdminPromotions() {
}
}
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>}
<div class="form-row">
<input
class="form-input"
placeholder="Identifiant (ex: 4A22)"
value={newId}
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
/>
<input
class="form-input"
placeholder="Année (ex: 2022-2023)"
value={newAnnee}
onInput={(e) => setNewAnnee((e.target as HTMLInputElement).value)}
style="min-width: 14rem"
/>
<button
type="button"
class="btn btn-primary"
onClick={createPromo}
disabled={creating}
>
+ Ajouter
</button>
{/* PromoBuilder */}
<div class="promo-builder">
<p class="promo-builder-title">Créer une promotion</p>
<p class="promo-builder-subtitle">
POST /promotions 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>
: (
@@ -105,35 +181,51 @@ export default function AdminPromotions() {
<table class="data-table">
<thead>
<tr>
<th>Identifiant</th>
<th>idPromo</th>
<th>Année</th>
<th>Action</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={3} class="state-empty">
<td colspan={6} class="state-empty">
Aucune promotion enregistrée
</td>
</tr>
)
: promos.map((p) => (
<tr key={p.id}>
<td class="col-promo">{p.id}</td>
<td>{p.annee ?? "—"}</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() => deletePromo(p.id)}
>
🗑
</button>
</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)}
>
🗑
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -0,0 +1,247 @@
import { useEffect, useState } from "preact/hooks";
type Student = {
numEtud: number;
nom: string;
prenom: string;
idPromo: string;
};
type Promo = { id: string; annee: string };
type Module = { id: string; nom: string };
type Props = { numEtud: number };
function anneeLabel(idPromo: string): string {
const m = idPromo.match(/^(\d+)A/);
if (!m) return "";
const n = m[1];
if (n === "3") return "3ème année";
if (n === "4") return "4ème année";
if (n === "5") return "5ème année";
return `${n}ème année`;
}
export default function EditStudents({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null);
const [promos, setPromos] = useState<Promo[]>([]);
const [_modules, setModules] = useState<Module[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Edit form state
const [nom, setNom] = useState("");
const [prenom, setPrenom] = useState("");
const [idPromo, setIdPromo] = useState("");
useEffect(() => {
async function load() {
try {
const [sRes, pRes, mRes] = await Promise.all([
fetch(`/students/api/students/${numEtud}`),
fetch("/students/api/promotions"),
fetch("/admin/api/modules"),
]);
if (!sRes.ok) throw new Error("Élève introuvable");
const s: Student = await sRes.json();
setStudent(s);
setNom(s.nom);
setPrenom(s.prenom);
setIdPromo(s.idPromo);
if (pRes.ok) setPromos(await pRes.json());
if (mRes.ok) setModules(await mRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
load();
}, [numEtud]);
async function saveInfos() {
if (!student) return;
setSaving(true);
setSaveMsg(null);
try {
const res = await fetch(`/students/api/students/${numEtud}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
nom: nom.trim(),
prenom: prenom.trim(),
idPromo,
}),
});
if (!res.ok) throw new Error("Modification échouée");
const updated: Student = await res.json();
setStudent(updated);
setSaveMsg("Informations enregistrées.");
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setSaving(false);
}
}
async function deleteStudent() {
if (!confirm(`Supprimer définitivement l'élève #${numEtud} ?`)) return;
try {
const res = await fetch(`/students/api/students/${numEtud}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Suppression échouée");
globalThis.location.href = "/students/consult";
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
if (loading) {
return (
<div class="page-content">
<p class="state-loading">Chargement</p>
</div>
);
}
if (error && !student) {
return (
<div class="page-content">
<p class="state-error">{error}</p>
</div>
);
}
if (!student) return null;
return (
<div class="page-content">
<a
class="back-link"
href="/students/consult"
f-partial="/students/partials/consult"
>
Retour à la liste
</a>
<h2 class="page-title" style="border-bottom: none; margin-bottom: 0.5rem">
Édition {student.prenom} {student.nom}
</h2>
{/* Info bar */}
<div class="info-bar">
<span class="numEtud-chip">{student.numEtud}</span>
<span>{student.idPromo}</span>
<span class="col-dim">{anneeLabel(student.idPromo)}</span>
</div>
{error && <p class="state-error">{error}</p>}
{saveMsg && (
<p style="font-size: 0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.5rem">
{saveMsg}
</p>
)}
{/* Section 1: Informations générales */}
<div class="edit-section">
<p class="edit-section-title">Informations générales</p>
<p class="edit-section-subtitle">PUT /students/{"{numEtud}"}</p>
<div class="form-grid">
<div class="form-field">
<label>Nom</label>
<input
class="form-input"
value={nom}
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>Prénom</label>
<input
class="form-input"
value={prenom}
onInput={(e) => setPrenom((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>N° Étudiant</label>
<input
class="form-input"
value={student.numEtud}
disabled
style="opacity: 0.6"
/>
</div>
<div class="form-field">
<label>Promo</label>
<select
class="filter-select"
value={idPromo}
onChange={(e) =>
setIdPromo((e.target as HTMLSelectElement).value)}
style="min-width: 0"
>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}
</option>)}
</select>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: space-between; flex-wrap: wrap">
<button
type="button"
class="btn btn-primary"
onClick={saveInfos}
disabled={saving}
>
{saving ? "…" : "Enregistrer infos"}
</button>
<button
type="button"
class="btn btn-danger"
onClick={deleteStudent}
>
Supprimer l'élève
</button>
</div>
</div>
{/* Section 2: Spécialisations */}
<div class="edit-section">
<p class="edit-section-title">Spécialisations</p>
<p class="edit-section-subtitle">
GET·POST·DELETE /spe5a plusieurs modules possibles
</p>
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
>
Fonctionnalité non disponible (endpoint non implémenté).
</p>
</div>
{/* Section 3: Notes lecture seule */}
<div class="edit-section">
<p class="edit-section-title">Notes (lecture seule)</p>
<p class="edit-section-subtitle">
GET /students/{"{numEtud}"}/notes voir récap complet
</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span class="col-dim" style="font-size: 0.82rem">
Voir le récap complet des notes et moyennes de cet étudiant
</span>
<a
class="btn btn-secondary"
href={`/notes/recap/${numEtud}`}
f-client-nav={false}
>
Récap notes
</a>
</div>
</div>
</div>
);
}