Files
PolyMPR/routes/(apps)/notes/(_islands)/AdminUEs.tsx
T
djalim 04be659d6b feat(app): add studentOnly pages and new routes
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
2026-04-29 09:12:55 +02:00

447 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "preact/hooks";
type UE = { id: number; nom: string };
type UEModule = {
idModule: string;
idUE: number;
idPromo: string;
coeff: number;
};
type Module = { id: string; nom: string };
type Promo = { id: string; annee: string };
export default function AdminUEs() {
const [ues, setUes] = useState<UE[]>([]);
const [ueModules, setUeModules] = useState<UEModule[]>([]);
const [modules, setModules] = useState<Module[]>([]);
const [promos, setPromos] = useState<Promo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUe, setSelectedUe] = useState<UE | null>(null);
// New UE form
const [newUeNom, setNewUeNom] = useState("");
const [creatingUe, setCreatingUe] = useState(false);
// Add UE-module form
const [addModuleId, setAddModuleId] = useState("");
const [addPromoId, setAddPromoId] = useState("");
const [addCoeff, setAddCoeff] = useState("1");
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
// Inline coeff editing
const [editingCoeff, setEditingCoeff] = useState<string | null>(null);
const [editCoeffValue, setEditCoeffValue] = useState("");
async function load() {
try {
const [uRes, umRes, mRes, pRes] = await Promise.all([
fetch("/notes/api/ues"),
fetch("/notes/api/ue-modules"),
fetch("/admin/api/modules"),
fetch("/students/api/promotions"),
]);
if (!uRes.ok) throw new Error("Impossible de charger les UEs");
const uesData: UE[] = await uRes.json();
setUes(uesData);
if (umRes.ok) setUeModules(await umRes.json());
if (mRes.ok) setModules(await mRes.json());
if (pRes.ok) setPromos(await pRes.json());
// Keep selection in sync
setSelectedUe((prev) =>
prev ? uesData.find((u) => u.id === prev.id) ?? null : null
);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function createUE() {
if (!newUeNom.trim()) return;
setCreatingUe(true);
try {
const res = await fetch("/notes/api/ues", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nom: newUeNom.trim() }),
});
if (!res.ok) throw new Error("Création échouée");
setNewUeNom("");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setCreatingUe(false);
}
}
async function deleteUeModule(
idModule: string,
idUE: number,
idPromo: string,
) {
if (!confirm("Supprimer ce module de la UE ?")) return;
try {
const res = await fetch(
`/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
encodeURIComponent(idPromo)
}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Suppression échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
}
}
async function addUeModule() {
if (!selectedUe || !addModuleId || !addPromoId) {
setAddError("Module et Promo sont requis");
return;
}
const coeff = parseFloat(addCoeff);
if (isNaN(coeff) || coeff <= 0) {
setAddError("Coefficient invalide");
return;
}
setAdding(true);
setAddError(null);
try {
const res = await fetch("/notes/api/ue-modules", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idModule: addModuleId,
idUE: selectedUe.id,
idPromo: addPromoId,
coeff,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Création échouée");
}
setAddModuleId("");
setAddPromoId("");
setAddCoeff("1");
await load();
} catch (e) {
setAddError(e instanceof Error ? e.message : "Erreur");
} finally {
setAdding(false);
}
}
async function updateCoeff(
idModule: string,
idUE: number,
idPromo: string,
coeff: number,
) {
try {
const res = await fetch(
`/notes/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
encodeURIComponent(idPromo)
}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ coeff }),
},
);
if (!res.ok) throw new Error("Modification échouée");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setEditingCoeff(null);
}
}
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
const selectedUeModules = selectedUe
? ueModules.filter((um) => um.idUE === selectedUe.id)
: [];
return (
<div class="page-content">
<h2 class="page-title">Gestion des UEs</h2>
<p
class="col-dim"
style="font-size: 0.78rem; margin: -0.5rem 0 1rem"
>
UE = Unité d'Enseignement regroupant plusieurs modules
</p>
{error && <p class="state-error">{error}</p>}
{loading
? <p class="state-loading">Chargement</p>
: (
<div class="ue-split">
{/* Left panel UE list */}
<div class="ue-panel-left">
<div class="panel-box">
<p class="panel-box-title">UEs existantes</p>
<div class="form-row" style="margin-bottom: 0.75rem">
<input
class="form-input"
placeholder="Nom de la nouvelle UE…"
value={newUeNom}
onInput={(e) =>
setNewUeNom((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && createUE()}
style="min-width: 0; flex: 1"
/>
</div>
<button
type="button"
class="btn btn-primary"
onClick={createUE}
disabled={creatingUe}
style="width: 100%; justify-content: center; margin-bottom: 0.5rem"
>
+ Nouvelle UE
</button>
<div>
{ues.map((ue) => (
<div
key={ue.id}
class={`ue-list-item${
selectedUe?.id === ue.id ? " active" : ""
}`}
onClick={() => {
setSelectedUe(ue);
setAddError(null);
}}
>
{ue.nom}
</div>
))}
{ues.length === 0 && (
<p class="state-empty" style="padding: 1rem 0">
Aucune UE
</p>
)}
</div>
</div>
</div>
{/* Right panel UE detail */}
<div class="ue-panel-right">
{selectedUe
? (
<div class="panel-box">
<p class="panel-box-title">{selectedUe.nom}</p>
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Modules assignés (UE_Module)
</p>
<div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table">
<thead>
<tr>
<th>Module</th>
<th>Promo</th>
<th>Coeff</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{selectedUeModules.length === 0
? (
<tr>
<td colspan={4} class="state-empty">
Aucun module assigné
</td>
</tr>
)
: selectedUeModules.map((um) => {
const mod = moduleMap[um.idModule];
return (
<tr
key={`${um.idModule}-${um.idPromo}`}
>
<td class="col-promo">
{mod
? `${mod.id} ${mod.nom}`
: um.idModule}
</td>
<td>
<span class="promo-chip">{um.idPromo}</span>
</td>
<td
onClick={() => {
const key =
`${um.idModule}-${um.idUE}-${um.idPromo}`;
setEditingCoeff(key);
setEditCoeffValue(String(um.coeff));
}}
style="cursor: pointer"
>
{editingCoeff ===
`${um.idModule}-${um.idUE}-${um.idPromo}`
? (
<input
type="number"
class="form-input"
value={editCoeffValue}
min="0.1"
step="0.5"
style="width: 5rem; padding: 0.2rem 0.4rem; font-size: 0.82rem"
autoFocus
onInput={(e) =>
setEditCoeffValue(
(e.target as HTMLInputElement)
.value,
)}
onBlur={() => {
const v = parseFloat(
editCoeffValue,
);
if (!isNaN(v) && v > 0) {
updateCoeff(
um.idModule,
um.idUE,
um.idPromo,
v,
);
} else {
setEditingCoeff(null);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
(e.target as HTMLInputElement)
.blur();
}
if (e.key === "Escape") {
setEditingCoeff(null);
}
}}
/>
)
: um.coeff}
</td>
<td>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
deleteUeModule(
um.idModule,
um.idUE,
um.idPromo,
)}
>
<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>
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un module à cette UE
</p>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
{addError}
</p>
)}
<div class="form-row">
<select
class="filter-select"
value={addModuleId}
onChange={(e) =>
setAddModuleId(
(e.target as HTMLSelectElement).value,
)}
style="min-width: 12rem"
>
<option value="">Module </option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} {m.nom}
</option>
))}
</select>
<select
class="filter-select"
value={addPromoId}
onChange={(e) =>
setAddPromoId(
(e.target as HTMLSelectElement).value,
)}
style="min-width: 9rem"
>
<option value="">Promo </option>
{promos.map((p) => (
<option key={p.id} value={p.id}>{p.id}</option>
))}
</select>
<input
type="number"
class="form-input"
placeholder="Coeff"
value={addCoeff}
min="0.1"
step="0.5"
onInput={(e) =>
setAddCoeff((e.target as HTMLInputElement).value)}
style="min-width: 5rem; max-width: 6rem"
/>
<button
type="button"
class="btn btn-primary"
onClick={addUeModule}
disabled={adding}
>
{adding ? "…" : "+ Ajouter"}
</button>
</div>
</div>
)
: (
<div class="panel-box">
<p class="state-empty" style="padding: 2rem 0">
Sélectionnez une UE pour voir ses modules
</p>
</div>
)}
</div>
</div>
)}
</div>
);
}