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
@@ -115,7 +115,7 @@ export default function AdminEnseignements() {
return (
<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>}
@@ -135,7 +135,7 @@ export default function AdminEnseignements() {
onChange={(e) =>
setFilterModule((e.target as HTMLSelectElement).value)}
>
<option value="">Module </option>
<option value="">ECUE </option>
{modules.map((m) => (
<option key={m.id} value={m.id}>{m.id} {m.nom}</option>
))}
@@ -194,7 +194,7 @@ export default function AdminEnseignements() {
</select>
</div>
<div class="form-field">
<label>Module</label>
<label>ECUE</label>
<select
class="filter-select"
value={addModule}
@@ -202,7 +202,7 @@ export default function AdminEnseignements() {
setAddModule((e.target as HTMLSelectElement).value)}
style="min-width: 0; width: 100%"
>
<option value="">Module...</option>
<option value="">ECUE...</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} -- {m.nom}
@@ -251,7 +251,7 @@ export default function AdminEnseignements() {
<thead>
<tr>
<th>Promo</th>
<th>Module</th>
<th>ECUE</th>
<th>Enseignant (User.id)</th>
<th>Actions</th>
</tr>
@@ -319,7 +319,7 @@ export default function AdminEnseignements() {
<div class="info-note">
<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.
</p>
<p class="info-note-dim">
@@ -22,7 +22,7 @@ export default function AdminModules() {
fetch("/admin/api/enseignements"),
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());
if (eRes.ok) setEnseignements(await eRes.json());
if (uRes.ok) setUsers(await uRes.json());
@@ -61,7 +61,7 @@ export default function AdminModules() {
}
async function deleteModule(id: string) {
if (!confirm(`Supprimer le module ${id} ?`)) return;
if (!confirm(`Supprimer l'ECUE ${id} ?`)) return;
try {
const res = await fetch(
`/admin/api/modules/${encodeURIComponent(id)}`,
@@ -102,7 +102,7 @@ export default function AdminModules() {
return (
<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>}
@@ -122,7 +122,7 @@ export default function AdminModules() {
}}
style="margin-left: auto"
>
+ Ajouter module
+ Ajouter ECUE
</button>
</div>
@@ -134,7 +134,7 @@ export default function AdminModules() {
<thead>
<tr>
<th>id (code)</th>
<th>Nom du module</th>
<th>Nom de l'ECUE</th>
<th>Enseignants assignes</th>
<th>Actions</th>
</tr>
@@ -144,7 +144,7 @@ export default function AdminModules() {
? (
<tr>
<td colspan={4} class="state-empty">
Aucun module enregistré
Aucun ECUE enregistré
</td>
</tr>
)
@@ -218,13 +218,13 @@ export default function AdminModules() {
</div>
)}
{/* Nouveau module */}
{/* Nouvel ECUE */}
<div
id="new-module-section"
class="edit-section"
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">
<input
class="form-input"
@@ -235,7 +235,7 @@ export default function AdminModules() {
/>
<input
class="form-input"
placeholder="Nom du module"
placeholder="Nom de l'ECUE"
value={newNom}
onInput={(e) => setNewNom((e.target as HTMLInputElement).value)}
/>
+9 -9
View File
@@ -104,7 +104,7 @@ export default function AdminUEs() {
idUE: number,
idPromo: string,
) {
if (!confirm("Supprimer ce module de la UE ?")) return;
if (!confirm("Supprimer cet ECUE de la UE ?")) return;
try {
const res = await fetch(
`/admin/api/ue-modules/${encodeURIComponent(idModule)}/${idUE}/${
@@ -121,7 +121,7 @@ export default function AdminUEs() {
async function addUeModule() {
if (!selectedUe || !addModuleId || !addPromoId) {
setAddError("Module et Promo sont requis");
setAddError("ECUE et Promo sont requis");
return;
}
const coeff = parseFloat(addCoeff);
@@ -203,7 +203,7 @@ export default function AdminUEs() {
class="col-dim"
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>
{error && <p class="state-error">{error}</p>}
@@ -314,13 +314,13 @@ export default function AdminUEs() {
<div class="panel-box">
<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">
Modules assignés (UE_Module)
ECUEs assignés (UE_Module)
</p>
<div class="data-table-wrap" style="margin-bottom: 1rem">
<table class="data-table">
<thead>
<tr>
<th>Module</th>
<th>ECUE</th>
<th>Promo</th>
<th>Coeff</th>
<th>Actions</th>
@@ -331,7 +331,7 @@ export default function AdminUEs() {
? (
<tr>
<td colspan={4} class="state-empty">
Aucun module assigné
Aucun ECUE assigné
</td>
</tr>
)
@@ -441,7 +441,7 @@ export default function AdminUEs() {
</div>
<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>
{addError && (
<p class="state-error" style="padding: 0.3rem 0.5rem">
@@ -458,7 +458,7 @@ export default function AdminUEs() {
)}
style="min-width: 12rem"
>
<option value="">Module </option>
<option value="">ECUE </option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id} {m.nom}
@@ -504,7 +504,7 @@ export default function AdminUEs() {
: (
<div class="panel-box">
<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>
</div>
)}
@@ -33,7 +33,7 @@ export default function EditModule({ moduleId }: Props) {
fetch("/admin/api/users"),
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();
setMod(m);
setNom(m.nom);
@@ -70,7 +70,7 @@ export default function EditModule({ moduleId }: Props) {
if (!res.ok) throw new Error("Modification échouée");
const updated: Module = await res.json();
setMod(updated);
setSaveMsg("Module enregistré.");
setSaveMsg("ECUE enregistré.");
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
@@ -79,7 +79,7 @@ export default function EditModule({ moduleId }: Props) {
}
async function deleteModule() {
if (!confirm(`Supprimer définitivement le module ${moduleId} ?`)) return;
if (!confirm(`Supprimer définitivement l'ECUE ${moduleId} ?`)) return;
try {
const res = await fetch(
`/admin/api/modules/${encodeURIComponent(moduleId)}`,
@@ -173,7 +173,7 @@ export default function EditModule({ moduleId }: Props) {
class="page-title"
style="border-bottom: none; margin-bottom: 0.5rem"
>
Module -- {mod.id}
ECUE -- {mod.id}
</h2>
<div class="info-bar">
@@ -202,7 +202,7 @@ export default function EditModule({ moduleId }: Props) {
/>
</div>
<div class="form-field">
<label>Nom du module</label>
<label>Nom de l'ECUE</label>
<input
class="form-input"
value={nom}
@@ -224,7 +224,7 @@ export default function EditModule({ moduleId }: Props) {
class="btn btn-danger"
onClick={deleteModule}
>
Supprimer le module
Supprimer l'ECUE
</button>
</div>
</div>
@@ -281,7 +281,7 @@ export default function ImportMaquette() {
globalThis.open("/templates/modele_maquette.xlsx", "_blank");
}
function downloadExport() {
function _downloadExport() {
Promise.all([
fetch("/admin/api/ues").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);
}
export default function MobilityOverview() {
export default function MobilityOverview(
{ initialNumEtud }: { initialNumEtud?: number } = {},
) {
const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]);
const [mobilites, setMobilites] = useState<Mobilite[]>([]);
@@ -105,6 +107,12 @@ export default function MobilityOverview() {
setStagesMap(
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) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
@@ -116,6 +124,18 @@ export default function MobilityOverview() {
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 (detailStudent) {
return (
@@ -128,11 +148,7 @@ export default function MobilityOverview() {
setEditingMob={setEditingMob}
showAddForm={showAddForm}
setShowAddForm={setShowAddForm}
onBack={() => {
setDetailStudent(null);
setEditingMob(null);
setShowAddForm(false);
}}
onBack={closeStudent}
onReload={load}
/>
);
@@ -207,14 +223,14 @@ export default function MobilityOverview() {
<ListView
students={filtered}
mobsByStudent={mobsByStudent}
onConsult={(s) => setDetailStudent(s)}
onConsult={(s) => openStudent(s)}
/>
)
: (
<KanbanView
students={filtered}
mobsByStudent={mobsByStudent}
onConsult={(s) => setDetailStudent(s)}
onConsult={(s) => openStudent(s)}
/>
)}
</div>
@@ -637,6 +653,9 @@ function DetailView(
numEtud={student.numEtud}
ecoles={ecoles}
paysList={paysList}
availableStages={Object.values(stagesMap)
.filter((s) => s.numEtud === student.numEtud)
.filter((s) => !mobilites.some((m) => m.idStage === s.id))}
onCancel={() => setShowAddForm(false)}
onSave={async () => {
setShowAddForm(false);
@@ -774,10 +793,11 @@ function MobEditForm(
}
function MobAddForm(
{ numEtud, ecoles, paysList, onCancel, onSave }: {
{ numEtud, ecoles, paysList, availableStages, onCancel, onSave }: {
numEtud: number;
ecoles: string[];
paysList: string[];
availableStages: Stage[];
onCancel: () => void;
onSave: () => Promise<void>;
},
@@ -786,8 +806,19 @@ function MobAddForm(
const [ecole, setEcole] = useState("");
const [pays, setPays] = useState("");
const [status, setStatus] = useState("contracts_received");
const [selectedStageId, setSelectedStageId] = useState("");
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() {
setBusy(true);
try {
@@ -797,9 +828,10 @@ function MobAddForm(
body: JSON.stringify({
numEtud,
duree: parseInt(duree),
ecole: ecole || null,
pays: pays || null,
status,
ecole: isStageLinked ? null : (ecole || null),
pays: isStageLinked ? null : (pays || null),
status: isStageLinked ? "validated" : status,
idStage: isStageLinked ? Number(selectedStageId) : null,
}),
});
if (!res.ok) throw new Error("Erreur");
@@ -815,6 +847,24 @@ function MobAddForm(
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Nouvelle mobilité</p>
<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">
<label>Durée (semaines)</label>
<input
@@ -825,43 +875,59 @@ function MobAddForm(
onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-field">
<label>École</label>
<input
class="form-input"
list="add-ecoles"
value={ecole}
onInput={(e) => setEcole((e.target as HTMLInputElement).value)}
/>
<datalist id="add-ecoles">
{ecoles.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Pays</label>
<input
class="form-input"
list="add-pays"
value={pays}
onInput={(e) => setPays((e.target as HTMLInputElement).value)}
/>
<datalist id="add-pays">
{paysList.map((p) => <option key={p} value={p} />)}
</datalist>
</div>
<div class="form-field">
<label>Status</label>
<select
class="filter-select"
value={status}
onChange={(e) => setStatus((e.target as HTMLSelectElement).value)}
>
{STATUS_ORDER.map((s) => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</div>
{!isStageLinked && (
<>
<div class="form-field">
<label>École</label>
<input
class="form-input"
list="add-ecoles"
value={ecole}
onInput={(e) => setEcole((e.target as HTMLInputElement).value)}
/>
<datalist id="add-ecoles">
{ecoles.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Pays</label>
<input
class="form-input"
list="add-pays"
value={pays}
onInput={(e) => setPays((e.target as HTMLInputElement).value)}
/>
<datalist id="add-pays">
{paysList.map((p) => <option key={p} value={p} />)}
</datalist>
</div>
<div class="form-field">
<label>Status</label>
<select
class="filter-select"
value={status}
onChange={(e) =>
setStatus((e.target as HTMLSelectElement).value)}
>
{STATUS_ORDER.map((s) => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</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" }}>
<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");
}
function downloadExport() {
function _downloadExport() {
// Export notes from the API in the same format
Promise.all([
fetch("/students/api/students").then((r) => r.json()),
@@ -17,7 +17,9 @@ type Stage = {
const REQUIRED_WEEKS = 40;
export default function StagesOverview() {
export default function StagesOverview(
{ initialNumEtud }: { initialNumEtud?: number } = {},
) {
const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]);
const [stagesList, setStagesList] = useState<Stage[]>([]);
@@ -48,6 +50,12 @@ export default function StagesOverview() {
setStudents(sData);
setPromos(pData);
setStagesList(stData);
if (initialNumEtud) {
const found = (sData as Student[]).find((s) =>
s.numEtud === initialNumEtud
);
if (found) setDetailStudent(found);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
@@ -59,6 +67,18 @@ export default function StagesOverview() {
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) {
return (
<DetailView
@@ -69,11 +89,7 @@ export default function StagesOverview() {
setEditingStage={setEditingStage}
showAddForm={showAddForm}
setShowAddForm={setShowAddForm}
onBack={() => {
setDetailStudent(null);
setEditingStage(null);
setShowAddForm(false);
}}
onBack={closeStudent}
onReload={load}
/>
);
@@ -129,7 +145,7 @@ export default function StagesOverview() {
<ListView
students={filtered}
stagesByStudent={stagesByStudent}
onConsult={(s) => setDetailStudent(s)}
onConsult={(s) => openStudent(s)}
/>
</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 Module = { id: string; nom: string };
type Mobilite = { id: number; duree: number; status: string };
type Stage = { id: number; duree: number };
type Props = { numEtud: number };
@@ -25,6 +27,8 @@ export default function EditStudents({ numEtud }: Props) {
const [student, setStudent] = useState<Student | null>(null);
const [promos, setPromos] = useState<Promo[]>([]);
const [_modules, setModules] = useState<Module[]>([]);
const [mobWeeks, setMobWeeks] = useState(0);
const [stageWeeks, setStageWeeks] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
@@ -38,10 +42,12 @@ export default function EditStudents({ numEtud }: Props) {
useEffect(() => {
async function load() {
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/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");
const s: Student = await sRes.json();
@@ -51,6 +57,19 @@ export default function EditStudents({ numEtud }: Props) {
setIdPromo(s.idPromo);
if (pRes.ok) setPromos(await pRes.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) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
@@ -207,30 +226,69 @@ export default function EditStudents({ numEtud }: Props) {
</div>
</div>
{/* Section 2: Spécialisations */}
{/* Section 2: Notes */}
<div class="edit-section">
<p class="edit-section-title">Spécialisations</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>
<p class="edit-section-title">Notes</p>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap">
<span class="col-dim" style="font-size: 0.82rem">
Voir le récap complet des notes et moyennes de cet étudiant
Récap complet des notes et moyennes
</span>
<a
class="btn btn-secondary"
href={`/notes/recap/${numEtud}`}
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>
</div>
</div>