feat: made stuff

This commit is contained in:
2026-05-01 14:14:33 +02:00
parent 9a4c6863d1
commit b6586f7715
19 changed files with 1870 additions and 116 deletions
+4
View File
@@ -56,6 +56,10 @@ jobs:
run: | run: |
sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \ sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
sed 's/--> statement-breakpoint/;/g' databases/migrations/0003_add_session2_and_malus.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
sed 's/--> statement-breakpoint/;/g' databases/migrations/0004_add_stages_and_mobilites.sql | \
PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test
- name: Install dependencies - name: Install dependencies
run: npm install --ignore-scripts && deno install run: npm install --ignore-scripts && deno install
+26
View File
@@ -27,6 +27,32 @@ export default function makeSlug(basePath: string): Route {
} }
} }
// For multi-segment slugs (e.g. "overview/12345"), try
// partials/<dir>/[param].tsx and inject the param into context.params
if (!page && slug.includes("/")) {
const idx = slug.indexOf("/");
const dir = slug.slice(0, idx);
const param = slug.slice(idx + 1);
// Discover the dynamic segment name from the file system
try {
const entries: string[] = [];
for await (const entry of Deno.readDir(`${basePath}/partials/${dir}`)) {
if (entry.isFile) entries.push(entry.name);
}
const dynFile = entries.find((n) =>
n.startsWith("[") && n.endsWith("].tsx")
);
if (dynFile) {
const paramName = dynFile.slice(1, -5); // "[numEtud].tsx" → "numEtud"
context.params[paramName] = param;
page = (await import(`${basePath}/partials/${dir}/${dynFile}`)).Page;
}
} catch {
// directory doesn't exist or no dynamic file
}
}
if (!page) { if (!page) {
return context.renderNotFound(); return context.renderNotFound();
} }
+1536
View File
File diff suppressed because it is too large Load Diff
@@ -115,7 +115,7 @@ export default function AdminEnseignements() {
return ( return (
<div class="page-content"> <div class="page-content">
<h2 class="page-title">Assignations Enseignant Module / Promo</h2> <h2 class="page-title">Assignations Enseignant ECUE / Promo</h2>
{error && <p class="state-error">{error}</p>} {error && <p class="state-error">{error}</p>}
@@ -135,7 +135,7 @@ export default function AdminEnseignements() {
onChange={(e) => onChange={(e) =>
setFilterModule((e.target as HTMLSelectElement).value)} setFilterModule((e.target as HTMLSelectElement).value)}
> >
<option value="">Module </option> <option value="">ECUE </option>
{modules.map((m) => ( {modules.map((m) => (
<option key={m.id} value={m.id}>{m.id} {m.nom}</option> <option key={m.id} value={m.id}>{m.id} {m.nom}</option>
))} ))}
@@ -194,7 +194,7 @@ export default function AdminEnseignements() {
</select> </select>
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Module</label> <label>ECUE</label>
<select <select
class="filter-select" class="filter-select"
value={addModule} value={addModule}
@@ -202,7 +202,7 @@ export default function AdminEnseignements() {
setAddModule((e.target as HTMLSelectElement).value)} setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%" style="min-width: 0; width: 100%"
> >
<option value="">Module...</option> <option value="">ECUE...</option>
{modules.map((m) => ( {modules.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.id} -- {m.nom} {m.id} -- {m.nom}
@@ -251,7 +251,7 @@ export default function AdminEnseignements() {
<thead> <thead>
<tr> <tr>
<th>Promo</th> <th>Promo</th>
<th>Module</th> <th>ECUE</th>
<th>Enseignant (User.id)</th> <th>Enseignant (User.id)</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -319,7 +319,7 @@ export default function AdminEnseignements() {
<div class="info-note"> <div class="info-note">
<p> <p>
Un même module peut être enseigné par plusieurs utilisateurs sur une Un même ECUE peut être enseigné par plusieurs utilisateurs sur une
même promo. même promo.
</p> </p>
<p class="info-note-dim"> <p class="info-note-dim">
@@ -22,7 +22,7 @@ export default function AdminModules() {
fetch("/admin/api/enseignements"), fetch("/admin/api/enseignements"),
fetch("/admin/api/users"), fetch("/admin/api/users"),
]); ]);
if (!mRes.ok) throw new Error("Impossible de charger les modules"); if (!mRes.ok) throw new Error("Impossible de charger les ECUEs");
setModules(await mRes.json()); setModules(await mRes.json());
if (eRes.ok) setEnseignements(await eRes.json()); if (eRes.ok) setEnseignements(await eRes.json());
if (uRes.ok) setUsers(await uRes.json()); if (uRes.ok) setUsers(await uRes.json());
@@ -61,7 +61,7 @@ export default function AdminModules() {
} }
async function deleteModule(id: string) { async function deleteModule(id: string) {
if (!confirm(`Supprimer le module ${id} ?`)) return; if (!confirm(`Supprimer l'ECUE ${id} ?`)) return;
try { try {
const res = await fetch( const res = await fetch(
`/admin/api/modules/${encodeURIComponent(id)}`, `/admin/api/modules/${encodeURIComponent(id)}`,
@@ -102,7 +102,7 @@ export default function AdminModules() {
return ( return (
<div class="page-content"> <div class="page-content">
<h2 class="page-title">Gestion des Modules</h2> <h2 class="page-title">Gestion des ECUEs</h2>
{error && <p class="state-error">{error}</p>} {error && <p class="state-error">{error}</p>}
@@ -122,7 +122,7 @@ export default function AdminModules() {
}} }}
style="margin-left: auto" style="margin-left: auto"
> >
+ Ajouter module + Ajouter ECUE
</button> </button>
</div> </div>
@@ -134,7 +134,7 @@ export default function AdminModules() {
<thead> <thead>
<tr> <tr>
<th>id (code)</th> <th>id (code)</th>
<th>Nom du module</th> <th>Nom de l'ECUE</th>
<th>Enseignants assignes</th> <th>Enseignants assignes</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -144,7 +144,7 @@ export default function AdminModules() {
? ( ? (
<tr> <tr>
<td colspan={4} class="state-empty"> <td colspan={4} class="state-empty">
Aucun module enregistré Aucun ECUE enregistré
</td> </td>
</tr> </tr>
) )
@@ -218,13 +218,13 @@ export default function AdminModules() {
</div> </div>
)} )}
{/* Nouveau module */} {/* Nouvel ECUE */}
<div <div
id="new-module-section" id="new-module-section"
class="edit-section" class="edit-section"
style="margin-top: 1.5rem" style="margin-top: 1.5rem"
> >
<p class="edit-section-title">Nouveau module</p> <p class="edit-section-title">Nouvel ECUE</p>
<div class="form-row"> <div class="form-row">
<input <input
class="form-input" class="form-input"
@@ -235,7 +235,7 @@ export default function AdminModules() {
/> />
<input <input
class="form-input" class="form-input"
placeholder="Nom du module" placeholder="Nom de l'ECUE"
value={newNom} value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)} onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/> />
+9 -9
View File
@@ -104,7 +104,7 @@ export default function AdminUEs() {
idUE: number, idUE: number,
idPromo: string, idPromo: string,
) { ) {
if (!confirm("Supprimer ce module de la UE ?")) return; if (!confirm("Supprimer cet ECUE de la UE ?")) return;
try { try {
const res = await fetch( const res = await fetch(
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${ `/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
@@ -121,7 +121,7 @@ export default function AdminUEs() {
async function addUeModule() { async function addUeModule() {
if (!selectedUe || !addModuleId || !addPromoId) { if (!selectedUe || !addModuleId || !addPromoId) {
setAddError("Module et Promo sont requis"); setAddError("ECUE et Promo sont requis");
return; return;
} }
const coeff = parseFloat(addCoeff); const coeff = parseFloat(addCoeff);
@@ -203,7 +203,7 @@ export default function AdminUEs() {
class="col-dim" class="col-dim"
style="font-size: 0.78rem; margin: -0.5rem 0 1rem" style="font-size: 0.78rem; margin: -0.5rem 0 1rem"
> >
UE = Unité d'Enseignement regroupant plusieurs modules UE = Unité d'Enseignement regroupant plusieurs ECUEs
</p> </p>
{error && <p class="state-error">{error}</p>} {error && <p class="state-error">{error}</p>}
@@ -314,13 +314,13 @@ export default function AdminUEs() {
<div class="panel-box"> <div class="panel-box">
<p class="panel-box-title">{selectedUe.nom}</p> <p class="panel-box-title">{selectedUe.nom}</p>
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem"> <p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Modules assignés (UE_Module) ECUEs assignés (UE_Module)
</p> </p>
<div class="data-table-wrap" style="margin-bottom: 1rem"> <div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Module</th> <th>ECUE</th>
<th>Promo</th> <th>Promo</th>
<th>Coeff</th> <th>Coeff</th>
<th>Actions</th> <th>Actions</th>
@@ -331,7 +331,7 @@ export default function AdminUEs() {
? ( ? (
<tr> <tr>
<td colspan={4} class="state-empty"> <td colspan={4} class="state-empty">
Aucun module assigné Aucun ECUE assigné
</td> </td>
</tr> </tr>
) )
@@ -441,7 +441,7 @@ export default function AdminUEs() {
</div> </div>
<p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem"> <p style="font-size: 0.78rem; font-weight: var(--font-weight-bold); margin: 0 0 0.5rem">
Ajouter un module à cette UE Ajouter un ECUE à cette UE
</p> </p>
{addError && ( {addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem"> <p class="state-error" style="padding: 0.3rem 0.5rem">
@@ -458,7 +458,7 @@ export default function AdminUEs() {
)} )}
style="min-width: 12rem" style="min-width: 12rem"
> >
<option value="">Module </option> <option value="">ECUE </option>
{modules.map((m) => ( {modules.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.id} {m.nom} {m.id} {m.nom}
@@ -504,7 +504,7 @@ export default function AdminUEs() {
: ( : (
<div class="panel-box"> <div class="panel-box">
<p class="state-empty" style="padding: 2rem 0"> <p class="state-empty" style="padding: 2rem 0">
Sélectionnez une UE pour voir ses modules Sélectionnez une UE pour voir ses ECUEs
</p> </p>
</div> </div>
)} )}
@@ -33,7 +33,7 @@ export default function EditModule({ moduleId }: Props) {
fetch("/admin/api/users"), fetch("/admin/api/users"),
fetch("/students/api/promotions"), fetch("/students/api/promotions"),
]); ]);
if (!mRes.ok) throw new Error("Module introuvable"); if (!mRes.ok) throw new Error("ECUE introuvable");
const m: Module = await mRes.json(); const m: Module = await mRes.json();
setMod(m); setMod(m);
setNom(m.nom); setNom(m.nom);
@@ -70,7 +70,7 @@ export default function EditModule({ moduleId }: Props) {
if (!res.ok) throw new Error("Modification échouée"); if (!res.ok) throw new Error("Modification échouée");
const updated: Module = await res.json(); const updated: Module = await res.json();
setMod(updated); setMod(updated);
setSaveMsg("Module enregistré."); setSaveMsg("ECUE enregistré.");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
@@ -79,7 +79,7 @@ export default function EditModule({ moduleId }: Props) {
} }
async function deleteModule() { async function deleteModule() {
if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return; if (!confirm(`Supprimer définitivement l'ECUE ${moduleId} ?`)) return;
try { try {
const res = await fetch( const res = await fetch(
`/admin/api/modules/${encodeURIComponent(moduleId)}`, `/admin/api/modules/${encodeURIComponent(moduleId)}`,
@@ -173,7 +173,7 @@ export default function EditModule({ moduleId }: Props) {
class="page-title" class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem" style="border-bottom: none; margin-bottom: 0.5rem"
> >
Module -- {mod.id} ECUE -- {mod.id}
</h2> </h2>
<div class="info-bar"> <div class="info-bar">
@@ -202,7 +202,7 @@ export default function EditModule({ moduleId }: Props) {
/> />
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Nom du module</label> <label>Nom de l'ECUE</label>
<input <input
class="form-input" class="form-input"
value={nom} value={nom}
@@ -224,7 +224,7 @@ export default function EditModule({ moduleId }: Props) {
class="btn btn-danger" class="btn btn-danger"
onClick={deleteModule} onClick={deleteModule}
> >
Supprimer le module Supprimer l'ECUE
</button> </button>
</div> </div>
</div> </div>
@@ -281,7 +281,7 @@ export default function ImportMaquette() {
globalThis.open("/templates/modele_maquette.xlsx", "_blank"); globalThis.open("/templates/modele_maquette.xlsx", "_blank");
} }
function downloadExport() { function _downloadExport() {
Promise.all([ Promise.all([
fetch("/admin/api/ues").then((r) => r.json()), fetch("/admin/api/ues").then((r) => r.json()),
fetch("/admin/api/ue-modules").then((r) => r.json()), fetch("/admin/api/ue-modules").then((r) => r.json()),
@@ -67,7 +67,9 @@ function validatedWeeks(mobs: Mobilite[]): number {
.reduce((sum, m) => sum + m.duree, 0); .reduce((sum, m) => sum + m.duree, 0);
} }
export default function MobilityOverview() { export default function MobilityOverview(
{ initialNumEtud }: { initialNumEtud?: number } = {},
) {
const [students, setStudents] = useState<Student[]>([]); const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]); const [promos, setPromos] = useState<Promotion[]>([]);
const [mobilites, setMobilites] = useState<Mobilite[]>([]); const [mobilites, setMobilites] = useState<Mobilite[]>([]);
@@ -105,6 +107,12 @@ export default function MobilityOverview() {
setStagesMap( setStagesMap(
Object.fromEntries((stData as Stage[]).map((s) => [s.id, s])), Object.fromEntries((stData as Stage[]).map((s) => [s.id, s])),
); );
if (initialNumEtud) {
const s = (sData as Student[]).find((s) =>
s.numEtud === initialNumEtud
);
if (s) setDetailStudent(s);
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
@@ -116,6 +124,18 @@ export default function MobilityOverview() {
load(); load();
}, []); }, []);
function openStudent(s: Student) {
setDetailStudent(s);
history.pushState(null, "", `/mobility/overview/${s.numEtud}`);
}
function closeStudent() {
setDetailStudent(null);
setEditingMob(null);
setShowAddForm(false);
history.pushState(null, "", "/mobility/overview");
}
// If in detail view, render that // If in detail view, render that
if (detailStudent) { if (detailStudent) {
return ( return (
@@ -128,11 +148,7 @@ export default function MobilityOverview() {
setEditingMob={setEditingMob} setEditingMob={setEditingMob}
showAddForm={showAddForm} showAddForm={showAddForm}
setShowAddForm={setShowAddForm} setShowAddForm={setShowAddForm}
onBack={() => { onBack={closeStudent}
setDetailStudent(null);
setEditingMob(null);
setShowAddForm(false);
}}
onReload={load} onReload={load}
/> />
); );
@@ -207,14 +223,14 @@ export default function MobilityOverview() {
<ListView <ListView
students={filtered} students={filtered}
mobsByStudent={mobsByStudent} mobsByStudent={mobsByStudent}
onConsult={(s) => setDetailStudent(s)} onConsult={(s) => openStudent(s)}
/> />
) )
: ( : (
<KanbanView <KanbanView
students={filtered} students={filtered}
mobsByStudent={mobsByStudent} mobsByStudent={mobsByStudent}
onConsult={(s) => setDetailStudent(s)} onConsult={(s) => openStudent(s)}
/> />
)} )}
</div> </div>
@@ -637,6 +653,9 @@ function DetailView(
numEtud={student.numEtud} numEtud={student.numEtud}
ecoles={ecoles} ecoles={ecoles}
paysList={paysList} paysList={paysList}
availableStages={Object.values(stagesMap)
.filter((s) => s.numEtud === student.numEtud)
.filter((s) => !mobilites.some((m) => m.idStage === s.id))}
onCancel={() => setShowAddForm(false)} onCancel={() => setShowAddForm(false)}
onSave={async () => { onSave={async () => {
setShowAddForm(false); setShowAddForm(false);
@@ -774,10 +793,11 @@ function MobEditForm(
} }
function MobAddForm( function MobAddForm(
{ numEtud, ecoles, paysList, onCancel, onSave }: { { numEtud, ecoles, paysList, availableStages, onCancel, onSave }: {
numEtud: number; numEtud: number;
ecoles: string[]; ecoles: string[];
paysList: string[]; paysList: string[];
availableStages: Stage[];
onCancel: () => void; onCancel: () => void;
onSave: () => Promise<void>; onSave: () => Promise<void>;
}, },
@@ -786,8 +806,19 @@ function MobAddForm(
const [ecole, setEcole] = useState(""); const [ecole, setEcole] = useState("");
const [pays, setPays] = useState(""); const [pays, setPays] = useState("");
const [status, setStatus] = useState("contracts_received"); const [status, setStatus] = useState("contracts_received");
const [selectedStageId, setSelectedStageId] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const isStageLinked = selectedStageId !== "";
function onStageChange(value: string) {
setSelectedStageId(value);
if (value) {
const stage = availableStages.find((s) => s.id === Number(value));
if (stage) setDuree(String(stage.duree));
}
}
async function submit() { async function submit() {
setBusy(true); setBusy(true);
try { try {
@@ -797,9 +828,10 @@ function MobAddForm(
body: JSON.stringify({ body: JSON.stringify({
numEtud, numEtud,
duree: parseInt(duree), duree: parseInt(duree),
ecole: ecole || null, ecole: isStageLinked ? null : (ecole || null),
pays: pays || null, pays: isStageLinked ? null : (pays || null),
status, status: isStageLinked ? "validated" : status,
idStage: isStageLinked ? Number(selectedStageId) : null,
}), }),
}); });
if (!res.ok) throw new Error("Erreur"); if (!res.ok) throw new Error("Erreur");
@@ -815,6 +847,24 @@ function MobAddForm(
<div class="edit-section" style={{ marginBottom: "1rem" }}> <div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Nouvelle mobilité</p> <p class="edit-section-title">Nouvelle mobilité</p>
<div class="form-grid"> <div class="form-grid">
{availableStages.length > 0 && (
<div class="form-field">
<label>Lier à un stage</label>
<select
class="filter-select"
value={selectedStageId}
onChange={(e) =>
onStageChange((e.target as HTMLSelectElement).value)}
>
<option value=""> Mobilité d'étude —</option>
{availableStages.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.nomEntreprise} ({s.duree} sem.)
</option>
))}
</select>
</div>
)}
<div class="form-field"> <div class="form-field">
<label>Durée (semaines)</label> <label>Durée (semaines)</label>
<input <input
@@ -825,6 +875,8 @@ function MobAddForm(
onInput={(e) => setDuree((e.target as HTMLInputElement).value)} onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/> />
</div> </div>
{!isStageLinked && (
<>
<div class="form-field"> <div class="form-field">
<label>École</label> <label>École</label>
<input <input
@@ -854,14 +906,28 @@ function MobAddForm(
<select <select
class="filter-select" class="filter-select"
value={status} value={status}
onChange={(e) => setStatus((e.target as HTMLSelectElement).value)} onChange={(e) =>
setStatus((e.target as HTMLSelectElement).value)}
> >
{STATUS_ORDER.map((s) => ( {STATUS_ORDER.map((s) => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option> <option key={s} value={s}>{STATUS_LABELS[s]}</option>
))} ))}
</select> </select>
</div> </div>
</>
)}
</div> </div>
{isStageLinked && (
<p
style={{
fontSize: "0.8rem",
opacity: 0.7,
margin: "0.4rem 0",
}}
>
Mobilité liée à un stage — status automatiquement « Validé »
</p>
)}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button <button
type="button" type="button"
@@ -0,0 +1,20 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import MobilityOverview from "../../(_islands)/MobilityOverview.tsx";
// deno-lint-ignore require-await
async function Overview(
_request: Request,
context: FreshContext<State>,
) {
const numEtud = Number(context.params.numEtud);
return <MobilityOverview initialNumEtud={numEtud} />;
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
@@ -268,7 +268,7 @@ export default function ImportNotes() {
globalThis.open("/templates/modele_notes.xlsx", "_blank"); globalThis.open("/templates/modele_notes.xlsx", "_blank");
} }
function downloadExport() { function _downloadExport() {
// Export notes from the API in the same format // Export notes from the API in the same format
Promise.all([ Promise.all([
fetch("/students/api/students").then((r) => r.json()), fetch("/students/api/students").then((r) => r.json()),
@@ -17,7 +17,9 @@ type Stage = {
const REQUIRED_WEEKS = 40; const REQUIRED_WEEKS = 40;
export default function StagesOverview() { export default function StagesOverview(
{ initialNumEtud }: { initialNumEtud?: number } = {},
) {
const [students, setStudents] = useState<Student[]>([]); const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]); const [promos, setPromos] = useState<Promotion[]>([]);
const [stagesList, setStagesList] = useState<Stage[]>([]); const [stagesList, setStagesList] = useState<Stage[]>([]);
@@ -48,6 +50,12 @@ export default function StagesOverview() {
setStudents(sData); setStudents(sData);
setPromos(pData); setPromos(pData);
setStagesList(stData); setStagesList(stData);
if (initialNumEtud) {
const found = (sData as Student[]).find((s) =>
s.numEtud === initialNumEtud
);
if (found) setDetailStudent(found);
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
@@ -59,6 +67,18 @@ export default function StagesOverview() {
load(); load();
}, []); }, []);
function openStudent(s: Student) {
setDetailStudent(s);
history.pushState(null, "", `/stages/overview/${s.numEtud}`);
}
function closeStudent() {
setDetailStudent(null);
setEditingStage(null);
setShowAddForm(false);
history.pushState(null, "", "/stages/overview");
}
if (detailStudent) { if (detailStudent) {
return ( return (
<DetailView <DetailView
@@ -69,11 +89,7 @@ export default function StagesOverview() {
setEditingStage={setEditingStage} setEditingStage={setEditingStage}
showAddForm={showAddForm} showAddForm={showAddForm}
setShowAddForm={setShowAddForm} setShowAddForm={setShowAddForm}
onBack={() => { onBack={closeStudent}
setDetailStudent(null);
setEditingStage(null);
setShowAddForm(false);
}}
onReload={load} onReload={load}
/> />
); );
@@ -129,7 +145,7 @@ export default function StagesOverview() {
<ListView <ListView
students={filtered} students={filtered}
stagesByStudent={stagesByStudent} stagesByStudent={stagesByStudent}
onConsult={(s) => setDetailStudent(s)} onConsult={(s) => openStudent(s)}
/> />
</div> </div>
); );
@@ -0,0 +1,20 @@
import {
getPartialsConfig,
makePartials,
} from "$root/defaults/makePartials.tsx";
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import StagesOverview from "../../(_islands)/StagesOverview.tsx";
// deno-lint-ignore require-await
async function Overview(
_request: Request,
context: FreshContext<State>,
) {
const numEtud = Number(context.params.numEtud);
return <StagesOverview initialNumEtud={numEtud} />;
}
export { Overview as Page };
export const config = getPartialsConfig();
export default makePartials(Overview);
@@ -8,6 +8,8 @@ type Student = {
}; };
type Promo = { id: string; annee: string }; type Promo = { id: string; annee: string };
type Module = { id: string; nom: string }; type Module = { id: string; nom: string };
type Mobilite = { id: number; duree: number; status: string };
type Stage = { id: number; duree: number };
type Props = { numEtud: number }; type Props = { numEtud: number };
@@ -25,6 +27,8 @@ export default function EditStudents({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null); const [student, setStudent] = useState<Student | null>(null);
const [promos, setPromos] = useState<Promo[]>([]); const [promos, setPromos] = useState<Promo[]>([]);
const [_modules, setModules] = useState<Module[]>([]); const [_modules, setModules] = useState<Module[]>([]);
const [mobWeeks, setMobWeeks] = useState(0);
const [stageWeeks, setStageWeeks] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<string | null>(null); const [saveMsg, setSaveMsg] = useState<string | null>(null);
@@ -38,10 +42,12 @@ export default function EditStudents({ numEtud }: Props) {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
const [sRes, pRes, mRes] = await Promise.all([ const [sRes, pRes, mRes, mobRes, stRes] = await Promise.all([
fetch(`/students/api/students/${numEtud}`), fetch(`/students/api/students/${numEtud}`),
fetch("/students/api/promotions"), fetch("/students/api/promotions"),
fetch("/admin/api/modules"), fetch("/notes/api/modules"),
fetch(`/mobility/api/mobilites?numEtud=${numEtud}`),
fetch(`/stages/api/stages?numEtud=${numEtud}`),
]); ]);
if (!sRes.ok) throw new Error("Élève introuvable"); if (!sRes.ok) throw new Error("Élève introuvable");
const s: Student = await sRes.json(); const s: Student = await sRes.json();
@@ -51,6 +57,19 @@ export default function EditStudents({ numEtud }: Props) {
setIdPromo(s.idPromo); setIdPromo(s.idPromo);
if (pRes.ok) setPromos(await pRes.json()); if (pRes.ok) setPromos(await pRes.json());
if (mRes.ok) setModules(await mRes.json()); if (mRes.ok) setModules(await mRes.json());
if (mobRes.ok) {
const mobs: Mobilite[] = await mobRes.json();
setMobWeeks(
mobs.filter((m) => m.status === "validated").reduce(
(s, m) => s + m.duree,
0,
),
);
}
if (stRes.ok) {
const stages: Stage[] = await stRes.json();
setStageWeeks(stages.reduce((s, st) => s + st.duree, 0));
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Erreur"); setError(e instanceof Error ? e.message : "Erreur");
} finally { } finally {
@@ -207,30 +226,69 @@ export default function EditStudents({ numEtud }: Props) {
</div> </div>
</div> </div>
{/* Section 2: Spécialisations */} {/* Section 2: Notes */}
<div class="edit-section"> <div class="edit-section">
<p class="edit-section-title">Spécialisations</p> <p class="edit-section-title">Notes</p>
<p
class="state-empty"
style="padding: 1rem 0; text-align: left"
>
Fonctionnalité non disponible (endpoint non implémenté).
</p>
</div>
{/* Section 3: Notes lecture seule */}
<div class="edit-section">
<p class="edit-section-title">Notes (lecture seule)</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap"> <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"> <span class="col-dim" style="font-size: 0.82rem">
Voir le récap complet des notes et moyennes de cet étudiant Récap complet des notes et moyennes
</span> </span>
<a <a
class="btn btn-secondary" class="btn btn-secondary"
href={`/notes/recap/${numEtud}`} href={`/notes/recap/${numEtud}`}
f-client-nav={false} f-client-nav={false}
> >
Récap notes Voir les notes
</a>
</div>
</div>
{/* Section 3: Mobilités */}
<div class="edit-section">
<p class="edit-section-title">Mobilités</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap">
<span
style={{
fontFamily: "monospace",
fontWeight: "var(--font-weight-bold)",
color: mobWeeks >= 12 ? "#22c55e" : "#dc2626",
}}
>
{mobWeeks}/12 semaines validées
</span>
</span>
<a
class="btn btn-secondary"
href={`/mobility/overview/${numEtud}`}
f-client-nav={false}
>
Consulter
</a>
</div>
</div>
{/* Section 4: Stages */}
<div class="edit-section">
<p class="edit-section-title">Stages</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap">
<span
style={{
fontFamily: "monospace",
fontWeight: "var(--font-weight-bold)",
color: stageWeeks >= 40 ? "#22c55e" : "#dc2626",
}}
>
{stageWeeks}/40 semaines
</span>
</span>
<a
class="btn btn-secondary"
href={`/stages/overview/${numEtud}`}
f-client-nav={false}
>
Consulter
</a> </a>
</div> </div>
</div> </div>
+10 -2
View File
@@ -40,6 +40,12 @@
font-size: 0.8rem; font-size: 0.8rem;
font-family: inherit; font-family: inherit;
min-width: 8rem; min-width: 8rem;
box-sizing: border-box;
}
.form-field .filter-select {
width: 100%;
min-width: 0;
} }
.filter-input:focus, .filter-input:focus,
@@ -368,7 +374,9 @@
color: light-dark(var(--light-foreground), var(--dark-foreground)); color: light-dark(var(--light-foreground), var(--dark-foreground));
font-size: 0.82rem; font-size: 0.82rem;
font-family: inherit; font-family: inherit;
min-width: 12rem; min-width: 0;
width: 100%;
box-sizing: border-box;
} }
.form-input:focus { .form-input:focus {
@@ -799,7 +807,7 @@
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.75rem 1rem; gap: 0.75rem 1rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
+8 -8
View File
@@ -1,15 +1,15 @@
(function () { (function () {
var t = localStorage.getItem("theme"); const t = localStorage.getItem("theme");
if (t) document.documentElement.style.colorScheme = t; if (t) document.documentElement.style.colorScheme = t;
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
var btn = e.target.closest("#theme-toggle"); const btn = e.target.closest("#theme-toggle");
if (!btn) return; if (!btn) return;
var cs = getComputedStyle(document.documentElement).colorScheme; const cs = getComputedStyle(document.documentElement).colorScheme;
var isDark = cs === "dark" || const isDark = cs === "dark" ||
(!cs || cs === "light dark") && (!cs || cs === "light dark") &&
matchMedia("(prefers-color-scheme:dark)").matches; matchMedia("(prefers-color-scheme:dark)").matches;
var next = isDark ? "light" : "dark"; const next = isDark ? "light" : "dark";
document.documentElement.style.colorScheme = next; document.documentElement.style.colorScheme = next;
localStorage.setItem("theme", next); localStorage.setItem("theme", next);
btn.querySelector("span").textContent = next === "dark" btn.querySelector("span").textContent = next === "dark"
@@ -18,10 +18,10 @@
}); });
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
var btn = document.getElementById("theme-toggle"); const btn = document.getElementById("theme-toggle");
if (!btn) return; if (!btn) return;
var cs = getComputedStyle(document.documentElement).colorScheme; const cs = getComputedStyle(document.documentElement).colorScheme;
var isDark = cs === "dark" || const isDark = cs === "dark" ||
(!cs || cs === "light dark") && (!cs || cs === "light dark") &&
matchMedia("(prefers-color-scheme:dark)").matches; matchMedia("(prefers-color-scheme:dark)").matches;
btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode"; btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode";
+2 -2
View File
@@ -34,7 +34,7 @@ Deno.test({
}); });
Deno.test({ Deno.test({
name: "e2e modules: GET /modules returns empty for non-employee", name: "e2e modules: GET /modules returns all for non-employee",
async fn() { async fn() {
await truncateAll(); await truncateAll();
await seedModules([{ id: "MATH101", nom: "Mathématiques" }]); await seedModules([{ id: "MATH101", nom: "Mathématiques" }]);
@@ -44,7 +44,7 @@ Deno.test({
); );
assertEquals(res.status, 200); assertEquals(res.status, 200);
const body = await res.json(); const body = await res.json();
assertEquals(body.length, 0); assertEquals(body.length, 1);
}, },
sanitizeResources: false, sanitizeResources: false,
sanitizeOps: false, sanitizeOps: false,