reconstruit le tableau de bord du prof dans le style de /cockpit, avec les vraies données (chantier vrai cockpit, étape 1)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-12 22:25:56 +02:00
parent 78030de488
commit 0573894721
6 changed files with 302 additions and 41 deletions

View file

@ -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).

View file

@ -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,
]);
}

View file

@ -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<int, array{nom: string, depuis: int}>, participants: array<int, string>, presences: array<int, array{nom: string, vu: int}>}
*/

View file

@ -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')
<div class="mx-auto max-w-5xl px-5 py-14 lg:px-10">
<div class="flex flex-wrap items-end justify-between gap-4">
@php
$etats = \App\Models\LabSession::class;
@endphp
<div class="mx-auto grid max-w-[1600px] lg:grid-cols-[240px_minmax(0,1fr)]">
<aside class="border-b border-[#e3e5e8] bg-[#f8f9fa] p-5 lg:border-b-0 lg:border-r lg:border-[#e3e5e8]">
<p class="font-mono text-[11px] font-semibold uppercase tracking-wider text-[#5f6368]">Mes classes</p>
<label class="mt-2 block">
<span class="sr-only">Changer de classe</span>
<select class="h-11 w-full rounded-lg border border-[#dadce0] bg-white px-2 font-bold" onchange="location = this.value">
@foreach ($classes as $autre)
<option value="{{ route('mes-classes.show', $autre) }}" @selected($autre->id === $classe->id)>{{ $autre->nom }} ({{ $autre->eleves_count }})</option>
@endforeach
</select>
</label>
<p class="mt-6 font-mono text-[11px] font-semibold uppercase tracking-wider text-[#5f6368]">Cette classe</p>
<ul class="mt-2 grid gap-1 text-sm font-bold">
<li><a href="#apercu" class="flex items-center rounded-lg bg-[#0b57d0] px-3 py-2 text-white">Vue d'ensemble</a></li>
<li><a href="#rejoindre" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Faire rejoindre</a></li>
<li><a href="#eleves" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Élèves</a></li>
<li><a href="#seance" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">{{ $classe->estArchivee() ? 'Classe archivée' : 'Séance' }}</a></li>
</ul>
@if ($seance)
<div class="mt-6 rounded-lg border border-[#dadce0] bg-white p-3 text-sm font-semibold">
<p class="font-extrabold">Séance en cours</p>
<p class="mt-1">{{ $seance->lab->titre }}</p>
<p class="mt-2 font-mono text-xs">{{ $seance->statut === \App\Models\Seance::EN_COURS ? 'En cours' : 'Salle d\'attente' }}</p>
</div>
@endif
</aside>
<div class="min-w-0 p-5 lg:p-8">
<div id="apercu" class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-extrabold tracking-tight">{{ $classe->nom }}</h1>
<p class="font-mono text-xs font-semibold text-[#5f6368]">Tableau de bord</p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">{{ $classe->nom }}</h1>
@if ($classe->formation)
<p class="mt-1 text-lg font-semibold">{{ $classe->formation }}</p>
@endif
@ -15,11 +53,101 @@
</div>
@if (session('classe_creee'))
<p class="mt-4 border border-[#e3e5e8] rounded-2xl bg-[#e6f4ea] p-4 font-semibold">Classe créée. Ajoute maintenant tes élèves ci-dessous.</p>
<p class="mt-4 rounded-lg border border-[#e3e5e8] bg-[#e6f4ea] p-4 font-semibold">Classe créée. Ajoute maintenant tes élèves ci-dessous.</p>
@endif
{{-- Indicateurs réels : rien ici qui ne vienne d'une vraie table ou d'un vrai cache. --}}
<ul class="mt-6 grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4" aria-label="Indicateurs">
<li class="flex flex-col rounded-lg border border-[#dadce0] bg-white p-4">
<p class="text-sm font-bold">Élèves</p>
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $classe->eleves->count() }}</p>
</li>
<li class="flex flex-col rounded-lg border border-[#dadce0] bg-white p-4">
<p class="text-sm font-bold">Demandes en attente</p>
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $demandes->count() }}</p>
</li>
@if ($seance)
<li class="flex flex-col rounded-lg border border-[#dadce0] bg-white p-4">
<p class="text-sm font-bold">Dans la séance</p>
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $seance->labSessions->whereIn('etat', [$etats::ACTIF, $etats::ADMIS])->count() }}/{{ $classe->eleves->count() }}</p>
<p class="mt-1 text-xs font-bold text-[#5f6368]">admis ou en préparation</p>
</li>
@if ($reussiteMoyenne !== null)
<li class="flex flex-col rounded-lg border border-[#dadce0] bg-white p-4">
<p class="text-sm font-bold">Réussite (dernière vérif.)</p>
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $reussiteMoyenne }}%</p>
</li>
@endif
@if ($mainsLevees !== null)
<li class="flex flex-col rounded-lg border border-[#dadce0] bg-[#fce8e6] p-4">
<p class="text-sm font-bold">Mains levées (visio)</p>
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $mainsLevees }}</p>
</li>
@endif
@endif
</ul>
@if ($seance)
{{-- Vraies données de la séance en cours : un élève, son état, sa dernière vérification. --}}
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white" aria-labelledby="t-seance-encours">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-[#e3e5e8] p-4">
<h2 id="t-seance-encours" class="text-lg font-extrabold">{{ $seance->lab->titre }} — {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'en cours' : 'salle d\'attente' }}</h2>
<span class="flex flex-wrap gap-2">
<a href="{{ route('seances.attente', $seance) }}" class="inline-flex h-10 items-center rounded-lg border border-[#dadce0] px-4 text-sm font-extrabold hover:bg-[#f8f9fa]">Salle d'attente</a>
<a href="{{ route('seances.visio.prof', $seance) }}" class="inline-flex h-10 items-center rounded-lg bg-[#0b57d0] px-4 text-sm font-extrabold text-white">{{ $seance->visioOuverte() ? 'Rejoindre la visio' : 'Lancer la visio' }}</a>
</span>
</div>
@if ($seance->labSessions->isEmpty())
<p class="p-4 font-semibold">Personne n'a encore rejoint.</p>
@else
<div class="overflow-x-auto"><table class="w-full min-w-[640px] border-collapse text-left text-sm">
<thead class="border-b border-[#e3e5e8] bg-[#f8f9fa] font-mono text-[11px] uppercase tracking-wider">
<tr><th class="px-4 py-3 font-semibold">Élève</th><th class="px-4 py-3 font-semibold">État</th><th class="px-4 py-3 font-semibold">Dernière vérification</th><th class="px-4 py-3 font-semibold"></th></tr>
</thead>
<tbody class="divide-y divide-[#e3e5e8]">
@foreach ($seance->labSessions as $labSession)
<tr>
<td class="px-4 py-3 font-extrabold">{{ $labSession->eleve->nomAffiche() }}</td>
<td class="px-4 py-3">
@switch($labSession->etat)
@case($etats::EN_ATTENTE)
<form action="{{ route('seances.admettre', [$seance, $labSession]) }}" method="post">@csrf<button type="submit" class="inline-flex h-9 items-center rounded-lg bg-[#0b57d0] px-3 text-xs font-extrabold text-white">Admettre</button></form>
@break
@case($etats::ADMIS)
<span class="rounded-lg border border-[#dadce0] bg-[#fef7e0] px-2 py-0.5 font-mono text-xs font-bold">Préparation des VM…</span>
@break
@case($etats::ACTIF)
<span class="rounded-lg border border-[#dadce0] bg-[#e6f4ea] px-2 py-0.5 font-mono text-xs font-bold">Admis</span>
@break
@case($etats::ECHEC)
<span class="rounded-lg border border-[#A63D2A] bg-[#fce8e6] px-2 py-0.5 font-mono text-xs font-bold" title="{{ $labSession->raison_echec }}">Échec (3 essais)</span>
@break
@default
<span class="rounded-lg border border-[#dadce0] px-2 py-0.5 font-mono text-xs font-bold">Terminé</span>
@endswitch
</td>
<td class="px-4 py-3 font-mono font-semibold">
@if ($labSession->derniereCorrection)
<span class="{{ $labSession->derniereCorrection->reussi ? 'text-[#3C8A5A]' : '' }}">{{ $labSession->derniereCorrection->reussi ? 'Réussi' : 'En cours' }} · {{ $labSession->derniereCorrection->note }}/{{ $labSession->derniereCorrection->note_max }}</span>
@else
—
@endif
</td>
<td class="px-4 py-3 text-right">
@if ($labSession->etat === $etats::ACTIF)
<a href="{{ route('seances.console.prof.page', [$seance, $labSession]) }}" class="font-bold underline underline-offset-4">Voir sa console</a>
@endif
</td>
</tr>
@endforeach
</tbody>
</table></div>
@endif
</section>
@endif
@unless ($classe->estArchivee())
<section class="mt-10 grid gap-6 rounded-2xl border border-[#e3e5e8] bg-white p-6 md:grid-cols-[1fr_auto]" aria-labelledby="t-rejoindre">
<section id="rejoindre" class="mt-6 grid gap-6 rounded-lg border border-[#dadce0] bg-white p-6 md:grid-cols-[1fr_auto]" aria-labelledby="t-rejoindre">
<div>
<h2 id="t-rejoindre" class="text-2xl font-extrabold">Faire rejoindre tes élèves</h2>
<p class="mt-2 font-semibold">Projette ce code ou ce QR. Chaque élève va sur <span class="font-mono">{{ preg_replace('#^https?://#', '', route('rejoindre')) }}</span>, 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.</p>
@ -30,7 +158,7 @@
@else
<ul class="mt-2 grid gap-2">
@foreach ($demandes as $demande)
<li class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#e3e5e8] p-3">
<li class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[#e3e5e8] p-3">
<span class="font-extrabold">{{ $demande->prenom }}@if ($demande->nom) {{ $demande->nom }}@endif <span class="font-mono text-sm font-semibold text-[#5f6368]">· {{ $demande->created_at->diffForHumans() }}</span></span>
<span class="flex gap-2">
<form action="{{ route('mes-classes.demandes.accepter', [$classe, $demande]) }}" method="post">@csrf<button type="submit" class="inline-flex h-10 items-center rounded-full bg-[#0b57d0] px-4 font-bold text-white">Accepter</button></form>
@ -46,7 +174,7 @@
@endunless
@if (session('fiches'))
<section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-[#fef7e0] p-6 print:border-0 print:bg-white" aria-labelledby="t-fiches">
<section class="mt-6 rounded-lg border border-[#e3e5e8] bg-[#fef7e0] p-6 print:border-0 print:bg-white" aria-labelledby="t-fiches">
<div class="flex flex-wrap items-center justify-between gap-3 print:hidden">
<h2 id="t-fiches" class="text-2xl font-extrabold">
Fiches à imprimer
@ -54,12 +182,12 @@
<span class="font-mono text-sm font-semibold">({{ session('nombre_importes') }} élève(s) importé(s))</span>
@endif
</h2>
<button type="button" data-imprimer class="border border-[#dadce0] rounded-lg bg-black px-4 py-2 font-extrabold text-white">Imprimer</button>
<button type="button" data-imprimer class="rounded-lg border border-[#dadce0] bg-black px-4 py-2 font-extrabold text-white">Imprimer</button>
</div>
<p class="mt-2 font-semibold print:hidden">Le code secret ne sera plus jamais affiché après cette page : imprime ou note-le maintenant.</p>
<div class="mt-6 grid gap-4 sm:grid-cols-2 print:grid-cols-1">
@foreach (session('fiches') as $fiche)
<div class="flex items-center gap-4 border border-[#dadce0] rounded-2xl bg-white p-5 print:break-inside-avoid">
<div class="flex items-center gap-4 rounded-lg border border-[#dadce0] bg-white p-5 print:break-inside-avoid">
<div class="shrink-0">{!! \App\Support\QrCode::svg(route('connexion.qr', $fiche['jeton_qr'])) !!}</div>
<div class="min-w-0">
<p class="text-lg font-extrabold">{{ $fiche['prenom'] }}@if($fiche['nom']) {{ $fiche['nom'] }}@endif</p>
@ -72,8 +200,8 @@
</section>
@endif
<div class="mt-10 grid gap-6 sm:grid-cols-2">
<section class="border border-[#e3e5e8] rounded-2xl bg-white p-6" aria-labelledby="t-ajout">
<div class="mt-6 grid gap-6 sm:grid-cols-2">
<section class="rounded-lg border border-[#dadce0] bg-white p-6" aria-labelledby="t-ajout">
<h2 id="t-ajout" class="text-xl font-extrabold">Ajouter un élève</h2>
@error('prenom')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
<form action="{{ route('mes-classes.eleves.store', $classe) }}" method="post" class="mt-4 grid gap-3">
@ -83,7 +211,7 @@
<button type="submit" class="mt-2 inline-flex h-11 items-center justify-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Ajouter</button>
</form>
</section>
<section class="border border-[#e3e5e8] rounded-2xl bg-white p-6" aria-labelledby="t-import">
<section class="rounded-lg border border-[#dadce0] bg-white p-6" aria-labelledby="t-import">
<h2 id="t-import" class="text-xl font-extrabold">Importer une liste (CSV)</h2>
<p class="mt-2 font-semibold">Une ligne par élève : <code class="font-mono">prenom,nom</code> (le nom peut être vide).</p>
@error('fichier')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
@ -95,7 +223,7 @@
</section>
</div>
<section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-white" aria-labelledby="t-eleves">
<section id="eleves" class="mt-6 rounded-lg border border-[#dadce0] bg-white" aria-labelledby="t-eleves">
<div class="flex flex-wrap items-center justify-between gap-3 p-6 pb-0">
<h2 id="t-eleves" class="text-2xl font-extrabold">Élèves ({{ $classe->eleves->count() }})</h2>
@if ($classe->eleves->isNotEmpty())
@ -133,8 +261,9 @@
@endif
</section>
<div id="seance">
@if ($classe->estArchivee())
<section class="mt-10 rounded-2xl border border-[#A63D2A] bg-[#fce8e6] p-6" aria-labelledby="t-archivee">
<section class="mt-6 rounded-lg border border-[#A63D2A] bg-[#fce8e6] p-6" aria-labelledby="t-archivee">
<h2 id="t-archivee" class="text-2xl font-extrabold">Classe archivée</h2>
<p class="mt-2 font-semibold">
Archivée le {{ $classe->archivee_a->timezone(config('app.fuseau_utilisateurs'))->format('d/m/Y') }}.
@ -147,11 +276,11 @@
</form>
</section>
@else
<section class="mt-10 border border-[#e3e5e8] rounded-2xl bg-white p-6" aria-labelledby="t-seance">
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white p-6" aria-labelledby="t-seance">
<h2 id="t-seance" class="text-2xl font-extrabold">Lancer ou planifier une séance</h2>
<p class="mt-2 font-semibold">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.</p>
@if (session('seance_planifiee'))
<p class="mt-4 rounded-2xl border border-[#e3e5e8] bg-[#e6f4ea] p-4 font-semibold">Séance planifiée.</p>
<p class="mt-4 rounded-lg border border-[#e3e5e8] bg-[#e6f4ea] p-4 font-semibold">Séance planifiée.</p>
@endif
<form action="{{ route('mes-classes.seances.store', $classe) }}" method="post" class="mt-4 flex flex-wrap items-end gap-3">
@csrf
@ -184,7 +313,7 @@
@endif
</section>
<section class="mt-10 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-archiver">
<section class="mt-6 rounded-lg border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-archiver">
<h2 id="t-archiver" class="text-2xl font-extrabold">Fin d'année</h2>
<p class="mt-2 font-semibold">
Archiver la classe ferme ses séances. {{ \App\Models\Classe::DELAI_PURGE_JOURS }} jours plus tard, la classe
@ -201,7 +330,9 @@
</section>
@endif
</div>
<script nonce="{{ Vite::cspNonce() }}">
</div>
</div>
<script nonce="{{ Vite::cspNonce() }}">
document.querySelectorAll('[data-tout-cocher]').forEach(function (c) {
c.addEventListener('change', function () { document.querySelectorAll('input[name="eleves[]"]').forEach(function (x) { x.checked = c.checked; }); });
});
@ -218,9 +349,17 @@
}, 4000);
})();
@endunless
@if ($seance)
// La séance en cours bouge vite (admissions, corrections, mains levées) :
// un rafraîchissement discret suffit, sans rien casser si le prof tape.
setInterval(function () {
var occupe = document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName);
if (!occupe) location.reload();
}, 15000);
@endif
document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); });
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
});
</script>
</script>
@endsection

View file

@ -1,5 +1,6 @@
<?php
use App\Models\Classe;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
@ -33,7 +34,7 @@ test('la page de connexion affiche le formulaire prof et élève', function () {
});
test('un prof connecté voit "Mes classes" dans le menu produit, pas la démo "Tableau de bord prof"', function () {
$classe = \App\Models\Classe::factory()->create();
$classe = Classe::factory()->create();
commeProf($this, $classe->prof)
->get(route('mes-classes.index'))
->assertSee('Mes classes')

View file

@ -0,0 +1,59 @@
<?php
use App\Models\Classe;
use App\Models\Correction;
use App\Models\Eleve;
use App\Models\Lab;
use App\Models\LabSession;
use App\Models\Seance;
test('le tableau de bord d’une classe vide affiche les cartes réelles, sans aucune donnée inventée', function () {
$classe = Classe::factory()->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');
});