2026-10-08 02:01:58 +02:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
|
|
|
|
|
|
|
|
use App\Models\Classe;
|
2026-10-12 23:20:28 +02:00
|
|
|
|
use App\Models\Correction;
|
2026-10-09 22:18:53 +02:00
|
|
|
|
use App\Models\DemandeInscription;
|
2026-10-08 02:01:58 +02:00
|
|
|
|
use App\Models\Eleve;
|
2026-10-12 22:25:56 +02:00
|
|
|
|
use App\Models\LabSession;
|
2026-10-09 21:07:25 +02:00
|
|
|
|
use App\Models\Seance;
|
2026-10-08 02:01:58 +02:00
|
|
|
|
use App\Services\GenerateurCompteEleve;
|
2026-10-12 23:20:28 +02:00
|
|
|
|
use App\Services\Labs\DefinitionLab;
|
2026-10-09 22:18:53 +02:00
|
|
|
|
use Illuminate\Http\JsonResponse;
|
2026-10-08 02:01:58 +02:00
|
|
|
|
use Illuminate\Http\RedirectResponse;
|
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
|
|
|
use Illuminate\Support\Facades\Hash;
|
|
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
|
|
use Illuminate\View\View;
|
|
|
|
|
|
|
|
|
|
|
|
class ClasseController extends Controller
|
|
|
|
|
|
{
|
|
|
|
|
|
public function __construct(private readonly GenerateurCompteEleve $generateur) {}
|
|
|
|
|
|
|
|
|
|
|
|
public function index(): View
|
|
|
|
|
|
{
|
|
|
|
|
|
$classes = Auth::guard('web')->user()->classes()->withCount('eleves')->latest()->get();
|
|
|
|
|
|
|
|
|
|
|
|
return view('mes-classes.index', ['classes' => $classes]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 22:25:56 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
2026-10-08 02:01:58 +02:00
|
|
|
|
public function show(Classe $classe): View
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->verifierProprietaire($classe);
|
|
|
|
|
|
|
2026-10-12 22:25:56 +02:00
|
|
|
|
// 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'])])
|
|
|
|
|
|
->latest('demarree_a')
|
|
|
|
|
|
->get()
|
|
|
|
|
|
->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS)
|
|
|
|
|
|
->first();
|
|
|
|
|
|
|
2026-10-12 23:20:28 +02:00
|
|
|
|
// 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.
|
2026-10-12 22:25:56 +02:00
|
|
|
|
$corrections = $seance
|
2026-10-12 23:20:28 +02:00
|
|
|
|
? $seance->labSessions->map(fn (LabSession $ls) => $ls->derniereCorrection)->filter()->values()
|
2026-10-12 22:25:56 +02:00
|
|
|
|
: collect();
|
|
|
|
|
|
|
2026-10-12 23:20:28 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-10-08 02:01:58 +02:00
|
|
|
|
return view('mes-classes.show', [
|
2026-10-12 22:25:56 +02:00
|
|
|
|
'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(),
|
2026-10-08 02:01:58 +02:00
|
|
|
|
'classe' => $classe->load('eleves'),
|
2026-10-09 22:18:53 +02:00
|
|
|
|
'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(),
|
2026-10-12 22:25:56 +02:00
|
|
|
|
'seance' => $seance,
|
2026-10-12 23:20:28 +02:00
|
|
|
|
'avancement' => $avancement,
|
|
|
|
|
|
'progressionSeance' => $progressionSeance,
|
2026-10-12 22:25:56 +02:00
|
|
|
|
'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null,
|
2026-10-12 23:20:28 +02:00
|
|
|
|
'mains' => ($seance && $seance->visioOuverte()) ? VisioController::mainsLevees($seance) : [],
|
2026-10-08 02:01:58 +02:00
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-10-09 22:18:53 +02:00
|
|
|
|
$fiche = $this->generateur->creerDans($classe, $prenom, $nom);
|
|
|
|
|
|
unset($fiche['eleve']);
|
2026-10-08 02:01:58 +02:00
|
|
|
|
|
2026-10-09 22:18:53 +02:00
|
|
|
|
return $fiche;
|
|
|
|
|
|
}
|
2026-10-08 02:01:58 +02:00
|
|
|
|
|
2026-10-09 22:18:53 +02:00
|
|
|
|
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);
|
2026-10-08 02:01:58 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 21:07:25 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 02:01:58 +02:00
|
|
|
|
private function verifierProprietaire(Classe $classe): void
|
|
|
|
|
|
{
|
|
|
|
|
|
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|