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 = { contracts_received: "Contrats reçus", under_revision: "En révision", done: "Signé", validated: "Validé", canceled: "Annulé", }; const STATUS_COLORS: Record = { 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([]); const [promos, setPromos] = useState([]); const [mobilites, setMobilites] = useState([]); const [stagesMap, setStagesMap] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tab, setTab] = useState<"liste" | "kanban">("liste"); const [filterPromo, setFilterPromo] = useState(""); const [filterNom, setFilterNom] = useState(""); // Detail view state const [detailStudent, setDetailStudent] = useState(null); const [editingMob, setEditingMob] = useState(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 ( 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 (

Chargement...

); } if (error) { return (

{error}

); } 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 (

Suivi des mobilités

setFilterNom((e.target as HTMLInputElement).value)} />
{tab === "liste" ? ( setDetailStudent(s)} /> ) : ( setDetailStudent(s)} /> )}
); } // ─── Liste View ───────────────────────────────────────────── function ListView( { students, mobsByStudent, onConsult }: { students: Student[]; mobsByStudent: (n: number) => Mobilite[]; onConsult: (s: Student) => void; }, ) { return (
{students.length === 0 ? ( ) : students.map((s) => { const mobs = mobsByStudent(s.numEtud); const weeks = validatedWeeks(mobs); const ok = weeks >= REQUIRED_WEEKS; return ( ); })}
N° étud. Nom Prénom Semaines Actions
Aucun élève trouvé
{s.numEtud} {s.nom} {s.prenom} {weeks}/{REQUIRED_WEEKS}
); } // ─── 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 = {}; 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 (
{STATUS_ORDER.map((status) => (
{STATUS_LABELS[status]} {columns[status].length}
{columns[status].length === 0 ? (

Aucun

) : columns[status].map((s) => { const mobs = mobsByStudent(s.numEtud); const weeks = validatedWeeks(mobs); return (
onConsult(s)} >
{s.nom} {s.prenom}
{s.numEtud} = REQUIRED_WEEKS ? "#22c55e" : "#dc2626", fontWeight: "var(--font-weight-bold)", fontFamily: "monospace", }} > {weeks}/{REQUIRED_WEEKS} sem.
); })}
))}
); } // ─── Detail View ──────────────────────────────────────────── function DetailView( { student, mobilites, allMobilites, stagesMap, editingMob, setEditingMob, showAddForm, setShowAddForm, onBack, onReload, }: { student: Student; mobilites: Mobilite[]; allMobilites: Mobilite[]; stagesMap: Record; editingMob: Mobilite | null; setEditingMob: (m: Mobilite | null) => void; showAddForm: boolean; setShowAddForm: (v: boolean) => void; onBack: () => void; onReload: () => Promise; }, ) { 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 (

Consulter : {student.prenom} {student.nom} = REQUIRED_WEEKS ? "#22c55e" : "#dc2626", fontFamily: "monospace", }} > {weeks}/{REQUIRED_WEEKS} semaines validées

{mobilites.length === 0 && (

Aucune mobilité enregistrée.

)} {mobilites.map((mob, i) => { const stage = mob.idStage ? stagesMap[mob.idStage] : null; const isEditing = editingMob?.id === mob.id; if (isEditing) { return ( setEditingMob(null)} onSave={async () => { setEditingMob(null); await onReload(); }} /> ); } return (

Mobilité {i + 1} {stage ? " : Stage" : " : Étude"}

{STATUS_LABELS[mob.status] ?? mob.status} Durée : {mob.duree} semaine(s)

{stage ? (

Entreprise : {stage.nomEntreprise} {stage.mission && — {stage.mission}}

) : (

{mob.ecole && ( <> École : {mob.ecole} )} {mob.ecole && mob.pays && ,} {mob.pays && ( <> Pays : {mob.pays} )}

)}
{mob.contratMob && ( Télécharger contrat )} {!mob.idStage && ( )}
); })} {showAddForm ? ( setShowAddForm(false)} onSave={async () => { setShowAddForm(false); await onReload(); }} /> ) : ( )}
); } // ─── Inline forms ─────────────────────────────────────────── function MobEditForm( { mob, ecoles, paysList, onCancel, onSave }: { mob: Mobilite; ecoles: string[]; paysList: string[]; onCancel: () => void; onSave: () => Promise; }, ) { 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 (

Modifier la mobilité #{mob.id}

setDuree((e.target as HTMLInputElement).value)} />
{!mob.idStage && ( <>
setEcole((e.target as HTMLInputElement).value)} /> {ecoles.map((e) =>
setPays((e.target as HTMLInputElement).value)} /> {paysList.map((p) =>
)}
); } function MobAddForm( { numEtud, ecoles, paysList, onCancel, onSave }: { numEtud: number; ecoles: string[]; paysList: string[]; onCancel: () => void; onSave: () => Promise; }, ) { 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 (

Nouvelle mobilité

setDuree((e.target as HTMLInputElement).value)} />
setEcole((e.target as HTMLInputElement).value)} /> {ecoles.map((e) =>
setPays((e.target as HTMLInputElement).value)} /> {paysList.map((p) =>
); } function UploadContratBtn( { mobId, hasContrat, onDone }: { mobId: number; hasContrat: boolean; onDone: () => Promise; }, ) { 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 ( ); }