feat(ui): implement full UI layer for all modules
Add interactive island components and server partials for notes, students, and admin modules, following the Figma prototype design. - static/styles/ui.css: shared component library (buttons, tables, chips, cards, filters, tabs, form inputs) - notes: NotesView (student grade view with UE cards, promo tabs, weighted averages), AdminConsultNotes, AdminUEs islands + partials - students: ConsultStudents (list/filter/delete), AdminPromotions (CRUD) islands + partials - admin: AdminModules, AdminUsers, AdminRoles islands + partials - All partials use State type with unknown cast for session access Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Note[]>([]);
|
||||
const [ues, setUes] = useState<UE[]>([]);
|
||||
const [ueModules, setUeModules] = useState<UEModule[]>([]);
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [ajustements, setAjustements] = useState<Ajustement[]>([]);
|
||||
const [promos, setPromos] = useState<string[]>([]);
|
||||
const [activePromo, setActivePromo] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div class="page-content">
|
||||
<p class="state-empty">
|
||||
Bonjour {prenom}{" "}
|
||||
— aucun dossier étudiant n'est associé à votre compte.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-loading">Chargement…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div class="page-content">
|
||||
<p class="state-error">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div class="page-content">
|
||||
{promos.length > 1 && (
|
||||
<div class="tabs">
|
||||
{promos.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p}
|
||||
class={`tab-btn${activePromo === p ? " active" : ""}`}
|
||||
onClick={() => setActivePromo(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ueIds.length === 0 && (
|
||||
<p class="state-empty">Aucune note disponible pour cette période.</p>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<div key={ueId} class="ue-card">
|
||||
<div class="ue-card-header">
|
||||
<p class="ue-card-title">UE : {ue.nom}</p>
|
||||
{finalAvg !== null && (
|
||||
<p class={`ue-card-avg ${avgClass(finalAvg)}`}>
|
||||
Moyenne : {finalAvg.toFixed(2)}/20
|
||||
{ajustement !== null && ajustement !== 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
(ajustement : {ajustement > 0 ? "+" : ""}
|
||||
{ajustement})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{finalAvg === null && (
|
||||
<p class="ue-card-avg avg-warn">Notes non disponibles</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ueModsForUE.map((um) => {
|
||||
const mod = moduleMap[um.idModule];
|
||||
const note = noteMap[um.idModule] ?? null;
|
||||
return (
|
||||
<div key={um.idModule} class="ue-module-row">
|
||||
<span class="ue-module-name">
|
||||
{mod ? mod.id : um.idModule} —{" "}
|
||||
{mod ? mod.nom : "Module inconnu"} (coef {um.coeff})
|
||||
</span>
|
||||
<span class={`score-chip ${scoreClass(note)}`}>
|
||||
{note !== null ? `${note}/20` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user