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
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|