user()->classes()->withCount('eleves')->latest()->get(); return view('mes-classes.index', ['classes' => $classes]); } /** * Le tableau de bord (étape 1 du chantier « vrai cockpit », 2026-10-12) * ne montre que des chiffres réels : rien n'est affiché qui ne vienne * pas d'une vraie table ou d'un vrai cache (CLAUDE.md : pas de KPI * inventé). Les fonctions sans backend (quiz, DM, observations, examen) * n'ont pas de carte ici — elles arriveront une par une. */ public function show(Classe $classe): View { $this->verifierProprietaire($classe); // La séance la plus pertinente à montrer : en cours avant salle // d'attente, la plus récemment démarrée d'abord. $seance = $classe->seances() ->whereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS]) ->with(['lab', 'labSessions' => fn ($q) => $q->with(['eleve', 'derniereCorrection', 'machines', 'evenementsExamen'])]) ->latest('demarree_a') ->get() ->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS) ->first(); // Déjà chargées par le eager loading ci-dessus (labSessions.derniereCorrection) : // repasser par une requête séparée perdait le type précis pour l'analyse statique. $corrections = $seance ? $seance->labSessions->map(fn (LabSession $ls) => $ls->derniereCorrection)->filter()->values() : collect(); // Répartition par vérification du lab (équivalent réel du « répartition // par étape » de la démo) : l'ordre et les titres viennent du lab.yaml, // le compte de réussite des vraies corrections. Jamais de bonus ici // (ils ajoutent des points, ils ne font pas partie du parcours normal). $avancement = collect(); if ($seance && $corrections->isNotEmpty()) { $definition = DefinitionLab::depuis($seance->lab->identifiant); $avancement = collect($definition->verifications) ->reject(fn ($v) => $v['bonus']) ->map(fn ($v) => [ 'titre' => $v['titre'], 'reussi' => $corrections->filter(fn (Correction $c) => collect($c->details)->firstWhere('id', $v['id'])['reussi'] ?? false)->count(), 'total' => $corrections->count(), ]); } // Progression dans le temps de la séance, d'après la durée estimée // du lab (Lab::duree_min) : absente si l'une des deux manque, jamais // une estimation inventée. $progressionSeance = ($seance?->demarree_a && $seance->lab->duree_min) ? min(100, (int) round($seance->demarree_a->diffInMinutes(now()) / $seance->lab->duree_min * 100)) : null; return view('mes-classes.show', [ 'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(), 'classe' => $classe->load(['eleves', 'annonces.auteur', 'observations.eleve']), 'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(), 'seance' => $seance, 'avancement' => $avancement, 'progressionSeance' => $progressionSeance, 'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null, 'mains' => ($seance && $seance->visioOuverte()) ? VisioController::mainsLevees($seance) : [], ]); } public function storeEleve(Request $request, Classe $classe): RedirectResponse { $this->verifierProprietaire($classe); $donnees = $request->validate([ 'prenom' => ['required', 'string', 'max:100'], 'nom' => ['nullable', 'string', 'max:100'], ]); $fiche = $this->creerEleve($classe, $donnees['prenom'], $donnees['nom'] ?? null); return redirect()->route('mes-classes.show', $classe) ->with('fiches', [$fiche]); } public function importerCsv(Request $request, Classe $classe): RedirectResponse { $this->verifierProprietaire($classe); $request->validate([ 'fichier' => ['required', 'file', 'mimes:csv,txt'], ]); $lignes = file($request->file('fichier')->getRealPath(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $fiches = []; foreach ($lignes as $ligne) { $colonnes = str_getcsv($ligne); $prenom = trim($colonnes[0] ?? ''); if ($prenom === '' || Str::lower($prenom) === 'prenom') { continue; // ligne vide ou en-tête } $nom = trim($colonnes[1] ?? '') ?: null; $fiches[] = $this->creerEleve($classe, $prenom, $nom); } return redirect()->route('mes-classes.show', $classe) ->with('fiches', $fiches) ->with('nombre_importes', count($fiches)); } public function regenererCode(Classe $classe, Eleve $eleve): RedirectResponse { $this->verifierProprietaire($classe); abort_if($eleve->classe_id !== $classe->id, 404); $codeSecret = $this->generateur->genererCodeSecret(); $jetonQr = Str::random(32); $eleve->forceFill([ 'code_secret' => Hash::make($codeSecret), 'jeton_qr' => $jetonQr, ])->save(); return redirect()->route('mes-classes.show', $classe) ->with('fiches', [[ 'identifiant' => $eleve->identifiant, 'prenom' => $eleve->prenom, 'nom' => $eleve->nom, 'code_secret' => $codeSecret, 'jeton_qr' => $jetonQr, ]]); } /** * @return array{identifiant: string, prenom: string, nom: ?string, code_secret: string, jeton_qr: string} */ private function creerEleve(Classe $classe, string $prenom, ?string $nom): array { $fiche = $this->generateur->creerDans($classe, $prenom, $nom); unset($fiche['eleve']); return $fiche; } public function accepterDemande(Classe $classe, DemandeInscription $demande): RedirectResponse { $this->verifierProprietaire($classe); abort_if($demande->classe_id !== $classe->id, 404); if ($demande->statut === DemandeInscription::EN_ATTENTE && ! $classe->estArchivee()) { $fiche = $this->generateur->creerDans($classe, $demande->prenom, $demande->nom); $demande->forceFill([ 'statut' => DemandeInscription::ACCEPTEE, 'eleve_id' => $fiche['eleve']->id, 'code_secret_chiffre' => $fiche['code_secret'], ])->save(); } return redirect()->route('mes-classes.show', $classe); } public function refuserDemande(Classe $classe, DemandeInscription $demande): RedirectResponse { $this->verifierProprietaire($classe); abort_if($demande->classe_id !== $classe->id, 404); $demande->forceFill(['statut' => DemandeInscription::REFUSEE])->save(); return redirect()->route('mes-classes.show', $classe); } public function nombreDemandes(Classe $classe): JsonResponse { $this->verifierProprietaire($classe); return response()->json(['en_attente' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->count()]); } /** * Nouvelles fiches (nouveau code, nouveau QR) pour les élèves cochés : * la fiche n'est visible qu'une fois, ceci permet de la refaire en lot. */ public function nouvellesFiches(Request $request, Classe $classe): RedirectResponse { $this->verifierProprietaire($classe); $ids = $request->validate(['eleves' => ['required', 'array'], 'eleves.*' => ['integer']])['eleves']; $fiches = []; foreach ($classe->eleves()->whereIn('id', $ids)->orderBy('prenom')->get() as $eleve) { $codeSecret = $this->generateur->genererCodeSecret(); $jetonQr = Str::random(32); $eleve->forceFill(['code_secret' => Hash::make($codeSecret), 'jeton_qr' => $jetonQr])->save(); $fiches[] = ['identifiant' => $eleve->identifiant, 'prenom' => $eleve->prenom, 'nom' => $eleve->nom, 'code_secret' => $codeSecret, 'jeton_qr' => $jetonQr]; } return redirect()->route('mes-classes.show', $classe)->with('fiches', $fiches); } /** * Fin d'année : plus de séance possible, et suppression définitive de la * classe et des données de ses élèves 90 jours plus tard. */ public function archiver(Classe $classe): RedirectResponse { $this->verifierProprietaire($classe); if ($classe->aDesLabsEnCours()) { return back()->withErrors(['archivage' => 'Des élèves ont encore un lab en cours : termine la séance avant d’archiver.']); } $classe->forceFill(['archivee_a' => now()])->save(); $classe->seances()->whereIn('statut', [Seance::PLANIFIEE, Seance::SALLE_ATTENTE, Seance::EN_COURS]) ->update(['statut' => Seance::TERMINEE, 'terminee_a' => now()]); return redirect()->route('mes-classes.show', $classe); } public function desarchiver(Classe $classe): RedirectResponse { $this->verifierProprietaire($classe); $classe->forceFill(['archivee_a' => null])->save(); return redirect()->route('mes-classes.show', $classe); } private function verifierProprietaire(Classe $classe): void { abort_if($classe->user_id !== Auth::guard('web')->id(), 403); } }