feat : fixed some page not being as described in the figma
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Enseignement = { idProf: string; idModule: string; idPromo: string };
|
||||
type Module = { id: string; nom: string };
|
||||
type Promo = { id: string; annee: string };
|
||||
|
||||
export default function AdminEnseignements() {
|
||||
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [promos, setPromos] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [filterPromo, setFilterPromo] = useState("");
|
||||
const [filterModule, setFilterModule] = useState("");
|
||||
const [filterEnseignant, setFilterEnseignant] = useState("");
|
||||
|
||||
// Add form
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addPromo, setAddPromo] = useState("");
|
||||
const [addModule, setAddModule] = useState("");
|
||||
const [addProf, setAddProf] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [eRes, mRes, pRes] = await Promise.all([
|
||||
fetch("/admin/api/enseignements"),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!eRes.ok) throw new Error("Impossible de charger les enseignements");
|
||||
setEnseignements(await eRes.json());
|
||||
if (mRes.ok) setModules(await mRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function deleteEnseignement(
|
||||
idProf: string,
|
||||
idModule: string,
|
||||
idPromo: string,
|
||||
) {
|
||||
if (
|
||||
!confirm(
|
||||
`Supprimer l'assignation ${idProf} → ${idModule} / ${idPromo} ?`,
|
||||
)
|
||||
) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/enseignements/${encodeURIComponent(idProf)}/${
|
||||
encodeURIComponent(idModule)
|
||||
}/${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 addEnseignement() {
|
||||
if (!addProf.trim() || !addModule || !addPromo) {
|
||||
setAddError("Tous les champs sont requis");
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
const res = await fetch("/admin/api/enseignements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idProf: addProf.trim(),
|
||||
idModule: addModule,
|
||||
idPromo: addPromo,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAddProf("");
|
||||
setAddModule("");
|
||||
setAddPromo("");
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
|
||||
|
||||
const filtered = enseignements.filter((e) => {
|
||||
const matchPromo = !filterPromo || e.idPromo === filterPromo;
|
||||
const matchModule = !filterModule || e.idModule === filterModule;
|
||||
const matchEns = !filterEnseignant ||
|
||||
e.idProf.toLowerCase().includes(filterEnseignant.toLowerCase());
|
||||
return matchPromo && matchModule && matchEns;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Assignations Enseignant → Module / Promo</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="filters">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={filterPromo}
|
||||
onChange={(e) =>
|
||||
setFilterPromo((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Promo ▾</option>
|
||||
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={filterModule}
|
||||
onChange={(e) =>
|
||||
setFilterModule((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Module ▾</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.id} – {m.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
class="filter-input"
|
||||
placeholder="Enseignant ▾"
|
||||
value={filterEnseignant}
|
||||
onInput={(e) =>
|
||||
setFilterEnseignant((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setFilterPromo("");
|
||||
setFilterModule("");
|
||||
setFilterEnseignant("");
|
||||
}}
|
||||
>
|
||||
Filtrer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={() => setShowAdd((v) => !v)}
|
||||
style="margin-left: auto"
|
||||
>
|
||||
+ Assigner
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div class="form-row" style="margin-bottom: 1.25rem">
|
||||
{addError && (
|
||||
<span class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addPromo}
|
||||
onChange={(e) => setAddPromo((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 10rem"
|
||||
>
|
||||
<option value="">Promo…</option>
|
||||
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addModule}
|
||||
onChange={(e) =>
|
||||
setAddModule((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 14rem"
|
||||
>
|
||||
<option value="">Module…</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.id} – {m.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="User ID enseignant…"
|
||||
value={addProf}
|
||||
onInput={(e) => setAddProf((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 10rem"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addEnseignement}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "…" : "Créer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={() => setShowAdd(false)}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Promo</th>
|
||||
<th>Module</th>
|
||||
<th>Enseignant (User.id)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={4} class="state-empty">
|
||||
Aucun enseignement trouvé
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filtered.map((e) => {
|
||||
const mod = moduleMap[e.idModule];
|
||||
return (
|
||||
<tr key={`${e.idProf}-${e.idModule}-${e.idPromo}`}>
|
||||
<td>
|
||||
<span class="promo-chip">{e.idPromo}</span>
|
||||
</td>
|
||||
<td class="col-promo">
|
||||
{mod ? `${mod.id} – ${mod.nom}` : e.idModule}
|
||||
</td>
|
||||
<td>{e.idProf}</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
deleteEnseignement(
|
||||
e.idProf,
|
||||
e.idModule,
|
||||
e.idPromo,
|
||||
)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="info-note">
|
||||
<p>
|
||||
Un même module peut être enseigné par plusieurs utilisateurs sur une
|
||||
même promo.
|
||||
</p>
|
||||
<p class="info-note-dim">
|
||||
Clé composite = idProf (User.Id) + idModule + idPromo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Perm = { id: string; nom: string };
|
||||
type Role = { id: number; nom: string; permissions: string[] };
|
||||
|
||||
export default function AdminPermissions() {
|
||||
const [permissions, setPermissions] = useState<Perm[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [pRes, rRes] = await Promise.all([
|
||||
fetch("/admin/api/permissions"),
|
||||
fetch("/admin/api/roles"),
|
||||
]);
|
||||
if (!pRes.ok) throw new Error("Impossible de charger les permissions");
|
||||
setPermissions(await pRes.json());
|
||||
if (rRes.ok) setRoles(await rRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
function rolesForPerm(permId: string): Role[] {
|
||||
return roles.filter((r) => r.permissions.includes(permId));
|
||||
}
|
||||
|
||||
const MAX_ROLE_CHIPS = 2;
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Permissions</h2>
|
||||
|
||||
<div class="info-note" style="margin-top: 0; margin-bottom: 1.25rem">
|
||||
<p>
|
||||
Les permissions sont définies statiquement par le serveur.
|
||||
</p>
|
||||
<p class="info-note-dim">
|
||||
Elles ne peuvent pas être créées ou supprimées via l'API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>idPermission</th>
|
||||
<th>nomPermission</th>
|
||||
<th>Rôles associés</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => {
|
||||
const associated = rolesForPerm(p.id);
|
||||
const shown = associated.slice(0, MAX_ROLE_CHIPS);
|
||||
const overflow = associated.length - MAX_ROLE_CHIPS;
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td>
|
||||
<span
|
||||
class="col-promo"
|
||||
style="font-family: monospace"
|
||||
>
|
||||
{p.id}
|
||||
</span>
|
||||
</td>
|
||||
<td>{p.nom}</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
|
||||
{shown.map((r) => (
|
||||
<span key={r.id} class="role-chip">{r.nom}</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
class="col-dim"
|
||||
style="font-size: 0.72rem; margin-left: 0.2rem"
|
||||
>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
{associated.length === 0 && (
|
||||
<span class="col-dim">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Role = { id: number; nom: string };
|
||||
type Permission = { id: string; nom: string };
|
||||
type Role = { id: number; nom: string; permissions: string[] };
|
||||
type Perm = { id: string; nom: string };
|
||||
|
||||
const MAX_CHIPS = 3;
|
||||
|
||||
export default function AdminRoles() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [permissions, setPermissions] = useState<Perm[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newNom, setNewNom] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [editNom, setEditNom] = useState("");
|
||||
|
||||
// Manage-perms sub-view
|
||||
const [managingRole, setManagingRole] = useState<Role | null>(null);
|
||||
const [editPerms, setEditPerms] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -55,21 +61,6 @@ export default function AdminRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(id: number) {
|
||||
try {
|
||||
const res = await fetch(`/admin/api/roles/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nom: editNom.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Modification échouée");
|
||||
setEditId(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRole(id: number) {
|
||||
if (!confirm("Supprimer ce rôle ?")) return;
|
||||
try {
|
||||
@@ -81,19 +72,143 @@ export default function AdminRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
function openManage(role: Role) {
|
||||
setManagingRole(role);
|
||||
setEditPerms(new Set(role.permissions));
|
||||
setSaveError(null);
|
||||
}
|
||||
|
||||
function togglePerm(permId: string) {
|
||||
setEditPerms((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(permId)) next.delete(permId);
|
||||
else next.add(permId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function savePerms() {
|
||||
if (!managingRole) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const res = await fetch(`/admin/api/roles/${managingRole.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
nom: managingRole.nom,
|
||||
permissions: [...editPerms],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Enregistrement échoué");
|
||||
await load();
|
||||
setManagingRole(null);
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Manage-perms view ----
|
||||
if (managingRole) {
|
||||
const activeCount = editPerms.size;
|
||||
return (
|
||||
<div class="page-content">
|
||||
<a
|
||||
class="back-link"
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setManagingRole(null);
|
||||
}}
|
||||
>
|
||||
← Retour à la liste des rôles
|
||||
</a>
|
||||
<h2 class="page-title">
|
||||
Permissions du rôle – {managingRole.nom}
|
||||
</h2>
|
||||
|
||||
{saveError && <p class="state-error">{saveError}</p>}
|
||||
|
||||
<div
|
||||
class="toolbar"
|
||||
style="margin-bottom: 1.25rem; align-items: center"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 0.6rem">
|
||||
<span class="numEtud-chip">idRole : {managingRole.id}</span>
|
||||
<span style="font-weight: var(--font-weight-bold); font-size: 0.9rem">
|
||||
{managingRole.nom}
|
||||
</span>
|
||||
<span style="font-size: 0.8rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
|
||||
{activeCount} permission{activeCount !== 1 ? "s" : ""} active
|
||||
{activeCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={savePerms}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "…" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 0.5rem; display: flex; justify-content: space-between">
|
||||
<span style="font-size: 0.78rem; font-weight: var(--font-weight-bold)">
|
||||
Permissions disponibles
|
||||
</span>
|
||||
<span style="font-size: 0.72rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
|
||||
Activer = inclure dans le rôle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="perm-toggle-grid">
|
||||
{permissions.map((p) => {
|
||||
const active = editPerms.has(p.id);
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
class={`perm-toggle-card${active ? " active" : ""}`}
|
||||
onClick={() => togglePerm(p.id)}
|
||||
>
|
||||
<div class="perm-toggle-label">
|
||||
<span class="perm-toggle-id">{p.id}</span>
|
||||
<span class="perm-toggle-nom">{p.nom}</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={() => togglePerm(p.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="toggle-slider" />
|
||||
</label>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Main list view ----
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Rôles</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="toolbar">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom du rôle"
|
||||
placeholder="Nom du rôle…"
|
||||
value={newNom}
|
||||
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createRole()}
|
||||
style="min-width: 14rem"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -101,7 +216,7 @@ export default function AdminRoles() {
|
||||
onClick={createRole}
|
||||
disabled={creating}
|
||||
>
|
||||
+ Ajouter
|
||||
+ Créer rôle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -112,8 +227,9 @@ export default function AdminRoles() {
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nom</th>
|
||||
<th>idRole</th>
|
||||
<th>Nom du rôle</th>
|
||||
<th>Permissions</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -121,105 +237,60 @@ export default function AdminRoles() {
|
||||
{roles.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={3} class="state-empty">
|
||||
<td colspan={4} class="state-empty">
|
||||
Aucun rôle enregistré
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: roles.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td class="col-dim">{r.id}</td>
|
||||
<td>
|
||||
{editId === r.id
|
||||
? (
|
||||
<input
|
||||
class="form-input"
|
||||
value={editNom}
|
||||
onInput={(e) =>
|
||||
setEditNom(
|
||||
(e.target as HTMLInputElement).value,
|
||||
)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
)
|
||||
: r.nom}
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
{editId === r.id
|
||||
? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
onClick={() => saveEdit(r.id)}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
onClick={() => setEditId(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
onClick={() => {
|
||||
setEditId(r.id);
|
||||
setEditNom(r.nom);
|
||||
}}
|
||||
>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteRole(r.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</>
|
||||
: roles.map((r) => {
|
||||
const shown = r.permissions.slice(0, MAX_CHIPS);
|
||||
const overflow = r.permissions.length - MAX_CHIPS;
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td class="col-dim">{r.id}</td>
|
||||
<td style="font-weight: var(--font-weight-bold)">
|
||||
{r.nom}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.1rem">
|
||||
{shown.map((p) => (
|
||||
<span key={p} class="perm-chip">{p}</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
class="col-dim"
|
||||
style="font-size: 0.72rem; margin-left: 0.2rem"
|
||||
>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
onClick={() => openManage(r)}
|
||||
>
|
||||
⚙ Gérer perms
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteRole(r.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permissions.length > 0 && (
|
||||
<div style="margin-top: 2rem">
|
||||
<h3 style="font-size: 0.9rem; font-weight: var(--font-weight-bold); margin-bottom: 0.75rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
|
||||
Permissions disponibles
|
||||
</h3>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nom</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td class="col-dim">{p.id}</td>
|
||||
<td>{p.nom}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ const properties: AppProperties = {
|
||||
icon: "school",
|
||||
pages: {
|
||||
index: "Accueil",
|
||||
modules: "Modules",
|
||||
users: "Utilisateurs",
|
||||
roles: "Rôles",
|
||||
permissions: "Permissions",
|
||||
modules: "Modules",
|
||||
enseignements: "Enseignements",
|
||||
},
|
||||
adminOnly: ["modules", "users", "roles"],
|
||||
adminOnly: ["users", "roles", "permissions", "modules", "enseignements"],
|
||||
hint: "PolyMPR module",
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,22 @@ const CONFLICT = new Response(
|
||||
);
|
||||
|
||||
export const handler: Handlers<null, AuthenticatedState> = {
|
||||
// GET /enseignements
|
||||
async GET(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return new Response(JSON.stringify([]), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const rows = await db.select().from(enseignements);
|
||||
return new Response(JSON.stringify(rows), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
// #29 POST /enseignements
|
||||
async POST(
|
||||
request: Request,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminEnseignements from "../(_islands)/AdminEnseignements.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Enseignements(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminEnseignements />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Enseignements);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminPermissions from "../(_islands)/AdminPermissions.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Permissions(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminPermissions />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Permissions);
|
||||
@@ -1,19 +1,54 @@
|
||||
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 [newNom, setNewNom] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
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);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/notes/api/ues");
|
||||
if (!res.ok) throw new Error("Impossible de charger les UEs");
|
||||
setUes(await res.json());
|
||||
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 {
|
||||
@@ -26,28 +61,37 @@ export default function AdminUEs() {
|
||||
}, []);
|
||||
|
||||
async function createUE() {
|
||||
if (!newNom.trim()) return;
|
||||
setCreating(true);
|
||||
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: newNom.trim() }),
|
||||
body: JSON.stringify({ nom: newUeNom.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Création échouée");
|
||||
setNewNom("");
|
||||
setNewUeNom("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
setCreatingUe(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUE(id: number) {
|
||||
if (!confirm("Supprimer cette UE ?")) return;
|
||||
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/ues/${id}`, { method: "DELETE" });
|
||||
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) {
|
||||
@@ -55,68 +99,247 @@ export default function AdminUEs() {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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>}
|
||||
|
||||
<div class="form-row">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom de la nouvelle UE"
|
||||
value={newNom}
|
||||
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createUE()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createUE}
|
||||
disabled={creating}
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nom</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ues.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={3} class="state-empty">
|
||||
Aucune UE enregistrée
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: ues.map((ue) => (
|
||||
<tr key={ue.id}>
|
||||
<td class="col-dim">{ue.id}</td>
|
||||
<td>{ue.nom}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteUE(ue.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{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>{um.coeff}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
deleteUeModule(
|
||||
um.idModule,
|
||||
um.idUE,
|
||||
um.idPromo,
|
||||
)}
|
||||
>
|
||||
🗑
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import EditStudents from "../(_islands)/EditStudents.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export default async function EditPage(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
) {
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
return <EditStudents numEtud={numEtud} />;
|
||||
}
|
||||
Reference in New Issue
Block a user