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(),
|
sépare la vitrine de l'application : vraie coquille, barre par rôle, chat
Une fois connecté, plus aucun lien vitrine : nouvelle layouts/app (barre
du haut avec menu avatar, barre latérale par rôle, bulle de chat
Messenger, barre d'onglets mobile). Les 10 entrées de navigation
demandées sont construites en vrai plutôt qu'en renvoi "bientôt
disponible" : Ma classe, Emploi du temps, Mes devoirs, Mes cours, Mes
notes côté élève ; Mes classes, Emploi du temps, Séances en direct,
Bibliothèque, Résultats (avec Observations, la dernière fonction
fictive du tableau de bord démo) côté prof.
Chat élève <-> prof de sa classe, jamais entre élèves. Mon profil,
Paramètres (thème, taille du texte), Signaler un problème, Mes
raccourcis. /eleve vidée des faux contenus ("À faire", "Ma progression",
"Mes QCM") restés avant le chantier "vrai cockpit".
Bug trouvé en testant en réel sur le serveur (pas en local) : le
compteur de messages non lus du chat ne redescendait jamais à zéro
(FilConversation sans lu_par_prof_a/lu_par_eleve_a dans son fillable) —
corrigé avec un test de non-régression.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-13 18:21:28 +02:00
|
|
|
|
'classe' => $classe->load(['eleves', 'annonces.auteur', 'observations.eleve']),
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|