diff --git a/ansible/roles/portail/tasks/08-banque-quiz.yml b/ansible/roles/portail/tasks/08-banque-quiz.yml index 8443073..abe6d94 100644 --- a/ansible/roles/portail/tasks/08-banque-quiz.yml +++ b/ansible/roles/portail/tasks/08-banque-quiz.yml @@ -27,3 +27,14 @@ chdir: "{{ portail_dossier_app }}" register: portail_banque_quiz_synchronisee changed_when: "'créé' in portail_banque_quiz_synchronisee.stdout" + +# « Mes cours » (séparation vitrine/application, 2026-10-13) : un seul +# cours.md existe aujourd'hui (08-deux-lan-routeur), jamais de contenu +# inventé pour les autres séquences. +- name: synchroniser le catalogue des cours + ansible.builtin.command: + cmd: runuser -u www-data -- php artisan cours:synchroniser + args: + chdir: "{{ portail_dossier_app }}" + register: portail_cours_synchronises + changed_when: "'créé' in portail_cours_synchronises.stdout" diff --git a/docs/dette.md b/docs/dette.md index 01056ee..3f54e5b 100644 --- a/docs/dette.md +++ b/docs/dette.md @@ -31,3 +31,8 @@ retirer la ligne (et le noter dans le journal) quand c'est fait. | Self-healing : ne couvre que les VM (pas le serveur, déjà en dette, ni les conteneurs/services, déjà `Restart=always`) | Documenté dans `docs/runbooks/self-healing.md`, pas une lacune à corriger — rappel pour ne pas le redemander. | — | 2026-10-12 | | Banque de quiz : un quiz partagé disparaît si la classe d'origine est archivée puis purgée (RGPD, 90 jours) | La banque n'a pas de propriétaire indépendant d'une classe (pas de `user_id` ni de `classe_id` nullable sur `quizzes`, pour rester simple). À corriger si des profs partagent des quiz qu'ils veulent garder au-delà de la durée de vie d'une classe. N'affecte pas les quiz officiels (voir ligne suivante). | Si ça gêne en usage réel | 2026-10-13 | | Banque de quiz officielle : établissement, prof et classe techniques invisibles (« Bibliothèque Paillasse » / « Banque officielle », `App\Services\Quiz\BanqueOfficielle`) | Simple propriétaire stable pour les quiz synchronisés depuis `paillasse-contenus/`, jamais archivé donc jamais purgé — mais apparaîtrait comme un établissement et un prof fictifs dans toute future liste d'administration globale. À filtrer explicitement si un tel écran est ajouté. | Si un écran d'admin globale est ajouté | 2026-10-13 | +| Thème sombre : pages migrées avant la coquille (quiz, devoirs, mes-classes…) gardent leurs couleurs fixes de la vitrine, pas les jetons `--sy-*` | Elles restent lisibles en clair ; en thème sombre, certains blocs à fond blanc ne basculent pas. Seules les pages écrites pour la coquille (Ma classe, Emploi du temps, Mes devoirs…) suivent le thème. Reprendre une par une si le thème sombre doit être parfait partout. | Si ça gêne en usage réel | 2026-10-14 | +| Pages immersives (console, visio) gardent la barre latérale de la coquille | Visio et console auraient pu passer en plein écran sans chrome ; laissées avec la barre latérale par simplicité, au lieu d'une troisième variante de layout. | Si ça gêne en usage réel | 2026-10-14 | +| Chat : pas de notification sonore ni de notification navigateur, sondage toutes les 3 s (ouvert) / 15 s (badge) | Suffisant pour un usage en classe ; à revoir si le besoin d'alerte immédiate apparaît. | Si ça gêne en usage réel | 2026-10-14 | +| « Mes cours » : un seul lab (08) a un vrai cours.md | Les séquences 01 à 07 n'ont pas de cours rédigé (Adam écrit ce contenu lui-même, voir la banque de quiz). La page reste honnête : vide pour un élève qui n'a pratiqué que ces labs, jamais de contenu inventé pour combler. | Quand Adam rédige les cours | 2026-10-14 | +| « Mes notes » : pas de notion de compétence pour les devoirs maison (seulement pour les labs, via `labs.competences`) | Un devoir maison n'est lié à aucune compétence du référentiel ; ses notes sortent sous « Sans compétence associée ». Ajouter un champ compétence(s) sur `Devoir` si besoin. | Si ça gêne en usage réel | 2026-10-14 | diff --git a/docs/journal.md b/docs/journal.md index f12e007..58bda03 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -977,3 +977,32 @@ Le choix qui évite le plus de dette ici : ne pas réutiliser le mécanisme « p **Suite** Reprendre le chantier « vrai cockpit » : Observations. + +## 2026-10-13/14 — Séparer la vitrine de l'application + +Demande d'Adam : une fois connecté (élève ou prof), on gardait le header vitrine (Fonctionnalités, Cours, Tarifs) — ça n'a pas de sens, comme sur Facebook il faut basculer dans une vraie interface d'application. Avant de coder, j'ai montré la liste des 10 entrées de navigation demandées (5 élève, 5 prof) : la plupart n'avaient pas de page réelle derrière (seulement `/mes-classes` et la banque de quiz existaient). Adam a choisi de construire une vraie version minimale de chaque page manquante plutôt qu'un simple renvoi « bientôt disponible ». + +**Fait** + +- `layouts/app` : barre du haut (logo, menu avatar — Mon profil, Paramètres, Aide, Signaler un problème, Se déconnecter), barre latérale (5 entrées par rôle + Mes raccourcis), zone centrale, bulle de chat Messenger. Mobile : barre d'onglets en bas (4 + Plus), chat plein écran. Thème clair/sombre et taille du texte réglables, appliqués via des attributs `data-theme`/`data-taille` sur ``. +- Nouvelles pages réelles, aucune donnée inventée : + - Élève : **Ma classe** (annonces du prof, camarades en prénom + initiale, qui est en ligne dans les 3 dernières minutes, lien ENT si connu) ; **Emploi du temps** (séances + échéances de devoirs réunies, pas d'examens tant qu'aucun modèle n'existe) ; **Mes devoirs** (devoirs maison, TP disponibles via une séance ouverte, quiz en cours — avec badge) ; **Mes cours** (uniquement les cours.md réels des labs déjà pratiqués par la classe — un seul existe aujourd'hui) ; **Mes notes** (devoirs notés et corrections de labs, groupées par compétence du lab.yaml, jamais lue jusqu'ici). + - Prof : **Emploi du temps** et **Séances en direct** (vue transverse à toutes les classes, plus besoin d'ouvrir chaque classe) ; **Bibliothèque** (labs + banque de quiz réels, distincte de la vitrine `/bibliotheque`) ; **Résultats** (corrections et nouvelles **Observations** — notes rapides sur un élève pour le conseil de classe, dernière fonction fictive du tableau de bord démo, jamais montrées à l'élève). Annonces et Observations se gèrent depuis le cockpit de la classe (`/mes-classes/{classe}`). + - Chat Messenger élève ↔ prof de sa classe, jamais entre élèves (structurellement impossible : aucune route élève-élève n'existe) ; le prof peut couper le chat de sa classe ; historique conservé, purgé avec la classe (RGPD). + - Mon profil, Paramètres, Signaler un problème (stocké, relu par Adam), Mes raccourcis (pages épinglées). +- `/eleve` vidée des faux contenus (« À faire », « Ma progression », « Mes QCM » — des données inventées qui restaient depuis avant le chantier « vrai cockpit ») : ne reste que le quiz flash réel et des liens vers les 5 nouvelles pages. +- Nouvelles tables : `annonces`, `observations`, `raccourcis`, `signalements`, `fils_conversation`, `messages_chat`, `cours` ; colonnes ajoutées à `users`/`eleves` (préférences, dernière activité), `labs` (compétences, enfin lues du lab.yaml), `classes` (chat_actif), `etablissements` (url_ent). +- `cours:synchroniser` (même esprit que `labs:synchroniser` et `quiz:synchroniser-banque`) : lit les `cours.md` de `paillasse-contenus/`. +- 9 nouveaux tests Pest (93 au total) : étanchéité des barres par rôle, impossibilité pour un élève d'atteindre les routes de chat du prof, deux élèves de classes différentes ne partagent jamais de fil, JavaScript valide des nouvelles pages, toutes les nouvelles pages se chargent sans erreur. + +**Bug trouvé et corrigé en vérifiant en réel** (pas en local) : le compteur de messages non lus du chat ne redescendait jamais à zéro — `FilConversation` n'avait pas `lu_par_prof_a`/`lu_par_eleve_a` dans son `$fillable`, donc `update()` les ignorait silencieusement, sans erreur. Trouvé en testant un vrai aller-retour élève ↔ prof sur le serveur, pas par les tests locaux (qui ne vérifiaient pas la valeur du compteur). Corrigé, et le test de non-régression ajouté aurait aussi révélé un second problème plus subtil : marquer « lu » au moment d'envoyer son propre message n'a pas de sens (le filtre par expéditeur l'exclut déjà) et créait un risque d'égalité de seconde avec un message suivant — retiré des deux côtés. + +**Vérifié sur le serveur réel** (un prof et un élève jetables, classe supprimée ensuite) : navigation cloisonnée par rôle (l'élève ne voit jamais les entrées prof et réciproquement), vitrine toujours accessible et intacte sans connexion, chat élève → prof → élève avec compteurs corrects après le correctif, coupure du chat par le prof effective immédiatement, annonce publiée visible dans Ma classe, observation ajoutée visible dans Résultats. La banque officielle et le quiz réel d'Adam n'ont pas bougé. + +**Comprendre** + +La leçon technique : les données injectées par un `View::composer` sur un layout ne sont visibles que dans le corps du layout lui-même (barre latérale, barre du haut) — pas dans le contenu de la page qui l'étend (`@section('contenu')`), parce que ce contenu est déjà rendu, en chaîne de caractères, avant que le composeur du layout ne s'exécute. Deux pages (`eleve.blade.php`, `aide-app.blade.php`) utilisaient à tort une variable du composeur dans leur propre contenu ; passées explicitement par leur contrôleur à la place. + +**Suite** + +Reprendre le chantier « vrai cockpit » : Observations étant maintenant construites, il ne reste que Surveillance d'examen — à cadrer avec Adam avant de coder (RGPD, mineurs). diff --git a/portail/app/Console/Commands/PurgerClassesArchivees.php b/portail/app/Console/Commands/PurgerClassesArchivees.php index 780c6f5..7f3ce47 100644 --- a/portail/app/Console/Commands/PurgerClassesArchivees.php +++ b/portail/app/Console/Commands/PurgerClassesArchivees.php @@ -10,9 +10,10 @@ use Illuminate\Support\Facades\Log; /** * RGPD (CLAUDE.md, étape 14) : supprime les classes archivées depuis * 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines, - * corrections, quiz et devoirs maison (chantier « vrai cockpit »). Le compte du - * prof est conservé. Le journal ne garde que des identifiants et des - * nombres, jamais de nom d'élève. + * corrections, quiz et devoirs maison (chantier « vrai cockpit »), ainsi que + * les annonces, observations et le chat élève ↔ prof (séparation vitrine/ + * application, 2026-10-13). Le compte du prof est conservé. Le journal ne + * garde que des identifiants et des nombres, jamais de nom d'élève. * * Les sauvegardes chiffrées contenant encore ces données expirent d'elles- * mêmes au plus 4 semaines plus tard (rotation, docs/runbooks/sauvegardes.md). diff --git a/portail/app/Console/Commands/SynchroniserCours.php b/portail/app/Console/Commands/SynchroniserCours.php new file mode 100644 index 0000000..08030fe --- /dev/null +++ b/portail/app/Console/Commands/SynchroniserCours.php @@ -0,0 +1,45 @@ + $identifiant], + ['titre' => $titre, 'contenu_markdown' => File::get($cheminCours)], + ); + + $this->line(($cours->wasRecentlyCreated ? 'créé' : 'à jour')." : {$identifiant}"); + } + } +} diff --git a/portail/app/Console/Commands/SynchroniserLabs.php b/portail/app/Console/Commands/SynchroniserLabs.php index 3f3eb6f..73e5059 100644 --- a/portail/app/Console/Commands/SynchroniserLabs.php +++ b/portail/app/Console/Commands/SynchroniserLabs.php @@ -24,7 +24,7 @@ class SynchroniserLabs extends Command $lab = Lab::updateOrCreate( ['identifiant' => $definition->identifiant], - ['titre' => $definition->titre, 'niveau' => $definition->niveau, 'duree_min' => $definition->dureeMin], + ['titre' => $definition->titre, 'niveau' => $definition->niveau, 'duree_min' => $definition->dureeMin, 'competences' => $definition->competences], ); $this->line(($lab->wasRecentlyCreated ? 'créé' : 'à jour')." : {$identifiant}"); diff --git a/portail/app/Http/Controllers/AideController.php b/portail/app/Http/Controllers/AideController.php new file mode 100644 index 0000000..05eb6e9 --- /dev/null +++ b/portail/app/Http/Controllers/AideController.php @@ -0,0 +1,14 @@ + Auth::guard('web')->check()]); + } +} diff --git a/portail/app/Http/Controllers/AnnonceController.php b/portail/app/Http/Controllers/AnnonceController.php new file mode 100644 index 0000000..b129978 --- /dev/null +++ b/portail/app/Http/Controllers/AnnonceController.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/portail/app/Http/Controllers/BibliothequeProfController.php b/portail/app/Http/Controllers/BibliothequeProfController.php new file mode 100644 index 0000000..7b4afef --- /dev/null +++ b/portail/app/Http/Controllers/BibliothequeProfController.php @@ -0,0 +1,29 @@ +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(), + ]); + } +} diff --git a/portail/app/Http/Controllers/ChatEleveController.php b/portail/app/Http/Controllers/ChatEleveController.php new file mode 100644 index 0000000..02e585e --- /dev/null +++ b/portail/app/Http/Controllers/ChatEleveController.php @@ -0,0 +1,64 @@ +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); + } +} diff --git a/portail/app/Http/Controllers/ChatProfController.php b/portail/app/Http/Controllers/ChatProfController.php new file mode 100644 index 0000000..7b7058c --- /dev/null +++ b/portail/app/Http/Controllers/ChatProfController.php @@ -0,0 +1,76 @@ +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); + } +} diff --git a/portail/app/Http/Controllers/ClasseChatController.php b/portail/app/Http/Controllers/ClasseChatController.php new file mode 100644 index 0000000..5c70abe --- /dev/null +++ b/portail/app/Http/Controllers/ClasseChatController.php @@ -0,0 +1,23 @@ +user_id !== Auth::guard('web')->id(), 403); + + $classe->update(['chat_actif' => ! $classe->chat_actif]); + + return redirect()->route('mes-classes.show', $classe); + } +} diff --git a/portail/app/Http/Controllers/ClasseController.php b/portail/app/Http/Controllers/ClasseController.php index aa7ee1c..3d0834f 100644 --- a/portail/app/Http/Controllers/ClasseController.php +++ b/portail/app/Http/Controllers/ClasseController.php @@ -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, diff --git a/portail/app/Http/Controllers/EleveTableauDeBordController.php b/portail/app/Http/Controllers/EleveTableauDeBordController.php index 334dee3..d4f58d2 100644 --- a/portail/app/Http/Controllers/EleveTableauDeBordController.php +++ b/portail/app/Http/Controllers/EleveTableauDeBordController.php @@ -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)]); } } diff --git a/portail/app/Http/Controllers/EmploiDuTempsController.php b/portail/app/Http/Controllers/EmploiDuTempsController.php new file mode 100644 index 0000000..b6c218a --- /dev/null +++ b/portail/app/Http/Controllers/EmploiDuTempsController.php @@ -0,0 +1,75 @@ +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 $seances + * @param Collection $devoirs + * @return Collection + */ + 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(); + } +} diff --git a/portail/app/Http/Controllers/MaClasseController.php b/portail/app/Http/Controllers/MaClasseController.php new file mode 100644 index 0000000..1a98b00 --- /dev/null +++ b/portail/app/Http/Controllers/MaClasseController.php @@ -0,0 +1,27 @@ +user(); + $classe = $eleve->classe()->with(['etablissement', 'annonces.auteur', 'eleves'])->first(); + + return view('ma-classe', [ + 'classe' => $classe, + 'eleve' => $eleve, + 'camarades' => $classe->eleves, + ]); + } +} diff --git a/portail/app/Http/Controllers/MesCoursController.php b/portail/app/Http/Controllers/MesCoursController.php new file mode 100644 index 0000000..15ebb69 --- /dev/null +++ b/portail/app/Http/Controllers/MesCoursController.php @@ -0,0 +1,40 @@ +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), + ]); + } +} diff --git a/portail/app/Http/Controllers/MesDevoirsController.php b/portail/app/Http/Controllers/MesDevoirsController.php new file mode 100644 index 0000000..a4b70c6 --- /dev/null +++ b/portail/app/Http/Controllers/MesDevoirsController.php @@ -0,0 +1,45 @@ +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), + ]); + } +} diff --git a/portail/app/Http/Controllers/MesNotesController.php b/portail/app/Http/Controllers/MesNotesController.php new file mode 100644 index 0000000..71e4a71 --- /dev/null +++ b/portail/app/Http/Controllers/MesNotesController.php @@ -0,0 +1,71 @@ +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}> $notes + * @return Collection + */ + 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), + ]); + } +} diff --git a/portail/app/Http/Controllers/ObservationController.php b/portail/app/Http/Controllers/ObservationController.php new file mode 100644 index 0000000..7729651 --- /dev/null +++ b/portail/app/Http/Controllers/ObservationController.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/portail/app/Http/Controllers/ParametresController.php b/portail/app/Http/Controllers/ParametresController.php new file mode 100644 index 0000000..9bba8dc --- /dev/null +++ b/portail/app/Http/Controllers/ParametresController.php @@ -0,0 +1,45 @@ +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(); + } +} diff --git a/portail/app/Http/Controllers/ProfilController.php b/portail/app/Http/Controllers/ProfilController.php new file mode 100644 index 0000000..11c16c7 --- /dev/null +++ b/portail/app/Http/Controllers/ProfilController.php @@ -0,0 +1,36 @@ +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]), + ]); + } +} diff --git a/portail/app/Http/Controllers/RaccourciController.php b/portail/app/Http/Controllers/RaccourciController.php new file mode 100644 index 0000000..cd36b18 --- /dev/null +++ b/portail/app/Http/Controllers/RaccourciController.php @@ -0,0 +1,51 @@ +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(); + } +} diff --git a/portail/app/Http/Controllers/ResultatsController.php b/portail/app/Http/Controllers/ResultatsController.php new file mode 100644 index 0000000..9028b16 --- /dev/null +++ b/portail/app/Http/Controllers/ResultatsController.php @@ -0,0 +1,40 @@ +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]); + } +} diff --git a/portail/app/Http/Controllers/SeancesEnDirectController.php b/portail/app/Http/Controllers/SeancesEnDirectController.php new file mode 100644 index 0000000..6d6c60d --- /dev/null +++ b/portail/app/Http/Controllers/SeancesEnDirectController.php @@ -0,0 +1,28 @@ +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]); + } +} diff --git a/portail/app/Http/Controllers/SignalementController.php b/portail/app/Http/Controllers/SignalementController.php new file mode 100644 index 0000000..77b44cb --- /dev/null +++ b/portail/app/Http/Controllers/SignalementController.php @@ -0,0 +1,37 @@ +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); + } +} diff --git a/portail/app/Http/Middleware/MetAJourActivite.php b/portail/app/Http/Middleware/MetAJourActivite.php new file mode 100644 index 0000000..d7ec258 --- /dev/null +++ b/portail/app/Http/Middleware/MetAJourActivite.php @@ -0,0 +1,26 @@ +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); + } +} diff --git a/portail/app/Models/Annonce.php b/portail/app/Models/Annonce.php new file mode 100644 index 0000000..cfd72d8 --- /dev/null +++ b/portail/app/Models/Annonce.php @@ -0,0 +1,23 @@ + */ + public function classe(): BelongsTo + { + return $this->belongsTo(Classe::class); + } + + /** @return BelongsTo */ + public function auteur(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/portail/app/Models/Classe.php b/portail/app/Models/Classe.php index f367d11..f594419 100644 --- a/portail/app/Models/Classe.php +++ b/portail/app/Models/Classe.php @@ -15,10 +15,11 @@ class Classe extends Model /** @use HasFactory */ use HasFactory; - protected $fillable = ['etablissement_id', 'user_id', 'nom', 'formation', 'code_classe']; + protected $fillable = ['etablissement_id', 'user_id', 'nom', 'formation', 'code_classe', 'chat_actif']; protected $casts = [ 'archivee_a' => 'datetime', + 'chat_actif' => 'boolean', ]; protected static function booted(): void @@ -79,6 +80,24 @@ class Classe extends Model return $this->hasMany(Devoir::class)->latest('echeance'); } + /** @return HasMany */ + public function annonces(): HasMany + { + return $this->hasMany(Annonce::class)->latest(); + } + + /** @return HasMany */ + public function observations(): HasMany + { + return $this->hasMany(Observation::class)->latest(); + } + + /** @return HasMany */ + public function filsConversation(): HasMany + { + return $this->hasMany(FilConversation::class); + } + public function estArchivee(): bool { return $this->archivee_a !== null; diff --git a/portail/app/Models/Concerns/GerePreferences.php b/portail/app/Models/Concerns/GerePreferences.php new file mode 100644 index 0000000..ab64f0a --- /dev/null +++ b/portail/app/Models/Concerns/GerePreferences.php @@ -0,0 +1,29 @@ +preferences['theme'] ?? 'systeme'; + } + + public function preferenceTailleTexte(): string + { + return $this->preferences['taille_texte'] ?? 'normal'; + } + + public function definirPreferences(string $theme, string $tailleTexte): void + { + $this->forceFill(['preferences' => ['theme' => $theme, 'taille_texte' => $tailleTexte]])->save(); + } +} diff --git a/portail/app/Models/Cours.php b/portail/app/Models/Cours.php new file mode 100644 index 0000000..91c965a --- /dev/null +++ b/portail/app/Models/Cours.php @@ -0,0 +1,14 @@ + */ - use Authenticatable, GereSessionUnique, HasFactory; + use Authenticatable, GerePreferences, GereSessionUnique, HasFactory; protected $fillable = ['classe_id', 'prenom', 'nom', 'identifiant', 'code_secret', 'jeton_qr']; @@ -22,8 +24,19 @@ class Eleve extends Model implements AuthenticatableContract protected $casts = [ 'derniere_connexion_a' => 'datetime', + 'derniere_activite_a' => 'datetime', + 'preferences' => 'array', ]; + /** + * En ligne : une requête authentifiée dans les 3 dernières minutes + * (middleware MetAJourActivite) — pas une vraie présence temps réel. + */ + public function estEnLigne(): bool + { + return $this->derniere_activite_a !== null && $this->derniere_activite_a->gt(now()->subMinutes(3)); + } + public function getAuthPasswordName(): string { return 'code_secret'; @@ -41,6 +54,18 @@ class Eleve extends Model implements AuthenticatableContract return $this->hasMany(LabSession::class); } + /** @return HasMany */ + public function raccourcis(): HasMany + { + return $this->hasMany(Raccourci::class)->orderBy('ordre'); + } + + /** @return HasOne */ + public function filConversation(): HasOne + { + return $this->hasOne(FilConversation::class); + } + public function nomAffiche(): string { return $this->nom diff --git a/portail/app/Models/Etablissement.php b/portail/app/Models/Etablissement.php index 87eb90b..f8be61e 100644 --- a/portail/app/Models/Etablissement.php +++ b/portail/app/Models/Etablissement.php @@ -12,7 +12,7 @@ class Etablissement extends Model /** @use HasFactory */ use HasFactory; - protected $fillable = ['nom', 'ville', 'uai']; + protected $fillable = ['nom', 'ville', 'uai', 'url_ent']; /** @return HasMany */ public function profs(): HasMany diff --git a/portail/app/Models/FilConversation.php b/portail/app/Models/FilConversation.php new file mode 100644 index 0000000..7dcd9ab --- /dev/null +++ b/portail/app/Models/FilConversation.php @@ -0,0 +1,57 @@ + 'datetime', + 'lu_par_eleve_a' => 'datetime', + ]; + + /** @return BelongsTo */ + public function classe(): BelongsTo + { + return $this->belongsTo(Classe::class); + } + + /** @return BelongsTo */ + public function eleve(): BelongsTo + { + return $this->belongsTo(Eleve::class); + } + + /** @return HasMany */ + public function messages(): HasMany + { + return $this->hasMany(MessageChat::class)->orderBy('created_at'); + } + + public function nonLusPourProf(): int + { + return $this->messages() + ->where('expediteur', MessageChat::ELEVE) + ->when($this->lu_par_prof_a, fn ($q) => $q->where('created_at', '>', $this->lu_par_prof_a)) + ->count(); + } + + public function nonLusPourEleve(): int + { + return $this->messages() + ->where('expediteur', MessageChat::PROF) + ->when($this->lu_par_eleve_a, fn ($q) => $q->where('created_at', '>', $this->lu_par_eleve_a)) + ->count(); + } +} diff --git a/portail/app/Models/Lab.php b/portail/app/Models/Lab.php index f8578c2..3d5012d 100644 --- a/portail/app/Models/Lab.php +++ b/portail/app/Models/Lab.php @@ -7,12 +7,19 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * @property-read array $competences + */ class Lab extends Model { /** @use HasFactory */ use HasFactory; - protected $fillable = ['identifiant', 'titre', 'niveau', 'duree_min']; + protected $fillable = ['identifiant', 'titre', 'niveau', 'duree_min', 'competences']; + + protected $casts = [ + 'competences' => 'array', + ]; /** @return HasMany */ public function seances(): HasMany diff --git a/portail/app/Models/MessageChat.php b/portail/app/Models/MessageChat.php new file mode 100644 index 0000000..c0f07a4 --- /dev/null +++ b/portail/app/Models/MessageChat.php @@ -0,0 +1,23 @@ + */ + public function fil(): BelongsTo + { + return $this->belongsTo(FilConversation::class, 'fil_conversation_id'); + } +} diff --git a/portail/app/Models/Observation.php b/portail/app/Models/Observation.php new file mode 100644 index 0000000..d066f88 --- /dev/null +++ b/portail/app/Models/Observation.php @@ -0,0 +1,34 @@ + */ + public function classe(): BelongsTo + { + return $this->belongsTo(Classe::class); + } + + /** @return BelongsTo */ + public function eleve(): BelongsTo + { + return $this->belongsTo(Eleve::class); + } + + /** @return BelongsTo */ + public function auteur(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/portail/app/Models/Raccourci.php b/portail/app/Models/Raccourci.php new file mode 100644 index 0000000..98f67d0 --- /dev/null +++ b/portail/app/Models/Raccourci.php @@ -0,0 +1,23 @@ + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return BelongsTo */ + public function eleve(): BelongsTo + { + return $this->belongsTo(Eleve::class); + } +} diff --git a/portail/app/Models/Signalement.php b/portail/app/Models/Signalement.php new file mode 100644 index 0000000..ebeb99e --- /dev/null +++ b/portail/app/Models/Signalement.php @@ -0,0 +1,23 @@ + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return BelongsTo */ + public function eleve(): BelongsTo + { + return $this->belongsTo(Eleve::class); + } +} diff --git a/portail/app/Models/User.php b/portail/app/Models/User.php index 722474f..81e6b2e 100644 --- a/portail/app/Models/User.php +++ b/portail/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Models\Concerns\GerePreferences; use App\Models\Concerns\GereSessionUnique; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -18,7 +19,7 @@ use Illuminate\Notifications\Notifiable; class User extends Authenticatable { /** @use HasFactory */ - use GereSessionUnique, HasFactory, Notifiable; + use GerePreferences, GereSessionUnique, HasFactory, Notifiable; /** * Get the attributes that should be cast. @@ -30,6 +31,7 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'preferences' => 'array', ]; } @@ -44,4 +46,10 @@ class User extends Authenticatable { return $this->hasMany(Classe::class); } + + /** @return HasMany */ + public function raccourcis(): HasMany + { + return $this->hasMany(Raccourci::class)->orderBy('ordre'); + } } diff --git a/portail/app/Providers/AppServiceProvider.php b/portail/app/Providers/AppServiceProvider.php index 452e6b6..96f7baa 100644 --- a/portail/app/Providers/AppServiceProvider.php +++ b/portail/app/Providers/AppServiceProvider.php @@ -2,6 +2,11 @@ namespace App\Providers; +use App\Models\Eleve; +use App\Models\User; +use App\Services\CompteurDevoirsEnAttente; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +24,20 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + // Coquille de l'application (layouts/app) : variables communes à + // la barre du haut et à la barre latérale, pour qu'aucun contrôleur + // n'ait à les repasser (séparation vitrine/application, 2026-10-13). + View::composer('layouts.app', function ($vue): void { + $prof = Auth::guard('web')->user(); + $compte = $prof ?? Auth::guard('eleve')->user(); + + $vue->with([ + 'compte' => $compte, + 'estProf' => $compte instanceof User, + 'nomAffiche' => $compte instanceof Eleve ? $compte->nomAffiche() : $compte->name, + 'raccourcis' => $compte->raccourcis, + 'badgeMesDevoirs' => $compte instanceof Eleve ? CompteurDevoirsEnAttente::pour($compte) : 0, + ]); + }); } } diff --git a/portail/app/Services/CompteurDevoirsEnAttente.php b/portail/app/Services/CompteurDevoirsEnAttente.php new file mode 100644 index 0000000..d5b15d7 --- /dev/null +++ b/portail/app/Services/CompteurDevoirsEnAttente.php @@ -0,0 +1,33 @@ +classe->devoirs() + ->where('echeance', '>=', now()) + ->whereDoesntHave('rendus', fn ($q) => $q->where('eleve_id', $eleve->id)) + ->count(); + + $seancesOuvertes = $eleve->classe->seances() + ->whereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS]) + ->count(); + + $quizEnCours = SessionQuiz::whereHas('quiz', fn ($q) => $q->where('classe_id', $eleve->classe_id)) + ->whereIn('statut', [SessionQuiz::EN_ATTENTE, SessionQuiz::EN_COURS]) + ->exists(); + + return $devoirsEnAttente + $seancesOuvertes + ($quizEnCours ? 1 : 0); + } +} diff --git a/portail/app/Services/Labs/DefinitionLab.php b/portail/app/Services/Labs/DefinitionLab.php index bb88588..a84b955 100644 --- a/portail/app/Services/Labs/DefinitionLab.php +++ b/portail/app/Services/Labs/DefinitionLab.php @@ -21,6 +21,9 @@ class DefinitionLab public readonly ?int $dureeMin; + /** @var array */ + public readonly array $competences; + /** @var array */ public readonly array $reseaux; @@ -71,6 +74,7 @@ class DefinitionLab $this->titre = $donnees['titre']; $this->niveau = $donnees['niveau'] ?? null; $this->dureeMin = $donnees['duree_min'] ?? null; + $this->competences = $donnees['competences'] ?? []; $this->reseaux = $donnees['reseaux']; $this->machines = array_map( fn (array $m) => [ diff --git a/portail/bootstrap/app.php b/portail/bootstrap/app.php index 2fc66ac..d4d21bc 100644 --- a/portail/bootstrap/app.php +++ b/portail/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'session.unique' => VerifieSessionUnique::class, + 'maj.activite' => MetAJourActivite::class, ]); $middleware->web(append: [PolitiqueSecuriteContenu::class]); }) diff --git a/portail/database/migrations/2026_10_14_090000_add_preferences_to_users_table.php b/portail/database/migrations/2026_10_14_090000_add_preferences_to_users_table.php new file mode 100644 index 0000000..bacf2f1 --- /dev/null +++ b/portail/database/migrations/2026_10_14_090000_add_preferences_to_users_table.php @@ -0,0 +1,26 @@ +json('preferences')->nullable()->after('password'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('preferences'); + }); + } +}; diff --git a/portail/database/migrations/2026_10_14_090001_add_preferences_and_activite_to_eleves_table.php b/portail/database/migrations/2026_10_14_090001_add_preferences_and_activite_to_eleves_table.php new file mode 100644 index 0000000..990882a --- /dev/null +++ b/portail/database/migrations/2026_10_14_090001_add_preferences_and_activite_to_eleves_table.php @@ -0,0 +1,30 @@ +json('preferences')->nullable()->after('code_secret'); + $table->timestamp('derniere_activite_a')->nullable()->after('derniere_connexion_a'); + }); + } + + public function down(): void + { + Schema::table('eleves', function (Blueprint $table) { + $table->dropColumn(['preferences', 'derniere_activite_a']); + }); + } +}; diff --git a/portail/database/migrations/2026_10_14_090002_add_competences_to_labs_table.php b/portail/database/migrations/2026_10_14_090002_add_competences_to_labs_table.php new file mode 100644 index 0000000..0e194be --- /dev/null +++ b/portail/database/migrations/2026_10_14_090002_add_competences_to_labs_table.php @@ -0,0 +1,27 @@ +json('competences')->nullable()->after('niveau'); + }); + } + + public function down(): void + { + Schema::table('labs', function (Blueprint $table) { + $table->dropColumn('competences'); + }); + } +}; diff --git a/portail/database/migrations/2026_10_14_090003_create_annonces_table.php b/portail/database/migrations/2026_10_14_090003_create_annonces_table.php new file mode 100644 index 0000000..bfc932e --- /dev/null +++ b/portail/database/migrations/2026_10_14_090003_create_annonces_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('classe_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('titre'); + $table->text('contenu'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('annonces'); + } +}; diff --git a/portail/database/migrations/2026_10_14_090004_create_raccourcis_table.php b/portail/database/migrations/2026_10_14_090004_create_raccourcis_table.php new file mode 100644 index 0000000..08bfeb6 --- /dev/null +++ b/portail/database/migrations/2026_10_14_090004_create_raccourcis_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('eleve_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('label'); + $table->string('url'); + $table->unsignedSmallInteger('ordre')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('raccourcis'); + } +}; diff --git a/portail/database/migrations/2026_10_14_090005_create_signalements_table.php b/portail/database/migrations/2026_10_14_090005_create_signalements_table.php new file mode 100644 index 0000000..3fb0d37 --- /dev/null +++ b/portail/database/migrations/2026_10_14_090005_create_signalements_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('eleve_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('page_url')->nullable(); + $table->text('message'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('signalements'); + } +}; diff --git a/portail/database/migrations/2026_10_14_090006_create_observations_table.php b/portail/database/migrations/2026_10_14_090006_create_observations_table.php new file mode 100644 index 0000000..a108b65 --- /dev/null +++ b/portail/database/migrations/2026_10_14_090006_create_observations_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('classe_id')->constrained()->cascadeOnDelete(); + $table->foreignId('eleve_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('contenu'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('observations'); + } +}; diff --git a/portail/database/migrations/2026_10_14_090007_add_chat_actif_to_classes_table.php b/portail/database/migrations/2026_10_14_090007_add_chat_actif_to_classes_table.php new file mode 100644 index 0000000..b4d5e4a --- /dev/null +++ b/portail/database/migrations/2026_10_14_090007_add_chat_actif_to_classes_table.php @@ -0,0 +1,26 @@ +boolean('chat_actif')->default(true)->after('code_classe'); + }); + } + + public function down(): void + { + Schema::table('classes', function (Blueprint $table) { + $table->dropColumn('chat_actif'); + }); + } +}; diff --git a/portail/database/migrations/2026_10_14_090008_create_fils_conversation_table.php b/portail/database/migrations/2026_10_14_090008_create_fils_conversation_table.php new file mode 100644 index 0000000..fbe790e --- /dev/null +++ b/portail/database/migrations/2026_10_14_090008_create_fils_conversation_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('classe_id')->constrained()->cascadeOnDelete(); + $table->foreignId('eleve_id')->unique()->constrained()->cascadeOnDelete(); + $table->timestamp('lu_par_prof_a')->nullable(); + $table->timestamp('lu_par_eleve_a')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('fils_conversation'); + } +}; diff --git a/portail/database/migrations/2026_10_14_090009_create_messages_chat_table.php b/portail/database/migrations/2026_10_14_090009_create_messages_chat_table.php new file mode 100644 index 0000000..22f27b3 --- /dev/null +++ b/portail/database/migrations/2026_10_14_090009_create_messages_chat_table.php @@ -0,0 +1,26 @@ +id(); + // constrained() devinerait "fil_conversations" (pluriel anglais) : + // notre table s'appelle "fils_conversation". + $table->foreignId('fil_conversation_id')->constrained('fils_conversation')->cascadeOnDelete(); + $table->string('expediteur')->comment('prof, eleve'); + $table->text('contenu'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('messages_chat'); + } +}; diff --git a/portail/database/migrations/2026_10_14_090010_create_cours_table.php b/portail/database/migrations/2026_10_14_090010_create_cours_table.php new file mode 100644 index 0000000..d518c42 --- /dev/null +++ b/portail/database/migrations/2026_10_14_090010_create_cours_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('identifiant')->unique()->comment('= identifiant du lab, ex: 08-deux-lan-routeur'); + $table->string('titre'); + $table->text('contenu_markdown'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cours'); + } +}; diff --git a/portail/database/migrations/2026_10_14_090011_add_url_ent_to_etablissements_table.php b/portail/database/migrations/2026_10_14_090011_add_url_ent_to_etablissements_table.php new file mode 100644 index 0000000..1534271 --- /dev/null +++ b/portail/database/migrations/2026_10_14_090011_add_url_ent_to_etablissements_table.php @@ -0,0 +1,27 @@ +string('url_ent')->nullable()->after('uai'); + }); + } + + public function down(): void + { + Schema::table('etablissements', function (Blueprint $table) { + $table->dropColumn('url_ent'); + }); + } +}; diff --git a/portail/public/css/app-shell.css b/portail/public/css/app-shell.css new file mode 100644 index 0000000..5a11650 --- /dev/null +++ b/portail/public/css/app-shell.css @@ -0,0 +1,172 @@ +/* Syllabus — coquille de l'application (séparation vitrine/application, 2026-10-13). */ +:root { + --app-topbar-h: 56px; + --app-sidebar-w: 248px; + --app-tabbar-h: 58px; +} + +html[data-taille="grand"] { font-size: 18px; } + +/* Thème sombre : explicite, ou "système" + préférence OS. */ +html[data-theme="sombre"] { + --sy-bg: #0f172a; --sy-surface: #17223a; --sy-line: #2a3b57; --sy-ink: #e8ecf3; --sy-muted: #9aa7bd; + --sy-blue: #5b8dff; --sy-blue-dark: #4876e0; --sy-navy: #c6d4f5; +} +@media (prefers-color-scheme: dark) { + html[data-theme="systeme"] { + --sy-bg: #0f172a; --sy-surface: #17223a; --sy-line: #2a3b57; --sy-ink: #e8ecf3; --sy-muted: #9aa7bd; + --sy-blue: #5b8dff; --sy-blue-dark: #4876e0; --sy-navy: #c6d4f5; + } +} +html[data-theme="sombre"] body, html[data-theme="sombre"] .app-topbar, html[data-theme="sombre"] .app-sidebar, +html[data-theme="sombre"] .app-tabbar, html[data-theme="sombre"] .app-chat-fenetre { color-scheme: dark; } +@media (prefers-color-scheme: dark) { + html[data-theme="systeme"] body, html[data-theme="systeme"] .app-topbar, html[data-theme="systeme"] .app-sidebar, + html[data-theme="systeme"] .app-tabbar, html[data-theme="systeme"] .app-chat-fenetre { color-scheme: dark; } +} + +body.app-corps { background: var(--sy-bg); } + +.app-topbar { + position: sticky; top: 0; z-index: 50; height: var(--app-topbar-h); + display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 0 16px; background: var(--sy-surface); border-bottom: 1px solid var(--sy-line); +} +.app-topbar__logo { display: flex; align-items: center; gap: 0; text-decoration: none; } +.app-topbar__logo img { height: 28px; width: auto; display: block; } +.app-topbar__logo span { font-family: Mulish, system-ui, sans-serif; font-weight: 800; font-size: 17px; color: var(--sy-ink); margin-left: -12px; } + +.app-topbar__avatar { position: relative; } +.app-topbar__avatar-btn { + display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 10px 0 4px; border: 0; border-radius: 999px; + background: transparent; cursor: pointer; font-family: Mulish, system-ui, sans-serif; font-weight: 700; color: var(--sy-ink); +} +.app-topbar__avatar-btn:hover { background: var(--sy-bg); } +.app-topbar__avatar-cercle { + width: 32px; height: 32px; border-radius: 999px; background: var(--sy-blue); color: #fff; + display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px; flex: none; +} +.app-topbar__menu { + position: absolute; right: 0; top: calc(100% + 6px); min-width: 240px; background: var(--sy-surface); + border: 1px solid var(--sy-line); border-radius: 14px; box-shadow: 0 16px 32px -12px rgba(15,23,42,.28); + padding: 6px; display: grid; gap: 2px; +} +.app-topbar__menu a, .app-topbar__menu button { + display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: 0; background: none; + padding: 9px 10px; border-radius: 9px; font-family: Mulish, system-ui, sans-serif; font-weight: 600; font-size: 14px; + color: var(--sy-ink); cursor: pointer; text-decoration: none; +} +.app-topbar__menu a:hover, .app-topbar__menu button:hover { background: var(--sy-bg); } +.app-topbar__menu hr { margin: 4px 2px; border: 0; border-top: 1px solid var(--sy-line); } + +.app-shell { display: flex; min-height: calc(100vh - var(--app-topbar-h)); } + +.app-sidebar { + width: var(--app-sidebar-w); flex: none; border-right: 1px solid var(--sy-line); background: var(--sy-surface); + padding: 16px 10px; position: sticky; top: var(--app-topbar-h); height: calc(100vh - var(--app-topbar-h)); overflow-y: auto; +} +.app-sidebar__nav { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; } +.app-sidebar__nav a { + display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; text-decoration: none; + font-family: Mulish, system-ui, sans-serif; font-weight: 700; font-size: 15px; color: var(--sy-ink); position: relative; +} +.app-sidebar__nav a:hover { background: var(--sy-bg); } +.app-sidebar__nav a[aria-current="page"] { background: #eef0fd; color: var(--sy-blue-dark); } +html[data-theme="sombre"] .app-sidebar__nav a[aria-current="page"], +html[data-theme="systeme"] .app-sidebar__nav a[aria-current="page"] { background: rgba(91,141,255,.16); } +.app-sidebar__nav svg { flex: none; width: 22px; height: 22px; } +.app-sidebar__badge { + margin-left: auto; min-width: 20px; height: 20px; padding: 0 5px; border-radius: 999px; background: #c5221f; color: #fff; + font-size: 11px; font-weight: 800; display: flex; align-items: center; justify-content: center; +} +.app-sidebar__titre { + margin: 18px 0 6px; padding: 0 12px; font-family: Mulish, system-ui, sans-serif; font-size: 12px; font-weight: 800; + letter-spacing: .02em; color: var(--sy-muted); text-transform: uppercase; +} +.app-sidebar__epingler { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 10px; width: 100%; border: 1px dashed var(--sy-line); + background: none; font-family: Mulish, system-ui, sans-serif; font-weight: 700; font-size: 13px; color: var(--sy-muted); cursor: pointer; +} +.app-sidebar__epingler:hover { color: var(--sy-blue); border-color: var(--sy-blue); } +.app-sidebar__raccourci { display: flex; align-items: center; gap: 8px; } +.app-sidebar__raccourci form { margin-left: auto; } +.app-sidebar__raccourci button[type="submit"] { border: 0; background: none; cursor: pointer; color: var(--sy-muted); font-size: 16px; line-height: 1; padding: 2px 6px; } +.app-sidebar__raccourci button[type="submit"]:hover { color: #c5221f; } + +.app-main { flex: 1; min-width: 0; } + +/* Chat Messenger */ +.app-chat-bulle { + position: fixed; right: 20px; bottom: 20px; z-index: 60; width: 56px; height: 56px; border-radius: 999px; border: 0; + background: var(--sy-blue); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; + box-shadow: 0 10px 24px -8px rgba(15,23,42,.4); +} +.app-chat-bulle svg { width: 26px; height: 26px; } +.app-chat-bulle__badge { + position: absolute; top: -4px; right: -4px; min-width: 20px; height: 20px; padding: 0 5px; border-radius: 999px; + background: #c5221f; color: #fff; font-size: 11px; font-weight: 800; display: flex; align-items: center; justify-content: center; + border: 2px solid var(--sy-bg); +} +.app-chat-fenetre { + position: fixed; right: 20px; bottom: 88px; z-index: 60; width: 340px; height: 460px; border-radius: 16px; + background: var(--sy-surface); border: 1px solid var(--sy-line); box-shadow: 0 20px 48px -16px rgba(15,23,42,.35); + display: flex; flex-direction: column; overflow: hidden; +} +.app-chat-fenetre__entete { + display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--sy-line); + font-family: Mulish, system-ui, sans-serif; font-weight: 800; color: var(--sy-ink); +} +.app-chat-fenetre__entete button { border: 0; background: none; cursor: pointer; color: var(--sy-muted); padding: 4px; } +.app-chat-fenetre__corps { flex: 1; overflow-y: auto; padding: 10px 12px; display: grid; gap: 8px; align-content: start; } +.app-chat-fenetre__liste-fils { list-style: none; margin: 0; padding: 0; display: grid; } +.app-chat-fenetre__liste-fils li button { + width: 100%; text-align: left; display: flex; align-items: center; gap: 8px; border: 0; background: none; cursor: pointer; + padding: 10px 6px; border-radius: 10px; font-family: Mulish, system-ui, sans-serif; font-weight: 700; color: var(--sy-ink); +} +.app-chat-fenetre__liste-fils li button:hover { background: var(--sy-bg); } +.app-bulle-message { max-width: 80%; padding: 8px 12px; border-radius: 14px; font-family: Mulish, system-ui, sans-serif; font-size: 14px; font-weight: 600; line-height: 1.35; } +.app-bulle-message--eleve { justify-self: start; background: var(--sy-bg); color: var(--sy-ink); border: 1px solid var(--sy-line); } +.app-bulle-message--prof { justify-self: end; background: var(--sy-blue); color: #fff; } +.app-chat-fenetre__pied { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--sy-line); } +.app-chat-fenetre__pied input { + flex: 1; border: 1px solid var(--sy-line); border-radius: 999px; padding: 8px 12px; font-family: inherit; background: var(--sy-bg); color: var(--sy-ink); +} +.app-chat-fenetre__pied button { + border: 0; background: var(--sy-blue); color: #fff; border-radius: 999px; padding: 0 16px; font-weight: 800; cursor: pointer; +} +.app-chat-fenetre__desactive { padding: 14px; text-align: center; color: var(--sy-muted); font-family: Mulish, system-ui, sans-serif; font-weight: 700; font-size: 13px; } + +/* Barre d'onglets mobile */ +.app-tabbar { + display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 55; height: var(--app-tabbar-h); + background: var(--sy-surface); border-top: 1px solid var(--sy-line); + padding-bottom: env(safe-area-inset-bottom, 0px); +} +.app-tabbar__liste { display: flex; height: 100%; margin: 0; padding: 0; list-style: none; } +.app-tabbar__liste a, .app-tabbar__liste button { + flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; + text-decoration: none; border: 0; background: none; cursor: pointer; font-family: Mulish, system-ui, sans-serif; + font-size: 11px; font-weight: 700; color: var(--sy-muted); position: relative; +} +.app-tabbar__liste a[aria-current="page"] { color: var(--sy-blue); } +.app-tabbar__liste svg { width: 22px; height: 22px; } +.app-feuille { + display: none; position: fixed; inset: 0; z-index: 65; background: var(--sy-surface); +} +.app-feuille__entete { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--sy-line); } +.app-feuille__contenu { padding: 10px; overflow-y: auto; height: calc(100% - 56px); } +.app-feuille__contenu a, .app-feuille__contenu button { + display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; border: 0; background: none; + padding: 14px 10px; font-family: Mulish, system-ui, sans-serif; font-weight: 700; font-size: 16px; color: var(--sy-ink); + text-decoration: none; cursor: pointer; +} +.app-feuille__contenu svg { width: 22px; height: 22px; flex: none; } + +@media (max-width: 860px) { + .app-sidebar { display: none; } + .app-tabbar { display: block; } + .app-shell { min-height: calc(100vh - var(--app-topbar-h) - var(--app-tabbar-h)); padding-bottom: var(--app-tabbar-h); } + .app-chat-bulle { display: none; } + .app-chat-fenetre.est-ouverte { right: 0; bottom: 0; left: 0; top: var(--app-topbar-h); width: auto; height: auto; border-radius: 0; } +} +@media (prefers-reduced-motion: reduce) { .app-chat-fenetre, .app-feuille { transition: none; } } diff --git a/portail/resources/views/aide-app.blade.php b/portail/resources/views/aide-app.blade.php new file mode 100644 index 0000000..1031327 --- /dev/null +++ b/portail/resources/views/aide-app.blade.php @@ -0,0 +1,35 @@ +@extends('layouts.app') + +@section('titre', 'Aide et assistance') + +@section('contenu') +
+

Aide et assistance

+ +
+ @if ($estProf) +
+

Créer une classe et ajouter des élèves

+

Depuis « Mes classes », crée une classe puis ajoute des élèves un par un, en lot avec un fichier CSV, ou laisse-les rejoindre avec le code de la classe.

+
+
+

Lancer un lab

+

Ouvre une classe, planifie une séance et admets les élèves en salle d'attente : chacun reçoit sa machine virtuelle.

+
+ @else +
+

Rejoindre une séance

+

Quand ton prof ouvre une séance, elle apparaît dans « Emploi du temps » et dans « Mes devoirs ». Clique sur « Rejoindre la séance » pour obtenir ta machine virtuelle.

+
+
+

Perdre son identifiant ou son code

+

Ton prof peut te régénérer une fiche de connexion depuis sa page « Mes classes ».

+
+ @endif +
+

Autre chose ?

+

Utilise « Signaler un problème » dans le menu du profil, en haut à droite.

+
+
+
+@endsection diff --git a/portail/resources/views/bibliotheque-app.blade.php b/portail/resources/views/bibliotheque-app.blade.php new file mode 100644 index 0000000..1967fcf --- /dev/null +++ b/portail/resources/views/bibliotheque-app.blade.php @@ -0,0 +1,42 @@ +@extends('layouts.app') + +@section('titre', 'Bibliothèque') + +@section('contenu') +
+

Bibliothèque

+ +
+

Labs

+ @if ($labs->isEmpty()) +

Aucun lab dans le catalogue.

+ @else +
    + @foreach ($labs as $lab) +
  • +

    {{ $lab->titre }}

    +

    {{ $lab->niveau }}@if($lab->duree_min) · {{ $lab->duree_min }} min @endif

    +
  • + @endforeach +
+ @endif +
+ +
+ + @foreach ($groupesQuiz as $theme => $quizzes) +
+

{{ $theme !== '' ? $theme : 'Partagés par des profs' }}

+
    + @foreach ($quizzes as $quiz) +
  • {{ $quiz->titre }}
  • + @endforeach +
+
+ @endforeach +
+
+@endsection diff --git a/portail/resources/views/cours-detail.blade.php b/portail/resources/views/cours-detail.blade.php new file mode 100644 index 0000000..3dcc7fe --- /dev/null +++ b/portail/resources/views/cours-detail.blade.php @@ -0,0 +1,21 @@ +@extends('layouts.app') + +@section('titre', $cours->titre) + +@push('head') + +@endpush + +@section('contenu') +
+

← Mes cours

+

{{ $cours->titre }}

+
{!! $html !!}
+
+@endsection diff --git a/portail/resources/views/devoirs/eleve.blade.php b/portail/resources/views/devoirs/eleve.blade.php index 256372d..0e9799d 100644 --- a/portail/resources/views/devoirs/eleve.blade.php +++ b/portail/resources/views/devoirs/eleve.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', $devoir->titre) diff --git a/portail/resources/views/devoirs/index.blade.php b/portail/resources/views/devoirs/index.blade.php index 94943c0..e5a72af 100644 --- a/portail/resources/views/devoirs/index.blade.php +++ b/portail/resources/views/devoirs/index.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', 'Devoirs · '.$classe->nom) diff --git a/portail/resources/views/devoirs/show.blade.php b/portail/resources/views/devoirs/show.blade.php index 9d852f8..7bda435 100644 --- a/portail/resources/views/devoirs/show.blade.php +++ b/portail/resources/views/devoirs/show.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', $devoir->titre) diff --git a/portail/resources/views/eleve.blade.php b/portail/resources/views/eleve.blade.php index 5477ba4..5352957 100644 --- a/portail/resources/views/eleve.blade.php +++ b/portail/resources/views/eleve.blade.php @@ -1,118 +1,32 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', 'Espace élève') @section('contenu') -
- -
-
-

Espace élève · {{ $eleve->identifiant }}

-

Pour les élèves

-

Salut {{ $eleve->prenom }}.

-

{{ $eleve->classe->nom }}@if($eleve->classe->formation) · {{ $eleve->classe->formation }}@endif

+
+

{{ $eleve->classe->nom }}

+

Salut {{ $eleve->prenom }}.

+ + {{-- Chantier « vrai cockpit », étape 2 : Quiz flash, réel, pas de démo. + Caché par défaut ; rempli et affiché par le sondage ci-dessous. --}} +
-
- {{-- Chantier « vrai cockpit », étape 2 : Quiz flash, réel, pas de démo. - Caché par défaut ; rempli et affiché par le sondage ci-dessous. --}} - - @php($seancesOuvertes = $eleve->classe->seances()->whereIn('statut', [\App\Models\Seance::SALLE_ATTENTE, \App\Models\Seance::EN_COURS])->with('lab')->get()) - @if ($seancesOuvertes->isNotEmpty()) -
-

Séance en cours

-
    - @foreach ($seancesOuvertes as $seance) -
  • - {{ $seance->lab->titre }} -
    - @csrf - -
    - @if ($seance->visioOuverte()) - Rejoindre la visio - @endif -
  • - @endforeach -
- @endif - {{-- Chantier « vrai cockpit », étape 3 : Devoirs maison, réels. --}} - @php($devoirsClasse = $eleve->classe->devoirs()->with(['rendus' => fn ($q) => $q->where('eleve_id', $eleve->id)])->where('echeance', '>=', now()->subDays(7))->get()) - @if ($devoirsClasse->isNotEmpty()) -
-

Devoirs maison

- -
- @endif - + + +@stack('scripts_bas') + + diff --git a/portail/resources/views/ma-classe.blade.php b/portail/resources/views/ma-classe.blade.php new file mode 100644 index 0000000..25a8e20 --- /dev/null +++ b/portail/resources/views/ma-classe.blade.php @@ -0,0 +1,53 @@ +@extends('layouts.app') + +@section('titre', 'Ma classe') + +@section('contenu') +
+

{{ $classe->nom }}

+ @if ($classe->etablissement) +

{{ $classe->etablissement->nom }}

+ @endif + +
+

Annonces

+ @if ($classe->annonces->isEmpty()) +

Aucune annonce pour l'instant.

+ @else +
    + @foreach ($classe->annonces as $annonce) +
  • +

    {{ $annonce->titre }}

    +

    {{ $annonce->contenu }}

    +

    {{ $annonce->auteur->name }} · {{ $annonce->created_at->translatedFormat('d/m à H:i') }}

    +
  • + @endforeach +
+ @endif +
+ +
+

Camarades

+
    + @foreach ($camarades as $camarade) +
  • + + {{ $camarade->nomAffiche() }} + @if ($camarade->id === $eleve->id) + (toi) + @elseif ($camarade->estEnLigne()) + en ligne + @endif +
  • + @endforeach +
+

Pour écrire à ton prof, utilise le chat en bas à droite — pas de message entre camarades.

+
+ + @if ($classe->etablissement?->url_ent) +

+ Accéder à l'ENT de l'établissement → +

+ @endif +
+@endsection diff --git a/portail/resources/views/mes-classes/index.blade.php b/portail/resources/views/mes-classes/index.blade.php index 5e4b69e..96ee022 100644 --- a/portail/resources/views/mes-classes/index.blade.php +++ b/portail/resources/views/mes-classes/index.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', 'Mes classes') diff --git a/portail/resources/views/mes-classes/show.blade.php b/portail/resources/views/mes-classes/show.blade.php index fe0642f..06043c9 100644 --- a/portail/resources/views/mes-classes/show.blade.php +++ b/portail/resources/views/mes-classes/show.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', $classe->nom) @@ -32,6 +32,9 @@
  • {{ $classe->estArchivee() ? 'Classe archivée' : 'Séance' }}
  • Quiz flash
  • Devoirs maison
  • +
  • Annonces
  • +
  • Observations
  • +
  • Chat
  • @if ($seance)
    @@ -320,6 +323,76 @@ @endif +
    +

    Annonces

    +

    Visibles par tes élèves dans « Ma classe ».

    +
    + @csrf + + + +
    + @if ($classe->annonces->isNotEmpty()) +
      + @foreach ($classe->annonces as $annonce) +
    • +
      +

      {{ $annonce->titre }}

      +

      {{ $annonce->contenu }}

      +

      {{ $annonce->created_at->translatedFormat('d/m à H:i') }}

      +
      +
      + @csrf + +
      +
    • + @endforeach +
    + @endif +
    + + @if ($classe->eleves->isNotEmpty()) +
    +

    Observations

    +

    Notes rapides pour le conseil de classe — jamais montrées à l'élève.

    +
    + @csrf + + + +
    + @if ($classe->observations->isNotEmpty()) +
      + @foreach ($classe->observations as $observation) +
    • +

      {{ $observation->eleve->nomAffiche() }} — {{ $observation->contenu }} + ({{ $observation->created_at->translatedFormat('d/m') }})

      +
      + @csrf + +
      +
    • + @endforeach +
    + @endif +
    + @endif + +
    +

    Chat

    +

    + {{ $classe->chat_actif ? 'Activé : tes élèves peuvent t\'écrire depuis la bulle en bas à droite.' : 'Désactivé : tes élèves ne peuvent plus t\'écrire.' }} +

    +
    + @csrf + +
    +
    +
    @if ($classe->estArchivee())
    diff --git a/portail/resources/views/mes-cours.blade.php b/portail/resources/views/mes-cours.blade.php new file mode 100644 index 0000000..fac6253 --- /dev/null +++ b/portail/resources/views/mes-cours.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app') + +@section('titre', 'Mes cours') + +@section('contenu') +
    +

    Mes cours

    + + @if ($cours->isEmpty()) +

    Pas encore de cours disponible. Fais ton premier TP pour voir apparaître son cours ici.

    + @else + + @endif +
    +@endsection diff --git a/portail/resources/views/mes-devoirs.blade.php b/portail/resources/views/mes-devoirs.blade.php new file mode 100644 index 0000000..8d7ab74 --- /dev/null +++ b/portail/resources/views/mes-devoirs.blade.php @@ -0,0 +1,59 @@ +@extends('layouts.app') + +@section('titre', 'Mes devoirs') + +@section('contenu') +
    +

    Mes devoirs

    + + @if ($sessionQuiz) +
    +

    QCM en cours

    +

    + Un quiz flash est en cours. Rends-toi sur ton tableau de bord pour y répondre. +

    +
    + @endif + + @if ($seancesOuvertes->isNotEmpty()) +
    +

    TP disponibles

    +
      + @foreach ($seancesOuvertes as $seance) +
    • + {{ $seance->lab->titre }} +
      + @csrf + +
      +
    • + @endforeach +
    +
    + @endif + +
    +

    Devoirs maison

    + @if ($devoirs->isEmpty()) +

    Aucun devoir maison pour l'instant.

    + @else + + @endif +
    +
    +@endsection diff --git a/portail/resources/views/mes-notes.blade.php b/portail/resources/views/mes-notes.blade.php new file mode 100644 index 0000000..522ac7c --- /dev/null +++ b/portail/resources/views/mes-notes.blade.php @@ -0,0 +1,42 @@ +@extends('layouts.app') + +@section('titre', 'Mes notes') + +@section('contenu') +
    +

    Mes notes

    + + @if ($notes->isEmpty()) +

    Pas encore de note.

    + @else +
    +

    Par compétence

    +
      + @foreach ($parCompetence as $competence => $donnees) +
    • +

      + {{ $competence }} + {{ $donnees['moyenne'] }} % ({{ $donnees['nombre'] }}) +

      +
      +
      +
      +
    • + @endforeach +
    +
    + +
    +

    Toutes mes notes

    +
      + @foreach ($notes as $note) +
    • + {{ $note['titre'] }} + {{ $note['note'] }}/{{ $note['note_max'] }} +
    • + @endforeach +
    +
    + @endif +
    +@endsection diff --git a/portail/resources/views/parametres.blade.php b/portail/resources/views/parametres.blade.php new file mode 100644 index 0000000..ea48ca1 --- /dev/null +++ b/portail/resources/views/parametres.blade.php @@ -0,0 +1,40 @@ +@extends('layouts.app') + +@section('titre', 'Paramètres') + +@section('contenu') +
    +

    Paramètres

    + + @if (session('parametres_enregistres')) +

    Enregistré.

    + @endif + +
    + @csrf +
    + Thème +
    + @foreach (['clair' => 'Clair', 'sombre' => 'Sombre', 'systeme' => 'Suivre l’appareil'] as $valeur => $label) + + @endforeach +
    +
    +
    + Taille du texte +
    + @foreach (['normal' => 'Normale', 'grand' => 'Grande'] as $valeur => $label) + + @endforeach +
    +
    + +
    +
    +@endsection diff --git a/portail/resources/views/partials/nav-application.blade.php b/portail/resources/views/partials/nav-application.blade.php new file mode 100644 index 0000000..84eaeaf --- /dev/null +++ b/portail/resources/views/partials/nav-application.blade.php @@ -0,0 +1,59 @@ +{{-- Barre latérale (desktop), barre d'onglets et feuille « Plus » (mobile) — + séparation vitrine/application, 2026-10-13. Même liste de 5 entrées, + rendue différemment selon le contexte. --}} +@php + $eleveNav = [ + ['ma-classe', 'Ma classe', '', null], + ['emploi-du-temps', 'Emploi du temps', '', null], + ['mes-devoirs', 'Mes devoirs', '', $badgeMesDevoirs], + ['mes-cours', 'Mes cours', '', null], + ['mes-notes', 'Mes notes', '', null], + ]; + $profNav = [ + ['mes-classes.index', 'Mes classes', '', null], + ['emploi-du-temps', 'Emploi du temps', '', null], + ['seances-en-direct', 'Séances en direct', '', null], + ['bibliotheque.app', 'Bibliothèque', '', null], + ['resultats', 'Résultats', '', null], + ]; + $entrees = $estProf ? $profNav : $eleveNav; +@endphp + +@if (! ($mobile ?? false) && ! ($mobilePlus ?? false)) + @foreach ($entrees as [$route, $label, $svg, $badge]) +
  • + routeIs($route)) aria-current="page" @endif> + {!! $svg !!} + {{ $label }} + @if ($badge) + {{ $badge }} + @endif + +
  • + @endforeach +@elseif ($mobile ?? false) + @foreach (array_slice($entrees, 0, 4) as [$route, $label, $svg, $badge]) +
  • + routeIs($route)) aria-current="page" @endif> + {!! $svg !!} + {{ $label }} + @if ($badge) + {{ $badge }} + @endif + +
  • + @endforeach +@else + @foreach (array_slice($entrees, 4) as [$route, $label, $svg, $badge]) + + {!! $svg !!} + {{ $label }} + + @endforeach + @foreach ($raccourcis as $raccourci) + + + {{ $raccourci->label }} + + @endforeach +@endif diff --git a/portail/resources/views/profil.blade.php b/portail/resources/views/profil.blade.php new file mode 100644 index 0000000..cd17b52 --- /dev/null +++ b/portail/resources/views/profil.blade.php @@ -0,0 +1,43 @@ +@extends('layouts.app') + +@section('titre', 'Mon profil') + +@section('contenu') +
    +

    Mon profil

    + +
    +
    +
    +
    Nom
    +
    {{ $nom }}
    +
    + @if ($role === 'prof') +
    +
    E-mail
    +
    {{ $email }}
    +
    + @else +
    +
    Identifiant
    +
    {{ $identifiant }}
    +
    + @endif + @if ($etablissement) +
    +
    Établissement
    +
    {{ $etablissement->nom }}
    +
    + @endif +
    +
    {{ $role === 'prof' ? 'Classes' : 'Classe' }}
    +
    + @foreach ($classes as $classe) + {{ $classe->nom }} + @endforeach +
    +
    +
    +
    +
    +@endsection diff --git a/portail/resources/views/quiz/banque.blade.php b/portail/resources/views/quiz/banque.blade.php index d39c48a..9eb1f25 100644 --- a/portail/resources/views/quiz/banque.blade.php +++ b/portail/resources/views/quiz/banque.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', 'Banque de quiz') diff --git a/portail/resources/views/quiz/index.blade.php b/portail/resources/views/quiz/index.blade.php index 73b7782..7c6a468 100644 --- a/portail/resources/views/quiz/index.blade.php +++ b/portail/resources/views/quiz/index.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', 'Quiz · '.$classe->nom) diff --git a/portail/resources/views/quiz/session-prof.blade.php b/portail/resources/views/quiz/session-prof.blade.php index 651b52b..7201541 100644 --- a/portail/resources/views/quiz/session-prof.blade.php +++ b/portail/resources/views/quiz/session-prof.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', $session->quiz->titre.' · en direct') diff --git a/portail/resources/views/quiz/show.blade.php b/portail/resources/views/quiz/show.blade.php index 27ab5ee..4e1a063 100644 --- a/portail/resources/views/quiz/show.blade.php +++ b/portail/resources/views/quiz/show.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', $quiz->titre) diff --git a/portail/resources/views/resultats.blade.php b/portail/resources/views/resultats.blade.php new file mode 100644 index 0000000..6ab3834 --- /dev/null +++ b/portail/resources/views/resultats.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.app') + +@section('titre', 'Résultats') + +@section('contenu') +
    +

    Résultats

    + + @if ($resultats->isEmpty()) +

    Crée d'abord une classe.

    + @else +
      + @foreach ($resultats as $r) +
    • +
      + {{ $r['classe']->nom }} + + {{ $r['nombreCorrections'] }} correction(s) + @if ($r['reussiteMoyenne'] !== null) · {{ $r['reussiteMoyenne'] }} % de réussite @endif + +
      + @if ($r['observations']->isNotEmpty()) +
        + @foreach ($r['observations'] as $observation) +
      • + {{ $observation->eleve->nomAffiche() }} · {{ $observation->created_at->translatedFormat('d/m') }} +
        {{ $observation->contenu }} +
      • + @endforeach +
      + @endif +

      Ajouter une observation →

      +
    • + @endforeach +
    + @endif +
    +@endsection diff --git a/portail/resources/views/seances-en-direct.blade.php b/portail/resources/views/seances-en-direct.blade.php new file mode 100644 index 0000000..c8c9514 --- /dev/null +++ b/portail/resources/views/seances-en-direct.blade.php @@ -0,0 +1,25 @@ +@extends('layouts.app') + +@section('titre', 'Séances en direct') + +@section('contenu') +
    +

    Séances en direct

    + + @if ($seances->isEmpty()) +

    Aucune séance en cours dans tes classes.

    + @else +
      + @foreach ($seances as $seance) +
    • +
      +

      {{ $seance->lab->titre }}

      +

      {{ $seance->classe->nom }} · {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'En cours' : 'Salle d\'attente' }}

      +
      + Ouvrir la classe +
    • + @endforeach +
    + @endif +
    +@endsection diff --git a/portail/resources/views/seances/attente-eleve.blade.php b/portail/resources/views/seances/attente-eleve.blade.php index b8c2ce2..6b12963 100644 --- a/portail/resources/views/seances/attente-eleve.blade.php +++ b/portail/resources/views/seances/attente-eleve.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', "Salle d'attente") diff --git a/portail/resources/views/seances/attente.blade.php b/portail/resources/views/seances/attente.blade.php index 8a05436..30da541 100644 --- a/portail/resources/views/seances/attente.blade.php +++ b/portail/resources/views/seances/attente.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', "Salle d'attente") diff --git a/portail/resources/views/seances/console-prof.blade.php b/portail/resources/views/seances/console-prof.blade.php index 8ccfbdc..d81d6e6 100644 --- a/portail/resources/views/seances/console-prof.blade.php +++ b/portail/resources/views/seances/console-prof.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', 'Console de ' . $labSession->eleve->nomAffiche()) diff --git a/portail/resources/views/seances/visio-eleve.blade.php b/portail/resources/views/seances/visio-eleve.blade.php index d695db7..4ea28ab 100644 --- a/portail/resources/views/seances/visio-eleve.blade.php +++ b/portail/resources/views/seances/visio-eleve.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', 'Visio · '.$seance->classe->nom) diff --git a/portail/resources/views/seances/visio-prof.blade.php b/portail/resources/views/seances/visio-prof.blade.php index dff4882..0e92e67 100644 --- a/portail/resources/views/seances/visio-prof.blade.php +++ b/portail/resources/views/seances/visio-prof.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.vitrine') +@extends('layouts.app') @section('titre', 'Visio · '.$seance->classe->nom) diff --git a/portail/routes/web.php b/portail/routes/web.php index 0827ecf..1fbd9a4 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -1,20 +1,38 @@ name( Route::get('/rejoindre/bienvenue', [RejoindreClasseController::class, 'bienvenue'])->name('rejoindre.bienvenue'); Route::post('/deconnexion', [ConnexionController::class, 'deconnexion'])->name('deconnexion'); +// --- Commun aux deux espaces (séparation vitrine/application, 2026-10-13) --- +Route::middleware(['auth:eleve,web', 'session.unique:eleve', 'session.unique:web', 'maj.activite'])->group(function () { + Route::get('/profil', [ProfilController::class, 'show'])->name('profil'); + Route::get('/parametres', [ParametresController::class, 'show'])->name('parametres'); + Route::post('/parametres', [ParametresController::class, 'update'])->name('parametres.update'); + Route::post('/signalements', [SignalementController::class, 'store'])->name('signalements.store'); + Route::get('/aide-app', [AideController::class, 'show'])->name('aide.app'); + Route::post('/raccourcis', [RaccourciController::class, 'store'])->name('raccourcis.store'); + Route::post('/raccourcis/{raccourci}/supprimer', [RaccourciController::class, 'destroy'])->name('raccourcis.destroy'); + Route::get('/emploi-du-temps', [EmploiDuTempsController::class, 'show'])->name('emploi-du-temps'); + Route::get('/mes-cours/{cours:identifiant}', [MesCoursController::class, 'montrer'])->name('mes-cours.show'); +}); + // --- Espace élève (protégé, session unique) --- -Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () { +Route::middleware(['auth:eleve', 'session.unique:eleve', 'maj.activite'])->group(function () { Route::get('/eleve', [EleveTableauDeBordController::class, 'show'])->name('eleve'); + Route::get('/ma-classe', [MaClasseController::class, 'show'])->name('ma-classe'); + Route::get('/mes-devoirs', [MesDevoirsController::class, 'show'])->name('mes-devoirs'); + Route::get('/mes-cours', [MesCoursController::class, 'show'])->name('mes-cours'); + Route::get('/mes-notes', [MesNotesController::class, 'show'])->name('mes-notes'); + Route::get('/chat/etat', [ChatEleveController::class, 'etat'])->name('chat.eleve.etat'); + Route::post('/chat/envoyer', [ChatEleveController::class, 'envoyer'])->middleware('throttle:30,1')->name('chat.eleve.envoyer'); Route::post('/seances/{seance}/rejoindre', [SeanceController::class, 'rejoindre'])->name('seances.rejoindre'); Route::get('/seances/{seance}/attente-eleve', [SeanceController::class, 'attenteEleve'])->name('seances.eleve.attente'); Route::get('/seances/{seance}/etat-eleve', [SeanceController::class, 'etatEleve'])->name('seances.eleve.etat'); @@ -162,4 +199,18 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () { Route::get('/mes-classes/{classe}/devoirs/{devoir}', [DevoirController::class, 'show'])->name('mes-classes.devoirs.show'); Route::post('/mes-classes/{classe}/devoirs/{devoir}/supprimer', [DevoirController::class, 'destroy'])->name('mes-classes.devoirs.destroy'); Route::post('/mes-classes/{classe}/devoirs/{devoir}/rendus/{rendu}/noter', [DevoirController::class, 'noter'])->name('mes-classes.devoirs.noter'); + + // Séparation vitrine/application (2026-10-13) : Ma classe (annonces), + // Résultats (observations), Séances en direct, Bibliothèque, chat. + Route::post('/mes-classes/{classe}/annonces', [AnnonceController::class, 'store'])->name('mes-classes.annonces.store'); + Route::post('/mes-classes/{classe}/annonces/{annonce}/supprimer', [AnnonceController::class, 'destroy'])->name('mes-classes.annonces.destroy'); + Route::post('/mes-classes/{classe}/observations', [ObservationController::class, 'store'])->name('mes-classes.observations.store'); + Route::post('/mes-classes/{classe}/observations/{observation}/supprimer', [ObservationController::class, 'destroy'])->name('mes-classes.observations.destroy'); + Route::post('/mes-classes/{classe}/chat/basculer', [ClasseChatController::class, 'basculer'])->name('mes-classes.chat.basculer'); + Route::get('/seances-en-direct', [SeancesEnDirectController::class, 'show'])->name('seances-en-direct'); + Route::get('/bibliotheque-app', [BibliothequeProfController::class, 'show'])->name('bibliotheque.app'); + Route::get('/resultats', [ResultatsController::class, 'show'])->name('resultats'); + Route::get('/chat', [ChatProfController::class, 'index'])->name('chat.prof.index'); + Route::get('/chat/{fil}/etat', [ChatProfController::class, 'etat'])->name('chat.prof.etat'); + Route::post('/chat/{fil}/envoyer', [ChatProfController::class, 'envoyer'])->middleware('throttle:30,1')->name('chat.prof.envoyer'); }); diff --git a/portail/tests/Feature/CoquilleApplicationTest.php b/portail/tests/Feature/CoquilleApplicationTest.php new file mode 100644 index 0000000..f4e1742 --- /dev/null +++ b/portail/tests/Feature/CoquilleApplicationTest.php @@ -0,0 +1,140 @@ +get(route('accueil'))->assertOk()->assertSee('Syllabus'); +}); + +test('un élève voit la barre latérale élève, jamais celle du prof', function () { + $eleve = Eleve::factory()->create(); + + $page = eleve($this, $eleve)->get(route('eleve')); + + $page->assertOk() + ->assertSee('Ma classe') + ->assertSee('Mes devoirs') + ->assertDontSee('Séances en direct') + ->assertDontSee('Bibliothèque'); +}); + +test('un prof voit la barre latérale prof, jamais celle des élèves', function () { + $prof = User::factory()->create(); + $classe = Classe::factory()->create(['user_id' => $prof->id]); + + $page = prof($this, $prof)->get(route('mes-classes.show', $classe)); + + $page->assertOk() + ->assertSee('Séances en direct') + ->assertSee('Bibliothèque') + ->assertDontSee('Mes notes') + ->assertDontSee('Mes cours'); +}); + +test('un élève ne peut jamais atteindre le chat (ni le sien, ni celui des profs) via les routes prof', function () { + $classeA = Classe::factory()->create(); + $eleveA = Eleve::factory()->create(['classe_id' => $classeA->id]); + $filA = FilConversation::create(['classe_id' => $classeA->id, 'eleve_id' => $eleveA->id]); + + eleve($this, $eleveA)->getJson(route('chat.prof.index'))->assertStatus(401); + eleve($this, $eleveA)->getJson(route('chat.prof.etat', $filA))->assertStatus(401); +}); + +test("un prof ne peut pas lire ou écrire dans le fil de conversation d'une classe qui n'est pas la sienne", function () { + $classeA = Classe::factory()->create(); + $eleveA = Eleve::factory()->create(['classe_id' => $classeA->id]); + $filA = FilConversation::create(['classe_id' => $classeA->id, 'eleve_id' => $eleveA->id]); + + $autreProf = User::factory()->create(); + + prof($this, $autreProf)->getJson(route('chat.prof.etat', $filA))->assertStatus(403); + prof($this, $autreProf)->postJson(route('chat.prof.envoyer', $filA), ['contenu' => 'Bonjour'])->assertStatus(403); + + expect($filA->messages()->count())->toBe(0); +}); + +test("deux élèves de classes différentes n'ont jamais le même fil de conversation et ne peuvent pas lire celui de l'autre", function () { + $classeA = Classe::factory()->create(); + $eleveA = Eleve::factory()->create(['classe_id' => $classeA->id]); + + $classeB = Classe::factory()->create(); + $eleveB = Eleve::factory()->create(['classe_id' => $classeB->id]); + + eleve($this, $eleveA)->postJson(route('chat.eleve.envoyer'), ['contenu' => 'Un message de A'])->assertCreated(); + eleve($this, $eleveB)->postJson(route('chat.eleve.envoyer'), ['contenu' => 'Un message de B'])->assertCreated(); + + $reponseA = eleve($this, $eleveA)->getJson(route('chat.eleve.etat'))->json(); + $reponseB = eleve($this, $eleveB)->getJson(route('chat.eleve.etat'))->json(); + + expect(collect($reponseA['messages'])->pluck('contenu')->all())->toBe(['Un message de A']) + ->and(collect($reponseB['messages'])->pluck('contenu')->all())->toBe(['Un message de B']); +}); + +test('les scripts en ligne de la coquille (barre du haut, chat) sont du JavaScript valide, côté élève et côté prof', function () { + if (Process::run('node --version')->failed()) { + $this->markTestSkipped('Node absent'); + } + + $eleve = Eleve::factory()->create(); + $prof = User::factory()->create(); + $classe = Classe::factory()->create(['user_id' => $prof->id]); + + $pages = [ + fn () => eleve($this, $eleve)->get(route('eleve')), + fn () => prof($this, $prof)->get(route('mes-classes.show', $classe)), + ]; + + $erreurs = []; + foreach ($pages as $i => $visite) { + preg_match_all('#]*\bsrc=)[^>]*>(.*?)#s', $visite()->getContent(), $scripts); + foreach ($scripts[1] as $j => $code) { + $fichier = tempnam(sys_get_temp_dir(), 'js').'.js'; + file_put_contents($fichier, $code); + $resultat = Process::run(['node', '--check', $fichier]); + unlink($fichier); + if ($resultat->failed()) { + $erreurs[] = "page {$i} script {$j} : ".strtok($resultat->errorOutput(), "\n"); + } + } + } + + expect($erreurs)->toBe([]); +}); + +test('toutes les nouvelles pages de la coquille se chargent sans erreur', function () { + $prof = User::factory()->create(); + $classe = Classe::factory()->create(['user_id' => $prof->id]); + $eleve = Eleve::factory()->create(['classe_id' => $classe->id]); + + $pagesEleve = ['ma-classe', 'emploi-du-temps', 'mes-devoirs', 'mes-cours', 'mes-notes', 'profil', 'parametres', 'aide.app']; + foreach ($pagesEleve as $route) { + eleve($this, $eleve)->get(route($route))->assertOk(); + } + + $pagesProf = ['seances-en-direct', 'bibliotheque.app', 'resultats', 'profil', 'parametres', 'aide.app', 'emploi-du-temps']; + foreach ($pagesProf as $route) { + prof($this, $prof)->get(route($route))->assertOk(); + } +}); + +test('le compteur de messages non lus retombe à zéro une fois le fil ouvert, côté prof et côté élève', function () { + $prof = User::factory()->create(); + $classe = Classe::factory()->create(['user_id' => $prof->id]); + $eleve = Eleve::factory()->create(['classe_id' => $classe->id]); + + eleve($this, $eleve)->postJson(route('chat.eleve.envoyer'), ['contenu' => 'Un message'])->assertCreated(); + $fil = FilConversation::where('eleve_id', $eleve->id)->first(); + + expect(prof($this, $prof)->getJson(route('chat.prof.index'))->json('fils.0.non_lus'))->toBe(1); + prof($this, $prof)->getJson(route('chat.prof.etat', $fil))->assertOk(); + expect(prof($this, $prof)->getJson(route('chat.prof.index'))->json('fils.0.non_lus'))->toBe(0); + + prof($this, $prof)->postJson(route('chat.prof.envoyer', $fil), ['contenu' => 'Une réponse'])->assertCreated(); + expect(eleve($this, $eleve)->getJson(route('chat.eleve.etat'))->json('non_lus'))->toBe(1); + eleve($this, $eleve)->getJson(route('chat.eleve.etat').'?lu=1')->assertOk(); + expect(eleve($this, $eleve)->getJson(route('chat.eleve.etat'))->json('non_lus'))->toBe(0); +}); diff --git a/portail/tests/Feature/LancementLabTest.php b/portail/tests/Feature/LancementLabTest.php index 861f85d..7c72e0c 100644 --- a/portail/tests/Feature/LancementLabTest.php +++ b/portail/tests/Feature/LancementLabTest.php @@ -130,16 +130,16 @@ test("une vérification ratée donne « échoué » avec le message du lab, une ->and($details['ip-pc-b']['message'])->toContain('ne répond pas'); }); -test("l'élève voit la séance ouverte de sa classe et peut la rejoindre ; la déconnexion porte un jeton CSRF", function () { +test("l'élève voit la séance ouverte de sa classe dans Mes devoirs et peut la rejoindre ; la déconnexion porte un jeton CSRF", function () { $session = sessionEnAttente(); $eleve = $session->eleve; $session->delete(); $jeton = $eleve->demarreNouvelleSession(); - $page = $this->withSession(['jeton_session_eleve' => $jeton])->actingAs($eleve, 'eleve')->get(route('eleve')); + $page = $this->withSession(['jeton_session_eleve' => $jeton])->actingAs($eleve, 'eleve')->get(route('mes-devoirs')); $page->assertOk() - ->assertSee('Rejoindre la séance') + ->assertSee('Rejoindre') ->assertSee(route('seances.rejoindre', $session->seance_id), false); expect(preg_match('#action="'.preg_quote(route('deconnexion'), '#').'" method="post"[^>]*>getContent()))->toBe(1); });