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
This commit is contained in:
@@ -19,6 +19,7 @@ export interface AppProperties {
|
||||
icon: string;
|
||||
pages: Record<string, string>;
|
||||
adminOnly: string[];
|
||||
studentOnly?: string[];
|
||||
hint: string;
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -15,12 +15,14 @@ import * as $_apps_admin_api_roles_idRole_ from "./routes/(apps)/admin/api/roles
|
||||
import * as $_apps_admin_api_users from "./routes/(apps)/admin/api/users.ts";
|
||||
import * as $_apps_admin_api_users_id_ from "./routes/(apps)/admin/api/users/[id].ts";
|
||||
import * as $_apps_admin_index from "./routes/(apps)/admin/index.tsx";
|
||||
import * as $_apps_admin_modules_idModule_ from "./routes/(apps)/admin/modules/[idModule].tsx";
|
||||
import * as $_apps_admin_partials_enseignements from "./routes/(apps)/admin/partials/enseignements.tsx";
|
||||
import * as $_apps_admin_partials_index from "./routes/(apps)/admin/partials/index.tsx";
|
||||
import * as $_apps_admin_partials_modules from "./routes/(apps)/admin/partials/modules.tsx";
|
||||
import * as $_apps_admin_partials_permissions from "./routes/(apps)/admin/partials/permissions.tsx";
|
||||
import * as $_apps_admin_partials_roles from "./routes/(apps)/admin/partials/roles.tsx";
|
||||
import * as $_apps_admin_partials_users from "./routes/(apps)/admin/partials/users.tsx";
|
||||
import * as $_apps_admin_users_id_ from "./routes/(apps)/admin/users/[id].tsx";
|
||||
import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts";
|
||||
import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx";
|
||||
import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx";
|
||||
@@ -30,18 +32,19 @@ import * as $_apps_notes_api_ajustements from "./routes/(apps)/notes/api/ajustem
|
||||
import * as $_apps_notes_api_ajustements_numEtud_idUE_ from "./routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts";
|
||||
import * as $_apps_notes_api_notes from "./routes/(apps)/notes/api/notes.ts";
|
||||
import * as $_apps_notes_api_notes_numEtud_idModule_ from "./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts";
|
||||
import * as $_apps_notes_api_notes_import_xlsx from "./routes/(apps)/notes/api/notes/import-xlsx.ts";
|
||||
import * as $_apps_notes_api_ue_modules from "./routes/(apps)/notes/api/ue-modules.ts";
|
||||
import * as $_apps_notes_api_ue_modules_idModule_idUE_idPromo_ from "./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts";
|
||||
import * as $_apps_notes_api_ues from "./routes/(apps)/notes/api/ues.ts";
|
||||
import * as $_apps_notes_api_ues_idUE_ from "./routes/(apps)/notes/api/ues/[idUE].ts";
|
||||
import * as $_apps_notes_edition_numEtud_ from "./routes/(apps)/notes/edition/[numEtud].tsx";
|
||||
import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx";
|
||||
import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx";
|
||||
import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx";
|
||||
import * as $_apps_notes_partials_admin_import from "./routes/(apps)/notes/partials/(admin)/import.tsx";
|
||||
import * as $_apps_notes_partials_admin_ues from "./routes/(apps)/notes/partials/(admin)/ues.tsx";
|
||||
import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx";
|
||||
import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx";
|
||||
import * as $_apps_notes_recap_numEtud_ from "./routes/(apps)/notes/recap/[numEtud].tsx";
|
||||
import * as $_apps_students_api_promotions from "./routes/(apps)/students/api/promotions.ts";
|
||||
import * as $_apps_students_api_promotions_idPromo_ from "./routes/(apps)/students/api/promotions/[idPromo].ts";
|
||||
import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts";
|
||||
@@ -70,6 +73,8 @@ import * as $_apps_admin_islands_AdminModules from "./routes/(apps)/admin/(_isla
|
||||
import * as $_apps_admin_islands_AdminPermissions from "./routes/(apps)/admin/(_islands)/AdminPermissions.tsx";
|
||||
import * as $_apps_admin_islands_AdminRoles from "./routes/(apps)/admin/(_islands)/AdminRoles.tsx";
|
||||
import * as $_apps_admin_islands_AdminUsers from "./routes/(apps)/admin/(_islands)/AdminUsers.tsx";
|
||||
import * as $_apps_admin_islands_EditModule from "./routes/(apps)/admin/(_islands)/EditModule.tsx";
|
||||
import * as $_apps_admin_islands_EditUser from "./routes/(apps)/admin/(_islands)/EditUser.tsx";
|
||||
import * as $_apps_mobility_islands_ConsultMobility from "./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx";
|
||||
import * as $_apps_mobility_islands_EditMobility from "./routes/(apps)/mobility/(_islands)/EditMobility.tsx";
|
||||
import * as $_apps_mobility_islands_ImportFile from "./routes/(apps)/mobility/(_islands)/ImportFile.tsx";
|
||||
@@ -103,6 +108,8 @@ const manifest = {
|
||||
"./routes/(apps)/admin/api/users.ts": $_apps_admin_api_users,
|
||||
"./routes/(apps)/admin/api/users/[id].ts": $_apps_admin_api_users_id_,
|
||||
"./routes/(apps)/admin/index.tsx": $_apps_admin_index,
|
||||
"./routes/(apps)/admin/modules/[idModule].tsx":
|
||||
$_apps_admin_modules_idModule_,
|
||||
"./routes/(apps)/admin/partials/enseignements.tsx":
|
||||
$_apps_admin_partials_enseignements,
|
||||
"./routes/(apps)/admin/partials/index.tsx": $_apps_admin_partials_index,
|
||||
@@ -111,6 +118,7 @@ const manifest = {
|
||||
$_apps_admin_partials_permissions,
|
||||
"./routes/(apps)/admin/partials/roles.tsx": $_apps_admin_partials_roles,
|
||||
"./routes/(apps)/admin/partials/users.tsx": $_apps_admin_partials_users,
|
||||
"./routes/(apps)/admin/users/[id].tsx": $_apps_admin_users_id_,
|
||||
"./routes/(apps)/mobility/api/insert_mobility.ts":
|
||||
$_apps_mobility_api_insert_mobility,
|
||||
"./routes/(apps)/mobility/index.tsx": $_apps_mobility_index,
|
||||
@@ -126,6 +134,8 @@ const manifest = {
|
||||
"./routes/(apps)/notes/api/notes.ts": $_apps_notes_api_notes,
|
||||
"./routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts":
|
||||
$_apps_notes_api_notes_numEtud_idModule_,
|
||||
"./routes/(apps)/notes/api/notes/import-xlsx.ts":
|
||||
$_apps_notes_api_notes_import_xlsx,
|
||||
"./routes/(apps)/notes/api/ue-modules.ts": $_apps_notes_api_ue_modules,
|
||||
"./routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts":
|
||||
$_apps_notes_api_ue_modules_idModule_idUE_idPromo_,
|
||||
@@ -134,7 +144,6 @@ const manifest = {
|
||||
"./routes/(apps)/notes/edition/[numEtud].tsx":
|
||||
$_apps_notes_edition_numEtud_,
|
||||
"./routes/(apps)/notes/index.tsx": $_apps_notes_index,
|
||||
"./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_,
|
||||
"./routes/(apps)/notes/partials/(admin)/courses.tsx":
|
||||
$_apps_notes_partials_admin_courses,
|
||||
"./routes/(apps)/notes/partials/(admin)/import.tsx":
|
||||
@@ -143,6 +152,7 @@ const manifest = {
|
||||
$_apps_notes_partials_admin_ues,
|
||||
"./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index,
|
||||
"./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes,
|
||||
"./routes/(apps)/notes/recap/[numEtud].tsx": $_apps_notes_recap_numEtud_,
|
||||
"./routes/(apps)/students/api/promotions.ts":
|
||||
$_apps_students_api_promotions,
|
||||
"./routes/(apps)/students/api/promotions/[idPromo].ts":
|
||||
@@ -187,6 +197,10 @@ const manifest = {
|
||||
$_apps_admin_islands_AdminRoles,
|
||||
"./routes/(apps)/admin/(_islands)/AdminUsers.tsx":
|
||||
$_apps_admin_islands_AdminUsers,
|
||||
"./routes/(apps)/admin/(_islands)/EditModule.tsx":
|
||||
$_apps_admin_islands_EditModule,
|
||||
"./routes/(apps)/admin/(_islands)/EditUser.tsx":
|
||||
$_apps_admin_islands_EditUser,
|
||||
"./routes/(apps)/mobility/(_islands)/ConsultMobility.tsx":
|
||||
$_apps_mobility_islands_ConsultMobility,
|
||||
"./routes/(apps)/mobility/(_islands)/EditMobility.tsx":
|
||||
|
||||
@@ -22,13 +22,18 @@ export const handler: MiddlewareHandler<AuthenticatedState>[] = [
|
||||
)).default;
|
||||
|
||||
context.state.availablePages = properties.pages;
|
||||
if (
|
||||
const isStudent =
|
||||
context.state.session.eduPersonPrimaryAffiliation == "student" &&
|
||||
Deno.env.get("LOCAL") != "true"
|
||||
) {
|
||||
Deno.env.get("LOCAL") != "true";
|
||||
|
||||
if (isStudent) {
|
||||
properties.adminOnly.forEach((page) =>
|
||||
delete context.state.availablePages[page]
|
||||
);
|
||||
} else {
|
||||
properties.studentOnly?.forEach((page) =>
|
||||
delete context.state.availablePages[page]
|
||||
);
|
||||
}
|
||||
|
||||
return await context.next();
|
||||
|
||||
@@ -169,55 +169,77 @@ export default function AdminEnseignements() {
|
||||
</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 class="modal-overlay" onClick={() => setShowAdd(false)}>
|
||||
<div class="modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<p class="modal-title">Assigner un enseignement</p>
|
||||
{addError && (
|
||||
<p class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
<div class="modal-form">
|
||||
<div class="form-field">
|
||||
<label>Promo</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addPromo}
|
||||
onChange={(e) =>
|
||||
setAddPromo((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
>
|
||||
<option value="">Promo...</option>
|
||||
{promos.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Module</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addModule}
|
||||
onChange={(e) =>
|
||||
setAddModule((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
>
|
||||
<option value="">Module...</option>
|
||||
{modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id} -- {m.nom}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>User ID enseignant</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="User ID enseignant..."
|
||||
value={addProf}
|
||||
onInput={(e) =>
|
||||
setAddProf((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={() => setShowAdd(false)}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addEnseignement}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "..." : "+ Assigner"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -266,7 +288,24 @@ export default function AdminEnseignements() {
|
||||
e.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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Module = { id: string; nom: string };
|
||||
type Enseignement = { idProf: string; idModule: string; idPromo: string };
|
||||
type User = { id: string; nom: string; prenom: string };
|
||||
|
||||
export default function AdminModules() {
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newId, setNewId] = useState("");
|
||||
const [newNom, setNewNom] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editNom, setEditNom] = useState("");
|
||||
const [filterNom, setFilterNom] = useState("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/admin/api/modules");
|
||||
if (!res.ok) throw new Error("Impossible de charger les modules");
|
||||
setModules(await res.json());
|
||||
const [mRes, eRes, uRes] = await Promise.all([
|
||||
fetch("/admin/api/modules"),
|
||||
fetch("/admin/api/enseignements"),
|
||||
fetch("/admin/api/users"),
|
||||
]);
|
||||
if (!mRes.ok) throw new Error("Impossible de charger les modules");
|
||||
setModules(await mRes.json());
|
||||
if (eRes.ok) setEnseignements(await eRes.json());
|
||||
if (uRes.ok) setUsers(await uRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
@@ -51,21 +60,6 @@ export default function AdminModules() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/admin/api/modules/${encodeURIComponent(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 deleteModule(id: string) {
|
||||
if (!confirm(`Supprimer le module ${id} ?`)) return;
|
||||
try {
|
||||
@@ -80,125 +74,181 @@ export default function AdminModules() {
|
||||
}
|
||||
}
|
||||
|
||||
const userMap = Object.fromEntries(
|
||||
users.map((u) => [u.id, u]),
|
||||
);
|
||||
|
||||
function enseignantsForModule(moduleId: string): string {
|
||||
const profs = [
|
||||
...new Set(
|
||||
enseignements
|
||||
.filter((e) => e.idModule === moduleId)
|
||||
.map((e) => e.idProf),
|
||||
),
|
||||
];
|
||||
if (profs.length === 0) return "";
|
||||
return profs
|
||||
.map((id) => {
|
||||
const u = userMap[id];
|
||||
return u ? `${u.nom} ${u.prenom.charAt(0)}.` : id;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
const filtered = modules.filter((m) =>
|
||||
!filterNom ||
|
||||
`${m.id} ${m.nom}`.toLowerCase().includes(filterNom.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Modules</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="filters">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Identifiant (ex: JIA3)"
|
||||
value={newId}
|
||||
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 10rem"
|
||||
/>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom du module"
|
||||
value={newNom}
|
||||
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
|
||||
class="filter-input"
|
||||
placeholder="Rechercher..."
|
||||
value={filterNom}
|
||||
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createModule}
|
||||
disabled={creating}
|
||||
onClick={() => {
|
||||
const el = document.getElementById("new-module-section");
|
||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||
}}
|
||||
style="margin-left: auto"
|
||||
>
|
||||
+ Ajouter
|
||||
+ Ajouter module
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
? <p class="state-loading">Chargement...</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Identifiant</th>
|
||||
<th>Nom</th>
|
||||
<th>id (code)</th>
|
||||
<th>Nom du module</th>
|
||||
<th>Enseignants assignes</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modules.length === 0
|
||||
{filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={3} class="state-empty">
|
||||
<td colspan={4} class="state-empty">
|
||||
Aucun module enregistré
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: modules.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td class="col-dim">{m.id}</td>
|
||||
<td>
|
||||
{editId === m.id
|
||||
? (
|
||||
<input
|
||||
class="form-input"
|
||||
value={editNom}
|
||||
onInput={(e) =>
|
||||
setEditNom(
|
||||
(e.target as HTMLInputElement).value,
|
||||
)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
)
|
||||
: m.nom}
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
{editId === m.id
|
||||
: filtered.map((m) => {
|
||||
const profs = enseignantsForModule(m.id);
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td class="col-dim">{m.id}</td>
|
||||
<td>{m.nom}</td>
|
||||
<td>
|
||||
{profs
|
||||
? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
onClick={() => saveEdit(m.id)}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
onClick={() => setEditId(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
<span style="font-size: 0.78rem">
|
||||
{profs}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
onClick={() => {
|
||||
setEditId(m.id);
|
||||
setEditNom(m.nom);
|
||||
}}
|
||||
>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteModule(m.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
: <span class="col-dim">--</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
<a
|
||||
class="btn btn-sm btn-secondary"
|
||||
href={`/admin/modules/${
|
||||
encodeURIComponent(m.id)
|
||||
}`}
|
||||
f-client-nav={false}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>{" "}
|
||||
edit
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteModule(m.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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nouveau module */}
|
||||
<div
|
||||
id="new-module-section"
|
||||
class="edit-section"
|
||||
style="margin-top: 1.5rem"
|
||||
>
|
||||
<p class="edit-section-title">Nouveau module</p>
|
||||
<div class="form-row">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Code"
|
||||
value={newId}
|
||||
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 8rem; max-width: 10rem"
|
||||
/>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom du module"
|
||||
value={newNom}
|
||||
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createModule}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? "..." : "+ Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,19 @@ import { useEffect, useState } from "preact/hooks";
|
||||
type Perm = { id: string; nom: string };
|
||||
type Role = { id: number; nom: string; permissions: string[] };
|
||||
|
||||
const ROLE_COLORS = [
|
||||
"#22c55e",
|
||||
"#d4a017",
|
||||
"#e07020",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#ec4899",
|
||||
];
|
||||
|
||||
function roleColor(roleId: number): string {
|
||||
return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length];
|
||||
}
|
||||
|
||||
export default function AdminPermissions() {
|
||||
const [permissions, setPermissions] = useState<Perm[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
@@ -80,7 +93,15 @@ export default function AdminPermissions() {
|
||||
<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>
|
||||
<span
|
||||
key={r.id}
|
||||
class="role-chip"
|
||||
style={`border-color: ${
|
||||
roleColor(r.id)
|
||||
}; color: ${roleColor(r.id)}`}
|
||||
>
|
||||
{r.nom}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
|
||||
@@ -131,16 +131,13 @@ export default function AdminRoles() {
|
||||
|
||||
{saveError && <p class="state-error">{saveError}</p>}
|
||||
|
||||
<div
|
||||
class="toolbar"
|
||||
style="margin-bottom: 1.25rem; align-items: center"
|
||||
>
|
||||
<div class="perm-header-bar">
|
||||
<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))">
|
||||
<span style="font-size: 0.8rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color))">
|
||||
{activeCount} permission{activeCount !== 1 ? "s" : ""} active
|
||||
{activeCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
@@ -151,7 +148,7 @@ export default function AdminRoles() {
|
||||
onClick={savePerms}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "…" : "Enregistrer"}
|
||||
{saving ? "..." : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -192,6 +189,8 @@ export default function AdminRoles() {
|
||||
);
|
||||
}
|
||||
|
||||
const permMap = Object.fromEntries(permissions.map((p) => [p.id, p.nom]));
|
||||
|
||||
// ---- Main list view ----
|
||||
return (
|
||||
<div class="page-content">
|
||||
@@ -202,7 +201,7 @@ export default function AdminRoles() {
|
||||
<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()}
|
||||
@@ -219,7 +218,7 @@ export default function AdminRoles() {
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
? <p class="state-loading">Chargement...</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
@@ -252,7 +251,9 @@ export default function AdminRoles() {
|
||||
<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>
|
||||
<span key={p} class="perm-chip">
|
||||
{permMap[p] ?? p}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
@@ -268,17 +269,45 @@ export default function AdminRoles() {
|
||||
<div class="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
class="btn btn-sm btn-primary"
|
||||
onClick={() => openManage(r)}
|
||||
>
|
||||
⚙ Gérer perms
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
||||
</svg>{" "}
|
||||
Gérer perms
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteRole(r.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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -3,11 +3,25 @@ import { useEffect, useState } from "preact/hooks";
|
||||
type User = { id: string; nom: string; prenom: string; idRole: number | null };
|
||||
type Role = { id: number; nom: string };
|
||||
|
||||
const ROLE_COLORS = [
|
||||
"#22c55e",
|
||||
"#d4a017",
|
||||
"#e07020",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#ec4899",
|
||||
];
|
||||
|
||||
function roleColor(roleId: number): string {
|
||||
return ROLE_COLORS[(roleId - 1) % ROLE_COLORS.length];
|
||||
}
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newId, setNewId] = useState("");
|
||||
const [newNom, setNewNom] = useState("");
|
||||
const [newPrenom, setNewPrenom] = useState("");
|
||||
@@ -15,6 +29,7 @@ export default function AdminUsers() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const [filterNom, setFilterNom] = useState("");
|
||||
const [filterRole, setFilterRole] = useState("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -58,6 +73,7 @@ export default function AdminUsers() {
|
||||
setNewNom("");
|
||||
setNewPrenom("");
|
||||
setNewIdRole("");
|
||||
setShowCreate(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
@@ -81,12 +97,14 @@ export default function AdminUsers() {
|
||||
|
||||
const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom]));
|
||||
|
||||
const filtered = users.filter((u) =>
|
||||
!filterNom ||
|
||||
`${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes(
|
||||
filterNom.toLowerCase(),
|
||||
)
|
||||
);
|
||||
const filtered = users.filter((u) => {
|
||||
const matchNom = !filterNom ||
|
||||
`${u.nom} ${u.prenom} ${u.id}`.toLowerCase().includes(
|
||||
filterNom.toLowerCase(),
|
||||
);
|
||||
const matchRole = !filterRole || String(u.idRole) === filterRole;
|
||||
return matchNom && matchRole;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
@@ -94,64 +112,121 @@ export default function AdminUsers() {
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
<div class="form-row">
|
||||
<div class="filters">
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Login (uid)"
|
||||
value={newId}
|
||||
onInput={(e) => setNewId((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 9rem"
|
||||
/>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom"
|
||||
value={newNom}
|
||||
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Prénom"
|
||||
value={newPrenom}
|
||||
onInput={(e) => setNewPrenom((e.target as HTMLInputElement).value)}
|
||||
class="filter-input"
|
||||
placeholder="Rechercher..."
|
||||
value={filterNom}
|
||||
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={newIdRole}
|
||||
onChange={(e) => setNewIdRole((e.target as HTMLSelectElement).value)}
|
||||
value={filterRole}
|
||||
onChange={(e) => setFilterRole((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Aucun rôle</option>
|
||||
<option value="">Role</option>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createUser}
|
||||
disabled={creating}
|
||||
onClick={() => setShowCreate(true)}
|
||||
style="margin-left: auto"
|
||||
>
|
||||
+ Ajouter
|
||||
+ Créer utilisateur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
class="filter-input"
|
||||
placeholder="Rechercher…"
|
||||
value={filterNom}
|
||||
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Creation modal */}
|
||||
{showCreate && (
|
||||
<div
|
||||
class="modal-overlay"
|
||||
onClick={() => setShowCreate(false)}
|
||||
>
|
||||
<div class="modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<p class="modal-title">Créer un utilisateur</p>
|
||||
<div class="modal-form">
|
||||
<div class="form-field">
|
||||
<label>Login (uid)</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Login (uid)"
|
||||
value={newId}
|
||||
onInput={(e) =>
|
||||
setNewId((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Nom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Nom"
|
||||
value={newNom}
|
||||
onInput={(e) =>
|
||||
setNewNom((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Prénom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="Prénom"
|
||||
value={newPrenom}
|
||||
onInput={(e) =>
|
||||
setNewPrenom((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Rôle</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={newIdRole}
|
||||
onChange={(e) =>
|
||||
setNewIdRole((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0; width: 100%"
|
||||
>
|
||||
<option value="">Aucun rôle</option>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onClick={() => setShowCreate(false)}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createUser}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? "..." : "+ Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
? <p class="state-loading">Chargement...</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Login</th>
|
||||
<th>id (login)</th>
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
<th>Rôle</th>
|
||||
<th>Rôle(s)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -170,7 +245,18 @@ export default function AdminUsers() {
|
||||
<td>{u.nom}</td>
|
||||
<td>{u.prenom}</td>
|
||||
<td>
|
||||
{u.idRole ? (roleMap[u.idRole] ?? `#${u.idRole}`) : "—"}
|
||||
{u.idRole
|
||||
? (
|
||||
<span
|
||||
class="role-chip"
|
||||
style={`border-color: ${
|
||||
roleColor(u.idRole)
|
||||
}; color: ${roleColor(u.idRole)}`}
|
||||
>
|
||||
{roleMap[u.idRole] ?? `#${u.idRole}`}
|
||||
</span>
|
||||
)
|
||||
: <span class="col-dim">--</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div class="col-actions">
|
||||
@@ -179,14 +265,35 @@ export default function AdminUsers() {
|
||||
href={`/admin/users/${encodeURIComponent(u.id)}`}
|
||||
f-client-nav={false}
|
||||
>
|
||||
✏
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>{" "}
|
||||
edit
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteUser(u.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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Module = { id: string; nom: string };
|
||||
type Enseignement = { idProf: string; idModule: string; idPromo: string };
|
||||
type User = { id: string; nom: string; prenom: string };
|
||||
type Promo = { id: string; annee: string };
|
||||
|
||||
type Props = { moduleId: string };
|
||||
|
||||
export default function EditModule({ moduleId }: Props) {
|
||||
const [mod, setMod] = useState<Module | null>(null);
|
||||
const [enseignements, setEnseignements] = useState<Enseignement[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [promos, setPromos] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [nom, setNom] = useState("");
|
||||
|
||||
// Add enseignement
|
||||
const [addProf, setAddProf] = useState("");
|
||||
const [addPromo, setAddPromo] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [mRes, eRes, uRes, pRes] = await Promise.all([
|
||||
fetch(`/admin/api/modules/${encodeURIComponent(moduleId)}`),
|
||||
fetch("/admin/api/enseignements"),
|
||||
fetch("/admin/api/users"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!mRes.ok) throw new Error("Module introuvable");
|
||||
const m: Module = await mRes.json();
|
||||
setMod(m);
|
||||
setNom(m.nom);
|
||||
if (eRes.ok) {
|
||||
const all: Enseignement[] = await eRes.json();
|
||||
setEnseignements(all.filter((e) => e.idModule === moduleId));
|
||||
}
|
||||
if (uRes.ok) setUsers(await uRes.json());
|
||||
if (pRes.ok) setPromos(await pRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [moduleId]);
|
||||
|
||||
async function saveInfos() {
|
||||
if (!mod) return;
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/modules/${encodeURIComponent(moduleId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ nom: nom.trim() }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Modification échouée");
|
||||
const updated: Module = await res.json();
|
||||
setMod(updated);
|
||||
setSaveMsg("Module enregistré.");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteModule() {
|
||||
if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/modules/${encodeURIComponent(moduleId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
globalThis.location.href = "/admin/modules";
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function addEnseignement() {
|
||||
if (!addProf || !addPromo) {
|
||||
setAddError("Enseignant et Promo 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,
|
||||
idModule: moduleId,
|
||||
idPromo: addPromo,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAddProf("");
|
||||
setAddPromo("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEnseignement(idProf: string, idPromo: string) {
|
||||
if (!confirm("Retirer cet enseignement ?")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/enseignements/${encodeURIComponent(idProf)}/${
|
||||
encodeURIComponent(moduleId)
|
||||
}/${encodeURIComponent(idPromo)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !mod) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-error">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mod) return null;
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<a
|
||||
class="back-link"
|
||||
href="/admin/modules"
|
||||
f-partial="/admin/partials/modules"
|
||||
>
|
||||
← Retour a la liste
|
||||
</a>
|
||||
|
||||
<h2
|
||||
class="page-title"
|
||||
style="border-bottom: none; margin-bottom: 0.5rem"
|
||||
>
|
||||
Module -- {mod.id}
|
||||
</h2>
|
||||
|
||||
<div class="info-bar">
|
||||
<span class="module-chip">{mod.id}</span>
|
||||
<span>{mod.nom}</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: Infos */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Informations</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>Code</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={mod.id}
|
||||
disabled
|
||||
style="opacity: 0.6"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Nom du module</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={nom}
|
||||
onInput={(e) => setNom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</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"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onClick={deleteModule}
|
||||
>
|
||||
Supprimer le module
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Enseignements */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Enseignants assignes</p>
|
||||
|
||||
{enseignements.length > 0
|
||||
? (
|
||||
<div class="data-table-wrap" style="margin-bottom: 1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enseignant</th>
|
||||
<th>Promo</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enseignements.map((e) => {
|
||||
const u = userMap[e.idProf];
|
||||
return (
|
||||
<tr key={`${e.idProf}-${e.idPromo}`}>
|
||||
<td>
|
||||
{u ? `${u.nom} ${u.prenom.charAt(0)}.` : e.idProf}
|
||||
</td>
|
||||
<td>
|
||||
<span class="promo-chip">{e.idPromo}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
removeEnseignement(e.idProf, e.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
|
||||
class="state-empty"
|
||||
style="padding: 1rem 0; text-align: left"
|
||||
>
|
||||
Aucun enseignant assigne.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
|
||||
Ajouter un enseignant
|
||||
</p>
|
||||
{addError && (
|
||||
<p class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
<div class="form-row">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addProf}
|
||||
onChange={(e) => setAddProf((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 12rem"
|
||||
>
|
||||
<option value="">Enseignant</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.nom} {u.prenom} ({u.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addPromo}
|
||||
onChange={(e) => setAddPromo((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>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addEnseignement}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "..." : "+ Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type User = { id: string; nom: string; prenom: string; idRole: number | null };
|
||||
type Role = { id: number; nom: string };
|
||||
type Enseignement = { idProf: string; idModule: string; idPromo: string };
|
||||
type Module = { id: string; nom: string };
|
||||
type Promo = { id: string; annee: string };
|
||||
|
||||
type Props = { userId: string };
|
||||
|
||||
export default function EditUser({ userId }: Props) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
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);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [nom, setNom] = useState("");
|
||||
const [prenom, setPrenom] = useState("");
|
||||
const [idRole, setIdRole] = useState("");
|
||||
|
||||
// Add enseignement form
|
||||
const [addModule, setAddModule] = useState("");
|
||||
const [addPromo, setAddPromo] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [uRes, rRes, eRes, mRes, pRes] = await Promise.all([
|
||||
fetch(`/admin/api/users/${encodeURIComponent(userId)}`),
|
||||
fetch("/admin/api/roles"),
|
||||
fetch("/admin/api/enseignements"),
|
||||
fetch("/admin/api/modules"),
|
||||
fetch("/students/api/promotions"),
|
||||
]);
|
||||
if (!uRes.ok) throw new Error("Utilisateur introuvable");
|
||||
const u: User = await uRes.json();
|
||||
setUser(u);
|
||||
setNom(u.nom);
|
||||
setPrenom(u.prenom);
|
||||
setIdRole(u.idRole !== null ? String(u.idRole) : "");
|
||||
if (rRes.ok) setRoles(await rRes.json());
|
||||
if (eRes.ok) {
|
||||
const allEns: Enseignement[] = await eRes.json();
|
||||
setEnseignements(allEns.filter((e) => e.idProf === userId));
|
||||
}
|
||||
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();
|
||||
}, [userId]);
|
||||
|
||||
async function saveInfos() {
|
||||
if (!user) return;
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/users/${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
nom: nom.trim(),
|
||||
prenom: prenom.trim(),
|
||||
idRole: idRole ? Number(idRole) : null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Modification échouée");
|
||||
const updated: User = await res.json();
|
||||
setUser(updated);
|
||||
setSaveMsg("Informations enregistrées.");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
if (!confirm(`Supprimer définitivement l'utilisateur ${userId} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/users/${encodeURIComponent(userId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
globalThis.location.href = "/admin/users";
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
async function addEnseignement() {
|
||||
if (!addModule || !addPromo) {
|
||||
setAddError("Module et Promo 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: userId,
|
||||
idModule: addModule,
|
||||
idPromo: addPromo,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAddModule("");
|
||||
setAddPromo("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEnseignement(idModule: string, idPromo: string) {
|
||||
if (!confirm("Retirer cet enseignement ?")) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/enseignements/${encodeURIComponent(userId)}/${
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m]));
|
||||
const roleMap = Object.fromEntries(roles.map((r) => [r.id, r.nom]));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !user) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-error">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<a
|
||||
class="back-link"
|
||||
href="/admin/users"
|
||||
f-partial="/admin/partials/users"
|
||||
>
|
||||
← Retour a la liste
|
||||
</a>
|
||||
|
||||
<h2
|
||||
class="page-title"
|
||||
style="border-bottom: none; margin-bottom: 0.5rem"
|
||||
>
|
||||
Edition -- {user.prenom} {user.nom}
|
||||
</h2>
|
||||
|
||||
<div class="info-bar">
|
||||
<span class="numEtud-chip">{user.id}</span>
|
||||
<span>
|
||||
{user.idRole
|
||||
? (roleMap[user.idRole] ?? `Role #${user.idRole}`)
|
||||
: "Aucun role"}
|
||||
</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 generales */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Informations generales</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>Prenom</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={prenom}
|
||||
onInput={(e) => setPrenom((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Login</label>
|
||||
<input
|
||||
class="form-input"
|
||||
value={user.id}
|
||||
disabled
|
||||
style="opacity: 0.6"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Role</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={idRole}
|
||||
onChange={(e) => setIdRole((e.target as HTMLSelectElement).value)}
|
||||
style="min-width: 0"
|
||||
>
|
||||
<option value="">Aucun role</option>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.nom}
|
||||
</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"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onClick={deleteUser}
|
||||
>
|
||||
Supprimer l'utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Enseignements */}
|
||||
<div class="edit-section">
|
||||
<p class="edit-section-title">Enseignements</p>
|
||||
<p
|
||||
class="col-dim"
|
||||
style="font-size: 0.75rem; margin: 0 0 0.75rem"
|
||||
>
|
||||
Modules enseignes par cet utilisateur
|
||||
</p>
|
||||
|
||||
{enseignements.length > 0
|
||||
? (
|
||||
<div class="data-table-wrap" style="margin-bottom: 1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
<th>Promo</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enseignements.map((e) => {
|
||||
const mod = moduleMap[e.idModule];
|
||||
return (
|
||||
<tr key={`${e.idModule}-${e.idPromo}`}>
|
||||
<td class="col-promo">
|
||||
{mod ? `${mod.id} -- ${mod.nom}` : e.idModule}
|
||||
</td>
|
||||
<td>
|
||||
<span class="promo-chip">{e.idPromo}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() =>
|
||||
removeEnseignement(e.idModule, e.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
|
||||
class="state-empty"
|
||||
style="padding: 1rem 0; text-align: left"
|
||||
>
|
||||
Aucun enseignement assigne.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
|
||||
Ajouter un enseignement
|
||||
</p>
|
||||
{addError && (
|
||||
<p class="state-error" style="padding: 0.3rem 0.5rem">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
<div class="form-row">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={addModule}
|
||||
onChange={(e) =>
|
||||
setAddModule((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={addPromo}
|
||||
onChange={(e) => setAddPromo((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>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={addEnseignement}
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? "..." : "+ Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import EditModule from "../(_islands)/EditModule.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export default async function EditModulePage(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
) {
|
||||
return <EditModule moduleId={context.params.idModule} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import EditUser from "../(_islands)/EditUser.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
export default async function EditUserPage(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
) {
|
||||
return <EditUser userId={context.params.id} />;
|
||||
}
|
||||
@@ -130,7 +130,17 @@ export default function AdminConsultNotes() {
|
||||
href={`/notes/edition/${s.numEtud}`}
|
||||
f-client-nav={false}
|
||||
>
|
||||
✏ édit
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>{" "}
|
||||
édit
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -31,6 +31,10 @@ export default function AdminUEs() {
|
||||
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([
|
||||
@@ -137,6 +141,32 @@ export default function AdminUEs() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -249,7 +279,59 @@ export default function AdminUEs() {
|
||||
<td>
|
||||
<span class="promo-chip">{um.idPromo}</span>
|
||||
</td>
|
||||
<td>{um.coeff}</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"
|
||||
@@ -261,7 +343,24 @@ export default function AdminUEs() {
|
||||
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>
|
||||
|
||||
@@ -326,7 +326,17 @@ export default function NoteRecap({ numEtud }: Props) {
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
✏ note
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>{" "}
|
||||
note
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ const properties: AppProperties = {
|
||||
import: "Import xlsx",
|
||||
},
|
||||
adminOnly: ["courses", "ues", "import"],
|
||||
studentOnly: ["notes"],
|
||||
hint: "Student grading management",
|
||||
};
|
||||
|
||||
|
||||
@@ -14,12 +14,6 @@ async function ImportNotesPage(
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Importer des Notes</h2>
|
||||
<p
|
||||
class="upload-format"
|
||||
style="margin-bottom: 1.25rem"
|
||||
>
|
||||
POST /notes/api/notes
|
||||
</p>
|
||||
<ImportNotes />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function AdminPromotions() {
|
||||
const [anneeSco, setAnneeSco] = useState("");
|
||||
|
||||
const generatedId = anneeSco.trim()
|
||||
? `${selectedAnnee}${selectedFiliere}${anneeSco.trim()}`
|
||||
? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}`
|
||||
: "";
|
||||
|
||||
async function load() {
|
||||
@@ -101,7 +101,7 @@ export default function AdminPromotions() {
|
||||
<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
|
||||
idPromo est généré automatiquement
|
||||
</p>
|
||||
|
||||
<div class="promo-builder-row">
|
||||
@@ -141,7 +141,7 @@ export default function AdminPromotions() {
|
||||
<label>Année scolaire</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="ex: 25/26, 24/27…"
|
||||
placeholder="ex: 25-26, 24-27…"
|
||||
value={anneeSco}
|
||||
onInput={(e) => setAnneeSco((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 9rem"
|
||||
@@ -220,7 +220,18 @@ export default function AdminPromotions() {
|
||||
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>
|
||||
|
||||
@@ -67,6 +67,7 @@ export default function ConsultStudents() {
|
||||
class="btn btn-primary"
|
||||
href="/students/upload"
|
||||
f-partial="/students/partials/upload"
|
||||
style="margin-left: auto"
|
||||
>
|
||||
Importer xlsx
|
||||
</a>
|
||||
@@ -128,14 +129,34 @@ export default function ConsultStudents() {
|
||||
href={`/students/edit/${s.numEtud}`}
|
||||
f-client-nav={false}
|
||||
>
|
||||
✏
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deleteStudent(s.numEtud)}
|
||||
>
|
||||
🗑
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -147,8 +147,6 @@ export default function EditStudents({ numEtud }: Props) {
|
||||
{/* 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>
|
||||
@@ -212,9 +210,6 @@ export default function EditStudents({ numEtud }: Props) {
|
||||
{/* 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"
|
||||
@@ -226,9 +221,6 @@ export default function EditStudents({ numEtud }: Props) {
|
||||
{/* 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 →
|
||||
|
||||
@@ -11,12 +11,6 @@ async function Students(_request: Request, _context: FreshContext<State>) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Importer des Élèves</h2>
|
||||
<p
|
||||
class="upload-format"
|
||||
style="margin-bottom: 1.25rem"
|
||||
>
|
||||
POST /students/api/students/import-csv
|
||||
</p>
|
||||
<UploadStudents />
|
||||
</div>
|
||||
);
|
||||
|
||||
+56
-1
@@ -470,6 +470,18 @@
|
||||
Permission toggle cards (role management)
|
||||
------------------------------------------------------- */
|
||||
|
||||
.perm-header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
background: light-dark(#f5f4ff, #141228);
|
||||
border: 1px solid
|
||||
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.perm-toggle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -740,7 +752,7 @@
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -947,6 +959,49 @@
|
||||
(end note recap)
|
||||
------------------------------------------------------- */
|
||||
|
||||
/* -------------------------------------------------------
|
||||
Modal overlay
|
||||
------------------------------------------------------- */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: light-dark(white, #1a172d);
|
||||
border: 1px solid
|
||||
light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer));
|
||||
border-radius: 6px;
|
||||
padding: 1.25rem;
|
||||
min-width: 22rem;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-note-dim {
|
||||
font-size: 0.7rem;
|
||||
color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim));
|
||||
|
||||
Reference in New Issue
Block a user