9a4c6863d1
- Add stages module with full CRUD API and admin overview island - Add mobility overview island (Liste, Kanban, Detail CRUD views) - Add contract PDF upload/download endpoints for mobilites - Add light/dark theme toggle in header - Add employeeOnly flag to hide entire modules from students (admin, students, stages) - Add read-only GET endpoints for modules/ues/ue-modules in notes module - Add [slug].tsx catch-all routes for direct URL navigation - Replace old mobility table with mobilites + stages schema (migration 0004) - Allow students to create mobilites and upload contracts - Redirect authenticated users from / to /apps catalog
250 lines
7.1 KiB
TypeScript
250 lines
7.1 KiB
TypeScript
import { useEffect, useState } from "preact/hooks";
|
|
|
|
type Note = {
|
|
numEtud: number;
|
|
idModule: string;
|
|
note: number;
|
|
noteSession2: number | null;
|
|
};
|
|
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;
|
|
malus: 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";
|
|
}
|
|
|
|
/** Returns the effective note (session 2 if exists, otherwise session 1). */
|
|
function effectiveNote(n: Note): number {
|
|
return n.noteSession2 ?? n.note;
|
|
}
|
|
|
|
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("/notes/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);
|
|
|
|
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 etudiant n'est associe a 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>
|
|
);
|
|
}
|
|
|
|
const filteredUeModules = activePromo
|
|
? ueModules.filter((um) => um.idPromo === activePromo)
|
|
: ueModules;
|
|
|
|
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]),
|
|
);
|
|
const ajMap = Object.fromEntries(
|
|
ajustements.map((a) => [a.idUE, a]),
|
|
);
|
|
|
|
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 periode.</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 noteObj = noteMap[um.idModule];
|
|
if (noteObj) {
|
|
const val = effectiveNote(noteObj);
|
|
weightedSum += val * um.coeff;
|
|
coveredCoeff += um.coeff;
|
|
}
|
|
});
|
|
|
|
const avg = coveredCoeff > 0 ? weightedSum / coveredCoeff : null;
|
|
const ajust = ajMap[ueId] ?? null;
|
|
|
|
// If ajust.valeur exists, it replaces the calculated average
|
|
// Then malus is subtracted
|
|
let finalAvg: number | null = avg;
|
|
if (ajust) {
|
|
finalAvg = ajust.valeur;
|
|
if (ajust.malus > 0) {
|
|
finalAvg = (finalAvg ?? 0) - ajust.malus;
|
|
}
|
|
}
|
|
|
|
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
|
|
{ajust && ajust.malus > 0 && (
|
|
<span>(malus : -{ajust.malus})</span>
|
|
)}
|
|
</p>
|
|
)
|
|
: <p class="ue-card-avg avg-warn">Notes non disponibles</p>}
|
|
</div>
|
|
|
|
{ueModsForUE.map((um) => {
|
|
const mod = moduleMap[um.idModule];
|
|
const noteObj = noteMap[um.idModule] ?? null;
|
|
const effective = noteObj ? effectiveNote(noteObj) : null;
|
|
const hasS2 = noteObj?.noteSession2 != 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(effective)}`}>
|
|
{effective !== null ? `${effective}/20` : "—"}
|
|
{hasS2 && (
|
|
<span
|
|
style="font-size: 0.7rem; opacity: 0.7; margin-left: 0.35rem"
|
|
title={`Session 1 : ${noteObj!.note}/20`}
|
|
>
|
|
(S1: {noteObj!.note})
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|