rapproche encore le tableau de bord de la démo /cockpit : avancement par vérification, mains levées avec action réelle, progression de la séance
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0573894721
commit
ee079ddffb
6 changed files with 178 additions and 6 deletions
|
|
@ -3,11 +3,13 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\Correction;
|
||||
use App\Models\DemandeInscription;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\LabSession;
|
||||
use App\Models\Seance;
|
||||
use App\Services\GenerateurCompteEleve;
|
||||
use App\Services\Labs\DefinitionLab;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -48,17 +50,44 @@ class ClasseController extends Controller
|
|||
->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
|
||||
? LabSession::query()->whereIn('id', $seance->labSessions->pluck('id'))->whereHas('derniereCorrection')->with('derniereCorrection')->get()->pluck('derniereCorrection')
|
||||
? $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'),
|
||||
'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,
|
||||
'mainsLevees' => ($seance && $seance->visioOuverte()) ? VisioController::nombreMainsLevees($seance) : null,
|
||||
'mains' => ($seance && $seance->visioOuverte()) ? VisioController::mainsLevees($seance) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -214,7 +214,26 @@ class VisioController extends Controller
|
|||
|
||||
public static function nombreMainsLevees(Seance $seance): int
|
||||
{
|
||||
return count((Cache::get(self::cleEtat($seance)) ?? [])['mains'] ?? []);
|
||||
return count(self::mainsLevees($seance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mains levées, premier arrivé en premier (même tri que etatProf) : nom
|
||||
* déjà minimisé (prénom + initiale), rien de plus que ce qu'affiche
|
||||
* déjà la page de visio.
|
||||
*
|
||||
* @return list<array{eleve_id: int, nom: string, depuis: int}>
|
||||
*/
|
||||
public static function mainsLevees(Seance $seance): array
|
||||
{
|
||||
/** @var array<int, array{nom: string, depuis: int}> $mains */
|
||||
$mains = (Cache::get(self::cleEtat($seance)) ?? [])['mains'] ?? [];
|
||||
|
||||
return collect($mains)
|
||||
->sortBy('depuis')
|
||||
->map(fn ($m, $id) => ['eleve_id' => (int) $id, 'nom' => $m['nom'], 'depuis' => $m['depuis']])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function cle(Seance $seance): string
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
* Une passe de vérification d'une session de lab (bouton « Vérifier mon
|
||||
* travail »). details : une entrée par vérification du lab.yaml, avec
|
||||
* id, titre, points, reussi et message (message_echec du lab si échouée).
|
||||
*
|
||||
* @property-read array<int, array{id: string, titre: string, points: int, bonus: bool, reussi: bool, message: ?string}> $details
|
||||
*/
|
||||
class Correction extends Model
|
||||
{
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@
|
|||
<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>
|
||||
@if ($progressionSeance !== null)
|
||||
<div class="mt-2 h-2 rounded-lg border border-[#dadce0] bg-white"><div class="h-full rounded-lg bg-black" style="width:{{ $progressionSeance }}%"></div></div>
|
||||
<p class="mt-1 font-mono text-[11px] text-[#5f6368]">{{ $progressionSeance }}% du temps estimé ({{ $seance->lab->duree_min }} min)</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</aside>
|
||||
|
|
@ -78,15 +82,68 @@
|
|||
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $reussiteMoyenne }}%</p>
|
||||
</li>
|
||||
@endif
|
||||
@if ($mainsLevees !== null)
|
||||
@if ($seance->visioOuverte())
|
||||
<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>
|
||||
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ count($mains) }}</p>
|
||||
</li>
|
||||
@endif
|
||||
@endif
|
||||
</ul>
|
||||
|
||||
@if ($seance && $avancement->isNotEmpty())
|
||||
{{-- Équivalent réel de la « répartition par étape » de la démo :
|
||||
une barre par vérification du lab, comptée sur les vraies
|
||||
dernières corrections (pas de fausse étape numérotée). --}}
|
||||
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white p-5" aria-labelledby="t-avancement">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 id="t-avancement" class="text-lg font-extrabold">Avancement du lab « {{ $seance->lab->titre }} »</h2>
|
||||
<p class="font-mono text-xs font-semibold text-[#5f6368]">{{ $avancement->count() }} vérifications · {{ $avancement->first()['total'] }} élève(s) corrigé(s)</p>
|
||||
</div>
|
||||
<ul class="mt-4 grid gap-2">
|
||||
@foreach ($avancement as $v)
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-40 shrink-0 truncate text-sm font-bold" title="{{ $v['titre'] }}">{{ $v['titre'] }}</span>
|
||||
<span class="h-3 flex-1 rounded-lg border border-[#dadce0] bg-[#f8f9fa]"><span class="block h-full rounded-lg bg-[#3C8A5A]" style="width:{{ $v['total'] ? round($v['reussi'] / $v['total'] * 100) : 0 }}%"></span></span>
|
||||
<span class="w-14 shrink-0 text-right font-mono text-sm font-semibold">{{ $v['reussi'] }}/{{ $v['total'] }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($seance && $seance->visioOuverte())
|
||||
{{-- Équivalent réel du panneau « Mains levées » de la démo : la
|
||||
même file que la page de visio, avec la même action (donner
|
||||
la parole), juste accessible sans quitter le tableau de bord. --}}
|
||||
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white" aria-labelledby="t-mains">
|
||||
<div class="flex items-center justify-between border-b border-[#e3e5e8] bg-[#fce8e6] p-4">
|
||||
<h2 id="t-mains" class="text-lg font-extrabold">Mains levées</h2>
|
||||
<span class="font-mono text-xs font-semibold">ordre d'arrivée</span>
|
||||
</div>
|
||||
@if (empty($mains))
|
||||
<p class="p-4 font-semibold">Aucune main levée pour l'instant.</p>
|
||||
@else
|
||||
<ol class="divide-y divide-[#e3e5e8]">
|
||||
@foreach ($mains as $i => $main)
|
||||
<li class="flex items-center justify-between gap-3 p-4">
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="font-mono text-sm font-semibold">{{ $i + 1 }}</span>
|
||||
<span class="font-extrabold">{{ $main['nom'] }}</span>
|
||||
@php($attente = max(0, time() - $main['depuis']))
|
||||
<span class="font-mono text-xs text-[#5f6368]">· depuis {{ intdiv($attente, 60) }} min {{ str_pad((string) ($attente % 60), 2, '0', STR_PAD_LEFT) }} s</span>
|
||||
</span>
|
||||
<form action="{{ route('seances.visio.parole', [$seance, $main['eleve_id']]) }}" method="post">
|
||||
@csrf
|
||||
<button type="submit" class="inline-flex h-9 items-center rounded-lg bg-black px-3 text-xs font-extrabold text-white hover:bg-[#ec3013]">Donner la parole</button>
|
||||
</form>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@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">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Models\Eleve;
|
|||
use App\Models\Lab;
|
||||
use App\Models\LabSession;
|
||||
use App\Models\Seance;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
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']);
|
||||
|
|
@ -23,7 +24,7 @@ test('le tableau de bord d’une classe vide affiche les cartes réelles, sans a
|
|||
|
||||
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']);
|
||||
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur', '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']);
|
||||
|
|
@ -46,6 +47,51 @@ test('une séance en cours montre les vrais élèves, leur état et leur derniè
|
|||
->assertDontSee('Mains levées'); // pas de visio ouverte : la carte n'existe pas
|
||||
});
|
||||
|
||||
test('l’avancement du lab compte les vraies vérifications réussies, jamais les bonus', function () {
|
||||
$classe = Classe::factory()->create();
|
||||
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']);
|
||||
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now()]);
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
$session = LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $lab->id, 'etat' => LabSession::ACTIF]);
|
||||
Correction::create(['lab_session_id' => $session->id, 'reussi' => false, 'note' => 1, 'note_max' => 7, 'details' => [
|
||||
['id' => 'ip-pc-a', 'titre' => 'Adresse de PC-A', 'points' => 1, 'bonus' => false, 'reussi' => true, 'message' => null],
|
||||
['id' => 'forwarding', 'titre' => 'Routage activé', 'points' => 1, 'bonus' => false, 'reussi' => false, 'message' => 'Regarde net.ipv4.ip_forward.'],
|
||||
['id' => 'bonus-permanent', 'titre' => 'Routage permanent', 'points' => 1, 'bonus' => true, 'reussi' => true, 'message' => null],
|
||||
]]);
|
||||
|
||||
commeProf($this, $classe->prof)
|
||||
->get(route('mes-classes.show', $classe))
|
||||
->assertOk()
|
||||
->assertSee('Avancement du lab')
|
||||
->assertSee('Adresse de PC-A')
|
||||
->assertSee('1/1') // réussi par le seul élève corrigé
|
||||
->assertSee('Routage activé')
|
||||
->assertSee('0/1') // pas réussi
|
||||
->assertDontSee('Routage permanent'); // bonus : hors du parcours normal
|
||||
});
|
||||
|
||||
test('les mains levées de la visio apparaissent sur le tableau de bord, avec l’action réelle « donner la parole »', function () {
|
||||
$classe = Classe::factory()->create();
|
||||
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']);
|
||||
$seance = Seance::create([
|
||||
'classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now(),
|
||||
'visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-test', 'visio_chat' => false,
|
||||
]);
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Léa']);
|
||||
Cache::put("visio:{$seance->id}", [
|
||||
'mains' => [$eleve->id => ['nom' => 'Léa', 'depuis' => time() - 90]],
|
||||
'participants' => [], 'presences' => [],
|
||||
], 3600);
|
||||
|
||||
commeProf($this, $classe->prof)
|
||||
->get(route('mes-classes.show', $classe))
|
||||
->assertOk()
|
||||
->assertSee('Mains levées')
|
||||
->assertSee('Léa')
|
||||
->assertSee('1 min 30 s')
|
||||
->assertSee(route('seances.visio.parole', [$seance, $eleve]), false);
|
||||
});
|
||||
|
||||
test('un prof ne voit pas la séance en cours d’une autre classe', function () {
|
||||
$classe = Classe::factory()->create();
|
||||
$autreClasse = Classe::factory()->create();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue