feat: made stuff
This commit is contained in:
@@ -56,6 +56,10 @@ jobs:
|
||||
run: |
|
||||
sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \
|
||||
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
|
||||
run: npm install --ignore-scripts && deno install
|
||||
|
||||
@@ -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) {
|
||||
return context.renderNotFound();
|
||||
}
|
||||
|
||||
+1536
File diff suppressed because it is too large
Load Diff
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+10
-2
@@ -40,6 +40,12 @@
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
min-width: 8rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-field .filter-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-input:focus,
|
||||
@@ -368,7 +374,9 @@
|
||||
color: light-dark(var(--light-foreground), var(--dark-foreground));
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
min-width: 12rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
@@ -799,7 +807,7 @@
|
||||
|
||||
.form-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;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
(function () {
|
||||
var t = localStorage.getItem("theme");
|
||||
const t = localStorage.getItem("theme");
|
||||
if (t) document.documentElement.style.colorScheme = t;
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest("#theme-toggle");
|
||||
const btn = e.target.closest("#theme-toggle");
|
||||
if (!btn) return;
|
||||
var cs = getComputedStyle(document.documentElement).colorScheme;
|
||||
var isDark = cs === "dark" ||
|
||||
const cs = getComputedStyle(document.documentElement).colorScheme;
|
||||
const isDark = cs === "dark" ||
|
||||
(!cs || cs === "light dark") &&
|
||||
matchMedia("(prefers-color-scheme:dark)").matches;
|
||||
var next = isDark ? "light" : "dark";
|
||||
const next = isDark ? "light" : "dark";
|
||||
document.documentElement.style.colorScheme = next;
|
||||
localStorage.setItem("theme", next);
|
||||
btn.querySelector("span").textContent = next === "dark"
|
||||
@@ -18,10 +18,10 @@
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var btn = document.getElementById("theme-toggle");
|
||||
const btn = document.getElementById("theme-toggle");
|
||||
if (!btn) return;
|
||||
var cs = getComputedStyle(document.documentElement).colorScheme;
|
||||
var isDark = cs === "dark" ||
|
||||
const cs = getComputedStyle(document.documentElement).colorScheme;
|
||||
const isDark = cs === "dark" ||
|
||||
(!cs || cs === "light dark") &&
|
||||
matchMedia("(prefers-color-scheme:dark)").matches;
|
||||
btn.querySelector("span").textContent = isDark ? "light_mode" : "dark_mode";
|
||||
|
||||
@@ -34,7 +34,7 @@ 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() {
|
||||
await truncateAll();
|
||||
await seedModules([{ id: "MATH101", nom: "Mathématiques" }]);
|
||||
@@ -44,7 +44,7 @@ Deno.test({
|
||||
);
|
||||
assertEquals(res.status, 200);
|
||||
const body = await res.json();
|
||||
assertEquals(body.length, 0);
|
||||
assertEquals(body.length, 1);
|
||||
},
|
||||
sanitizeResources: false,
|
||||
sanitizeOps: false,
|
||||
|
||||
Reference in New Issue
Block a user