diff --git a/CLAUDE.md b/CLAUDE.md index 435d43f..f3f37b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,8 @@ role-based administration. ### Current Status -🚧 **In Progress** - API layer largely complete, UI pages not yet built. -The schema below is the **final/definitive schema** that guides all development. +🚧 **In Progress** - API layer largely complete, UI pages not yet built. The +schema below is the **final/definitive schema** that guides all development. --- @@ -135,9 +135,9 @@ erDiagram ### Current Schema -The Drizzle ORM schema in `/databases/schema.ts` implements all tables: -`roles`, `permissions`, `rolePermissions`, `users`, `promotions`, `students`, -`modules`, `enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. +The Drizzle ORM schema in `/databases/schema.ts` implements all tables: `roles`, +`permissions`, `rolePermissions`, `users`, `promotions`, `students`, `modules`, +`enseignements`, `ues`, `ueModules`, `notes`, `ajustements`, `mobility`. --- @@ -303,7 +303,9 @@ deno task check - **E2E** (`tests/e2e/`) — Fresh handler + real DB (handler-level, not browser) Helpers in `tests/helpers/`: -- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, `makeJsonRequest`…) + +- `handler.ts` — builds fake Fresh contexts (`makeEmployeeContext`, + `makeJsonRequest`…) - `db_integration.ts` — seed functions + `truncateAll()` for test isolation - `db_mock.ts` / `api_mock.ts` — in-memory mocks for unit tests @@ -339,7 +341,8 @@ nix run nixpkgs#act -- -j integration --no-cache-server # integration + e2e via ## 💡 Important Notes -1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints are implemented. +1. **Only missing API**: `POST /notes/import-xlsx` (#44) — all other endpoints + are implemented. 2. **Next priority**: UI pages (none built yet) — follow the Figma prototype. 3. **Module Pattern**: Each module should follow the same pattern: routes, API endpoints, components, and tests. diff --git a/routes/(apps)/admin/(_islands)/AdminModules.tsx b/routes/(apps)/admin/(_islands)/AdminModules.tsx new file mode 100644 index 0000000..df0af41 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminModules.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from "preact/hooks"; + +type Module = { id: string; nom: string }; + +export default function AdminModules() { + const [modules, setModules] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newId, setNewId] = useState(""); + const [newNom, setNewNom] = useState(""); + const [creating, setCreating] = useState(false); + const [editId, setEditId] = useState(null); + const [editNom, setEditNom] = 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()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createModule() { + if (!newId.trim() || !newNom.trim()) return; + setCreating(true); + try { + const res = await fetch("/admin/api/modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: newId.trim(), nom: newNom.trim() }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewId(""); + setNewNom(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + 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 { + const res = await fetch( + `/admin/api/modules/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + return ( +
+

Gestion des Modules

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + style="min-width: 10rem" + /> + setNewNom((e.target as HTMLInputElement).value)} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {modules.length === 0 + ? ( + + + + ) + : modules.map((m) => ( + + + + + + ))} + +
IdentifiantNomActions
+ Aucun module enregistré +
{m.id} + {editId === m.id + ? ( + + setEditNom( + (e.target as HTMLInputElement).value, + )} + style="min-width: 0; width: 100%" + /> + ) + : m.nom} + +
+ {editId === m.id + ? ( + <> + + + + ) + : ( + <> + + + + )} +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminRoles.tsx b/routes/(apps)/admin/(_islands)/AdminRoles.tsx new file mode 100644 index 0000000..9e97fad --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminRoles.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState } from "preact/hooks"; + +type Role = { id: number; nom: string }; +type Permission = { id: string; nom: string }; + +export default function AdminRoles() { + const [roles, setRoles] = useState([]); + const [permissions, setPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newNom, setNewNom] = useState(""); + const [creating, setCreating] = useState(false); + const [editId, setEditId] = useState(null); + const [editNom, setEditNom] = useState(""); + + async function load() { + try { + const [rRes, pRes] = await Promise.all([ + fetch("/admin/api/roles"), + fetch("/admin/api/permissions"), + ]); + if (!rRes.ok) throw new Error("Impossible de charger les rôles"); + setRoles(await rRes.json()); + if (pRes.ok) setPermissions(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createRole() { + if (!newNom.trim()) return; + setCreating(true); + try { + const res = await fetch("/admin/api/roles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: newNom.trim() }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewNom(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + 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 { + const res = await fetch(`/admin/api/roles/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + return ( +
+

Gestion des RĂ´les

+ + {error &&

{error}

} + +
+ setNewNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createRole()} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {roles.length === 0 + ? ( + + + + ) + : roles.map((r) => ( + + + + + + ))} + +
IDNomActions
+ Aucun rôle enregistré +
{r.id} + {editId === r.id + ? ( + + setEditNom( + (e.target as HTMLInputElement).value, + )} + style="min-width: 0; width: 100%" + /> + ) + : r.nom} + +
+ {editId === r.id + ? ( + <> + + + + ) + : ( + <> + + + + )} +
+
+
+ )} + + {permissions.length > 0 && ( +
+

+ Permissions disponibles +

+
+ + + + + + + + + {permissions.map((p) => ( + + + + + ))} + +
IDNom
{p.id}{p.nom}
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_islands)/AdminUsers.tsx b/routes/(apps)/admin/(_islands)/AdminUsers.tsx new file mode 100644 index 0000000..eee86f9 --- /dev/null +++ b/routes/(apps)/admin/(_islands)/AdminUsers.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from "preact/hooks"; + +type User = { id: string; nom: string; prenom: string; idRole: number | null }; +type Role = { id: number; nom: string }; + +export default function AdminUsers() { + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newId, setNewId] = useState(""); + const [newNom, setNewNom] = useState(""); + const [newPrenom, setNewPrenom] = useState(""); + const [newIdRole, setNewIdRole] = useState(""); + const [creating, setCreating] = useState(false); + + const [filterNom, setFilterNom] = useState(""); + + async function load() { + try { + const [uRes, rRes] = await Promise.all([ + fetch("/admin/api/users"), + fetch("/admin/api/roles"), + ]); + if (!uRes.ok) throw new Error("Impossible de charger les utilisateurs"); + setUsers(await uRes.json()); + if (rRes.ok) setRoles(await rRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createUser() { + if (!newId.trim() || !newNom.trim() || !newPrenom.trim()) return; + setCreating(true); + try { + const res = await fetch("/admin/api/users", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: newId.trim(), + nom: newNom.trim(), + prenom: newPrenom.trim(), + idRole: newIdRole ? Number(newIdRole) : null, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewId(""); + setNewNom(""); + setNewPrenom(""); + setNewIdRole(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deleteUser(id: string) { + if (!confirm(`Supprimer l'utilisateur ${id} ?`)) return; + try { + const res = await fetch(`/admin/api/users/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + 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(), + ) + ); + + return ( +
+

Gestion des Utilisateurs

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + style="min-width: 9rem" + /> + setNewNom((e.target as HTMLInputElement).value)} + /> + setNewPrenom((e.target as HTMLInputElement).value)} + /> + + +
+ +
+ setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((u) => ( + + + + + + + + ))} + +
LoginNomPrénomRôleActions
+ Aucun utilisateur trouvé +
{u.id}{u.nom}{u.prenom} + {u.idRole ? (roleMap[u.idRole] ?? `#${u.idRole}`) : "—"} + +
+ + ✏ + + +
+
+
+ )} +
+ ); +} diff --git a/routes/(apps)/admin/(_props)/props.ts b/routes/(apps)/admin/(_props)/props.ts index 3ae55a1..a95fb54 100644 --- a/routes/(apps)/admin/(_props)/props.ts +++ b/routes/(apps)/admin/(_props)/props.ts @@ -4,9 +4,12 @@ const properties: AppProperties = { name: "Admin", icon: "school", pages: { - index: "Homepage", + index: "Accueil", + modules: "Modules", + users: "Utilisateurs", + roles: "RĂ´les", }, - adminOnly: [], + adminOnly: ["modules", "users", "roles"], hint: "PolyMPR module", }; diff --git a/routes/(apps)/admin/partials/index.tsx b/routes/(apps)/admin/partials/index.tsx index 4e0c915..bedfc1e 100644 --- a/routes/(apps)/admin/partials/index.tsx +++ b/routes/(apps)/admin/partials/index.tsx @@ -3,10 +3,41 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; -export function Index(_request: Request, _context: FreshContext) { - return

Welcome to Admin.

; +// deno-lint-ignore require-await +export async function Index( + _request: Request, + context: FreshContext, +) { + return ( +
+

Administration

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+

+ Gérez les{" "} + + modules + + ,{" "} + + utilisateurs + + ,{" "} + + rôles + {" "} + depuis la barre de navigation. +

+
+ ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/admin/partials/modules.tsx b/routes/(apps)/admin/partials/modules.tsx new file mode 100644 index 0000000..a36640d --- /dev/null +++ b/routes/(apps)/admin/partials/modules.tsx @@ -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 AdminModules from "../(_islands)/AdminModules.tsx"; + +// deno-lint-ignore require-await +async function Modules( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Modules); diff --git a/routes/(apps)/admin/partials/roles.tsx b/routes/(apps)/admin/partials/roles.tsx new file mode 100644 index 0000000..b40aeb0 --- /dev/null +++ b/routes/(apps)/admin/partials/roles.tsx @@ -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 AdminRoles from "../(_islands)/AdminRoles.tsx"; + +// deno-lint-ignore require-await +async function Roles( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Roles); diff --git a/routes/(apps)/admin/partials/users.tsx b/routes/(apps)/admin/partials/users.tsx new file mode 100644 index 0000000..837d515 --- /dev/null +++ b/routes/(apps)/admin/partials/users.tsx @@ -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 AdminUsers from "../(_islands)/AdminUsers.tsx"; + +// deno-lint-ignore require-await +async function Users( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Users); diff --git a/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx new file mode 100644 index 0000000..9ae2c94 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/AdminConsultNotes.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "preact/hooks"; + +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string | null }; + +export default function AdminConsultNotes() { + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + const [filterPrenom, setFilterPrenom] = useState(""); + const [applied, setApplied] = useState({ + promo: "", + nom: "", + prenom: "", + }); + + useEffect(() => { + async function load() { + try { + const [sRes, pRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les étudiants"); + setStudents(await sRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + const filtered = students.filter((s) => { + if (applied.promo && s.idPromo !== applied.promo) return false; + if ( + applied.nom && + !s.nom.toLowerCase().includes(applied.nom.toLowerCase()) + ) return false; + if ( + applied.prenom && + !s.prenom.toLowerCase().includes(applied.prenom.toLowerCase()) + ) return false; + return true; + }); + + function applyFilters() { + setApplied({ promo: filterPromo, nom: filterNom, prenom: filterPrenom }); + } + + return ( +
+
+

Consulter les Notes

+
+ + {error &&

{error}

} + +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> + setFilterPrenom((e.target as HTMLInputElement).value)} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((s) => ( + + + + + + + + ))} + +
PromoNomPrénomN° ÉtudiantAction
+ Aucun étudiant trouvé +
{s.idPromo}{s.nom}{s.prenom}{s.numEtud} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/notes/(_islands)/AdminUEs.tsx b/routes/(apps)/notes/(_islands)/AdminUEs.tsx new file mode 100644 index 0000000..d698c34 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/AdminUEs.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "preact/hooks"; + +type UE = { id: number; nom: string }; + +export default function AdminUEs() { + const [ues, setUes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newNom, setNewNom] = useState(""); + const [creating, setCreating] = useState(false); + + 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()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createUE() { + if (!newNom.trim()) return; + setCreating(true); + try { + const res = await fetch("/notes/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: newNom.trim() }), + }); + if (!res.ok) throw new Error("Création échouée"); + setNewNom(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deleteUE(id: number) { + if (!confirm("Supprimer cette UE ?")) return; + try { + const res = await fetch(`/notes/api/ues/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + return ( +
+

Gestion des UEs

+ + {error &&

{error}

} + +
+ setNewNom((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && createUE()} + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {ues.length === 0 + ? ( + + + + ) + : ues.map((ue) => ( + + + + + + ))} + +
IDNomAction
+ Aucune UE enregistrée +
{ue.id}{ue.nom} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/notes/(_islands)/NotesView.tsx b/routes/(apps)/notes/(_islands)/NotesView.tsx new file mode 100644 index 0000000..fd77b87 --- /dev/null +++ b/routes/(apps)/notes/(_islands)/NotesView.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "preact/hooks"; + +type Note = { numEtud: number; idModule: string; note: number }; +type UE = { id: number; nom: string }; +type UEModule = { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +}; +type Module = { id: string; nom: string }; +type Ajustement = { numEtud: number; idUE: number; valeur: number }; + +type Props = { + numEtud: number | null; + prenom: string; +}; + +function scoreClass(score: number | null): string { + if (score === null) return "score-none"; + return score >= 10 ? "score-good" : "score-warn"; +} + +function avgClass(avg: number | null): string { + if (avg === null) return ""; + return avg >= 10 ? "avg-good" : "avg-warn"; +} + +export default function NotesView({ numEtud, prenom }: Props) { + const [notes, setNotes] = useState([]); + const [ues, setUes] = useState([]); + const [ueModules, setUeModules] = useState([]); + const [modules, setModules] = useState([]); + const [ajustements, setAjustements] = useState([]); + const [promos, setPromos] = useState([]); + const [activePromo, setActivePromo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (numEtud === null) { + setLoading(false); + return; + } + + async function load() { + try { + const [notesRes, uesRes, ueModRes, modRes, ajRes] = await Promise.all([ + fetch(`/notes/api/notes?numEtud=${numEtud}`), + fetch("/notes/api/ues"), + fetch("/notes/api/ue-modules"), + fetch("/admin/api/modules"), + fetch(`/notes/api/ajustements?numEtud=${numEtud}`), + ]); + + if (!notesRes.ok || !uesRes.ok || !ueModRes.ok) { + throw new Error("Erreur lors du chargement"); + } + + const [notesData, uesData, ueModData, modData, ajData] = await Promise + .all([ + notesRes.json(), + uesRes.json(), + ueModRes.json(), + modRes.ok ? modRes.json() : [], + ajRes.ok ? ajRes.json() : [], + ]); + + setNotes(notesData); + setUes(uesData); + setUeModules(ueModData); + setModules(modData); + setAjustements(ajData); + + // Derive promos from UE-modules for this student's notes + const noteModuleIds = new Set(notesData.map((n: Note) => n.idModule)); + const relevantPromos = [ + ...new Set( + ueModData + .filter((um: UEModule) => noteModuleIds.has(um.idModule)) + .map((um: UEModule) => um.idPromo), + ), + ] as string[]; + + setPromos(relevantPromos); + if (relevantPromos.length > 0) setActivePromo(relevantPromos[0]); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur inconnue"); + } finally { + setLoading(false); + } + } + + load(); + }, [numEtud]); + + if (numEtud === null) { + return ( +
+

+ Bonjour {prenom}{" "} + — aucun dossier étudiant n'est associé à votre compte. +

+
+ ); + } + + if (loading) { + return ( +
+

Chargement…

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + // Filter UE-modules by active promo + const filteredUeModules = activePromo + ? ueModules.filter((um) => um.idPromo === activePromo) + : ueModules; + + // Group UE-modules by UE + const ueIds = [...new Set(filteredUeModules.map((um) => um.idUE))]; + + const moduleMap = Object.fromEntries(modules.map((m) => [m.id, m])); + const noteMap = Object.fromEntries( + notes.map((n) => [n.idModule, n.note]), + ); + const ajMap = Object.fromEntries( + ajustements.map((a) => [a.idUE, a.valeur]), + ); + + return ( +
+ {promos.length > 1 && ( +
+ {promos.map((p) => ( + + ))} +
+ )} + + {ueIds.length === 0 && ( +

Aucune note disponible pour cette période.

+ )} + + {ueIds.map((ueId) => { + const ue = ues.find((u) => u.id === ueId); + if (!ue) return null; + + const ueModsForUE = filteredUeModules.filter((um) => um.idUE === ueId); + let weightedSum = 0; + let coveredCoeff = 0; + ueModsForUE.forEach((um) => { + const note = noteMap[um.idModule]; + if (note !== undefined) { + weightedSum += note * um.coeff; + coveredCoeff += um.coeff; + } + }); + + const avg = coveredCoeff > 0 ? weightedSum / coveredCoeff : null; + const ajustement = ajMap[ueId] ?? null; + const finalAvg = avg !== null && ajustement !== null + ? avg + ajustement + : avg; + + return ( +
+
+

UE : {ue.nom}

+ {finalAvg !== null && ( +

+ Moyenne : {finalAvg.toFixed(2)}/20 + {ajustement !== null && ajustement !== 0 && ( + + {" "} + (ajustement : {ajustement > 0 ? "+" : ""} + {ajustement}) + + )} +

+ )} + {finalAvg === null && ( +

Notes non disponibles

+ )} +
+ + {ueModsForUE.map((um) => { + const mod = moduleMap[um.idModule]; + const note = noteMap[um.idModule] ?? null; + return ( +
+ + {mod ? mod.id : um.idModule} —{" "} + {mod ? mod.nom : "Module inconnu"} (coef {um.coeff}) + + + {note !== null ? `${note}/20` : "—"} + +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/routes/(apps)/notes/(_props)/props.ts b/routes/(apps)/notes/(_props)/props.ts index 36b0f28..fb7f11b 100644 --- a/routes/(apps)/notes/(_props)/props.ts +++ b/routes/(apps)/notes/(_props)/props.ts @@ -4,11 +4,12 @@ const properties: AppProperties = { name: "PolyNotes", icon: "school", pages: { - index: "Homepage", - notes: "Notes", - courses: "Courses management", + index: "Accueil", + notes: "Mes notes", + courses: "Consulter", + ues: "UEs", }, - adminOnly: ["courses", "students"], + adminOnly: ["courses", "ues"], hint: "Student grading management", }; diff --git a/routes/(apps)/notes/partials/(admin)/courses.tsx b/routes/(apps)/notes/partials/(admin)/courses.tsx index 3ac215d..0ec8ebe 100644 --- a/routes/(apps)/notes/partials/(admin)/courses.tsx +++ b/routes/(apps)/notes/partials/(admin)/courses.tsx @@ -3,11 +3,12 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import AdminConsultNotes from "../../(_islands)/AdminConsultNotes.tsx"; // deno-lint-ignore require-await -async function Courses(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +async function Courses(_request: Request, _context: FreshContext) { + return ; } export const config = getPartialsConfig(); diff --git a/routes/(apps)/notes/partials/(admin)/ues.tsx b/routes/(apps)/notes/partials/(admin)/ues.tsx new file mode 100644 index 0000000..2d6b0e9 --- /dev/null +++ b/routes/(apps)/notes/partials/(admin)/ues.tsx @@ -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 AdminUEs from "../../(_islands)/AdminUEs.tsx"; + +// deno-lint-ignore require-await +async function UEs( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(UEs); diff --git a/routes/(apps)/notes/partials/index.tsx b/routes/(apps)/notes/partials/index.tsx index 2971e0e..e5d80ce 100644 --- a/routes/(apps)/notes/partials/index.tsx +++ b/routes/(apps)/notes/partials/index.tsx @@ -3,11 +3,53 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await -export async function Index(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +export async function Index( + _request: Request, + context: FreshContext, +) { + const isEmployee = + (context.state as unknown as { session: Record }).session + .eduPersonPrimaryAffiliation === "employee"; + + return ( +
+

PolyNotes

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+ {isEmployee + ? ( +

+ Consultez les{" "} + + notes des élèves + {" "} + ou gérez les{" "} + + UEs + + . +

+ ) + : ( +

+ Consultez vos{" "} + + notes + + . +

+ )} +
+ ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/notes/partials/notes.tsx b/routes/(apps)/notes/partials/notes.tsx index 1e3bbc1..188a05e 100644 --- a/routes/(apps)/notes/partials/notes.tsx +++ b/routes/(apps)/notes/partials/notes.tsx @@ -1,13 +1,36 @@ +import { FreshContext } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { students } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; import { getPartialsConfig, makePartials, } from "$root/defaults/makePartials.tsx"; -import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; +import NotesView from "../(_islands)/NotesView.tsx"; -// deno-lint-ignore require-await -async function Notes(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; +async function Notes( + _request: Request, + context: FreshContext, +) { + const session = + (context.state as unknown as { session: { sn: string; givenName: string } }) + .session; + const { sn, givenName } = session; + + let numEtud: number | null = null; + try { + const student = await db + .select() + .from(students) + .where(and(eq(students.nom, sn), eq(students.prenom, givenName))) + .then((rows) => rows[0] ?? null); + numEtud = student?.numEtud ?? null; + } catch { + // DB lookup failed — island will show fallback message + } + + return ; } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/(_islands)/AdminPromotions.tsx b/routes/(apps)/students/(_islands)/AdminPromotions.tsx new file mode 100644 index 0000000..5461d9e --- /dev/null +++ b/routes/(apps)/students/(_islands)/AdminPromotions.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "preact/hooks"; + +type Promotion = { id: string; annee: string | null }; + +export default function AdminPromotions() { + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newId, setNewId] = useState(""); + const [newAnnee, setNewAnnee] = useState(""); + const [creating, setCreating] = useState(false); + + 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()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function createPromo() { + if (!newId.trim()) 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, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Création échouée"); + } + setNewId(""); + setNewAnnee(""); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setCreating(false); + } + } + + async function deletePromo(id: string) { + if (!confirm(`Supprimer la promotion ${id} ?`)) return; + try { + const res = await fetch( + `/students/api/promotions/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); + if (!res.ok) throw new Error("Suppression échouée"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + return ( +
+

Gestion des Promotions

+ + {error &&

{error}

} + +
+ setNewId((e.target as HTMLInputElement).value)} + /> + setNewAnnee((e.target as HTMLInputElement).value)} + style="min-width: 14rem" + /> + +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + {promos.length === 0 + ? ( + + + + ) + : promos.map((p) => ( + + + + + + ))} + +
IdentifiantAnnéeAction
+ Aucune promotion enregistrée +
{p.id}{p.annee ?? "—"} + +
+
+ )} +
+ ); +} diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index f67036b..031bbe9 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -1,45 +1,150 @@ import { useEffect, useState } from "preact/hooks"; -import Promotion from "$root/routes/(apps)/students/(_components)/Promotion.tsx"; -type SingleUserResponse = { promo: Promotion; student: Student }; -type ManyUsersResponse = { promos: Promotion[]; students: Student[] }; - -type APIResponse = SingleUserResponse | ManyUsersResponse; +type Student = { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +}; +type Promotion = { id: string; annee: string }; export default function ConsultStudents() { - const [data, setData] = useState(null); + const [students, setStudents] = useState([]); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [filterPromo, setFilterPromo] = useState(""); + const [filterNom, setFilterNom] = useState(""); + + async function load() { + try { + const [sRes, pRes] = await Promise.all([ + fetch("/students/api/students"), + fetch("/students/api/promotions"), + ]); + if (!sRes.ok) throw new Error("Impossible de charger les élèves"); + setStudents(await sRes.json()); + if (pRes.ok) setPromos(await pRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } finally { + setLoading(false); + } + } useEffect(() => { - const fetchData = async () => { - const response = await fetch("/students/api/students"); - if (!response.ok) { - setError("Failed to load data. Please try again later."); - } - - const result: APIResponse = await response.json(); - setData(result); - }; - - fetchData(); + load(); }, []); + async function deleteStudent(numEtud: number) { + if (!confirm(`Supprimer 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"); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Erreur"); + } + } + + const filtered = students.filter((s) => { + const matchPromo = !filterPromo || s.idPromo === filterPromo; + const matchNom = !filterNom || + `${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase()); + return matchPromo && matchNom; + }); + return ( - <> - {error &&

{error}

} - {data && ((Object.hasOwn(data, "student")) - ? ( - - ) - : (data as ManyUsersResponse).promos.map((promo) => ( - - )))} - +
+

Gestion des Élèves

+ + {error &&

{error}

} + + + +
+ + setFilterNom((e.target as HTMLInputElement).value)} + /> +
+ + {loading + ?

Chargement…

+ : ( +
+ + + + + + + + + + + + {filtered.length === 0 + ? ( + + + + ) + : filtered.map((s) => ( + + + + + + + + ))} + +
N° étud.NomPrénomPromoActions
+ Aucun élève trouvé +
{s.numEtud}{s.nom}{s.prenom}{s.idPromo} +
+ + ✏ + + +
+
+
+ )} +
); } diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index 13bafe9..5483732 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -4,11 +4,12 @@ const properties: AppProperties = { name: "Students", icon: "badge", pages: { - index: "Homepage", - upload: "Upload students", - consult: "Consult students", + index: "Accueil", + consult: "Élèves", + promotions: "Promotions", + upload: "Import xlsx", }, - adminOnly: ["upload", "consult"], + adminOnly: ["consult", "promotions", "upload"], hint: "Create students promotion and see informations", }; diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index b685c5c..4c81c71 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -8,12 +8,7 @@ import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await async function Students(_request: Request, _context: FreshContext) { - return ( - <> -

Consult students

- - - ); + return ; } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/partials/(admin)/promotions.tsx b/routes/(apps)/students/partials/(admin)/promotions.tsx new file mode 100644 index 0000000..003f993 --- /dev/null +++ b/routes/(apps)/students/partials/(admin)/promotions.tsx @@ -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 AdminPromotions from "../../(_islands)/AdminPromotions.tsx"; + +// deno-lint-ignore require-await +async function Promotions( + _request: Request, + _context: FreshContext, +) { + return ; +} + +export const config = getPartialsConfig(); +export default makePartials(Promotions); diff --git a/routes/(apps)/students/partials/index.tsx b/routes/(apps)/students/partials/index.tsx index 78931b5..c696b94 100644 --- a/routes/(apps)/students/partials/index.tsx +++ b/routes/(apps)/students/partials/index.tsx @@ -4,16 +4,44 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; -import SelfPortrait from "$root/routes/(apps)/students/(_components)/SelfPortrait.tsx"; // deno-lint-ignore require-await -export async function Index(_request: Request, context: FreshContext) { +export async function Index( + _request: Request, + context: FreshContext, +) { + const isEmployee = + (context.state as unknown as { session: Record }).session + .eduPersonPrimaryAffiliation === "employee"; + return ( - <> -

Welcome {context.state.session?.givenName}!

-

Your amU identity

- - +
+

Étudiants

+

+ Bienvenue{" "} + + {(context.state as unknown as { session: Record }) + .session.displayName} + + . +

+ {isEmployee && ( +

+ Consultez la{" "} + + liste des élèves + {" "} + ou gérez les{" "} + + promotions + + . +

+ )} +
); } diff --git a/routes/_app.tsx b/routes/_app.tsx index 60d03a9..8162820 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -28,6 +28,7 @@ export default async function App( +
diff --git a/static/styles/ui.css b/static/styles/ui.css new file mode 100644 index 0000000..e56daa2 --- /dev/null +++ b/static/styles/ui.css @@ -0,0 +1,393 @@ +/* ui.css — Shared UI components for PolyMPR app pages */ + +/* ------------------------------------------------------- + Page layout +------------------------------------------------------- */ + +.page-content { + padding: 1.5rem; + max-width: 960px; +} + +.page-title { + font-size: 1.2rem; + font-weight: var(--font-weight-bold); + margin: 0 0 0.75rem 0; + padding-bottom: 0.75rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +/* ------------------------------------------------------- + Filters bar +------------------------------------------------------- */ + +.filters { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.filter-input, +.filter-select { + padding: 0.3rem 0.5rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + color: light-dark(var(--light-foreground), var(--dark-foreground)); + font-size: 0.8rem; + font-family: inherit; + min-width: 8rem; +} + +.filter-input:focus, +.filter-select:focus { + outline: none; + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); +} + +/* ------------------------------------------------------- + Buttons +------------------------------------------------------- */ + +.btn { + padding: 0.3rem 0.75rem; + border-radius: 3px; + font-size: 0.8rem; + font-family: inherit; + font-weight: var(--font-weight-bold); + cursor: pointer; + border: 1px solid; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.3rem; + line-height: 1.4; + background: transparent; + transition: background 100ms, color 100ms; +} + +.btn::before { + all: unset; +} + +.btn-primary { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.btn-primary:hover { + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark( + var(--light-background-color), + var(--dark-background-color) + ); +} + +.btn-secondary { + border-color: light-dark( + var(--light-foreground-dimmer), + var(--dark-foreground-dimmer) + ); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +.btn-secondary:hover { + border-color: light-dark( + var(--light-foreground-dim), + var(--dark-foreground-dim) + ); + color: light-dark(var(--light-foreground), var(--dark-foreground)); +} + +.btn-danger { + border-color: #933; + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.btn-danger:hover { + background: #933; + color: white; +} + +.btn-sm { + padding: 0.15rem 0.5rem; + font-size: 0.75rem; +} + +/* ------------------------------------------------------- + Data table +------------------------------------------------------- */ + +.data-table-wrap { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + overflow: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.data-table th { + padding: 0.5rem 1rem; + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + text-align: left; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.data-table td { + padding: 0.55rem 1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:nth-child(even) td { + background: light-dark(#f5f4ff, #141229); +} + +.data-table .col-promo { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-weight: var(--font-weight-bold); + font-size: 0.75rem; +} + +.data-table .col-dim { + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.75rem; +} + +.data-table .col-actions { + display: flex; + gap: 0.4rem; + align-items: center; +} + +/* ------------------------------------------------------- + UE card (student notes view) +------------------------------------------------------- */ + +.ue-card { + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 4px; + margin-bottom: 1rem; + overflow: hidden; + position: relative; + padding-left: 3px; +} + +.ue-card::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: light-dark(var(--light-accent-color), var(--dark-accent-color)); + transform: none; + transition: none; + border-radius: 0; +} + +.ue-card-header { + padding: 0.65rem 1rem 0.5rem 1.1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ue-card-title { + font-weight: var(--font-weight-bold); + font-size: 0.85rem; + margin: 0; +} + +.ue-card-avg { + font-size: 0.7rem; + margin: 0.2rem 0 0; +} + +.ue-card-avg.avg-good { + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.ue-card-avg.avg-warn { + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.ue-module-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.45rem 1rem 0.45rem 1.1rem; + border-bottom: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); +} + +.ue-module-row:last-child { + border-bottom: none; +} + +.ue-module-name { + font-size: 0.8rem; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* ------------------------------------------------------- + Score chip +------------------------------------------------------- */ + +.score-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 4.5rem; + padding: 0.15rem 0.5rem; + border-radius: 12px; + border: 1px solid; + font-size: 0.75rem; + font-weight: var(--font-weight-bold); + background: light-dark(white, #1a172d); +} + +.score-chip.score-good { + border-color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); +} + +.score-chip.score-warn { + border-color: light-dark( + var(--light-strong-color), + var(--dark-strong-color) + ); + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); +} + +.score-chip.score-none { + border-color: light-dark( + var(--light-foreground-dimmer), + var(--dark-foreground-dimmer) + ); + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); +} + +/* ------------------------------------------------------- + Tabs +------------------------------------------------------- */ + +.tabs { + display: flex; + gap: 0.6rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.tab-btn { + padding: 0.35rem 0.9rem; + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + background: transparent; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.82rem; + font-family: inherit; + cursor: pointer; +} + +.tab-btn::before { + all: unset; +} + +.tab-btn.active { + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); + color: light-dark(var(--light-accent-color), var(--dark-accent-color)); + font-weight: var(--font-weight-bold); + border-bottom-width: 2px; +} + +/* ------------------------------------------------------- + Status states +------------------------------------------------------- */ + +.state-loading, +.state-empty { + padding: 2.5rem; + text-align: center; + color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim)); + font-size: 0.9rem; +} + +.state-error { + padding: 1rem; + color: light-dark(var(--light-strong-color), var(--dark-strong-color)); + font-size: 0.85rem; + border: 1px solid #933; + border-radius: 4px; + background: light-dark(#fff0f0, #1a1010); + margin-bottom: 1rem; +} + +/* ------------------------------------------------------- + Inline form row (for inline add/edit) +------------------------------------------------------- */ + +.form-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.form-input { + padding: 0.35rem 0.5rem; + background: light-dark(white, #141228); + border: 1px solid + light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer)); + border-radius: 3px; + color: light-dark(var(--light-foreground), var(--dark-foreground)); + font-size: 0.82rem; + font-family: inherit; + min-width: 12rem; +} + +.form-input:focus { + outline: none; + border-color: light-dark( + var(--light-accent-color), + var(--dark-accent-color) + ); +} + +/* ------------------------------------------------------- + Toolbar (title + action button) +------------------------------------------------------- */ + +.toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + gap: 1rem; +}