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>
This commit is contained in:
Adam Belkacemi 2026-10-13 18:21:28 +02:00
parent a41951d4e6
commit 592cba9c4c
89 changed files with 2890 additions and 138 deletions

View file

@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AideController extends Controller
{
public function show(): View
{
return view('aide-app', ['estProf' => Auth::guard('web')->check()]);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Models\Annonce;
use App\Models\Classe;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Annonces du prof à sa classe, lues par l'élève dans « Ma classe »
* (séparation vitrine/application, 2026-10-13).
*/
class AnnonceController extends Controller
{
public function store(Request $request, Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
$donnees = $request->validate([
'titre' => ['required', 'string', 'max:150'],
'contenu' => ['required', 'string', 'max:3000'],
]);
$classe->annonces()->create([...$donnees, 'user_id' => Auth::guard('web')->id()]);
return redirect()->route('mes-classes.show', $classe);
}
public function destroy(Classe $classe, Annonce $annonce): RedirectResponse
{
$this->verifierProprietaire($classe);
abort_if($annonce->classe_id !== $classe->id, 404);
$annonce->delete();
return redirect()->route('mes-classes.show', $classe);
}
private function verifierProprietaire(Classe $classe): void
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Models\Lab;
use App\Models\Quiz;
use Illuminate\View\View;
/**
* Bibliothèque du prof : labs et quiz réels (séparation vitrine/
* application, 2026-10-13) — distincte de /bibliotheque (vitrine,
* démonstration). Regroupée comme /banque-quiz.
*/
class BibliothequeProfController extends Controller
{
public function show(): View
{
$quizzes = Quiz::where('partage', true)
->with(['classe.etablissement'])
->withCount('questions')
->latest()
->get();
return view('bibliotheque-app', [
'labs' => Lab::orderBy('titre')->get(),
'groupesQuiz' => $quizzes->groupBy(fn (Quiz $q) => $q->theme ?? '')->sortKeys(),
]);
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Models\FilConversation;
use App\Models\MessageChat;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Chat Messenger élève ↔ prof de sa classe (règles d'Adam, 2026-10-13) :
* un seul fil, créé à la première ouverture. Jamais de message si le prof
* a coupé le chat de la classe.
*/
class ChatEleveController extends Controller
{
/**
* `lu=1` seulement quand la fenêtre de chat est réellement ouverte :
* le sondage du badge en arrière-plan ne doit jamais marquer comme lu.
*/
public function etat(Request $request): JsonResponse
{
$eleve = Auth::guard('eleve')->user()->load('classe');
$fil = $eleve->filConversation;
if ($fil && $request->boolean('lu')) {
$fil->update(['lu_par_eleve_a' => now()]);
}
return response()->json([
'actif' => $eleve->classe->chat_actif,
'non_lus' => $fil ? $fil->nonLusPourEleve() : 0,
'messages' => $fil ? $fil->messages->map(fn (MessageChat $m) => [
'id' => $m->id,
'expediteur' => $m->expediteur,
'contenu' => $m->contenu,
'envoye_a' => $m->created_at->toIso8601String(),
]) : [],
]);
}
public function envoyer(Request $request): JsonResponse
{
$eleve = Auth::guard('eleve')->user()->load('classe');
abort_unless($eleve->classe->chat_actif, 403, 'Le chat est désactivé pour ta classe.');
$donnees = $request->validate(['contenu' => ['required', 'string', 'max:2000']]);
$fil = FilConversation::firstOrCreate(
['eleve_id' => $eleve->id],
['classe_id' => $eleve->classe_id],
);
// Pas de mise à jour de lu_par_eleve_a ici : nonLusPourEleve() ne
// compte que les messages du prof, jamais les siens.
$message = $fil->messages()->create([
'expediteur' => MessageChat::ELEVE,
'contenu' => $donnees['contenu'],
]);
return response()->json(['id' => $message->id], 201);
}
}

View file

@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers;
use App\Models\FilConversation;
use App\Models\MessageChat;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Chat Messenger élève ↔ prof : côté prof, une liste de fils (un par
* élève lui ayant déjà écrit), jamais de fil entre deux élèves.
*/
class ChatProfController extends Controller
{
public function index(): JsonResponse
{
$classeIds = Auth::guard('web')->user()->classes()->pluck('id');
$fils = FilConversation::whereIn('classe_id', $classeIds)
->whereHas('messages')
->with(['eleve', 'classe'])
->get()
->map(fn (FilConversation $f) => [
'id' => $f->id,
'eleve' => $f->eleve->nomAffiche(),
'classe' => $f->classe->nom,
'non_lus' => $f->nonLusPourProf(),
'dernier_message_a' => $f->messages->last()?->created_at->toIso8601String(),
])
->sortByDesc('dernier_message_a')
->values();
return response()->json(['fils' => $fils]);
}
public function etat(FilConversation $fil): JsonResponse
{
$this->verifierProprietaire($fil);
$fil->update(['lu_par_prof_a' => now()]);
return response()->json([
'eleve' => $fil->eleve->nomAffiche(),
'actif' => $fil->classe->chat_actif,
'messages' => $fil->messages->map(fn (MessageChat $m) => [
'id' => $m->id,
'expediteur' => $m->expediteur,
'contenu' => $m->contenu,
'envoye_a' => $m->created_at->toIso8601String(),
]),
]);
}
public function envoyer(Request $request, FilConversation $fil): JsonResponse
{
$this->verifierProprietaire($fil);
abort_unless($fil->classe->chat_actif, 403, 'Le chat est désactivé pour cette classe.');
$donnees = $request->validate(['contenu' => ['required', 'string', 'max:2000']]);
// Pas de mise à jour de lu_par_prof_a ici : nonLusPourProf() ne
// compte que les messages de l'élève, jamais les siens.
$message = $fil->messages()->create([
'expediteur' => MessageChat::PROF,
'contenu' => $donnees['contenu'],
]);
return response()->json(['id' => $message->id], 201);
}
private function verifierProprietaire(FilConversation $fil): void
{
abort_if($fil->classe->user_id !== Auth::guard('web')->id(), 403);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
/**
* Le prof peut couper le chat élève ↔ prof de sa classe (règle d'Adam,
* 2026-10-13).
*/
class ClasseChatController extends Controller
{
public function basculer(Classe $classe): RedirectResponse
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
$classe->update(['chat_actif' => ! $classe->chat_actif]);
return redirect()->route('mes-classes.show', $classe);
}
}

View file

@ -81,7 +81,7 @@ class ClasseController extends Controller
return view('mes-classes.show', [
'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(),
'classe' => $classe->load('eleves'),
'classe' => $classe->load(['eleves', 'annonces.auteur', 'observations.eleve']),
'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(),
'seance' => $seance,
'avancement' => $avancement,

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Services\CompteurDevoirsEnAttente;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
@ -11,6 +12,6 @@ class EleveTableauDeBordController extends Controller
{
$eleve = Auth::guard('eleve')->user()->load('classe');
return view('eleve', ['eleve' => $eleve]);
return view('eleve', ['eleve' => $eleve, 'badgeMesDevoirs' => CompteurDevoirsEnAttente::pour($eleve)]);
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers;
use App\Models\Devoir;
use App\Models\Seance;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Emploi du temps (séparation vitrine/application, 2026-10-13) : séances
* et échéances de devoirs, réunies et triées par date. Pas d'examens ici
* tant qu'aucun modèle d'examen n'existe (CLAUDE.md : pas de ligne vide ni
* de donnée inventée).
*/
class EmploiDuTempsController extends Controller
{
public function show(): View
{
if ($prof = Auth::guard('web')->user()) {
$classeIds = $prof->classes()->whereNull('archivee_a')->pluck('id');
$seances = Seance::whereIn('classe_id', $classeIds)
->whereIn('statut', [Seance::PLANIFIEE, Seance::SALLE_ATTENTE, Seance::EN_COURS])
->whereNotNull('prevue_a')
->with(['classe', 'lab'])
->get();
$devoirs = Devoir::whereIn('classe_id', $classeIds)
->where('echeance', '>=', now())
->with('classe')
->get();
} else {
$eleve = Auth::guard('eleve')->user();
$seances = $eleve->classe->seances()
->whereIn('statut', [Seance::PLANIFIEE, Seance::SALLE_ATTENTE, Seance::EN_COURS])
->whereNotNull('prevue_a')
->with(['lab', 'classe'])
->get();
$devoirs = $eleve->classe->devoirs()->where('echeance', '>=', now())->get();
}
$evenements = $this->fusionner($seances, $devoirs);
return view('emploi-du-temps', ['evenements' => $evenements]);
}
/**
* @param Collection<int, Seance> $seances
* @param Collection<int, Devoir> $devoirs
* @return Collection<int, array{date: ?Carbon, type: string, titre: string, classe: string}>
*/
private function fusionner(Collection $seances, Collection $devoirs): Collection
{
$depuisSeances = $seances->map(fn (Seance $s) => [
'date' => $s->prevue_a,
'type' => 'seance',
'titre' => $s->lab->titre,
'classe' => $s->classe->nom,
]);
$depuisDevoirs = $devoirs->map(fn (Devoir $d) => [
'date' => $d->echeance,
'type' => 'devoir',
'titre' => $d->titre,
'classe' => $d->classe->nom,
]);
return $depuisSeances->concat($depuisDevoirs)->sortBy('date')->values();
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* « Ma classe » (séparation vitrine/application, 2026-10-13) : annonces du
* prof, camarades (prénom + initiale, comme partout ailleurs pour les
* mineurs), qui est connecté. Jamais de message privé entre élèves —
* voir le chat, limité à élève ↔ prof.
*/
class MaClasseController extends Controller
{
public function show(): View
{
$eleve = Auth::guard('eleve')->user();
$classe = $eleve->classe()->with(['etablissement', 'annonces.auteur', 'eleves'])->first();
return view('ma-classe', [
'classe' => $classe,
'eleve' => $eleve,
'camarades' => $classe->eleves,
]);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Models\Cours;
use App\Models\Lab;
use App\Models\Seance;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* « Mes cours » (séparation vitrine/application, 2026-10-13) : seulement
* les cours des labs déjà pratiqués par la classe, et seulement ceux qui
* ont un vrai cours.md (un seul aujourd'hui). Pas de fiche de démonstration
* (CLAUDE.md : pas de donnée inventée).
*/
class MesCoursController extends Controller
{
public function show(): View
{
$eleve = Auth::guard('eleve')->user();
$labIds = Seance::where('classe_id', $eleve->classe_id)->distinct()->pluck('lab_id');
$identifiants = Lab::whereIn('id', $labIds)->pluck('identifiant');
$cours = Cours::whereIn('identifiant', $identifiants)->orderBy('titre')->get();
return view('mes-cours', ['cours' => $cours]);
}
public function montrer(Cours $cours): View
{
return view('cours-detail', [
'cours' => $cours,
'html' => Str::markdown($cours->contenu_markdown),
]);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Models\Seance;
use App\Models\SessionQuiz;
use App\Services\CompteurDevoirsEnAttente;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* « Mes devoirs » (séparation vitrine/application, 2026-10-13) : devoirs
* maison réels, QCM en direct si une session de quiz tourne, TP joignables
* via une séance ouverte par le prof. Pas de TD assigné : ce type n'existe
* pas encore comme devoir (CLAUDE.md : pas de donnée inventée).
*/
class MesDevoirsController extends Controller
{
public function show(): View
{
$eleve = Auth::guard('eleve')->user()->load('classe');
$devoirs = $eleve->classe->devoirs()
->where('echeance', '>=', now()->subDays(7))
->with(['rendus' => fn ($q) => $q->where('eleve_id', $eleve->id)])
->get();
$seancesOuvertes = $eleve->classe->seances()
->whereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS])
->with('lab')
->get();
$sessionQuiz = SessionQuiz::whereHas('quiz', fn ($q) => $q->where('classe_id', $eleve->classe_id))
->whereIn('statut', [SessionQuiz::EN_ATTENTE, SessionQuiz::EN_COURS])
->latest()
->first();
return view('mes-devoirs', [
'devoirs' => $devoirs,
'seancesOuvertes' => $seancesOuvertes,
'sessionQuiz' => $sessionQuiz,
'nombreEnAttente' => CompteurDevoirsEnAttente::pour($eleve),
]);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Models\Correction;
use App\Models\RenduDevoir;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* « Mes notes » (séparation vitrine/application, 2026-10-13) : notes des
* devoirs maison et des labs, groupées par compétence quand le lab en a
* (lab.yaml, jamais lu jusqu'ici). « Sans compétence associée » sinon —
* jamais de taux inventé.
*/
class MesNotesController extends Controller
{
public function show(): View
{
$eleve = Auth::guard('eleve')->user();
$notesDevoirs = RenduDevoir::where('eleve_id', $eleve->id)
->whereNotNull('note')
->with('devoir')
->get()
->map(fn (RenduDevoir $r) => [
'titre' => $r->devoir->titre,
'note' => (int) $r->note,
'note_max' => 20,
'competences' => [],
]);
$notesLabs = Correction::whereHas('labSession', fn ($q) => $q->where('eleve_id', $eleve->id))
->with('labSession.lab')
->get()
->map(fn (Correction $c) => [
'titre' => $c->labSession->lab->titre,
'note' => $c->note,
'note_max' => $c->note_max,
'competences' => $c->labSession->lab->competences ?? [],
]);
$notes = $notesDevoirs->concat($notesLabs);
return view('mes-notes', ['notes' => $notes, 'parCompetence' => $this->grouperParCompetence($notes)]);
}
/**
* @param Collection<int, array{titre: string, note: int, note_max: int, competences: array<int, string>}> $notes
* @return Collection<string, array{moyenne: int, nombre: int}>
*/
private function grouperParCompetence(Collection $notes): Collection
{
$parCompetence = [];
foreach ($notes as $note) {
$competences = $note['competences'] ?: ['Sans compétence associée'];
$taux = $note['note_max'] > 0 ? $note['note'] / $note['note_max'] * 100 : 0;
foreach ($competences as $competence) {
$parCompetence[$competence][] = $taux;
}
}
return collect($parCompetence)->map(fn (array $taux) => [
'moyenne' => (int) round(array_sum($taux) / count($taux)),
'nombre' => count($taux),
]);
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Observation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Observations : notes rapides du prof sur un élève, pour le conseil de
* classe (chantier « vrai cockpit », dernière fonction fictive du tableau
* de bord démo). Jamais montrées à l'élève.
*/
class ObservationController extends Controller
{
public function store(Request $request, Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
$donnees = $request->validate([
'eleve_id' => ['required', 'integer'],
'contenu' => ['required', 'string', 'max:2000'],
]);
$classe->eleves()->findOrFail($donnees['eleve_id']);
$classe->observations()->create([...$donnees, 'user_id' => Auth::guard('web')->id()]);
return redirect()->route('mes-classes.show', $classe);
}
public function destroy(Classe $classe, Observation $observation): RedirectResponse
{
$this->verifierProprietaire($classe);
abort_if($observation->classe_id !== $classe->id, 404);
$observation->delete();
return redirect()->route('mes-classes.show', $classe);
}
private function verifierProprietaire(Classe $classe): void
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Models\Eleve;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Thème clair/sombre et taille du texte (séparation vitrine/application,
* 2026-10-13) : un réglage réel, appliqué immédiatement, pas de données
* inventées.
*/
class ParametresController extends Controller
{
public function show(): View
{
$compte = $this->compteConnecte();
return view('parametres', [
'theme' => $compte->preferenceTheme(),
'tailleTexte' => $compte->preferenceTailleTexte(),
]);
}
public function update(Request $request): RedirectResponse
{
$donnees = $request->validate([
'theme' => ['required', 'in:'.implode(',', User::THEMES)],
'taille_texte' => ['required', 'in:'.implode(',', User::TAILLES_TEXTE)],
]);
$this->compteConnecte()->definirPreferences($donnees['theme'], $donnees['taille_texte']);
return back()->with('parametres_enregistres', true);
}
private function compteConnecte(): User|Eleve
{
return Auth::guard('web')->user() ?? Auth::guard('eleve')->user();
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* « Mon profil » du menu avatar (séparation vitrine/application,
* 2026-10-13) : une seule page, pour un prof ou un élève connecté.
*/
class ProfilController extends Controller
{
public function show(): View
{
if ($prof = Auth::guard('web')->user()) {
return view('profil', [
'role' => 'prof',
'nom' => $prof->name,
'email' => $prof->email,
'etablissement' => $prof->etablissement,
'classes' => $prof->classes()->whereNull('archivee_a')->orderBy('nom')->get(),
]);
}
$eleve = Auth::guard('eleve')->user()->load('classe.etablissement');
return view('profil', [
'role' => 'eleve',
'nom' => $eleve->nomAffiche(),
'identifiant' => $eleve->identifiant,
'etablissement' => $eleve->classe->etablissement,
'classes' => collect([$eleve->classe]),
]);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\Eleve;
use App\Models\Raccourci;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* « Mes raccourcis » dans la barre latérale (séparation vitrine/application,
* 2026-10-13) : le prof ou l'élève épingle une page qu'il visite déjà.
*/
class RaccourciController extends Controller
{
public function store(Request $request): RedirectResponse
{
$donnees = $request->validate([
'label' => ['required', 'string', 'max:80'],
'url' => ['required', 'string', 'max:500'],
]);
$compte = Auth::guard('web')->user() ?? Auth::guard('eleve')->user();
$ordre = Raccourci::when($compte instanceof User, fn ($q) => $q->where('user_id', $compte->id))
->when($compte instanceof Eleve, fn ($q) => $q->where('eleve_id', $compte->id))
->max('ordre');
Raccourci::create([
'user_id' => $compte instanceof User ? $compte->id : null,
'eleve_id' => $compte instanceof Eleve ? $compte->id : null,
'label' => $donnees['label'],
'url' => $donnees['url'],
'ordre' => ($ordre ?? 0) + 1,
]);
return back()->with('raccourci_ajoute', true);
}
public function destroy(Raccourci $raccourci): RedirectResponse
{
$compte = Auth::guard('web')->user() ?? Auth::guard('eleve')->user();
$proprietaire = $compte instanceof User ? $raccourci->user_id === $compte->id : $raccourci->eleve_id === $compte->id;
abort_unless($proprietaire, 403);
$raccourci->delete();
return back();
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Correction;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* « Résultats » (séparation vitrine/application, 2026-10-13) : vue
* d'ensemble, par classe, des corrections et des observations. Le détail
* séance par séance reste dans le cockpit de chaque classe.
*/
class ResultatsController extends Controller
{
public function show(): View
{
$prof = Auth::guard('web')->user();
$classes = $prof->classes()->whereNull('archivee_a')->orderBy('nom')->get();
$resultats = $classes->map(function (Classe $classe) {
$corrections = Correction::whereHas('labSession', fn ($q) => $q->whereIn('seance_id', $classe->seances()->select('id')))
->latest()
->limit(50)
->get();
return [
'classe' => $classe,
'nombreCorrections' => $corrections->count(),
'reussiteMoyenne' => $corrections->isNotEmpty()
? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0))
: null,
'observations' => $classe->observations()->with('eleve')->limit(5)->get(),
];
});
return view('resultats', ['resultats' => $resultats]);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\Seance;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Vue transverse de toutes les classes du prof (séparation vitrine/
* application, 2026-10-13) : avant, il fallait ouvrir chaque classe pour
* savoir où une séance était en cours.
*/
class SeancesEnDirectController extends Controller
{
public function show(): View
{
$prof = Auth::guard('web')->user();
$classeIds = $prof->classes()->whereNull('archivee_a')->pluck('id');
$seances = Seance::whereIn('classe_id', $classeIds)
->whereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS])
->with(['classe', 'lab'])
->get();
return view('seances-en-direct', ['seances' => $seances]);
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use App\Models\Eleve;
use App\Models\Signalement;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* « Signaler un problème » du menu avatar (séparation vitrine/application,
* 2026-10-13) : juste enregistré, relu par Adam — pas de traitement
* automatique, pas de ticket inventé.
*/
class SignalementController extends Controller
{
public function store(Request $request): RedirectResponse
{
$donnees = $request->validate([
'message' => ['required', 'string', 'max:2000'],
'page_url' => ['nullable', 'string', 'max:500'],
]);
$compte = Auth::guard('web')->user() ?? Auth::guard('eleve')->user();
Signalement::create([
'user_id' => $compte instanceof User ? $compte->id : null,
'eleve_id' => $compte instanceof Eleve ? $compte->id : null,
'page_url' => $donnees['page_url'] ?? null,
'message' => $donnees['message'],
]);
return back()->with('signalement_envoye', true);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* « Qui est connecté » dans Ma classe (séparation vitrine/application,
* 2026-10-13) : une seule écriture par minute, pas à chaque requête.
*/
class MetAJourActivite
{
public function handle(Request $request, Closure $next): Response
{
$eleve = Auth::guard('eleve')->user();
if ($eleve !== null && (! $eleve->derniere_activite_a || $eleve->derniere_activite_a->lt(now()->subMinute()))) {
$eleve->forceFill(['derniere_activite_a' => now()])->save();
}
return $next($request);
}
}