Files
PolyMPR/routes/(apps)/mobility/(_islands)/MobilityOverview.tsx
T
djalim 9a4c6863d1 feat: stages module, mobility frontend, theme toggle, employeeOnly access control
- 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
2026-05-01 12:47:23 +02:00

932 lines
26 KiB
TypeScript

import { useEffect, useState } from "preact/hooks";
type Student = {
numEtud: number;
nom: string;
prenom: string;
idPromo: string;
};
type Promotion = { id: string; annee: string };
type Mobilite = {
id: number;
numEtud: number;
duree: number;
contratMob: string | null;
ecole: string | null;
pays: string | null;
status: string;
idStage: number | null;
};
type Stage = {
id: number;
numEtud: number;
duree: number;
nomEntreprise: string;
mission: string | null;
};
const REQUIRED_WEEKS = 12;
const STATUS_ORDER = [
"contracts_received",
"under_revision",
"done",
"validated",
"canceled",
] as const;
const STATUS_LABELS: Record<string, string> = {
contracts_received: "Contrats reçus",
under_revision: "En révision",
done: "Signé",
validated: "Validé",
canceled: "Annulé",
};
const STATUS_COLORS: Record<string, string> = {
contracts_received: "#f5a623",
under_revision: "#dc2626",
done: "#22c55e",
validated: "light-dark(var(--light-accent-color), var(--dark-accent-color))",
canceled:
"light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))",
};
function lowestStatus(mobs: Mobilite[]): string {
let lowest = STATUS_ORDER.length - 1;
for (const m of mobs) {
const idx = STATUS_ORDER.indexOf(m.status as typeof STATUS_ORDER[number]);
if (idx >= 0 && idx < lowest) lowest = idx;
}
return STATUS_ORDER[lowest];
}
function validatedWeeks(mobs: Mobilite[]): number {
return mobs
.filter((m) => m.status === "validated")
.reduce((sum, m) => sum + m.duree, 0);
}
export default function MobilityOverview() {
const [students, setStudents] = useState<Student[]>([]);
const [promos, setPromos] = useState<Promotion[]>([]);
const [mobilites, setMobilites] = useState<Mobilite[]>([]);
const [stagesMap, setStagesMap] = useState<Record<number, Stage>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"liste" | "kanban">("liste");
const [filterPromo, setFilterPromo] = useState("");
const [filterNom, setFilterNom] = useState("");
// Detail view state
const [detailStudent, setDetailStudent] = useState<Student | null>(null);
const [editingMob, setEditingMob] = useState<Mobilite | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
async function load() {
try {
const [sRes, pRes, mRes, stRes] = await Promise.all([
fetch("/students/api/students"),
fetch("/students/api/promotions"),
fetch("/mobility/api/mobilites"),
fetch("/stages/api/stages"),
]);
if (!sRes.ok) throw new Error("Impossible de charger les données");
const [sData, pData, mData, stData] = await Promise.all([
sRes.json(),
pRes.ok ? pRes.json() : [],
mRes.ok ? mRes.json() : [],
stRes.ok ? stRes.json() : [],
]);
setStudents(sData);
setPromos(pData);
setMobilites(mData);
setStagesMap(
Object.fromEntries((stData as Stage[]).map((s) => [s.id, s])),
);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
// If in detail view, render that
if (detailStudent) {
return (
<DetailView
student={detailStudent}
mobilites={mobilites.filter((m) => m.numEtud === detailStudent.numEtud)}
allMobilites={mobilites}
stagesMap={stagesMap}
editingMob={editingMob}
setEditingMob={setEditingMob}
showAddForm={showAddForm}
setShowAddForm={setShowAddForm}
onBack={() => {
setDetailStudent(null);
setEditingMob(null);
setShowAddForm(false);
}}
onReload={load}
/>
);
}
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 filtered = students.filter((s) => {
const matchPromo = !filterPromo || s.idPromo === filterPromo;
const matchNom = !filterNom ||
`${s.nom} ${s.prenom}`.toLowerCase().includes(filterNom.toLowerCase());
return matchPromo && matchNom;
});
const mobsByStudent = (numEtud: number) =>
mobilites.filter((m) => m.numEtud === numEtud);
return (
<div class="page-content">
<h2 class="page-title">Suivi des mobilités</h2>
<div class="tabs">
<button
type="button"
class={`tab-btn${tab === "liste" ? " active" : ""}`}
onClick={() => setTab("liste")}
>
Liste
</button>
<button
type="button"
class={`tab-btn${tab === "kanban" ? " active" : ""}`}
onClick={() => setTab("kanban")}
>
Kanban
</button>
</div>
<div class="filters">
<select
class="filter-select"
value={filterPromo}
onChange={(e) =>
setFilterPromo((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les promos</option>
{promos.map((p) => <option key={p.id} value={p.id}>{p.id}</option>)}
</select>
<input
class="filter-input"
placeholder="Rechercher..."
value={filterNom}
onInput={(e) => setFilterNom((e.target as HTMLInputElement).value)}
/>
</div>
{tab === "liste"
? (
<ListView
students={filtered}
mobsByStudent={mobsByStudent}
onConsult={(s) => setDetailStudent(s)}
/>
)
: (
<KanbanView
students={filtered}
mobsByStudent={mobsByStudent}
onConsult={(s) => setDetailStudent(s)}
/>
)}
</div>
);
}
// ─── Liste View ─────────────────────────────────────────────
function ListView(
{ students, mobsByStudent, onConsult }: {
students: Student[];
mobsByStudent: (n: number) => Mobilite[];
onConsult: (s: Student) => void;
},
) {
return (
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>N° étud.</th>
<th>Nom</th>
<th>Prénom</th>
<th>Semaines</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{students.length === 0
? (
<tr>
<td colspan={5} class="state-empty">Aucun élève trouvé</td>
</tr>
)
: students.map((s) => {
const mobs = mobsByStudent(s.numEtud);
const weeks = validatedWeeks(mobs);
const ok = weeks >= REQUIRED_WEEKS;
return (
<tr key={s.numEtud}>
<td class="col-dim">{s.numEtud}</td>
<td>{s.nom}</td>
<td>{s.prenom}</td>
<td>
<span
style={{
color: ok ? "var(--ok-color,#22c55e)" : "#dc2626",
fontWeight: "var(--font-weight-bold)",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS}
</span>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-primary"
onClick={() => onConsult(s)}
>
Consulter
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── Kanban View ────────────────────────────────────────────
function KanbanView(
{ students, mobsByStudent, onConsult }: {
students: Student[];
mobsByStudent: (n: number) => Mobilite[];
onConsult: (s: Student) => void;
},
) {
// Students who have at least one mobility
const studentsWithMobs = students.filter(
(s) => mobsByStudent(s.numEtud).length > 0,
);
// Group students by their lowest status
const columns: Record<string, Student[]> = {};
for (const status of STATUS_ORDER) columns[status] = [];
for (const s of studentsWithMobs) {
const mobs = mobsByStudent(s.numEtud);
// Filter out canceled for lowest-status calc (canceled is separate)
const activeMobs = mobs.filter((m) => m.status !== "canceled");
if (activeMobs.length === 0) {
// All canceled
columns["canceled"].push(s);
} else {
const lowest = lowestStatus(activeMobs);
columns[lowest].push(s);
}
}
return (
<div
style={{
display: "flex",
gap: "0.75rem",
overflowX: "auto",
paddingBottom: "0.5rem",
}}
>
{STATUS_ORDER.map((status) => (
<div
key={status}
style={{
minWidth: "14rem",
flex: "1",
borderRadius: "4px",
border:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
background: "light-dark(white, #141228)",
}}
>
<div
style={{
padding: "0.6rem 0.75rem",
borderBottom:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span
style={{
width: "0.6rem",
height: "0.6rem",
borderRadius: "50%",
background: STATUS_COLORS[status],
display: "inline-block",
}}
/>
<span
style={{
fontWeight: "var(--font-weight-bold)",
fontSize: "0.82rem",
}}
>
{STATUS_LABELS[status]}
</span>
<span
style={{
fontSize: "0.7rem",
opacity: "0.7",
marginLeft: "auto",
}}
>
{columns[status].length}
</span>
</div>
<div style={{ padding: "0.5rem" }}>
{columns[status].length === 0
? (
<p
style={{
fontSize: "0.75rem",
opacity: "0.5",
textAlign: "center",
margin: "1rem 0",
}}
>
Aucun
</p>
)
: columns[status].map((s) => {
const mobs = mobsByStudent(s.numEtud);
const weeks = validatedWeeks(mobs);
return (
<div
key={s.numEtud}
style={{
padding: "0.5rem 0.6rem",
marginBottom: "0.4rem",
borderRadius: "3px",
border:
"1px solid light-dark(var(--light-foreground-dimmer), var(--dark-foreground-dimmer))",
cursor: "pointer",
fontSize: "0.8rem",
}}
onClick={() => onConsult(s)}
>
<div
style={{
fontWeight: "var(--font-weight-bold)",
marginBottom: "0.15rem",
}}
>
{s.nom} {s.prenom}
</div>
<div
style={{
fontSize: "0.72rem",
display: "flex",
justifyContent: "space-between",
}}
>
<span style={{ opacity: "0.7" }}>{s.numEtud}</span>
<span
style={{
color: weeks >= REQUIRED_WEEKS
? "#22c55e"
: "#dc2626",
fontWeight: "var(--font-weight-bold)",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS} sem.
</span>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// ─── Detail View ────────────────────────────────────────────
function DetailView(
{
student,
mobilites,
allMobilites,
stagesMap,
editingMob,
setEditingMob,
showAddForm,
setShowAddForm,
onBack,
onReload,
}: {
student: Student;
mobilites: Mobilite[];
allMobilites: Mobilite[];
stagesMap: Record<number, Stage>;
editingMob: Mobilite | null;
setEditingMob: (m: Mobilite | null) => void;
showAddForm: boolean;
setShowAddForm: (v: boolean) => void;
onBack: () => void;
onReload: () => Promise<void>;
},
) {
const weeks = validatedWeeks(mobilites);
const ecoles = [...new Set(allMobilites.map((m) => m.ecole).filter(Boolean))];
const paysList = [
...new Set(allMobilites.map((m) => m.pays).filter(Boolean)),
];
async function deleteMob(id: number) {
if (!confirm("Supprimer cette mobilité ?")) return;
await fetch(`/mobility/api/mobilites/${id}`, { method: "DELETE" });
await onReload();
}
return (
<div class="page-content">
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onBack}
style={{ marginBottom: "0.75rem" }}
>
Retour
</button>
<h2 class="page-title">
Consulter : {student.prenom} {student.nom}
<span
style={{
fontSize: "0.8rem",
fontWeight: "normal",
marginLeft: "0.75rem",
color: weeks >= REQUIRED_WEEKS ? "#22c55e" : "#dc2626",
fontFamily: "monospace",
}}
>
{weeks}/{REQUIRED_WEEKS} semaines validées
</span>
</h2>
{mobilites.length === 0 && (
<p class="state-empty">Aucune mobilité enregistrée.</p>
)}
{mobilites.map((mob, i) => {
const stage = mob.idStage ? stagesMap[mob.idStage] : null;
const isEditing = editingMob?.id === mob.id;
if (isEditing) {
return (
<MobEditForm
key={mob.id}
mob={mob}
ecoles={ecoles}
paysList={paysList}
onCancel={() => setEditingMob(null)}
onSave={async () => {
setEditingMob(null);
await onReload();
}}
/>
);
}
return (
<div key={mob.id} class="ue-card" style={{ marginBottom: "1rem" }}>
<div class="ue-card-header">
<p class="ue-card-title">
Mobilité {i + 1}
{stage ? " : Stage" : " : Étude"}
</p>
<p
class="ue-card-avg"
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
}}
>
<span
class={`note-chip ${
mob.status === "validated"
? "note-chip--ok"
: mob.status === "canceled"
? "note-chip--fail"
: "note-chip--none"
}`}
>
{STATUS_LABELS[mob.status] ?? mob.status}
</span>
<span>Durée : {mob.duree} semaine(s)</span>
</p>
</div>
<div style={{ padding: "0.6rem 1.1rem" }}>
{stage
? (
<p style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
Entreprise : <strong>{stage.nomEntreprise}</strong>
{stage.mission && <span> {stage.mission}</span>}
</p>
)
: (
<p style={{ fontSize: "0.82rem", margin: "0 0 0.4rem" }}>
{mob.ecole && (
<>
École : <strong>{mob.ecole}</strong>
</>
)}
{mob.ecole && mob.pays && <span>,</span>}
{mob.pays && (
<>
Pays : <strong>{mob.pays}</strong>
</>
)}
</p>
)}
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginTop: "0.5rem",
}}
>
{mob.contratMob && (
<a
class="btn btn-sm btn-primary"
href={`/mobility/api/mobilites/${mob.id}/contrat`}
target="_blank"
>
Télécharger contrat
</a>
)}
{!mob.idStage && (
<UploadContratBtn
mobId={mob.id}
hasContrat={!!mob.contratMob}
onDone={onReload}
/>
)}
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={() =>
setEditingMob(mob)}
>
Modifier
</button>
<button
type="button"
class="btn btn-sm btn-danger"
onClick={() =>
deleteMob(mob.id)}
>
Supprimer
</button>
</div>
</div>
</div>
);
})}
{showAddForm
? (
<MobAddForm
numEtud={student.numEtud}
ecoles={ecoles}
paysList={paysList}
onCancel={() => setShowAddForm(false)}
onSave={async () => {
setShowAddForm(false);
await onReload();
}}
/>
)
: (
<button
type="button"
class="btn btn-primary"
onClick={() => setShowAddForm(true)}
>
+ Nouvelle mobilité
</button>
)}
</div>
);
}
// ─── Inline forms ───────────────────────────────────────────
function MobEditForm(
{ mob, ecoles, paysList, onCancel, onSave }: {
mob: Mobilite;
ecoles: string[];
paysList: string[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
const [duree, setDuree] = useState(String(mob.duree));
const [ecole, setEcole] = useState(mob.ecole ?? "");
const [pays, setPays] = useState(mob.pays ?? "");
const [status, setStatus] = useState(mob.status);
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
try {
const res = await fetch(`/mobility/api/mobilites/${mob.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
duree: parseInt(duree),
ecole: ecole || null,
pays: pays || null,
status,
}),
});
if (!res.ok) throw new Error("Erreur");
await onSave();
} catch {
alert("Erreur lors de la modification");
} finally {
setBusy(false);
}
}
return (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Modifier la mobilité #{mob.id}</p>
<div class="form-grid">
<div class="form-field">
<label>Durée (semaines)</label>
<input
class="form-input"
type="number"
min="1"
value={duree}
onInput={(e) => setDuree((e.target as HTMLInputElement).value)}
/>
</div>
{!mob.idStage && (
<>
<div class="form-field">
<label>École</label>
<input
class="form-input"
list="edit-ecoles"
value={ecole}
onInput={(e) => setEcole((e.target as HTMLInputElement).value)}
/>
<datalist id="edit-ecoles">
{ecoles.map((e) => <option key={e} value={e} />)}
</datalist>
</div>
<div class="form-field">
<label>Pays</label>
<input
class="form-input"
list="edit-pays"
value={pays}
onInput={(e) => setPays((e.target as HTMLInputElement).value)}
/>
<datalist id="edit-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>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy}
onClick={submit}
>
Enregistrer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
function MobAddForm(
{ numEtud, ecoles, paysList, onCancel, onSave }: {
numEtud: number;
ecoles: string[];
paysList: string[];
onCancel: () => void;
onSave: () => Promise<void>;
},
) {
const [duree, setDuree] = useState("4");
const [ecole, setEcole] = useState("");
const [pays, setPays] = useState("");
const [status, setStatus] = useState("contracts_received");
const [busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
try {
const res = await fetch("/mobility/api/mobilites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
numEtud,
duree: parseInt(duree),
ecole: ecole || null,
pays: pays || null,
status,
}),
});
if (!res.ok) throw new Error("Erreur");
await onSave();
} catch {
alert("Erreur lors de la création");
} finally {
setBusy(false);
}
}
return (
<div class="edit-section" style={{ marginBottom: "1rem" }}>
<p class="edit-section-title">Nouvelle mobilité</p>
<div class="form-grid">
<div class="form-field">
<label>Durée (semaines)</label>
<input
class="form-input"
type="number"
min="1"
value={duree}
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>
</div>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={busy || !duree}
onClick={submit}
>
Créer
</button>
<button
type="button"
class="btn btn-sm btn-secondary"
onClick={onCancel}
>
Annuler
</button>
</div>
</div>
);
}
function UploadContratBtn(
{ mobId, hasContrat, onDone }: {
mobId: number;
hasContrat: boolean;
onDone: () => Promise<void>;
},
) {
const [busy, setBusy] = useState(false);
function upload() {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/pdf";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
setBusy(true);
try {
const fd = new FormData();
fd.append("contrat", file);
const res = await fetch(`/mobility/api/mobilites/${mobId}/contrat`, {
method: "POST",
body: fd,
});
if (!res.ok) throw new Error("Erreur upload");
await onDone();
} catch {
alert("Erreur lors de l'upload du contrat");
} finally {
setBusy(false);
}
};
input.click();
}
return (
<button
type="button"
class="btn btn-sm btn-secondary"
disabled={busy}
onClick={upload}
>
{busy ? "..." : hasContrat ? "Remplacer contrat" : "Ajouter contrat"}
</button>
);
}