04be659d6b
Add routes for modules, users, notes import, recap, and islands edit. Update middleware to filter pages based on user role. feat(admin): add modal for assigning teaching, replace delete icon with SVG refactor(server): rename port variable to uppercase and add env support feat(admin): add enseignants, users, filtering and role colors refactor(AdminRoles): improve role UI and add permission mapping feat(admin-users): add role colors, role filter, and modal for creating users feat(admin): add EditModule component for module editing feat(admin): add EditUser page for editing users and managing enseignements feat(promo-select): display id and name in options for promo dropdown feat: add edit module/user routes, inline coeff editing, UI tweaks refactor: UI – icons, modal overlay, grid, subtitles, import margin
247 lines
7.8 KiB
TypeScript
247 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|