From 0573894721605d56c4b9b9fcb71937e5a37900ee Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Mon, 12 Oct 2026 22:25:56 +0200 Subject: [PATCH] =?UTF-8?q?reconstruit=20le=20tableau=20de=20bord=20du=20p?= =?UTF-8?q?rof=20dans=20le=20style=20de=20/cockpit,=20avec=20les=20vraies?= =?UTF-8?q?=20donn=C3=A9es=20(chantier=20vrai=20cockpit,=20=C3=A9tape=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- docs/journal.md | 21 ++ .../app/Http/Controllers/ClasseController.php | 26 +++ .../app/Http/Controllers/VisioController.php | 17 +- .../views/mes-classes/show.blade.php | 217 ++++++++++++++---- portail/tests/Feature/ConnexionProfTest.php | 3 +- .../tests/Feature/TableauDeBordProfTest.php | 59 +++++ 6 files changed, 302 insertions(+), 41 deletions(-) create mode 100644 portail/tests/Feature/TableauDeBordProfTest.php diff --git a/docs/journal.md b/docs/journal.md index 897bc6f..511f88f 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -808,3 +808,24 @@ Toujours en attente du DNS `turn.syllabuslab.fr` (ADR 0006) pour le relais TURN **Comprendre** Reprendre une maquette « telle quelle » (la règle de l'étape 8) a du sens pour la partie vitrine, qui s'adresse à un visiteur ; mais un même fichier de layout servait les deux publics à la fois, vitrine et application, sans jamais se demander qui regardait. La bonne clé n'était pas de reconstruire le menu, juste de lui apprendre à se taire sur les démos pour quelqu'un qui a déjà un compte — trois liens corrigés, pas toute la page. C'est aussi un bon rappel que « toutes les pages marchent » (dit à l'étape 8) voulait dire « s'affichent sans erreur », pas « mènent au bon endroit une fois connecté » : deux vérifications différentes, et seule un vrai parcours de connexion (pas un tinker) pouvait montrer celle-ci. + +## 2026-10-12 — Chantier « vrai cockpit », étape 1 : tableau de bord réel + +Après avoir montré que `/mes-classes` fonctionnait (voir plus haut), Adam a précisé le vrai besoin : il voulait le **style et la mise en page** de la démo `/cockpit`, mais branché sur ses vraies données. Quatre widgets de la démo (QCM, devoirs maison, observations, surveillance d'examen) n'ont pas de fonction réelle derrière : décidé avec Adam un ordre de construction (tableau de bord d'abord, puis une fonction à la fois), en commençant par ce qui existe déjà vraiment. + +**Fait** + +- `ClasseController::show()` calcule maintenant, en plus de ce qu'il chargeait déjà : la séance la plus pertinente de la classe (en cours avant salle d'attente, la plus récente d'abord), ses sessions de lab avec élève/dernière correction/machines, un taux de réussite moyen (sur les dernières corrections existantes, absent s'il n'y en a aucune — jamais un faux 0 %), le nombre de mains levées si une visio est ouverte, et la liste des autres classes du prof (pour le sélecteur). +- `VisioController::nombreMainsLevees()` (nouveau, public) : lit le même cache que la page de visio, pour que le tableau de bord puisse afficher un vrai chiffre sans dupliquer le format de la clé. +- `resources/views/mes-classes/show.blade.php` réécrite dans la mise en page de `/cockpit` (barre latérale avec sélecteur de classe, cartes d'indicateurs, tableau des élèves de la séance en cours) : **chaque chiffre vient d'une vraie table ou d'un vrai cache**. Aucune carte pour les fonctions non construites. Tout ce qui existait déjà (rejoindre, fiches, ajout/import d'élève, régénération de code, planification/archivage) est conservé, juste réorganisé. +- 3 nouveaux tests Pest (59 au total) : classe vide (aucune carte inventée), séance en cours avec élèves réels/état/correction, étanchéité entre classes d'un même prof. + +**Vérifié sur le serveur réel** (scénario de test jetable, supprimé ensuite) : connexion par le vrai formulaire → `/mes-classes/{id}` en 200, avec la vraie classe, le vrai élève, le vrai état (« Réussi », 10/10), le vrai ratio (1/1 dans la séance), le vrai taux (100 %), et le lien console — aucune erreur. + +**Comprendre** + +Le piège de ce chantier n'était pas technique mais une tentation facile : recopier `/cockpit` presque à l'identique en remplaçant juste un ou deux chiffres aurait été plus rapide, et personne n'aurait rien vu tout de suite. Mais une carte « Moyenne QCM : 13,4 » sans aucun QCM derrière, ou une carte « DM rendus » sans aucun devoir, ce n'est plus une démo marketing honnête (où le bandeau d'aperçu le dit) — c'est un mensonge dans l'outil que le prof utilise vraiment pour piloter sa classe. La règle « pas de KPI inventé » du CLAUDE.md vise la vitrine ; elle compte encore plus ici. + +**Suite** + +Chantier « vrai cockpit », étape 2 : Quiz flash (prochaine fonction à construire pour de vrai). diff --git a/portail/app/Http/Controllers/ClasseController.php b/portail/app/Http/Controllers/ClasseController.php index 0e3b956..0a909a4 100644 --- a/portail/app/Http/Controllers/ClasseController.php +++ b/portail/app/Http/Controllers/ClasseController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Classe; use App\Models\DemandeInscription; use App\Models\Eleve; +use App\Models\LabSession; use App\Models\Seance; use App\Services\GenerateurCompteEleve; use Illuminate\Http\JsonResponse; @@ -26,13 +27,38 @@ class ClasseController extends Controller 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'])]) + ->latest('demarree_a') + ->get() + ->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS) + ->first(); + + $corrections = $seance + ? LabSession::query()->whereIn('id', $seance->labSessions->pluck('id'))->whereHas('derniereCorrection')->with('derniereCorrection')->get()->pluck('derniereCorrection') + : collect(); + return view('mes-classes.show', [ + 'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(), 'classe' => $classe->load('eleves'), 'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(), + 'seance' => $seance, + 'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null, + 'mainsLevees' => ($seance && $seance->visioOuverte()) ? VisioController::nombreMainsLevees($seance) : null, ]); } diff --git a/portail/app/Http/Controllers/VisioController.php b/portail/app/Http/Controllers/VisioController.php index e643385..de57a96 100644 --- a/portail/app/Http/Controllers/VisioController.php +++ b/portail/app/Http/Controllers/VisioController.php @@ -202,11 +202,26 @@ class VisioController extends Controller return $eleve->prenom.($eleve->nom ? ' '.mb_strtoupper(mb_substr($eleve->nom, 0, 1)).'.' : ''); } - private function cle(Seance $seance): string + /** + * Lu aussi par le tableau de bord du prof (ClasseController), d'où la + * visibilité publique de la clé et de ce compteur — jamais du contenu + * complet de l'état (noms des élèves), seulement un chiffre. + */ + public static function cleEtat(Seance $seance): string { return "visio:{$seance->id}"; } + public static function nombreMainsLevees(Seance $seance): int + { + return count((Cache::get(self::cleEtat($seance)) ?? [])['mains'] ?? []); + } + + private function cle(Seance $seance): string + { + return self::cleEtat($seance); + } + /** * @return array{mains: array, participants: array, presences: array} */ diff --git a/portail/resources/views/mes-classes/show.blade.php b/portail/resources/views/mes-classes/show.blade.php index 2b67cdf..4788bf1 100644 --- a/portail/resources/views/mes-classes/show.blade.php +++ b/portail/resources/views/mes-classes/show.blade.php @@ -2,11 +2,49 @@ @section('titre', $classe->nom) +{{-- Tableau de bord prof (chantier « vrai cockpit », étape 1 — 2026-10-12) : + même mise en page que la démonstration /cockpit (sélecteur de classe, + cartes d'indicateurs, tableau des élèves), mais chaque chiffre vient + d'une vraie table ou d'un vrai cache. Rien n'est inventé (CLAUDE.md) : + les cartes des fonctions pas encore construites (quiz, DM, + observations, examen) n'existent pas ici tant qu'elles n'existent pas + vraiment — elles arriveront une par une. --}} @section('contenu') -
-
+@php + $etats = \App\Models\LabSession::class; +@endphp +
+ + +
+
-

{{ $classe->nom }}

+

Tableau de bord

+

{{ $classe->nom }}

@if ($classe->formation)

{{ $classe->formation }}

@endif @@ -15,11 +53,101 @@
@if (session('classe_creee')) -

Classe créée. Ajoute maintenant tes élèves ci-dessous.

+

Classe créée. Ajoute maintenant tes élèves ci-dessous.

+ @endif + + {{-- Indicateurs réels : rien ici qui ne vienne d'une vraie table ou d'un vrai cache. --}} +
    +
  • +

    Élèves

    +

    {{ $classe->eleves->count() }}

    +
  • +
  • +

    Demandes en attente

    +

    {{ $demandes->count() }}

    +
  • + @if ($seance) +
  • +

    Dans la séance

    +

    {{ $seance->labSessions->whereIn('etat', [$etats::ACTIF, $etats::ADMIS])->count() }}/{{ $classe->eleves->count() }}

    +

    admis ou en préparation

    +
  • + @if ($reussiteMoyenne !== null) +
  • +

    Réussite (dernière vérif.)

    +

    {{ $reussiteMoyenne }}%

    +
  • + @endif + @if ($mainsLevees !== null) +
  • +

    Mains levées (visio)

    +

    {{ $mainsLevees }}

    +
  • + @endif + @endif +
+ + @if ($seance) + {{-- Vraies données de la séance en cours : un élève, son état, sa dernière vérification. --}} +
+
+

{{ $seance->lab->titre }} — {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'en cours' : 'salle d\'attente' }}

+ + Salle d'attente + {{ $seance->visioOuverte() ? 'Rejoindre la visio' : 'Lancer la visio' }} + +
+ @if ($seance->labSessions->isEmpty()) +

Personne n'a encore rejoint.

+ @else +
+ + + + + @foreach ($seance->labSessions as $labSession) + + + + + + + @endforeach + +
ÉlèveÉtatDernière vérification
{{ $labSession->eleve->nomAffiche() }} + @switch($labSession->etat) + @case($etats::EN_ATTENTE) +
@csrf
+ @break + @case($etats::ADMIS) + Préparation des VM… + @break + @case($etats::ACTIF) + Admis + @break + @case($etats::ECHEC) + Échec (3 essais) + @break + @default + Terminé + @endswitch +
+ @if ($labSession->derniereCorrection) + {{ $labSession->derniereCorrection->reussi ? 'Réussi' : 'En cours' }} · {{ $labSession->derniereCorrection->note }}/{{ $labSession->derniereCorrection->note_max }} + @else + — + @endif + + @if ($labSession->etat === $etats::ACTIF) + Voir sa console + @endif +
+ @endif +
@endif @unless ($classe->estArchivee()) -
+

Faire rejoindre tes élèves

Projette ce code ou ce QR. Chaque élève va sur {{ preg_replace('#^https?://#', '', route('rejoindre')) }}, tape le code et son prénom ; tu acceptes sa demande ici, et il reçoit à l'écran son identifiant et son code secret. Aucun e-mail.

@@ -30,7 +158,7 @@ @else
    @foreach ($demandes as $demande) -
  • +
  • {{ $demande->prenom }}@if ($demande->nom) {{ $demande->nom }}@endif · {{ $demande->created_at->diffForHumans() }}
    @csrf
    @@ -46,7 +174,7 @@ @endunless @if (session('fiches')) -
    +

    Fiches à imprimer @@ -54,12 +182,12 @@ ({{ session('nombre_importes') }} élève(s) importé(s)) @endif

    - +

    Le code secret ne sera plus jamais affiché après cette page : imprime ou note-le maintenant.

    @foreach (session('fiches') as $fiche) -
    +
    {!! \App\Support\QrCode::svg(route('connexion.qr', $fiche['jeton_qr'])) !!}

    {{ $fiche['prenom'] }}@if($fiche['nom']) {{ $fiche['nom'] }}@endif

    @@ -72,8 +200,8 @@
    @endif -
    -
    +
    +

    Ajouter un élève

    @error('prenom')

    {{ $message }}

    @enderror
    @@ -83,7 +211,7 @@
    -
    +

    Importer une liste (CSV)

    Une ligne par élève : prenom,nom (le nom peut être vide).

    @error('fichier')

    {{ $message }}

    @enderror @@ -95,7 +223,7 @@
    -
    +

    Élèves ({{ $classe->eleves->count() }})

    @if ($classe->eleves->isNotEmpty()) @@ -133,8 +261,9 @@ @endif
    +
    @if ($classe->estArchivee()) -
    +

    Classe archivée

    Archivée le {{ $classe->archivee_a->timezone(config('app.fuseau_utilisateurs'))->format('d/m/Y') }}. @@ -147,11 +276,11 @@

    @else -
    +

    Lancer ou planifier une séance

    Les élèves qui rejoignent patientent dans la salle d'attente jusqu'à ce que tu les admettes. Si tu planifies la séance, les machines de toute la classe sont préparées 10 minutes avant : elles s'ouvrent alors en quelques secondes.

    @if (session('seance_planifiee')) -

    Séance planifiée.

    +

    Séance planifiée.

    @endif
    @csrf @@ -184,7 +313,7 @@ @endif
    -
    +

    Fin d'année

    Archiver la classe ferme ses séances. {{ \App\Models\Classe::DELAI_PURGE_JOURS }} jours plus tard, la classe @@ -200,27 +329,37 @@

    @endif +
    - +
+ @endsection diff --git a/portail/tests/Feature/ConnexionProfTest.php b/portail/tests/Feature/ConnexionProfTest.php index 4859ea0..53780ff 100644 --- a/portail/tests/Feature/ConnexionProfTest.php +++ b/portail/tests/Feature/ConnexionProfTest.php @@ -1,5 +1,6 @@ create(); + $classe = Classe::factory()->create(); commeProf($this, $classe->prof) ->get(route('mes-classes.index')) ->assertSee('Mes classes') diff --git a/portail/tests/Feature/TableauDeBordProfTest.php b/portail/tests/Feature/TableauDeBordProfTest.php new file mode 100644 index 0000000..9bc87e7 --- /dev/null +++ b/portail/tests/Feature/TableauDeBordProfTest.php @@ -0,0 +1,59 @@ +create(['nom' => 'Classe A']); + $autre = Classe::factory()->create(['user_id' => $classe->user_id, 'nom' => 'Classe B']); + + commeProf($this, $classe->prof) + ->get(route('mes-classes.show', $classe)) + ->assertOk() + ->assertSee('Classe A') + ->assertSee('Classe B') // le sélecteur de classe liste bien les deux + ->assertSee('0', false) // Élèves : 0 + ->assertDontSee('Mains levées') + ->assertDontSee('Dans la séance'); +}); + +test('une séance en cours montre les vrais élèves, leur état et leur dernière correction', function () { + $classe = Classe::factory()->create(); + $lab = Lab::factory()->create(['titre' => 'Lab 08']); + $seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now()]); + $eleveActif = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Léa']); + $eleveEchec = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Yanis']); + + $sessionActive = LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleveActif->id, 'lab_id' => $lab->id, 'etat' => LabSession::ACTIF]); + Correction::create(['lab_session_id' => $sessionActive->id, 'reussi' => true, 'note' => 8, 'note_max' => 10, 'details' => []]); + LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleveEchec->id, 'lab_id' => $lab->id, 'etat' => LabSession::ECHEC, 'raison_echec' => 'CREATE_FAILED']); + + commeProf($this, $classe->prof) + ->get(route('mes-classes.show', $classe)) + ->assertOk() + ->assertSee('Lab 08') + ->assertSee('Léa') + ->assertSee('Réussi') + ->assertSee('8/10') + ->assertSee('Yanis') + ->assertSee('Échec (3 essais)') + ->assertSee('1/2') // dans la séance : 1 admis/actif sur 2 élèves + ->assertSee('100%') // réussite moyenne : la seule correction existante a réussi + ->assertDontSee('Mains levées'); // pas de visio ouverte : la carte n'existe pas +}); + +test('un prof ne voit pas la séance en cours d’une autre classe', function () { + $classe = Classe::factory()->create(); + $autreClasse = Classe::factory()->create(); + $lab = Lab::factory()->create(); + Seance::create(['classe_id' => $autreClasse->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now()]); + + commeProf($this, $classe->prof) + ->get(route('mes-classes.show', $classe)) + ->assertOk() + ->assertDontSee('Dans la séance'); +});