# CLAUDE.md

Ce fichier fournit des repères à Claude Code (claude.ai/code) pour travailler dans ce dépôt.

## Vue d'ensemble du projet

TECHNIFORM : une plateforme de gestion multi-centres de formation pour TECHNIDEV et ses partenaires (3 centres multimédia actuellement, dont Goz Beida — extensible, le nombre de centres n'est jamais figé en dur). Le cahier des charges complet (v2.5) se trouve dans [docs/cahier-des-charges.md](docs/cahier-des-charges.md) — à lire avant toute décision d'architecture ; ce fichier ne fait que résumer ce qui est utile au quotidien.

**Statut : les 6 phases du plan d'implémentation sont terminées.** Application fonctionnelle de bout en bout (staff + espace apprenant + dashboard partenaire + rapports/assiduité), avec une suite de tests automatisés (325 tests, 1773 assertions) couvrant l'isolation par centre, la portée de l'espace apprenant, et les règles métier de la section 8 du cahier des charges. Le projet est passé en revue de recette (voir « Recette » ci-dessous) et un audit de sécurité complet (voir « Audit de sécurité du 2026-08-10 » ci-dessous) ; il reste des corrections/ajustements à faire au fil de l'eau — ce fichier doit continuer à être tenu à jour à cette occasion. **Base de données vidée à nouveau le 2026-08-13** (voir section dédiée plus bas) pour repartir propre, seul le compte admin conservé.

**Simplification majeure du 2026-08-07 : Groupe, Période et Répartition ont été entièrement supprimés** (voir la section dédiée plus bas, juste avant « Organisation du code »). Un apprenant est désormais lié directement à sa Formation + son Centre, sans étape intermédiaire de répartition en groupe ni notion de session datée. **Toute mention de `Groupe`/`Periode`/répartition dans les sections historiques ci-dessous (chantiers datés du 2026-08-05/06) documente une architecture qui n'existe plus** — conservée telle quelle pour comprendre le raisonnement de l'époque, mais à ne plus utiliser comme référence du code actuel. Les sections « Modèle métier », « Décisions structurantes » et « Organisation du code » ont, elles, été mises à jour pour refléter l'état présent.

**Revirement du 2026-08-29 : le fichier `Techniform designer skill.MD` (racine du dépôt) n'est plus la référence de design.** Ce skill a guidé toute la refonte visuelle documentée dans ce fichier depuis le 2026-08-05 (palette, typographie, budget d'animation quasi nul, interdiction des librairies d'animation JS, sobriété pour l'espace apprenant) — le porteur de projet a explicitement demandé de ne plus en tenir compte à partir de maintenant, sans le supprimer du dépôt (conservé tel quel comme trace historique, mais à ne plus consulter pour une décision de design). Seule dérogation précisée explicitement à ce moment : les micro-animations sont désormais autorisées largement (au-delà des quelques transitions listées par l'ancien skill), y compris là où il les interdisait (badges/compteurs qui se rafraîchissent, etc.) — reste à trancher au cas par cas si une librairie d'animation JS (Framer Motion, GSAP...) devient nécessaire, l'ancien interdit de principe ne tient plus mais aucune n'a encore été ajoutée. Toute décision de design prise avant cette date reste documentée ci-dessous pour comprendre le raisonnement de l'époque, mais n'est plus une contrainte contraignante pour la suite — seul le jugement au cas par cas (cohérence avec l'existant, lisibilité, performance) fait foi désormais.

**Base de données vidée le 2026-08-05** pour démarrer la saisie de vraies données (`php artisan migrate:fresh` puis `php artisan db:seed --class=RoleSeeder` uniquement — les données de démo, comptes de démo listés ci-dessous inclus, n'existent donc plus). Seule la table `roles` (spatie/laravel-permission) est réensemencée : indispensable au fonctionnement de l'app dès la première inscription (`UserObserver::syncRoles()` échoue avec `RoleDoesNotExist` sinon), ce n'est pas une donnée de démo. Premier compte à créer : un admin, via `php artisan tinker` (pas de route d'auto-inscription pour l'admin, voir plus haut) ; les autres rôles staff peuvent ensuite s'auto-inscrire via `/register`. Pour retrouver un jeu de données de démo complet à des fins de test, `php artisan migrate:fresh --seed` (tous les seeders) reste disponible mais n'a plus été rejoué depuis ce vidage.

### Stack (installée)
Laravel 12 (backend) + Inertia.js + React 18 (frontend) + Tailwind CSS v3 + MySQL (XAMPP, base `techniform`). Rôles/permissions staff via `spatie/laravel-permission` (table `users.role` = source de vérité pour les Policies, synchronisée vers spatie par `UserObserver`). Guard Laravel dédié `apprenant` (voir `config/auth.php`), distinct du guard `web` du staff — modèle `Apprenant` avec mot de passe stocké réversible (pas de hash), comparaison manuelle dans `ApprenantAuth\AuthenticatedSessionController` avec rate limiting dédié.

**Écart volontaire par rapport au cahier des charges** : la colonne `users.name` (convention Breeze) a été conservée telle quelle plutôt que renommée en `nom`, pour éviter de modifier tout le scaffold Breeze généré (ProfileController, formulaires React, etc.) sans bénéfice fonctionnel — c'est un nom de colonne technique, couvert par la règle CLAUDE.md « noms techniques en anglais ».

### Modèle métier — points clés à connaître

- Hiérarchie staff : **Admin (TECHNIDEV, portée globale)** → **Gestionnaire (portée limitée à son centre, ex-"Responsable de centre")** → **Formateur / Secrétaire (portée limitée à leurs apprenants/inscriptions)**. **Partenaire** dispose d'un accès en lecture globale.
- **L'apprenant a désormais un compte propre** (identifiant `prenom.nom` normalisé + mot de passe 4 chiffres, générés automatiquement), avec une portée strictement limitée à sa propre fiche — ce n'est pas un rôle staff, mais ce n'est plus une simple donnée passive non plus.
- Entités principales : `centres`, `users` (staff : admin/gestionnaire/formateur/secretaire/partenaire), `formations` (catalogue global) + `chapitres` (propres à chaque centre) + `modules_formation` (leçons, rattachées à un chapitre), `apprenant_statuts` (table de référence extensible, pas un enum figé), `apprenants` (compte dédié, guard séparé, lié directement à sa `formation_id`+`centre_id`), `inscriptions` (une par épisode d'inscription, garde sa propre `formation_id` en instantané — voir §Groupe/Période/Répartition supprimés), `lecons_du_jour` (planification par le formateur, par formation+centre), `progression_modules` (une ligne **uniquement** quand une leçon est cochée — pas de pré-création), `presences` (présent/absent par jour, écrit en temps réel à la coche).
- Pas de suppression physique — l'historique est conservé pour les rapports.
- **Mot de passe apprenant stocké de façon réversible** (`Crypt::encryptString()`, pas un hash irréversible) — décision assumée pour permettre au staff du centre de dépanner un apprenant qui a oublié ses identifiants. Accès à journaliser (qui consulte le mot de passe de qui, quand).
- **Invariant critique de sécurité** : chaque ressource propre à un centre (apprenants, inscriptions, chapitres, utilisateurs staff) doit être filtrée par `centre_id` via des Policies et scopes Laravel — ne jamais faire confiance à un identifiant de ressource venant de l'URL seul. Un gestionnaire/formateur/secrétaire du centre A ne doit jamais pouvoir lire ou modifier les données du centre B ; un apprenant ne doit jamais sortir de sa propre fiche. À construire dès la Phase 1, pas en rattrapage.

### Décisions structurantes déjà actées (voir section 5 du cahier des charges pour le détail complet)
- Le catalogue de formations est global/partagé, pas propre à chaque centre.
- Le gestionnaire (ex-responsable de centre) est un rôle distinct, pas "formateur + permissions supplémentaires".
- L'accès partenaire est global (tous les centres), sans filtrage par partenaire.
- Les rapports s'exportent en PDF (`barryvdh/laravel-dompdf`) pour la v1 ; l'export Excel est reporté.
- Le dashboard partenaire utilise du rafraîchissement/polling (Inertia), pas de WebSockets, pour la v1.
- Espace apprenant : cocher les leçons du jour prédéfinies par le formateur (validation immédiate, correction possible par le formateur), saisie libre uniquement si `formations.saisie_libre_autorisee` est activé.
- `progression_modules` : création à la coche uniquement (pas de fan-out) ; leçons restantes calculées par différence.
- Durée totale du projet jugée non nécessaire à préciser par le porteur du projet — pas de planning jour par jour, les phases du cahier des charges (§6) suffisent.
- **Revirement du 2026-08-05** : auto-inscription staff **ouverte** (gestionnaire/formateur/secrétaire/partenaire — admin exclu) via `/register`, chacun choisit son propre rôle **sans validation d'un admin**. Ceci **annule** la décision initiale du cahier des charges §3.1 ("Admin crée les gestionnaires, gestionnaires créent formateurs/secrétaires", aucune auto-inscription). Le porteur du projet a été explicitement informé du risque (n'importe qui peut s'auto-attribuer un accès gestionnaire ou partenaire) et a choisi cette option en connaissance de cause plutôt que l'alternative avec validation admin proposée. ~~À ne pas re-proposer de "corriger" sans qu'il le redemande.~~ **Re-demandé et corrigé le 2026-08-29** : voir la section dédiée plus bas — l'auto-inscription reste ouverte (rôle/centre toujours choisis librement), mais un admin doit désormais valider le compte avant qu'il puisse se connecter. **Supprimée entièrement le 2026-09-24** (voir la section dédiée) : `/register` n'existe plus, seuls les comptes créés par l'admin dans `/utilisateurs` existent.
- **Revirement du 2026-08-05 (suite)** : la gestion des comptes staff (`/utilisateurs` — liste, création, modification) est désormais **réservée à l'admin**. Avant ce correctif, le gestionnaire pouvait créer/modifier des formateurs/secrétaires de son centre (`UserPolicy`) — cohérent avec l'auto-inscription désormais ouverte, qui a remplacé ce besoin. **Bug de sécurité corrigé au passage** : `UserController::index()` n'appelait jamais `$this->authorize()`, donc n'importe quel staff connecté (formateur, secrétaire, y compris partenaire en portée globale) pouvait consulter `/utilisateurs` directement par URL, pas seulement le gestionnaire — la Policy `viewAny()` existait mais n'était simplement jamais invoquée. Verrouillé par `Tests\Feature\Security\CentreIsolationTest` (`gestionnaire/formateur cannot view the users list`, `admin can view the users list`).

### Chantier « simplification du parcours admin » (2026-08-06)

Retour d'usage du porteur de projet : la notion de "groupe" mélangeait plusieurs choses et se retrouvait dispersée dans toute l'app. Traité en 6 blocs validés un par un (voir le plan complet, conservé pour référence, dans les plans Claude Code de la session), puis implémentés dans l'ordre ci-dessous — chaque étape vérifiée par `php artisan test` + `npm run build` avant de passer à la suivante.

- **Bloc 1 — Groupe/Période inchangés.** La proposition initiale de fusionner Groupe+Période en une entité "Session" a été étudiée puis **rejetée** : `Groupe` porte déjà `periode_id` → `Periode` porte déjà `formation_id`+`centre_id`, la fusion conceptuelle existait déjà dans les données. Décision : garder les noms "Groupe"/"Période" tels quels (pas de renommage), garder Période comme écran séparé, garder la répartition "répartir seulement" (distribue dans des groupes déjà créés, ne les crée pas à la volée). **Risque de nommage évité** : `SESSION_DRIVER=database` — Laravel a sa propre table `sessions` (stockage des sessions HTTP, créée dans la migration `users`) ; renommer l'entité métier "Session" aurait collisionné avec cette table système.
- **Bloc 2 — Page d'accueil avec sélection de rôle** (`Pages/Auth/SelectionRole.jsx`, nouvelle) : `/` affiche désormais, pour un visiteur non connecté, 6 tuiles avec icône (Admin/Partenaire/Gestionnaire/Formateur/Secrétaire/Apprenant) au lieu de rediriger aveuglément vers `/login`. Les 5 tuiles staff mènent à `route('login')` (formulaire déjà existant, inchangé — le rôle est déterminé par le compte en base, pas saisi à la connexion), la tuile Apprenant mène à `route('apprenant.login')`. `routes/web.php` : le fallback `redirect()->route('login')` est remplacé par `Inertia::render('Auth/SelectionRole')`, la redirection des utilisateurs déjà connectés vers leur dashboard reste inchangée.
- **Bloc 3 — Toutes les ressources admin en modale, plus de pages Create/Edit dédiées.** `Components/Modal.jsx` (Headless UI, déjà présent mais utilisé nulle part sauf la suppression de compte) généralisé à Centres, Utilisateurs, Formations, Statuts apprenant, Périodes, Groupes, Apprenants, Inscriptions, Répartition — plus `backdrop-blur-sm` sur l'overlay ("overlay flouté"). Chaque page `Index.jsx` gère désormais un état local (`useState(null | 'create' | enregistrement)`) ; le contenu de chaque `Form.jsx` a été "dénudé" (plus de `AuthenticatedLayout`/`Head`, un `onClose`/`onSuccess` en props) pour vivre dans la modale. **Les routes/méthodes `create`/`edit` ont été supprimées** (`Route::resource(...)->except([..., 'create', 'edit'])`, méthodes de contrôleur retirées) — `/centres/create`, `/groupes/{id}/edit`, etc. n'existent plus du tout, seul le clic sur "Nouveau"/"Modifier" depuis la liste ouvre la modale. Conséquence en cascade : le lien nav "Inscriptions" (`LIENS_PAR_ROLE`, `Layouts/AuthenticatedLayout.jsx`) et le bouton "Placer dans un groupe" ne peuvent plus pointer vers une page `.../create` dédiée — ils pointent maintenant vers `apprenants.index`/`inscriptions.index` avec `?action=create` en query string, que la page cible lit au montage (`new URLSearchParams(window.location.search)`) pour ouvrir la modale directement, sans liste intermédiaire. La répartition automatique (Bloc 1, inchangée dans son comportement) est devenue `Pages/Staff/Repartition/Modale.jsx`, un composant partagé ouvert soit depuis `Periodes/Index.jsx` soit depuis `Apprenants/Index.jsx` : comme les données dépendent de la période choisie (pas connues à l'avance par la page appelante), `RepartitionController::create()` renvoie désormais du JSON (`response()->json()`, plus `Inertia::render()`) récupéré via `axios` à l'ouverture de la modale — `store()` inchangé.
- **Bloc 4 — Filtre centre sur le dashboard partenaire/admin.** Constat : Rapports (écran + export PDF) et Répartition avaient déjà un filtre/scope par centre correct — seul `Partenaire\DashboardController` affichait systématiquement *tous* les centres sans possibilité d'en isoler un seul. Ajout d'un `<select>` centre (même pattern que Rapports) ; quand renseigné, la liste de centres **et** les cartes de statistiques globales en haut de page se recalculent sur ce seul centre (évite l'incohérence "totaux globaux au-dessus d'un centre affiché en dessous). Compatible avec le polling existant (`usePolling` relit l'URL courante, query string incluse).
- **Bloc 5 — Programme (chapitres/leçons) par formation, propre à chaque centre, préparable par le formateur.** **Revirement assumé sur une décision structurante déjà actée** : le catalogue de formations était "global/partagé, pas propre à chaque centre" (§5 point 1 du cahier des charges) — ça reste vrai pour la `Formation` elle-même (titre/catégorie/description/saisie libre, toujours géré par l'admin uniquement), mais son **découpage en chapitres/leçons devient propre à chaque centre** qui l'enseigne. Nouvelle table `chapitres` (`formation_id`, `centre_id`, `titre`, `ordre`, softDeletes — même pattern `centre_id` direct que `Periode`/`Apprenant`, trait `BelongsToCentreScope`). `modules_formation` gagne `chapitre_id` (le centre se déduit via `chapitre.centre_id`, comme `Groupe` le fait déjà via `periode.centre_id` — `ChecksCentreScope::resolveCentreId()` étendu avec un cas `method_exists($model, 'chapitre')`). `ModuleFormationPolicy` n'est plus admin-only : Gestionnaire/Formateur du centre du chapitre parent peuvent créer/modifier/supprimer, comme la nouvelle `ChapitrePolicy` (même structure que `GroupePolicy`). Écran `Staff/Formations/Programme.jsx` (route `formations.programme`, lien "Programme" sur `Formations/Index.jsx`, visible à tout le staff — pas seulement l'admin) construit directement en modale (Bloc 3) : liste des chapitres du centre de l'acteur (ou filtrable par centre pour admin/partenaire), chacun avec ses leçons, actions Chapitre/Leçon en modale (`ChapitreController`/`ModuleFormationController`, nouveau `StoreChapitreRequest`/`UpdateChapitreRequest`). `ModuleFormationController::store()` prend désormais `Chapitre $chapitre` en route (plus `Formation $formation`) — l'ancienne gestion inline des leçons sur `Staff/Formations/Form.jsx` a été retirée (remplacée par ce nouvel écran). `FormationSeeder` mis à jour pour créer un chapitre de démo par centre existant (les leçons de démo ne sont plus rattachées directement à la formation). Aucune donnée existante à migrer (base vidée le 2026-08-05).
- **Bloc 6 — Feuille de route complète côté apprenant, suite directe du bloc 5.** `Apprenant\DashboardController::index()` charge désormais tous les `Chapitre` de la formation/centre de l'apprenant (pas seulement ceux déjà entamés) et calcule un état par chapitre en croisant avec les `lecons_du_jour` déjà planifiées pour son groupe (même borne `whereDate('date_prevue', '<=', Carbon::today())` que la liste de leçons, pour rester cohérent avec ce qui est réellement affiché) : **à venir** (aucun module planifié), **en cours** (au moins un module planifié, pas tous cochés), **terminé** (tous les modules planifiés et cochés). `Apprenant/Dashboard.jsx` restructuré en une carte par chapitre (titre + badge d'état) ; les chapitres "à venir" n'affichent qu'un texte "Pas encore commencé", sans liste de leçons. Le pourcentage de progression global en en-tête garde son calcul existant (inchangé). La prop Inertia `lecons` (liste à plat) a été remplacée par `chapitres` (imbriqué) — tout test qui l'attendait a été adapté en conséquence.
- **Nouveaux tests** : `Tests\Feature\Auth\SelectionRoleTest` (fusionné dans `tests/Feature/ExampleTest.php` qui couvrait déjà exactement ce cas, pour éviter le doublon), `Tests\Feature\Metier\PartenaireDashboardFiltreTest`, `Tests\Feature\Metier\ChapitreProgrammeTest` (création chapitre/leçon par le bon centre, 403 croisé centre A/B, filtre `formations.programme`), extension de `Tests\Feature\Metier\ProgressionTest` (feuille de route, chapitre "à venir"). Plusieurs tests de `Tests\Feature\Security\CentreIsolationTest` qui visitaient une page `create`/`edit` désormais supprimée ont été réécrits pour exercer le même contrôle d'accès via les routes `store`/`update` qui, elles, subsistent (le comportement testé — le refus par centre — n'a pas changé, seul le chemin HTTP pour le déclencher).

### Suite du chantier — espace apprenant, suivi formateur, historique (2026-08-06, même jour)

Retour d'usage après la première passe du chantier ci-dessus : l'espace apprenant devait être redesigné dans le même esprit que le reste de l'app, le suivi formateur mieux organisé, et un besoin d'historique jusque-là non exprimé (rapports générés, répartitions effectuées).

- **Redesign de l'espace apprenant** : `Apprenant/Dashboard.jsx` n'utilisait ni `AuthenticatedLayout` (réservé au guard `web`) ni son propre wrapper cohérent — juste un `<div>` centré sur fond gris. Remplacé par une petite barre du haut inline (logo TechniDev + `Avatar` + nom + bouton déconnexion `IconLogout`, même look que `BlocUtilisateur` de la sidebar staff) suivie du contenu en cartes `rounded-lg shadow`/bleu ciel/gras, cohérent avec le reste de l'app. Pas de `Layouts/ApprenantLayout.jsx` séparé créé exprès : une seule page consommatrice, l'abstraction n'aurait rien apporté.
- **Bug corrigé : le dropdown "Planifier une leçon" sur `Staff/Groupes/Show.jsx` proposait la liste des modules à plat, tous centres confondus** — régression introduite par le bloc 5 (chapitres propres à chaque centre) et jamais corrigée à l'époque : `GroupeController::show()` chargeait encore `periode.formation.modulesFormation` (relation globale non filtrée) au lieu des chapitres du **centre de ce groupe précisément**. Corrigé : `show()` charge désormais `Chapitre::where('formation_id', ...)->where('centre_id', $groupe->periode->centre_id)`, et le dropdown React groupe les leçons par `<optgroup>` de chapitre plutôt qu'une liste plate — cohérent avec "tout doit être organisé par chapitre" répété par le porteur de projet. Verrouillé par `Tests\Feature\Metier\GroupeShowProgrammeTest`.
- **Tri alphabétique du suivi formateur** : la section "Leçons cochées par les apprenants" de `Staff/Groupes/Show.jsx` (`Progression`) affichait les coches dans l'ordre de récupération des inscriptions (non déterministe pour l'utilisateur) — triée maintenant par nom d'apprenant (`localeCompare`). Tri côté client (React), pas testable par `php artisan test` (pas de framework JS de test dans ce projet) — vérifié par lecture de code uniquement, à confirmer visuellement par le porteur de projet.
- **Nouveau : Historique** (`/historique`, lien nav pour admin/gestionnaire/formateur — même périmètre que "Rapports", pas secrétaire/partenaire qui n'y ont pas accès non plus). Deux tables immuables (`public const UPDATED_AT = null`, même pattern que `ConsultationMotDePasseApprenant`) :
  - `rapports_generes` (`RapportGenere`) : `user_id`, `centre_id`/`formation_id`/`periode_id`/`groupe_id` (nullable, les filtres du rapport), `nombre_lignes`. Journalisé dans `RapportController::export()` — pour un acteur non global qui n'a pas explicitement filtré par centre, `centre_id` est auto-résolu à son propre centre (son export l'est de toute façon implicitement via `visibleTo()`), pour que l'historique reste exact même sans filtre explicite.
  - `repartitions_effectuees` (`RepartitionEffectuee`) : `user_id`, `periode_id`, `nombre_apprenants_repartis`, `nombre_non_places`. Journalisé dans `RepartitionController::store()`, juste après l'exécution de `RepartirApprenantsEnGroupes`.
  - `Staff/HistoriqueController::index()` scope les deux listes par centre pour un acteur non global (direct pour les rapports, via `whereHas('periode', ...)` pour les répartitions puisque la table n'a pas de `centre_id` propre) — même garde `viewAny Inscription` que Rapports.
  - Hors périmètre pour l'instant (explicitement écarté par le porteur de projet à ce stade) : historiser les consultations de mot de passe apprenant dans cet écran — cette traçabilité existe déjà en base (`consultations_mot_de_passe_apprenant`, depuis la Phase 3) mais reste sans interface de consultation dédiée.

### Retours d'usage sur une capture d'inspiration (2026-08-06, encore le même jour)

Le porteur de projet a fourni une capture d'une app de référence (Fixoria, gestion hôtelière) pour s'inspirer de sa liste avec recherche/filtres/actions, et remonté un bug + une confusion de navigation en même temps.

- **Bug corrigé : le bouton d'action "•••" (`Staff/Apprenants/Index.jsx`, seul endroit du code utilisant `Components/Dropdown.jsx`) semblait "ne rien faire".** Cause réelle : `Dropdown.Content` s'ouvrait en `position: absolute` à l'intérieur du conteneur `overflow-x-auto` de `DataTable` (nécessaire pour le défilement horizontal du tableau sur mobile) — ce conteneur coupe tout contenu qui déborde verticalement, y compris un menu déroulant censé s'ouvrir par-dessus. Corrigé en profondeur dans `Dropdown.jsx` : le menu se rend maintenant via `createPortal(document.body)`, en `position: fixed` avec des coordonnées calculées au clic (`getBoundingClientRect()` du déclencheur) — il n'est donc plus jamais contraint par un ancêtre `overflow`. Un seul composant partagé à corriger, bénéficie à tout futur usage de `Dropdown` ailleurs dans l'app.
- **Lien nav "Inscriptions" retiré** (`LIENS_PAR_ROLE`, `Layouts/AuthenticatedLayout.jsx`, les 4 rôles qui l'avaient) : il menait vers `apprenants.index?action=create`, exactement la même page que le lien "Apprenants" juste au-dessus (modale d'inscription auto-ouverte) — confusion remontée explicitement ("ça se redirige vers la même page"). Le bouton "Nouvelle inscription" reste accessible directement depuis la page Apprenants elle-même ; rien n'est perdu, seule l'entrée de nav redondante disparaît.
- **Nouveau : fiche détail par apprenant** (`Staff/Apprenants/Show.jsx`, route `apprenants.show` — resource `apprenants` avait `show` explicitement exclu depuis le bloc 3, réintégré ici). Regroupe tout ce qui n'était visible qu'éparpillé (menu "•••" + infos manquantes) : informations personnelles complètes (sexe, âge, classe/niveau, école d'origine, statut, centre, inscrit par), formation/groupe en cours avec barre de progression et taux de présence, **historique de toutes les inscriptions** (pas seulement la courante). Actions (Identifiants/Régénérer/Modifier/Supprimer) consolidées en boutons dans l'en-tête plutôt qu'un menu cliquable en plus à ouvrir. Le nom dans `Apprenants/Index.jsx` est désormais un lien direct vers cette fiche (plus l'entrée "Voir la fiche" du menu "•••", qui reste aussi disponible).
  - **Piège de routing découvert au passage** : réactiver `show` sur `Route::resource('apprenants', ...)` a cassé silencieusement `GET apprenants/export-identifiants` — cette route littérale à un seul segment, déclarée *après* le `Route::resource()` dans le fichier, se faisait désormais intercepter par `apprenants.show` (`GET apprenants/{apprenant}`, enregistrée avant elle), Laravel traitant `"export-identifiants"` comme une valeur candidate pour `{apprenant}`. Symptôme repéré uniquement via la suite de tests (`IdentifiantsExportTest` a commencé à échouer), pas par une erreur explicite. Corrigé en déplaçant cette route AVANT `Route::resource(...)` dans `routes/staff.php` — **règle à retenir : toute route littérale à un seul segment sous un préfixe de resource (`apprenants/xxx`) doit être déclarée avant `Route::resource()` dès que `show` fait partie de la resource, jamais après.**
- **Nouveau : filtre par centre sur `Apprenants/Index.jsx`** (admin/partenaire uniquement, même pattern `<select>` que Rapports/Emploi du temps/Dashboard partenaire) — un acteur d'un centre continue de ne voir que le sien via `visibleTo()`, sans filtre affiché (inutile pour lui). La liste reste organisée par formation (`grouperParFormation`, déjà en place) — pas de mélange, conforme à la demande explicite du porteur de projet.
- **Nouveau : barre de recherche client-side** (`Components/SearchInput.jsx`, nouveau composant partagé) sur les 5 pages de liste les plus consultées : Apprenants (nom, identifiant), Groupes (nom, formateur), Utilisateurs (nom, email), Périodes (centre), Inscriptions (apprenant). Filtrage en mémoire sur les données déjà chargées (pas de requête serveur) — cohérent avec l'absence de pagination sur ces listes (gap déjà documenté, volume de données encore faible).
- **Nouveaux tests** : `Tests\Feature\Metier\ApprenantShowEtFiltreTest` (filtre centre, fiche détail avec bonnes données, 403 croisé centre A/B sur la fiche). Le tri alphabétique et les barres de recherche (logique 100% côté client) ne sont pas couverts par `php artisan test` — à vérifier visuellement.
- **Correctif du 2026-08-06 (suite) — le premier jet du filtre ne correspondait pas à la demande.** Après la passe ci-dessus, `Staff/Apprenants/Index.jsx` groupait toujours les apprenants en sections empilées par formation (`grouperParFormation`, une `DataTable` par formation) avec seulement un filtre centre en plus — pas de vrai filtre formation, et un rendu jugé "trop compliqué à manipuler" pour ce qui devait être une liste simple. Remplacé par : **un filtre "Formation" en `<select>`** (à côté du filtre centre et de la recherche) et **une seule `DataTable` plate** (plus de sections empilées) — la colonne "Formation" ne réapparaît que quand aucun filtre formation n'est actif (redondante sinon). Les actions "Répartir en groupes" / "Exporter les identifiants (PDF)" — qui n'ont de sens que pour UNE formation à la fois (on ne répartit/exporte jamais "toutes formations mélangées") — ne s'affichent désormais que lorsqu'une formation précise est sélectionnée dans le filtre, juste au-dessus du tableau.
- **Bug corrigé le 2026-08-06 (suite) — le filtre centre "disparaissait" en conditions réelles.** Repéré par le porteur de projet en testant avec sa vraie base (vidée le 2026-08-05, donc 0 ou 1 seul centre créé pour l'instant) : `Staff/Apprenants/Index.jsx`, `Staff/Rapports/Index.jsx`, `Staff/EmploiDuTemps/Index.jsx` et `Staff/Formations/Programme.jsx` conditionnaient tous l'affichage du `<select>` centre sur `centres.length > 0` (ou `> 1`) — un raccourci qui marche tant qu'il existe déjà plusieurs centres en base, mais qui masque le filtre pour un admin/partenaire tant que la liste des centres n'est pas encore étoffée, alors que c'est précisément quand la base vient d'être vidée que ce filtre est censé exister pour l'avenir. Corrigé en se fiant au **rôle** de l'acteur (`['admin', 'partenaire'].includes(auth.user.role)`) plutôt qu'au nombre de centres actuellement en base — le filtre s'affiche désormais dès qu'un acteur a la portée globale, même avec 0 ou 1 centre. Seul le `<select>` centre de `ChapitreForm` (choix obligatoire pour créer un chapitre) reste conditionné à `centres.length > 0` intentionnellement : sans aucun centre en base, il n'y a réellement rien à choisir, ce n'est pas un filtre optionnel.

### Revirement du 2026-08-06 (suite) — le formateur est en plus restreint à ses propres formations, pas tout son centre

Jusqu'ici, un formateur avait accès à **tout** son centre (toutes formations confondues), au même titre qu'un gestionnaire — seule sa portée de *modification* était plus restreinte par endroits (ex. `GroupePolicy::update` limité à ses propres groupes). Retour explicite du porteur de projet : "le formateur lui doit pouvoir seulement la liste de ses apprenants... un formateur n'a pas accès à tous le système mais juste ce qui concerne seulement sa formation" — avec demande explicite de revoir ça **dans l'ensemble de l'app**, pas page par page. Décisions actées avant implémentation (via questions posées à l'utilisateur) : la ou les formations d'un formateur sont **stockées explicitement sur son compte** (pas déduites de ses groupes assignés — un formateur tout juste inscrit doit pouvoir inscrire des apprenants avant même qu'un groupe existe, cohérent avec "l'inscription précède toujours la formation d'un groupe") ; un formateur **peut enseigner plusieurs formations** (relation plusieurs-à-plusieurs, pas un simple `formation_id`). Seul le rôle Formateur est concerné — gestionnaire/secrétaire restent scopés au centre entier, comme avant.

**Portée technique** :
- Nouvelle table pivot `formateur_formations` (`user_id`, `formation_id`, unique sur la paire) — `User::formations()` / `Formation::formateurs()` (`belongsToMany`).
- **`app/Models/Concerns/RestreintAuxFormationsDuFormateur.php`** (nouveau trait, pendant modèle de `ChecksFormationScope` ci-dessous) : `limiterAuxFormationsDuFormateur($query, $actor, Closure $appliquer)` — no-op pour tout rôle autre que Formateur, sinon applique la closure avec les ids de formations de l'acteur. Utilisé dans `scopeVisibleTo()` de `Apprenant`, `Periode`, `Groupe`, `Inscription` (ces deux derniers via `whereHas('periode', ...)`, même schéma que le filtrage centre existant) — **`User` n'utilise PAS ce trait** (pas de colonne `formation_id` sur `users`, l'appliquer aveuglément y casserait la requête). `Apprenant`/`Periode` définissent désormais leur propre `scopeVisibleTo()` explicite (centre + formation) au lieu de se reposer sur `BelongsToCentreScope` seul — `User` reste le seul modèle à utiliser encore ce trait tel quel.
- **`app/Policies/Concerns/ChecksFormationScope.php`** (nouveau trait, même structure que `ChecksCentreScope` — `resolveFormationId()` calque `resolveCentreId()`) : `sameFormation()`, `true` immédiat pour tout rôle autre que Formateur. Appliqué en plus de `sameCentre()` dans `ApprenantPolicy`/`GroupePolicy`/`PeriodePolicy`/`InscriptionPolicy`/`ChapitrePolicy`/`ModuleFormationPolicy` (`view`/`update`/`delete`). `ChapitrePolicy::viewAny()`/`create()` acceptent désormais une `?Formation $formation` optionnelle (pattern Laravel `can('viewAny', [Chapitre::class, $formation])`) pour vérifier l'appartenance **avant** même qu'un `Chapitre` existe — utilisé par `ChapitreController::index()` (écran Programme) et `StoreChapitreRequest::authorize()`.
- Cohérence à la source, pas seulement en lecture : `StoreGroupeRequest`/`UpdateGroupeRequest` rejettent désormais l'assignation d'un `formateur_id` à un groupe dont la période appartient à une formation qu'il n'enseigne pas (**pour tout acteur**, y compris un gestionnaire qui assignerait par erreur) — sinon le Dashboard formateur (scopé par `groupe.formateur_id`, resté inchangé) et les listes scopées par formation (`visibleTo()`) auraient pu diverger silencieusement. Même logique dans `StoreApprenantRequest`/`UpdateApprenantRequest` (un formateur ne peut inscrire/déplacer un apprenant que vers une formation qu'il enseigne) et `StoreInscriptionRequest` ("placer dans un groupe" — le groupe visé doit être d'une formation du formateur).
- Contrôleurs : `ApprenantController`/`RapportController`/`FormationController` restreignent leur liste de `formations` proposée (filtre, `<select>` de création) à `$user->formations()` pour un acteur Formateur ; `GroupeController::formOptions()` utilise désormais `Periode::visibleTo($user)` (formation comprise) pour les périodes proposées, et enrichit chaque formateur listé d'un `formation_ids` (pour le filtrage côté React) ; `EmploiDuTempsController`/`InscriptionController` remplacés en `visibleTo()` là où c'était auparavant un `when(!$global, whereHas(centre))` manuel — factorise centre + formation d'un coup. `RegisteredUserController`/`UserController` (`/utilisateurs`, admin-only) synchronisent `formation_ids` (`sync()`) à la création/modification d'un compte Formateur.
- React : `Auth/Register.jsx` et `Staff/Utilisateurs/Form.jsx` affichent des cases à cocher "Formations enseignées" quand `role === 'formateur'` (`formation_ids`, requis min. 1) ; `Staff/Utilisateurs/Index.jsx` affiche les formations d'un formateur en colonne ; `Staff/Groupes/Form.jsx` filtre en plus la liste des formateurs proposés par `formation_ids.includes(periodeSelectionnee.formation_id)` (confort client, la vraie garde est côté serveur).
- Historique (`/historique`) **non retouché** : reste centre-large pour tous les rôles y ayant accès (gap connu, accepté — `RapportGenere.formation_id` peut être `null` selon le filtre choisi à l'export, un filtrage naïf par formation y aurait exclu à tort des rapports légitimes du formateur ; à refaire proprement si le besoin se confirme).
- Tests existants ajustés (formateurs de test créés sans formation attachée = accès nul avec le nouveau scope, comportement voulu mais qui cassait des tests écrits avant ce chantier) : `ChapitreProgrammeTest`, `GroupeHoraireTest`, `IdentifiantsExportTest`, `ProgressionTest`, `RepartitionTest`, `RegistrationTest` — chacun attache désormais explicitement la formation pertinente au formateur de test via `$formateur->formations()->attach($formation->id)`. Nouveau : `Tests\Feature\Security\FormationScopeTest` (matrice formateur × formation, même centre — apprenants/groupes/inscriptions/chapitres invisibles ou 403 hors formation, assignation formateur↔groupe incohérente rejetée, inscription formateur avec/sans `formation_ids`).
- **Suite directe (même jour) — un formateur gère lui-même ses formations depuis son profil.** Retour du porteur de projet après ce chantier : « chaque utilisateur doit pouvoir éditer son profil » — creusé jusqu'à confirmer que le vrai manque concernait spécifiquement le formateur : nom/email/mot de passe se modifiaient déjà pour tous les rôles via la page Profil (scaffold Breeze, `ProfileController`), mais les formations enseignées restaient strictement admin-only (`/utilisateurs`), sans aucune vue ni action pour le formateur lui-même sur sa propre donnée la plus structurante (elle conditionne tout ce qu'il voit désormais). `ProfileController::edit()` transmet `formations` (catalogue complet) + `formationIds` (siennes) uniquement pour un acteur Formateur ; `ProfileUpdateRequest` valide `formation_ids` (requis, min 1) dans ce cas ; `ProfileController::update()` fait `$user->formations()->sync(...)` après la sauvegarde du reste. `Profile/Partials/UpdateProfileInformationForm.jsx` affiche les mêmes cases à cocher que `Register.jsx`/`Staff/Utilisateurs/Form.jsx` quand `auth.user.role === 'formateur'`. **Risque accepté en connaissance de cause, cohérent avec le précédent déjà posé pour l'auto-inscription** (rôle/centre choisis librement sans validation admin) : un formateur peut ainsi s'auto-attribuer l'accès à n'importe quelle formation du catalogue, pas seulement celles qui lui ont été confiées — le porteur de projet a explicitement demandé cette autonomie plutôt qu'un circuit de validation admin. Le rôle et le centre, eux, restent exclusivement modifiables par l'admin via `/utilisateurs`. **Piège de test découvert au passage** : `UserFactory` a `RoleStaff::Formateur` comme rôle par défaut — tout `User::factory()->create()` sans rôle explicite crée donc un formateur, et `PATCH /profile` sans `formation_ids` échoue désormais la validation ; `Tests\Feature\ProfileTest` ajusté en conséquence (formation créée + attachée avant chaque test de mise à jour de profil), plus deux tests dédiés (gestion des formations depuis le profil, erreur si aucune formation soumise).

### Groupe, Période et Répartition supprimés — inscription directe à la formation (2026-08-07)

Retour explicite du porteur de projet, dans la continuité du chantier de restriction du formateur par formation (ci-dessus) : l'app doit rester simple. Le double niveau **Formation → Période → Groupe** (une période datée contenant des groupes avec formateur/horaire/jours/capacité, dans lesquels un apprenant devait être placé après une étape de répartition manuelle ou automatique) a été jugé inutilement complexe. **Groupe, Période et Répartition n'existent plus du tout** — ni tables, ni modèles, ni contrôleurs, ni pages. Un apprenant inscrit atterrit **immédiatement** dans la liste de sa formation, plus d'état intermédiaire « en attente de répartition ».

**Modèle de données** :
- `inscriptions` perd `groupe_id`, gagne `formation_id` — un **instantané** de la formation suivie à ce moment précis (distinct de `apprenants.formation_id`, qui peut changer plus tard : préserve l'exactitude de l'historique des inscriptions passées même après un changement de formation).
- `lecons_du_jour` perd `groupe_id` — ne garde que `module_formation_id` + `date_prevue` ; formation et centre se déduisent via `module_formation → chapitre` (déjà propre à chaque centre depuis le bloc 5 du chantier précédent).
- `presences` inchangée dans sa forme, mais **écrite en temps réel** : `Actions\Progression\CocherLecon` fait désormais aussi `$inscription->presences()->updateOrCreate(['date_session' => today()], ['present' => true])` juste après avoir enregistré la coche. Plus de job nocturne (`presences:generer-du-jour` supprimé, ainsi que `CalculerPresenceJournaliere`), plus de notion de « jours prévus » par un horaire de groupe — un apprenant est présent un jour donné s'il a réellement coché quelque chose ce jour-là, point.
- `rapports_generes` perd `periode_id`/`groupe_id` (garde `centre_id`/`formation_id`).

**Inscription automatique** : `Actions\Apprenants\CreateApprenantAccount` crée désormais l'`Inscription` (`statut = en_cours`) dans la foulée de l'`Apprenant`, plus d'étape séparée de placement. Si `formation_id` change à l'édition (`ApprenantController::update()`), l'inscription active est close (`statut → abandonne`) et une nouvelle est ouverte pour la nouvelle formation. « Marquer abandonnée » (ex-`InscriptionController::destroy`) est désormais une action sur la fiche apprenant : `PATCH apprenants/{apprenant}/abandonner`.

**Ce qui a disparu** : modèles `Groupe`/`Periode`/`RepartitionEffectuee` ; contrôleurs `GroupeController`/`PeriodeController`/`RepartitionController`/`EmploiDuTempsController`/`InscriptionController` ; leurs Requests/Policies ; Actions `RepartirApprenantsEnGroupes`/`DetectHoraireConflict`/`CalculerPresenceJournaliere` + règle `NoHoraireOverlapRule` ; commande `presences:generer-du-jour` ; pages React `Staff/{Groupes,Periodes,Repartition,EmploiDuTemps,Inscriptions}/*` ; vue PDF `identifiants-groupe.blade.php` ; seeders `PeriodeSeeder`/`GroupeSeeder`/`InscriptionSeeder` (ce dernier redondant : `ApprenantSeeder` crée déjà l'inscription via `CreateApprenantAccount`) ; utilitaire `Utils/grouperParFormation.js` (plus aucun appelant) ; enum `StatutPeriode` ; tableau « Répartitions effectuées » de l'Historique ; entrées de nav Périodes/Groupes/Emploi du temps.

**Ce qui a été ajouté ou étendu** :
- **Planification de la leçon du jour** déplacée sur `Staff/Formations/Programme.jsx` (bouton « Planifier une leçon », `<select>` de modules groupés par chapitre en `<optgroup>`) — `LeconDuJourController::store()` prend `Formation $formation` en route, autorisé via `ChapitrePolicy::create` (même garde centre+formation que la création d'un chapitre). Décoche formateur (`Staff\ProgressionController::destroy`) autorisée via `ChapitrePolicy::update` sur `$leconDuJour->moduleFormation->chapitre`.
- **Retour d'usage du 2026-08-07 — bouton « Nouveau chapitre » retiré, fondu dans « Planifier un cours ».** Constat : ce bouton en tête de page, séparé de la planification, n'était pas jugé utile isolément. `Staff/Formations/Programme.jsx` : le bouton « Nouveau chapitre » (et son accès à `ChapitreForm` en mode création) disparaît de l'en-tête ; `ChapitreForm` devient édition seule (déclenché uniquement par « Modifier » sur un chapitre existant). Le seul bouton d'en-tête restant, « Planifier un cours », reprend le style plein `bg-sky-600` (au lieu du style secondaire blanc) pour rester bien visible. La modale de planification (`PlanifierCoursForm`, ex-`PlanifierLeconForm`) couvre désormais tout le flux : `<select>` de chapitre existant **ou** bascule « + Nouveau chapitre » (titre libre, + `<select>` centre si acteur à portée globale) ; une fois un chapitre choisi/créé, `<select>` de leçon existante de ce chapitre **ou** bascule « + Nouvelle leçon » (titre libre) ; toujours forcé sur « nouvelle leçon » si le chapitre est neuf ou n'a encore aucune leçon. `StoreLeconDuJourRequest`/`LeconDuJourController::store()` acceptent désormais `chapitre_id`|`nouveau_chapitre_titre` et `module_formation_id`|`nouveau_module_titre` en plus de `date_prevue` — création du chapitre et/ou du module dans une transaction DB si besoin (`ordre` auto-calculé via `max('ordre')+1`), puis création de la `LeconDuJour`, avant de vérifier au passage (`withValidator`) que tout chapitre/module choisi existant appartient bien à la formation et au centre de l'acteur — même garde que l'ancien flux, désormais aussi appliquée au chapitre. `chapitres.store`/`ChapitreController::store()` restent en place (testés, potentiellement réutilisables) mais ne sont plus atteignables depuis cette UI.
- **Bug critique corrigé le 2026-08-07 — « Planifier un cours » ne créait silencieusement rien de nouveau, dès qu'au moins un chapitre existait déjà.** Signalé par le porteur de projet : le chaînon formateur planifie → apprenant voit/coche n'était « pas fonctionnel ». Cause : dans `PlanifierCoursForm` (`Staff/Formations/Programme.jsx`), `useForm()` initialise `chapitre_id`/`module_formation_id` avec le premier chapitre/module existant (`chapitres[0]?.id`) — en cliquant « + Nouveau chapitre »/« + Nouvelle leçon », l'UI bascule bien sur les champs texte, mais **ces deux ids restaient dans `data`** (jamais vidés) et partaient donc dans la requête **en plus de** `nouveau_chapitre_titre`/`nouveau_module_titre`. Côté serveur, `LeconDuJourController::store()` et `StoreLeconDuJourRequest` donnaient la priorité à `chapitre_id`/`module_formation_id` dès qu'ils étaient renseignés (`if ($data['chapitre_id'] ?? null)`) — le nouveau titre saisi par le formateur était donc silencieusement ignoré, et la leçon replanifiée sur le **premier chapitre/leçon existant**, sans aucune erreur visible. Les tests HTTP existants ne l'avaient jamais détecté car ils postaient directement `nouveau_chapitre_titre` seul, sans jamais simuler l'état réel du formulaire React (ids résiduels inclus) — piège classique « le contrat serveur est testé, pas le payload réel envoyé par le client ». Corrigé à deux niveaux : (1) `Programme.jsx` — `toggleNouveauChapitre()`/`toggleNouveauModule()` vident désormais explicitement `chapitre_id`/`module_formation_id` (et réciproquement `nouveau_*_titre`) à chaque bascule, et `choisirChapitre()` resynchronise `module_formation_id` sur le premier module du nouveau chapitre choisi (même bug latent si on changeait de chapitre après avoir sélectionné une leçon) ; (2) `StoreLeconDuJourRequest::withValidator()` — défense en profondeur, rejette désormais explicitement (422) tout payload où `chapitre_id`+`nouveau_chapitre_titre` (ou `module_formation_id`+`nouveau_module_titre`) sont renseignés ensemble, pour qu'un futur bug similaire échoue bruyamment plutôt que de replanifier silencieusement au mauvais endroit. Verrouillé par `Tests\Feature\Metier\ChapitreProgrammeTest::test_cannot_plan_a_cours_with_both_an_existing_and_a_new_chapitre`/`..._module`.
- **Retour d'usage du 2026-08-07 (suite) — ordre des chapitres/leçons toujours auto-incrémenté, jamais saisi à la main.** `StoreChapitreRequest`/`StoreModuleFormationRequest` n'acceptent plus du tout de champ `ordre` en entrée (retiré des règles de validation — une valeur envoyée quand même est silencieusement ignorée par `validated()`) ; `ChapitreController::store()`/`ModuleFormationController::store()` calculent systématiquement `(max('ordre') sur le même périmètre ?? 0) + 1` avant l'insertion — même logique que celle déjà en place dans `LeconDuJourController::store()` pour la création à la volée. Le champ "Ordre" a été retiré du formulaire `ModuleForm` en mode création (`Staff/Formations/Programme.jsx`) ; il reste affiché en mode édition sur `ChapitreForm`/`ModuleForm` pour permettre de réordonner manuellement un chapitre/une leçon existant. Verrouillé par `Tests\Feature\Metier\ChapitreProgrammeTest::test_chapitre_and_module_ordre_auto_increment_and_ignore_client_input`.
- **Retour d'usage du 2026-08-07 (suite) — colonne de numérotation par défaut sur toutes les listes.** `Components/DataTable.jsx` affiche désormais une colonne `#` (numéro de ligne, `rowIndex + 1`) en première position par défaut (prop `numerote`, `true` sauf désactivation explicite) — s'applique automatiquement aux 7 pages qui utilisent ce composant partagé (Apprenants, Utilisateurs, Formations, Statuts apprenant, Centres, Rapports, Présences), sans avoir à toucher chacune. **Volontairement pas de `sticky`** sur cette colonne : au défilement horizontal mobile, c'est la colonne suivante (nom, généralement) qui doit rester visible pour savoir "quelle ligne c'est" — un numéro seul ne suffit pas, elle garde donc son propre traitement `sticky` déjà en place. Décision de ne **pas** numéroter les tableaux construits à la main hors `DataTable` (dashboards par rôle, Historique, Partenaire/Dashboard) : ce sont des aperçus courts/chronologiques (activité récente, audit par date), pas des listes de référence à interroger par position — jugé non nécessaire par le porteur de projet lui-même (« les cas où ce n'est pas nécessaire... ne le fait pas »).
- **Nouvelle page « Présence du jour »** (`Staff/Presences/Index.jsx` + `Staff\PresenceController`, route `presences.index`) : filtrable par formation (+ centre pour les rôles à portée globale) et par date, liste tous les apprenants à inscription active avec un badge présent/absent lu directement dans `presences`.
- **Colonne « Activité »** sur `Staff/Apprenants/Index.jsx` (remplace l'ancienne colonne « Groupe ») : `"{coché}/{total} leçons"` par apprenant, calculé en une requête groupée (`ApprenantController::attacherActivite()`) plutôt qu'une par ligne — répond à la demande explicite du porteur de projet sur l'utilité réelle des identifiants apprenant côté formateur (« voir ce qu'il a travaillé »).
- **Export CSV**, en plus du PDF déjà en place, sans nouvelle dépendance (`fputcsv` en flux via `response()->streamDownload()`) : `apprenants.export-identifiants-csv` (liste d'une formation) et `rapports.export-csv` (rapport filtré).
- **Nouveau (2026-08-07) — export de la liste générale des apprenants** (`apprenants.export-liste`/`apprenants.export-liste-csv`, boutons « Exporter les apprenants (PDF/CSV) » toujours visibles sur `Staff/Apprenants/Index.jsx`, plus besoin de sélectionner une formation au préalable) : nom/identifiant/formation/statut/activité — **sans mot de passe** et sans journalisation de consultation, volontairement distinct de l'export « identifiants » (renommé « Exporter les identifiants (PDF/CSV) » sur cette même page pour éviter la confusion entre les deux exports désormais présents) qui, lui, reste réservé aux apprenants déjà inscrits à UNE formation choisie et journalise chaque mot de passe consulté (§2.6). Respecte les mêmes filtres que l'écran (centre pour un acteur à portée globale, formation si sélectionnée) via `ApprenantController::lignesPourListe()`, factorisé pour les deux formats. Vue PDF `pdf/liste-apprenants.blade.php` (numérotée). Verrouillé par `Tests\Feature\Metier\IdentifiantsExportTest::test_export_liste_includes_all_visible_apprenants_without_password_and_respects_centre_isolation`.
- **Correctif du 2026-08-07 — colonne « Statut inscription » du rapport incompréhensible + export PDF sans numéro.** Retour du porteur de projet : la colonne affichait la valeur technique brute (`en_cours`) au lieu d'un libellé, et se confondait avec la colonne « Statut » (statut administratif général de l'apprenant, `apprenant_statuts`) déjà présente juste avant — deux notions de « statut » différentes, l'une pour l'apprenant, l'autre pour l'épisode d'inscription en cours. `StatutInscription` gagne une méthode `label()` (même convention que `RoleStaff::label()`) utilisée par `RapportController::lignesFiltrees()` ; `Staff/Rapports/Index.jsx` renomme la colonne écran ambiguë « Statut » en « Statut inscription » pour cohérence avec le PDF/CSV. Numérotation ajoutée à l'export PDF (`pdf/rapport-apprenants.blade.php`, `$loop->iteration`) — l'écran l'avait déjà via la colonne `#` par défaut de `DataTable` (point ci-dessus), le CSV aussi (déjà fait au même moment), seul le PDF manquait.
- Dashboards (3 rôles staff + partenaire) : « Groupes actifs »/« Mes groupes » → « Formations actives »/« Mes formations » (effectif par formation, plus de notion de capacité/horaire/session du jour) ; `Partenaire\DashboardController` : `periodes_en_cours_count` → `formations_actives_count` (formations distinctes à inscription active par centre).
- `Inscription::scopeVisibleTo()` simplifié : centre via `whereHas('apprenant', ...)`, formation via `whereIn('formation_id', ...)` direct — plus de traversée `groupe.periode`. `ChecksCentreScope`/`ChecksFormationScope` (Policies) simplifiés en cascade : `resolveCentreId()` perd ses branches `periode`/`groupe`, gagne une branche `apprenant` (pour `Inscription`) ; `resolveFormationId()` se réduit à `$model->formation_id` (tous les modèles concernés le portent désormais directement).
- Tests : fichiers entièrement supprimés (`GroupeHoraireTest`, `GroupeShowProgrammeTest`, `CalculerPresenceJournaliereTest`, `RepartitionTest`) ; réécrits en profondeur (`CentreIsolationTest`, `FormationScopeTest`, `ProgressionTest`, `ApprenantScopeTest`, `RapportTest`, `ApprenantShowEtFiltreTest`, `IdentifiantsExportTest`, `InscriptionTest`, `PartenaireDashboardFiltreTest`, `DashboardRedirectTest`) ; nouveaux (`PresenceScreenTest`, présence temps réel dans `ProgressionTest`).

- **Correctif de sécurité du 2026-08-07 — page mise en cache par le navigateur donnant l'impression trompeuse que « le même compte reste connecté ».** Signalé par le porteur de projet, capture à l'appui, sur la page d'accueil (`Auth/SelectionRole.jsx`, sélection de rôle) : sur un poste partagé (centre multimédia), après déconnexion, le bouton « Précédent » du navigateur pouvait resservir une page encore authentifiée depuis son cache local (bfcache) — cliquer une tuile de rôle sur cette page redonnait alors accès au compte précédent sans repasser par le formulaire de connexion, comme si "le même compte prenait toujours la main". Ce n'était pas un bug de routing (`/` redirige déjà correctement un utilisateur authentifié vers son tableau de bord, cette redirection n'a pas changé) mais un problème de mise en cache navigateur. Corrigé une fois pour toutes par un nouveau middleware **`App\Http\Middleware\PreventStaleAuthenticatedPages`**, ajouté au groupe `web` global (`bootstrap/app.php`) — donc sur toute l'app (staff, apprenant, pages publiques) — qui force `Cache-Control: no-store, no-cache, must-revalidate` + `Pragma: no-cache` sur chaque réponse ; le navigateur ne peut alors plus mettre une page en cache local, et « Précédent » déclenche toujours une vraie requête serveur qui reflète l'état réel de la session. Vérifié par requête HTTP directe (`curl -I`), pas par un test PHPUnit dédié (comportement de cache navigateur, pas de logique métier). **Au même moment** : le lien « Créer un compte » retiré de `Auth/SelectionRole.jsx` (demande explicite du porteur de projet) — l'auto-inscription staff reste possible via `/register` (lien encore présent sur `Login.jsx`), simplement plus mis en avant sur cette page d'accueil.
- **Nouveau (2026-08-07) — raccourci flottant vers l'espace apprenant sur les pages d'authentification staff.** `Layouts/GuestLayout.jsx` (connexion, inscription, mot de passe oublié, etc. — toute page staff non connectée) affiche désormais un bouton rond flottant en bas à droite (icône `IconUserCircle`, déjà présente dans `Components/Icons.jsx`, même style que l'icône fournie par le porteur de projet) menant directement à `route('apprenant.login')` — évite d'obliger un apprenant à repasser par `/` (`Auth/SelectionRole.jsx`) pour trouver son espace. Placé au niveau du layout partagé plutôt que sur `Login.jsx` seul : apparaît sur toutes les pages d'auth staff, coût nul à maintenir. **Devenu redondant, retiré au même moment** : le lien texte « Vous êtes apprenant ? Accéder à l'espace apprenant » qui vivait spécifiquement en bas de `Login.jsx` — le bouton flottant du layout couvre désormais ce besoin partout, pas seulement sur cette page.
- **Bug critique corrigé le 2026-08-07 — pourcentage de progression pouvant dépasser 100% (signalé à 200% par le porteur de projet).** Cause : `Chapitre`/`ModuleFormation` utilisent `SoftDeletes` (« pas de suppression physique », historique conservé) — mais soft-supprimer un chapitre/module n'est qu'un `UPDATE` (`deleted_at`), donc la cascade DB `cascadeOnDelete()` posée sur `modules_formation.chapitre_id`/`lecons_du_jour.module_formation_id`/`progression_modules.lecon_du_jour_id` ne se déclenche **jamais** dans ce cas (elle ne réagit qu'à un vrai `DELETE`). Résultat : les lignes `ProgressionModule` (les coches) d'un chapitre/module supprimé restaient physiquement en base et continuaient d'être comptées au numérateur (`$inscription->progressionModules->count()`), alors que le dénominateur (`LeconDuJour::whereHas('moduleFormation.chapitre', ...)`) les excluait déjà correctement grâce au scope automatique d'Eloquent sur les relations soft-deletables — d'où un ratio qui pouvait dépasser 100%. Corrigé par une nouvelle relation **`Inscription::progressionModulesActives()`** (`progressionModules()->whereHas('leconDuJour.moduleFormation.chapitre')`) à utiliser **systématiquement** à la place de `progressionModules()` brute pour tout calcul de progression — remplacée dans les 4 endroits concernés : `Apprenant\DashboardController` (tableau de bord + coche), `Staff\ApprenantController` (`pourcentageProgression()`, `attacherActivite()` — dont la jointure SQL brute de `totalLeconsParFormationCentre` gagne aussi `whereNull('modules_formation.deleted_at')`/`whereNull('chapitres.deleted_at')`, une jointure manuelle ne bénéficiant pas du scope automatique d'Eloquent), `Staff\RapportController`. Verrouillé par `Tests\Feature\Metier\ProgressionTest::test_progression_percentage_never_exceeds_100_after_a_chapitre_is_deleted`. **Règle à retenir** : toute nouvelle requête comptant des `ProgressionModule` doit passer par `progressionModulesActives()`, jamais par `progressionModules()` directement.
- **Bug critique corrigé le 2026-08-07 (suite, même cause racine) — 500 sur le tableau de bord formateur/gestionnaire dès qu'une leçon cochée pointait vers un module supprimé.** Conséquence directe du même trou (soft delete ne déclenche pas la cascade DB, voir ci-dessus) : `DashboardController::activiteRecente()` (fil « Activité récente ») accédait à `$pm->leconDuJour->moduleFormation->titre` sans jamais vérifier que `moduleFormation` existait encore — `ErrorException: Tentative de lire la propriété « titre » sur null` dès qu'un chapitre/module contenant une leçon déjà cochée était supprimé (page Whoops complète en capture d'écran, `app\Http\Controllers\Staff\DashboardController.php:171`). Corrigé en deux temps : (1) l'eager load de `moduleFormation` gagne `->withTrashed()` pour continuer à afficher le **vrai** titre historique (« pas de suppression physique », cohérent avec le reste de l'app) plutôt qu'un texte générique ou un crash ; (2) chaînage défensif (`?->`, avec fallback `'Leçon supprimée'`/`'—'`) sur toute la chaîne `leconDuJour→moduleFormation`/`inscription→apprenant`/`inscription→formation` pour ne plus jamais planter, y compris dans l'hypothèse (normalement impossible via l'app, mais pas à exclure) d'une relation totalement introuvable. **Même durcissement appliqué par prévention aux 3 autres endroits qui déréférençaient `moduleFormation->chapitre`/`->formation` sans garde** (le même `ErrorException` aurait pu s'y produire dès qu'une action était tentée sur une donnée dont le chapitre/module a depuis été supprimé) : `Apprenant\ProgressionController::store()` (coche), `Staff\ProgressionController::destroy()` (décoche — route existante mais actuellement sans bouton dans l'UI, gap déjà noté, pas traité ici), `Staff\LeconDuJourController::destroy()` (retirer une leçon planifiée) — les trois lèvent désormais un 404 propre (`abort_if(! $chapitre, 404)`) plutôt que de risquer un 500 si le chapitre/module a disparu entre le chargement de la page et le clic. Verrouillé par `Tests\Feature\DashboardRedirectTest::test_formateur_dashboard_does_not_crash_when_a_recently_checked_lecon_module_was_deleted`.
- **Nouveau (2026-08-07) — leçons déjà validées repliées par défaut côté apprenant.** `Apprenant/Dashboard.jsx` (`ChapitreCard`) sépare désormais les modules d'un chapitre en « restants » (affichés directement, y compris les leçons pas encore programmées) et « déjà validés » (repliés sous un bouton « Voir les N leçon(s) déjà validée(s) ») — évite que l'historique s'accumule visuellement au fil du temps (demande du porteur de projet). Purement un changement d'affichage React : le calcul du pourcentage de progression (côté serveur, `progressionModulesActives()` ci-dessus) n'y est pas sensible, aucun test backend à adapter.
- **Nouveau (2026-08-07) — modale « Planifier un cours » élargie.** `Components/Modal.jsx` gagne une taille `3xl` (`sm:max-w-3xl`, au-delà du `2xl` qui était jusque-là le maximum disponible) ; `Staff/Formations/Programme.jsx` l'utilise pour la modale de planification (demande du porteur de projet — le formulaire choisi/tapé « leçon » restait à discuter mais le fonctionnement actuel — liste déroulante existante OU bouton « + Nouvelle leçon » séparé — est conservé tel quel, seul l'espace disponible a changé).

### Organisation du code (repères de navigation)
- **Refonte design du 2026-08-05, sur inspiration de 3 captures fournies par l'utilisateur** (FlowMail, Nucleus, meetmind.ai) — direction commune retenue : sidebar gauche avec icônes (pas de nav horizontale), cartes arrondies à ombre douce, avatar en cercle (initiales, pas de vraie photo) plutôt qu'un simple nom en texte, boutons pleins sans majuscules ("Se connecter" pas "SE CONNECTER" — l'ancien style venait tel quel du scaffold Breeze par défaut, jamais personnalisé).
  - `Components/Icons.jsx` : petite bibliothèque d'icônes SVG maison (traits fins, 24×24) — pas de dépendance ajoutée (`@heroicons/react` etc.), juste pour éviter d'alourdir `package.json` pour une douzaine de pictos simples.
  - `Components/Avatar.jsx` : cercle coloré avec initiales, couleur dérivée d'un hash du nom (pas de vraie photo de profil dans l'app).
  - `Layouts/AuthenticatedLayout.jsx` : entièrement réécrit en sidebar fixe (desktop, `lg:flex`) + tiroir coulissant avec overlay (mobile, remplace l'ancien dropdown sous le header). Chaque lien de `LIENS_PAR_ROLE` porte désormais une icône (3ᵉ élément du tableau). Bloc utilisateur en bas de sidebar : `Avatar` + nom + libellé de rôle (lien direct vers `Profil`) + bouton déconnexion séparé — **volontairement pas un `Dropdown`** ici : le composant `Dropdown` partagé ouvre toujours vers le bas (`mt-2`), ce qui aurait pu sortir de l'écran pour un déclencheur tout en bas de la sidebar. `NavLink.jsx`/`ResponsiveNavLink.jsx` (scaffold Breeze, plus utilisés après la réécriture) supprimés.
  - `Layouts/GuestLayout.jsx` : écran scindé (panneau de marque + formulaire), inspiré de Nucleus — **pas de vraie photo** (on ne met pas en scène une personne réelle/fictive sur une plateforme TECHNIDEV).
  - `Components/{PrimaryButton,SecondaryButton,DangerButton}.jsx` : `bg-gray-800`/majuscules/`tracking-widest` (scaffold Breeze non retouché jusqu'ici) remplacés par des boutons pleins couleur d'accent/texte normal/`rounded-lg` — seul `DangerButton` garde le rouge. `TextInput.jsx` et tous les `<select>`/`<textarea>` inline des pages (23 occurrences, remplacement global) passés de `rounded-md` à `rounded-lg` pour rester cohérents avec les boutons.
  - **Limite assumée, comme pour l'audit responsive** : aucun outil de capture d'écran/navigateur n'est disponible dans cet environnement — ce chantier est vérifié par build + tests + `curl` (pages qui se chargent sans erreur 500, bons composants/props Inertia), pas par un rendu visuel réel. À confirmer par l'utilisateur avec des captures s'il y a un problème visuel après coup.
- **Refonte design du 2026-08-05 (suite) — retouches précises demandées après la première passe** :
  - **Couleur d'accent passée d'indigo à bleu ciel (`sky-*`)** sur toute l'app : remplacement global `indigo-` → `sky-` (56 occurrences, 31 fichiers — boutons, liens, focus ring, état actif de la sidebar, etc.). Cas particulier `Components/Avatar.jsx` : sa palette de rotation à 6 couleurs contenait déjà une entrée `sky` (pour la variété visuelle entre avatars) — le remplacement aurait créé un doublon et réduit la palette à 5 teintes distinctes ; l'ancienne entrée indigo a donc été remplacée à la main par `teal` plutôt que par le sed global. `Layouts/GuestLayout.jsx` : dégradé `from-indigo-700 via-indigo-600 to-violet-700` remplacé par un dégradé entièrement bleu (`from-sky-600 via-sky-600 to-blue-700`), le violet ne collait plus à une identité "bleu ciel".
  - **`Layouts/GuestLayout.jsx` — panneau de gauche simplifié** : suppression du wordmark texte "TechniForm" et de l'accroche/citation en français ; ne reste que le logo TechniDev (`ApplicationLogo`), posé sur une plaque blanche arrondie avec ombre pour rester lisible sur fond dégradé bleu — plus de texte du tout sur ce panneau, sur demande explicite.
  - **Textes en gras pour plus de visibilité** : titres de page (`header` des `AuthenticatedLayout`, motif `text-xl font-semibold text-gray-800` répété sur 23 pages) passés à `text-xl font-bold text-gray-900` (remplacement global) ; valeurs chiffrées des cartes statistiques des tableaux de bord (`text-2xl font-semibold` → `text-2xl font-bold text-gray-900`) ; titre `title` de `GuestLayout` passé à `font-bold` ; en-tête de l'espace apprenant (`Apprenant/Dashboard.jsx`) aligné pareil.
  - **Formulaires élargis pour mieux occuper l'espace disponible** : conteneurs `mx-auto max-w-lg`/`max-w-xl` (trop étroits vus l'espace écran dispo) des pages `Staff/{Utilisateurs,Formations,Centres,Inscriptions,Periodes,Apprenants,Groupes}/Form.jsx` passés à `max-w-2xl`. Formulaire de connexion (`GuestLayout`) élargi de `max-w-sm` à `max-w-md`.
- **`resources/js/Utils/grouperParFormation.js`** : utilitaire partagé (extrait le 2026-08-05, sur demande explicite de ne rien afficher "mélangé") qui regroupe une liste par formation plutôt qu'un tableau unique — utilisé par `Staff/{Apprenants,Groupes,Periodes,Inscriptions}/Index.jsx`. Prend un accesseur `(item) => item.formation` (chemin différent selon la page : `a.formation`, `g.periode?.formation`, `p.formation`, `i.groupe?.periode?.formation`) et renvoie `{ sansFormation, parFormation }`, `parFormation` étant `[{ formation, items }]` trié par titre. Chaque page rend une `<section>` par formation avec sa propre `DataTable` plutôt qu'un tableau global mélangeant toutes les formations — la colonne "Formation" devient alors redondante (c'est le titre de section) et a été retirée des colonnes de `Groupes`/`Périodes`/`Inscriptions`.
- `app/Enums/` : `RoleStaff` (avec `isGlobalScope()` pour admin/partenaire), `StatutCentre`, `StatutPeriode`, `StatutInscription`, `ActionConsultation`.
- `app/Models/Concerns/BelongsToCentreScope.php` : trait `scopeVisibleTo()` pour les modèles avec `centre_id` direct — **seul `User` l'utilise encore tel quel** depuis le revirement du 2026-08-06 (restriction par formation, voir plus haut) ; `Apprenant`/`Periode` définissent maintenant leur propre `scopeVisibleTo()` (centre + formation via `RestreintAuxFormationsDuFormateur`). Les modèles sans `centre_id` direct (`Groupe`, `Inscription`) définissent aussi leur propre `scopeVisibleTo()` via `whereHas(...)`, formation comprise.
- `app/Policies/Concerns/ChecksCentreScope.php` : trait `sameCentre()` réutilisé par les Policies dont l'accès dépend du centre (`Periode`, `Groupe`, `Apprenant`, `Inscription`) — résout le `centre_id` d'un modèle qu'il soit direct ou via relation (`periode`/`groupe`). `UserPolicy` ne l'utilise plus depuis le verrouillage admin-only (voir plus haut) — plus besoin de comparer les centres quand personne d'autre que l'admin n'a accès. Chaque Policy a un `before()` qui laisse passer l'admin.
- `app/Observers/UserObserver.php` : synchronise `users.role` vers spatie/laravel-permission (`syncRoles`) à la création **et** à la mise à jour — enregistré dans `AppServiceProvider::boot()`.
- Routes séparées par audience, toutes enregistrées depuis `bootstrap/app.php` (`then:`) : `routes/apprenant.php` (guard `apprenant`), `routes/staff.php` (guard `web`), `routes/partenaire.php` (guard `web`, accès restreint vérifié dans le contrôleur — pas de guard dédié, le partenaire est un `User` staff comme les autres). `routes/auth.php` ne contient plus `register` depuis le 2026-09-24 — l'apprenant, lui, n'a **toujours** aucune route d'auto-inscription.
- ~~`RegisteredUserController` + `StoreRegistrationRequest`~~ : **supprimés le 2026-09-24** avec `/register` (plus d'auto-inscription staff). Les comptes staff se créent uniquement via `/utilisateurs` (admin), avec identifiant et/ou e-mail.
- `app/Http/Controllers/ApprenantAuth/` et `app/Http/Controllers/Apprenant/` : authentification et espace personnel apprenant (guard dédié, jamais de Policy — la ressource est toujours dérivée de `Auth::guard('apprenant')->user()`).
- **`app/Http/Controllers/Controller.php`** utilise le trait `AuthorizesRequests` (plus inclus par défaut depuis Laravel 11) — nécessaire pour que `$this->authorize()` fonctionne dans tous les contrôleurs staff.
- `app/Http/Controllers/Staff/` : un seul jeu de contrôleurs par ressource (Centre, User, Formation, Chapitre, ModuleFormation, ApprenantStatut, Apprenant, LeconDuJour, Presence, Rapport, Historique) — le scope est géré par Policy + `visibleTo()`, pas par arborescence de routes dupliquée par rôle.
- Attention route-model-binding : le nom du paramètre dans la signature du contrôleur doit correspondre exactement au paramètre de route généré par `Route::resource()` (ex. `utilisateurs` → `{user}` via `->parameters(['utilisateurs' => 'user'])`, sinon Laravel ne résout pas le modèle). Les ressources non conventionnelles (`ApprenantStatut`, `ModuleFormation`) sont déclarées en routes explicites plutôt qu'en `Route::resource()` pour éviter toute ambiguïté d'inflexion.
- Composants React partagés : `Components/DataTable.jsx` (tableau générique colonnes/actions), `Components/FlashStatus.jsx` (bannière de statut après redirection, alimentée par `HandleInertiaRequests::share()` → `flash.status`).
- Navigation staff (`Layouts/AuthenticatedLayout.jsx`) pilotée par rôle via la constante `LIENS_PAR_ROLE` — confort UX uniquement, la sécurité réelle reste dans les Policies.
- `app/Actions/Apprenants/` : `GenerateApprenantIdentifiant` (normalisation + collision via `Apprenant::withTrashed()`), `GenerateApprenantPassword` (4 chiffres), `CreateApprenantAccount` (orchestre les deux + `Crypt::encryptString()` + crée l'`Inscription` en cours, renvoie le mot de passe en clair une seule fois à l'appelant). `app/Actions/Securite/` : `ConsulterMotDePasseApprenant`/`RegenererMotDePasseApprenant` (déchiffrement + journalisation dans `consultations_mot_de_passe_apprenant`, exposées en JSON via `ApprenantCredentialController` — jamais rendues dans une page Inertia classique, `Cache-Control: no-store`). `app/Actions/Progression/` : `CocherLecon` (restaure une ligne soft-supprimée plutôt que d'en recréer une, écrit aussi `presences` en temps réel) / `DecocherLecon` (soft delete).
- Espace apprenant (`app/Http/Controllers/Apprenant/ProgressionController.php`) : la portée est garantie en dérivant systématiquement l'inscription via `Auth::guard('apprenant')->user()->inscriptions()->where('statut', EnCours)->where('formation_id', $leconDuJour->moduleFormation->chapitre->formation_id)->firstOrFail()` (+ vérification que le centre du chapitre correspond au centre de l'apprenant) — vérifié qu'une leçon d'une formation où l'apprenant n'est pas inscrit renvoie bien 404, pas juste refusé silencieusement.
- Piège table name : tout modèle dont le nom de classe ne se pluralise pas trivialement en snake_case doit déclarer `protected $table` explicitement (ex. `LeconDuJour` → `lecons_du_jour`, sinon Eloquent cherche `lecon_du_jours`). Déjà fait pour `ModuleFormation`, `LeconDuJour`, `ConsultationMotDePasseApprenant`.
- Casts de date : utiliser `'date:Y-m-d'` (pas juste `'date'`) sur toute colonne `date` affichée dans un `<input type="date">` React ou une liste — sinon Eloquent sérialise en ISO datetime complet (`2026-08-05T00:00:00.000000Z`) et casse le pré-remplissage du champ. Appliqué à `Presence`, `LeconDuJour`. **Attention, ce cast ne protège que la LECTURE** — voir le piège `where()` vs `whereDate()` ci-dessous, découvert le 2026-08-05 : il ne tronque jamais l'heure à l'écriture, contrairement à ce qu'on pourrait supposer.
- **Piège découvert le 2026-08-05 — `where('date_x', ...)` sur une colonne `date:Y-m-d` peut silencieusement exclure la bonne ligne ; utiliser `whereDate()`.** Le cast `date:Y-m-d` ne s'applique qu'à la LECTURE (accès à l'attribut) — Eloquent ne le reconnaît pas comme un cast "date" au sens de `isDateCastable()` (c'est un `custom_datetime` en interne), donc `setAttribute()` n'appelle jamais `fromDateTime()` à l'écriture : une valeur passée comme objet Carbon (ex. `now()`, comme le font les seeders) est stockée avec son heure d'origine, pas juste la date. **MySQL masque ce problème** (colonne `DATE` native qui tronque l'heure au niveau moteur au INSERT et à la comparaison), ce qui l'a caché en conditions réelles — mais **SQLite (utilisé par les tests, `DB_DATABASE=:memory:` dans `phpunit.xml`) ne tronque rien**, stocke la chaîne telle quelle, et une comparaison `where('date_x', '<=', Carbon::today())` (minuit) exclut alors à tort toute ligne écrite aujourd'hui avec une heure non nulle. Corrigé dans `Apprenant\DashboardController` (leçons du jour) et `CalculerPresenceJournaliere` (`date_debut`/`date_fin` d'une période) en remplaçant `where()` par `whereDate()`, qui extrait la partie date des deux côtés de la comparaison quel que soit le moteur — **toujours utiliser `whereDate()`, jamais `where()`, pour comparer une colonne `date:Y-m-d` à un Carbon**, y compris pour du nouveau code.
- **Bug corrigé le 2026-08-05 : le tableau de bord apprenant limitait les leçons affichées à une fenêtre glissante de 7 jours.** Une leçon planifiée par le formateur au-delà de cette fenêtre (rattrapage, reprise après pause...) disparaissait **définitivement** de l'espace apprenant, alors qu'elle comptait toujours dans `$totalLecons` (dénominateur de `progression_pourcentage`) — la progression ne pouvait alors plus jamais atteindre 100%, sans que personne ne le sache. Trouvé en testant en conditions réelles (planification d'une leçon à J-11, absente du dashboard apprenant). Corrigé : `Apprenant\DashboardController::index()` affiche désormais toutes les leçons jusqu'à aujourd'hui, sans borne basse. Verrouillé par `Tests\Feature\Metier\ProgressionTest::test_apprenant_dashboard_shows_lecons_older_than_seven_days`.
- `app/Http/Controllers/Staff/RapportController.php` : `index()` affiche un tableau à l'écran (filtres formation/centre, scoping `visibleTo()`), `export()`/`exportCsv()` génèrent le même jeu de données en PDF (`Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.rapport-apprenants', ...)->download(...)`) et en CSV (`fputcsv` en flux, `response()->streamDownload()`). Liens `<a href>` directs côté React (pas un `router.get` Inertia) car ce sont des téléchargements binaires — piège Inertia classique à éviter.
- `app/Http/Controllers/Partenaire/DashboardController.php` : lecture globale, accès vérifié par `abort_unless(in_array(role, [Partenaire, Admin]))` en début de méthode (pas de Policy dédiée, un seul contrôleur). `resources/js/Hooks/usePolling.js` : `setInterval` + `router.reload({ only, preserveScroll: true, preserveState: true })` — réutilisable pour tout futur écran qui a besoin d'un rafraîchissement périodique.
- **`app/Http/Controllers/Staff/DashboardController.php`** (route `/dashboard`) : un tableau de bord distinct par rôle plutôt qu'un stub générique. Admin/Partenaire (`role->isGlobalScope()`) sont redirigés vers `partenaire.dashboard` (vue multi-centres, inchangée) ; gestionnaire/formateur/secrétaire ont chacun leur propre vue rendue par ce contrôleur (`Staff/Dashboard/{Gestionnaire,Formateur,Secretaire}.jsx`) : gestionnaire voit les formations actives de son centre + effectif staff, formateur voit uniquement **ses** formations (`$user->formations()`) + effectif, secrétaire voit les inscriptions récentes + répartition des apprenants par statut (portée `apprenants`/`inscriptions` uniquement, cohérent avec `LIENS_PAR_ROLE`).
- **Correctif du 2026-08-05 : l'inscription se fait par formation, jamais directement par groupe** (cahier des charges §2.5 "un apprenant suit une seule formation à la fois" / §2.8 "les inscriptions se faisant déjà par niveau et par type de formation"). Avant ce correctif, `apprenants.formation_id` n'existait pas du tout : la seule façon de savoir "quelle formation suit cet apprenant" était de traverser `inscription.groupe.periode.formation`, ce qui obligeait à connaître le groupe **avant** l'inscription — l'inverse du flux voulu, et le formulaire manuel `Staff/Inscriptions/Create` laissait inscrire n'importe quel apprenant dans n'importe quel groupe, toutes formations confondues. Corrigé par :
  - `apprenants.formation_id` (nullable en base, `constrained('formations')->nullOnDelete()`, migration `create_apprenants_table` éditée directement — projet toujours pré-production) — choisi **à la création** de l'apprenant (`Staff/Apprenants/Form.jsx`), pas à l'inscription en groupe.
  - `Staff/Apprenants/Index.jsx` : liste regroupée par formation (même pattern que `Utilisateurs/Index.jsx` par centre) — la colonne "Formation en cours" (dérivée de l'inscription, vide tant que non groupé) est remplacée par une colonne "Groupe" (`En attente de répartition` si aucune inscription active).
  - `RepartitionController::create()` : ne propose plus que les apprenants dont `formation_id` correspond à la formation de la `Periode` en cours de répartition (avant : tous les apprenants du centre, toutes formations mélangées — bug corrigé au passage).
  - `Staff/Inscriptions/Form.jsx` (placement manuel d'UN apprenant dans un groupe précis, cas plus rare que la répartition en masse) : le choix de l'apprenant filtre désormais dynamiquement, côté React, la liste des groupes proposés à ceux de sa formation. Doublé d'une validation serveur dans `StoreInscriptionRequest` (`$apprenant->formation_id !== $groupe->periode->formation_id` → erreur) — la protection réelle est côté serveur, le filtrage React n'est que du confort.
  - `ApprenantSeeder`/`InscriptionSeeder` : la moitié des apprenants de démo par centre a `formation_id` = Informatique, l'autre moitié = Alphabétisation, cohérent avec leur répartition ultérieure en groupes.
- **Correctif du 2026-08-05 (suite) — vocabulaire "inscription" clarifié dans l'UI.** Même après l'ajout de `formation_id`, l'utilisateur a signalé que la page **Inscriptions** ne proposait toujours pas de "vrai formulaire d'inscription" : son bouton "Nouvelle inscription" ouvrait l'écran `apprenant_id` + `groupe_id` (deux `<select>`, aucune information personnelle) plutôt que la fiche complète — parce que le formulaire complet (toutes les infos + formation) vivait sous **Apprenants**, une section différente. Recadrage **sans nouveau contrôleur ni duplication de formulaire** :
  - `Staff/Apprenants/Form.jsx` (création) est désormais **le** formulaire d'inscription : titre "Nouvelle inscription", bouton "Inscrire" — c'est le formulaire déjà complet (toutes les infos + formation) qui existait déjà, juste renommé pour refléter ce qu'il est réellement.
  - Le bouton "Nouvelle inscription" de `Staff/Inscriptions/Index.jsx` pointe maintenant vers `route('apprenants.create')` (même contrôleur `ApprenantController`, pas de doublon).
  - L'ancien écran `apprenant_id`+`groupe_id` (`InscriptionController@create`, route `inscriptions.create`) est conservé — il sert toujours à placer manuellement un apprenant déjà inscrit mais pas encore réparti dans un groupe précis — mais renommé partout en **"Placer dans un groupe"**, positionné en action secondaire à côté de "Nouvelle inscription", jamais comme action principale.
  - `Staff/Inscriptions/Index.jsx` affiche désormais une note expliquant que cette liste ne montre que les apprenants **déjà groupés** — ceux inscrits mais en attente de répartition sont visibles dans **Apprenants** (`En attente de répartition`), pour éviter que quelqu'un cherche en vain un apprenant tout juste inscrit dans cette liste.
- **Correctif du 2026-08-05 (suite) — le lien nav "Inscriptions" ouvrait encore la mauvaise page.** Même après le recadrage précédent, `LIENS_PAR_ROLE` (`AuthenticatedLayout.jsx`) pointait toujours "Inscriptions" vers `inscriptions.index` (la liste des apprenants déjà groupés) au lieu du formulaire — donc cliquer sur "Inscriptions" dans la barre de nav montrait encore une liste, pas un formulaire. Corrigé : le lien nav "Inscriptions" pointe maintenant vers `apprenants.create` pour les 4 rôles concernés (admin/gestionnaire/formateur/secrétaire) ; la liste complète reste accessible via le lien nav "Apprenants" (inchangé), et la liste des groupes/réassignations via un lien "Suivi des groupes & réassignations" sur la page Apprenants.
- **Nouveau : raccourci "Répartir en groupes" par formation.** Constat terrain : une fois plusieurs apprenants inscrits pour une formation, le chemin vers "créer des groupes et les y placer" (Répartition, déjà existante depuis Phase 3) n'était pas assez visible depuis la liste des apprenants. `ApprenantController::index()` calcule désormais `periodesEnCoursParFormation` (formation_id → periode_id de la période "en cours" du centre, vide pour les rôles à portée globale) ; `Staff/Apprenants/Index.jsx` affiche un badge "N en attente — Répartir en groupes" par section de formation dès qu'il existe des apprenants sans inscription active ET une période en cours pour cette formation, menant directement à `periodes.repartition.create`.
- **Nettoyage : actions de `Staff/Apprenants/Index.jsx` regroupées dans un menu déroulant** (`Components/Dropdown.jsx`, déjà utilisé pour le menu utilisateur) plutôt que 4 boutons texte accolés (Identifiants/Régénérer/Modifier/Supprimer) — même retour que pour les pages d'auth : des actions nombreuses côte à côte ne sont "pas bien disposées".
- **Nouveau : export PDF groupé des identifiants d'un groupe** (`GroupeController::identifiants()`, route `groupes.identifiants`, bouton "Générer les identifiants (PDF)" sur `Staff/Groupes/Show.jsx`) — le formateur n'a plus besoin d'ouvrir la fiche de chaque apprenant une par une pour communiquer identifiant + mot de passe à toute une classe. Réutilise `Actions/Securite/ConsulterMotDePasseApprenant` **par apprenant** (donc chaque mot de passe affiché reste journalisé individuellement dans `consultations_mot_de_passe_apprenant`, comme une consultation unitaire — pas de contournement de la traçabilité §2.6 pour l'export groupé). Vue `resources/views/pdf/identifiants-groupe.blade.php`, même pattern dompdf que `RapportController`. **Rappel implicite confirmé au passage** : le mot de passe apprenant n'a jamais eu de logique d'expiration nulle part dans le code — il reste valable tant qu'il n'est pas régénéré manuellement par le staff, conforme à l'usage voulu (valable toute la durée de la formation).
- **Correctif du 2026-08-05 (suite) — l'export PDF demandé n'était pas celui-là.** L'utilisateur avait explicitement demandé un export de la **liste générale** des apprenants (après répartition) avec identifiants/mots de passe, pas seulement un export par groupe — le per-groupe (point ci-dessus) répondait à un besoin réel mais différent de ce qui avait été demandé. Ajouté : `ApprenantController::exportIdentifiants()` (route `apprenants.export-identifiants`). **Par la même occasion**, le lien "Suivi des groupes & réassignations" ajouté juste avant (pointant vers `inscriptions.index`) s'est révélé incompréhensible pour l'utilisateur ("est-ce que toi-même tu comprends quelque chose") et a été retiré — `inscriptions.index` reste fonctionnel mais n'est plus lié nulle part dans l'UI pour l'instant ; ne pas le relier avec un intitulé aussi peu clair si le besoin ressort à nouveau.
- **Correctif du 2026-08-05 (suite) — l'export devait être scopé et filtré, pas "au hasard".** Premier jet : un unique bouton "Exporter les identifiants" en haut de `Staff/Apprenants/Index.jsx`, exportant tous les apprenants visibles toutes formations confondues, y compris ceux pas encore répartis. Retour direct de l'utilisateur : (1) il doit pouvoir choisir **pour quelle formation** exporter — "ne pas se compliquer" exclut d'ajouter un sélecteur/filtre séparé, donc le bouton global a été remplacé par un **bouton d'export par section de formation** (celles déjà affichées à l'écran), passant `formation_id` en query string ; (2) l'export ne doit lister **que les apprenants déjà dans un groupe** — un apprenant en attente de répartition n'a pas de créneau/formateur à qui se présenter, ça n'a pas de sens de lui communiquer des identifiants. `ApprenantController::exportIdentifiants()` exige désormais `formation_id` (422/redirect avec erreur sinon) et filtre sur `whereHas('inscriptions', statut EnCours)`. Vue PDF simplifiée en conséquence (une formation par export, plus de regroupement multi-formations dans un seul PDF). Testé : `Tests\Feature\Metier\IdentifiantsExportTest::test_export_requires_a_formation_and_excludes_apprenants_not_yet_grouped`.
- **Piège de test découvert en Phase 6** : enchaîner deux `actingAs($model, $guard)` avec des guards différents (ex. `apprenant` puis `web`) dans le **même** test fait échouer silencieusement la seconde requête HTTP ("Unauthenticated", redirigée vers `/login`) — la session du premier guard interfère. Chaque test HTTP ne doit exercer qu'un seul guard ; pour enchaîner apprenant→formateur (ex. coche puis décoche), créer l'état intermédiaire directement via Eloquent plutôt que par une requête HTTP sur l'autre guard.
- **Piège `tests/TestCase.php`** : `UserObserver` synchronise `users.role` vers spatie/laravel-permission à chaque création de `User` — sans les rôles seedés, **tout** test créant un `User` (factory ou direct) échoue avec `RoleDoesNotExist`. Corrigé une fois pour toutes dans `tests/TestCase::setUp()` (`$this->seed(RoleSeeder::class)` si la table `roles` existe) plutôt que répété dans chaque classe de test.
- **Bug découvert le 2026-08-05 en conditions réelles — `Model::find($this->route('param'))` dans une FormRequest renvoie une `Collection`, pas un modèle.** Signalé par capture d'écran d'une 500 ("Property [centre_id] does not exist on this collection instance.") sur la répartition automatique — jamais détecté avant car aucun test HTTP n'exerçait réellement `periodes.repartition.store` (seulement l'Action appelée directement). Cause : `{periode}`/`{groupe}` sont déjà résolus en modèle par le route model binding **avant** que la FormRequest s'exécute ; passer ce modèle déjà résolu à `Periode::find()`/`Groupe::find()` déclenche la branche `findMany()` d'Eloquent (le Model implémente `Arrayable`), qui aplatit tous les attributs du modèle en tableau et fait un `whereIn('id', [...])` — d'où la requête absurde `where periodes.id in (1, 1, 1, 1, 2026, 2026, 0, ...)` visible dans les logs. Trouvé et corrigé dans `StoreRepartitionRequest.php` et `StoreLeconDuJourRequest.php` (mêmes deux occurrences dans tout le code, cherché avec `grep -rn "::find($this->route("`) : utiliser directement `$this->route('x')` (déjà le bon modèle) plutôt que de le re-requêter. Partout ailleurs dans `app/Http/Requests/`, le pattern correct était déjà utilisé — piège isolé à ces deux fichiers. Verrouillé par `Tests\Feature\Metier\RepartitionTest` (répartition **et** planification d'une leçon du jour, exercées en HTTP réel, pas juste l'Action) — **si un futur `FormRequest::withValidator()`/`authorize()` a besoin du modèle d'un paramètre de route déjà typé dans la signature du contrôleur, ne jamais le re-requêter via `Model::find($this->route(...))`.**
- **Piège découvert le 2026-08-05 : Laravel 11+ ne fournit plus de dossier `lang/` par défaut.** Sans lui, tous les messages standards (`validation.required`, `passwords.sent`, etc.) s'affichaient comme des **clés brutes non traduites** dans toute l'application — pas seulement sur la page mot de passe oublié qui avait été signalée, mais sur **tous** les formulaires (tout appel `__()`/`trans()`/`Lang::get()` sans fichier de langue derrière). Corrigé via `php artisan lang:publish` puis traduction complète en français : `lang/fr/{auth,passwords,validation,pagination}.php` (messages Laravel standards) + `lang/fr.json` (traductions par phrase-clé littérale, nécessaires pour le `MailMessage` par défaut de `Illuminate\Auth\Notifications\ResetPassword` qui appelle `Lang::get('Reset Password Notification')` etc. — la chaîne anglaise elle-même sert de clé). Si un nouveau message Laravel standard apparaît encore en anglais/en clé brute quelque part, c'est très probablement une clé manquante dans l'un de ces fichiers, pas un nouveau bug distinct.

## Audit de sécurité du 2026-08-10

Audit complet en 12 sections (secrets, Eloquent, authentification/sessions, autorisation/cloisonnement, validation serveur, props Inertia, rate limiting, upload, dépendances, déploiement, cohérence React/serveur, données personnelles) mené sur l'ensemble du code — rapport détaillé conservé dans `SECURITY_AUDIT.md` à la racine (fichier de travail, pas commité). 8 conclusions formalisées, corrigées une par une avec validation utilisateur explicite à chaque étape et couverture de tests (110 → 115 tests).

- **CRITIQUE, corrigée — un compte Partenaire auto-créé (sans validation admin, via `/register`) pouvait déchiffrer/exporter les mots de passe en clair de tous les apprenants, de tous les centres.** Cause : `ApprenantPolicy::view()`/`viewAny()` traitaient `isGlobalScope()` (vrai pour Admin **et** Partenaire) comme suffisant pour accéder aux mots de passe (`apprenants.identifiants`, `apprenants.export-identifiants(-csv)`), alors que cette portée globale n'a de sens que pour la *lecture de suivi*, jamais pour les secrets. Corrigé par une nouvelle méthode `ApprenantPolicy::viewCredentials()`, strictement réservée à Gestionnaire/Formateur/Secrétaire du centre+formation concernés (jamais Partenaire) — utilisée par `ApprenantCredentialController` et `ApprenantController::apprenantsPourExport()` (qui ne se fiait plus qu'à `viewAny()`, toujours vraie pour tout rôle). **Piège à retenir** : ne jamais réutiliser une méthode de Policy conçue pour un niveau de sensibilité (voir/lister un profil) pour protéger un niveau de sensibilité différent (voir un secret) — même si le rôle autorisé semble se recouper au premier coup d'œil.
- **Décision produit prise au même moment (conclusion #8, MOYENNE) : le partenaire est maintenant restreint aux statistiques agrégées de `partenaire.dashboard`.** Avant : `ApprenantPolicy` laissait aussi un partenaire ouvrir `apprenants.index`/`apprenants.show` (nom, âge, sexe, école, classe, statut de chaque apprenant) — au-delà de ce que son tableau de bord (compteurs par centre) suggérait. Retiré explicitement à la demande du porteur de projet, plutôt que documenté comme un choix assumé (alternative proposée mais non retenue).
- **MOYENNE, corrigée — mot de passe admin de seed prévisible.** `DatabaseSeeder` (compte `admin@techniform.test` / `password`) refuse désormais de s'exécuter si `APP_ENV=production` — aucun changement en local, où ce mot de passe reste un identifiant de démo documenté et acceptable.
- **MOYENNE, corrigée — un apprenant pouvait cocher une leçon planifiée pour le futur** (`Apprenant\ProgressionController::store()` ne vérifiait pas `date_prevue`, contrairement au tableau de bord qui filtre déjà `<= aujourd'hui`). Comparaison faite en chaîne de date (`toDateString()`), pas en objets Carbon complets — piège déjà documenté plus haut dans ce fichier (colonnes `date:Y-m-d` non fiables à l'écriture) qui s'est reproduit ici côté lecture/comparaison PHP, pas seulement côté requêtes `where()`.
- **MOYENNE, corrigée — injection de formule CSV (CSV Injection)** sur les 3 exports CSV (`apprenants.export-identifiants-csv`, `apprenants.export-liste-csv`, `rapports.export-csv`) : un nom d'apprenant/centre/formation commençant par `=`, `+`, `-` ou `@` s'exécuterait comme une formule à l'ouverture dans Excel. Neutralisé par un nouveau trait partagé `App\Http\Controllers\Concerns\EscapesCsvFormulas`.
- **MOYENNE, corrigée — historique client Inertia (`history.state`) non chiffré sur les postes partagés**, angle mort distinct du correctif bfcache déjà fait le 2026-08-07 (`PreventStaleAuthenticatedPages` neutralise le cache HTTP, pas l'historique interne d'Inertia). `encryptHistory: true` ajouté dans `resources/js/app.jsx`, `router.clearHistory()` appelé aux deux points de déconnexion (staff dans `AuthenticatedLayout.jsx`, apprenant dans `Apprenant/Dashboard.jsx`).
- **BASSE, corrigée — `commentaire_libre` (saisie libre apprenant) sans limite de longueur ni validation de type** avant `Apprenant\ProgressionController::store()` — `max:2000` ajouté.
- **BASSE, mise de côté par choix du porteur de projet — actions à effet de bord sur des routes GET** (`apprenants.identifiants`, `rapports.export(-csv)` journalisent tout en étant des GET). Risque déjà atténué par `SESSION_SAME_SITE=lax` et l'absence de lien `<a>` réellement rendu (appels `axios`/téléchargements directs) ; le correctif complet (passage en POST) demanderait de retoucher l'UI de 3 pages pour un gain jugé non prioritaire — à reconsidérer si le contexte change.
- **BASSE, corrigée — en-têtes de sécurité manquants.** Nouveau middleware global `App\Http\Middleware\SecurityHeaders` (`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`) — pas de CSP stricte pour l'instant (demanderait un nonce pour les scripts injectés par Inertia/Vite, risque de régression réel si mal calibrée).
- **BASSE, documentée sans modifier le `.env` actif — `SESSION_SECURE_COOKIE` non définie.** Ajoutée en commentaire dans `.env.example` (`# SESSION_SECURE_COOKIE=true`) : l'activer sur l'environnement local actuel (HTTP) casserait la session immédiatement, à définir uniquement au moment d'un déploiement HTTPS réel. `SESSION_LIFETIME=120` (2h, partagée entre guards `web` et `apprenant`) volontairement non modifiée — trade-off sécurité (postes partagés) vs confort du staff qui n'a pas été tranché, à revisiter si le besoin se précise.
- **INFO, documentée sans retirer le code — `spatie/laravel-permission` installé mais jamais consulté pour une décision d'autorisation** (toutes les Policies utilisent `users.role`, l'enum PHP natif, jamais `hasRole()`/`can()` de ce package — seul `UserObserver::syncRoles()` le maintient synchronisé, en pure perte). Retrait non fait : toucherait des tables déjà peuplées dans la vraie base (`roles`, `model_has_roles`...), jugé disproportionné pour un constat sans impact de sécurité direct. `laravel/sanctum` dans le même cas (seule la route `sanctum/csrf-cookie` auto-enregistrée existe, jamais utilisée).
- **INFO, corrigée — rotation des logs.** `LOG_STACK=single` (fichier `storage/logs/laravel.log` ne tournant jamais, déjà 457 Ko) remplacé par `LOG_STACK=daily` dans `.env`/`.env.example` — un fichier daté par jour désormais (`laravel-2026-08-10.log`), rétention par défaut de Laravel (14 jours).

## Base vidée le 2026-08-13 (hors compte admin)

Retour d'usage du porteur de projet, pour repartir sur une base propre avant saisie réelle : toutes les tables métier (centres, formations, chapitres, apprenants, inscriptions, leçons du jour, progression, présences, rapports générés, consultations de mot de passe, staff non-admin) vidées, ainsi que sessions/cache/jobs. **Seul le compte admin (`admin@techniform.test`, id 1) a été conservé**, avec son mapping spatie (`model_has_roles`) intact — `roles` (5 rôles définis) également conservée, indispensable au fonctionnement de l'app. Vidage fait via `DB::table(...)->truncate()` (FK checks désactivés le temps de l'opération) plutôt que `migrate:fresh`, précisément pour ne pas perdre ce compte.

## Planification multiple + visibilité des leçons futures côté apprenant (2026-08-13)

Retour d'usage du porteur de projet, capture à l'appui : un chapitre avec plusieurs leçons (ex. "vocabulaire" avec "les articles"/"les verbes"/"les interrogations") obligeait le formateur à répéter « Planifier un cours » une fois par leçon — celles non traitées restaient sans date, donc **invisibles** côté apprenant (le tableau de bord ne montrait que les leçons déjà planifiées jusqu'à aujourd'hui). Deux correctifs liés, tranchés avec le porteur de projet avant implémentation :

- **Planification multiple, dates consécutives.** `StoreLeconDuJourRequest`/`LeconDuJourController::store()` remplacent le champ `module_formation_id` (un seul) par `module_formation_ids` (tableau) — chaque leçon sélectionnée, triée par `ordre` (pas par ordre de sélection à l'écran), prend un jour de plus que la précédente à partir de `date_prevue` (rebaptisé "Date de départ" côté formulaire). Le flux "+ Nouvelle leçon" (création à la volée d'une leçon unique) est inchangé. **Piège retombé sur celui déjà documenté dans ce fichier** (cast `date:Y-m-d` qui ne tronque pas l'heure à l'écriture) : `$date->copy()->addDays($i)` est un objet Carbon, il faut explicitement `->toDateString()` avant de le passer à `LeconDuJour::create()`, sinon l'heure `00:00:00` est stockée en plus de la date et casse toute comparaison de chaîne exacte en aval.
- **`Staff/Formations/Programme.jsx`** : le `<select>` de leçon devient une liste de cases à cocher (`chapitre.modules_formation`, dans une zone scrollable si le chapitre est long) — cochées par défaut : les leçons **jamais planifiées** (`module.lecons_du_jour.length === 0`), pour que le cas courant ("terminer de planifier les leçons restantes d'un chapitre") ne demande qu'un clic sur "Planifier".
- **Visibilité côté apprenant, sans pouvoir cocher en avance.** `Apprenant\DashboardController::index()` n'exclut plus les leçons planifiées pour plus tard (`whereDate('date_prevue', '<=', today())` retiré de la requête `$lecons` — `$totalLecons` ne l'avait déjà jamais eu, donc le pourcentage de progression n'est pas affecté) ; chaque leçon renvoyée gagne un flag `cochable` (`date_prevue <= aujourd'hui`). `Apprenant/Dashboard.jsx` (`LeconItem`) grise la leçon et désactive la case à cocher quand `cochable === false`, avec le texte "Disponible le {date}" à la place de la date brute. **La garde serveur empêchant réellement de cocher en avance existait déjà** (`Apprenant\ProgressionController::store()`, audit de sécurité du 2026-08-10 conclusion #3) — ce correctif ne change que l'affichage, pas l'autorisation.
- Tests : `ChapitreProgrammeTest`/`ProgressionTest` adaptés au nouveau nom de champ (`module_formation_ids`), nouveaux `test_formateur_can_plan_several_existing_modules_at_once_on_consecutive_days`, `test_apprenant_dashboard_shows_future_lecons_as_visible_but_not_cochable`, `test_apprenant_dashboard_marks_todays_lecon_as_cochable`.

### Correctifs du 2026-08-13 (suite, même jour) — planification en double + date d'une leçon déjà planifiée non modifiable

Deux bugs remontés par le porteur de projet, capture à l'appui, juste après le chantier ci-dessus : une leçon ("les virgules") se retrouvait avec **deux** dates affichées (« Planifiée le 2026-08-15 » et « Planifiée le 2026-08-13 ») après une tentative de correction, et la modale « Modifier » d'une leçon (`ModuleForm` — titre/ordre) ne permettait pas du tout de toucher à sa date planifiée. Cause racine commune : il n'existait **aucune façon d'éditer la date d'une `LeconDuJour` existante** — seul « Planifier un cours » (crée) et le « × » sur le badge (`LeconDuJourController::destroy()`, un vrai `DELETE`) étaient disponibles ; corriger une date obligeait donc à retirer puis replanifier, ce qui aurait en plus **supprimé la validation de l'apprenant** si la leçon avait déjà été cochée (`progression_modules.lecon_du_jour_id` en `cascadeOnDelete()`) — d'où « un cours déjà validé ne peut plus être modifié » signalé par le porteur de projet.

- **Contrainte d'unicité `lecons_du_jour.module_formation_id`** (migration `2026_08_13_111044_add_unique_constraint_to_lecons_du_jour_table`) : une leçon n'a plus qu'une seule date planifiée à la fois, jamais deux. La migration commence par dédupliquer les lignes déjà en base (garde celle cochée par un apprenant s'il y en a une — ne jamais perdre une validation —, sinon la plus récente) avant de poser la contrainte.
- **`LeconDuJourController::store()`** : `LeconDuJour::create()` remplacé par `updateOrCreate(['module_formation_id' => ...], ['date_prevue' => ...])` — recocher dans « Planifier un cours » une leçon déjà planifiée **déplace** sa date au lieu d'en créer une seconde (source du bug des deux badges).
- **Nouvelle route `PUT lecons-du-jour/{leconDuJour}` → `LeconDuJourController::update()`** : corrige la date d'une leçon déjà planifiée par une vraie mise à jour en place (même garde d'autorisation que `destroy()` — chapitre potentiellement soft-supprimé → 404, sinon `ChapitrePolicy::update`), **y compris si elle est déjà cochée par un apprenant** — l'`id` de la `LeconDuJour` ne change pas, donc `progression_modules` (lié par cet id) n'est jamais touché. Déclenchée depuis `Staff/Formations/Programme.jsx` par un lien « Modifier » à côté de chaque badge « Planifiée le … », ouvrant une petite modale dédiée (`ModifierDateLeconForm`) plutôt que d'alourdir `ModuleForm` (qui reste titre/ordre, une leçon pouvant avoir zéro planification).
- **`Apprenant/Dashboard.jsx`** : `verrouillee` (case à cocher grisée pour une leçon future) exclut désormais explicitement `lecon.coche` — sans ce garde-fou, déplacer dans le futur la date d'une leçon déjà validée par l'apprenant (cas de correction ci-dessus) l'aurait affichée à tort comme « pas encore disponible » alors qu'elle est déjà faite.
- Tests : `ChapitreProgrammeTest::test_replanning_an_already_planned_module_moves_it_instead_of_duplicating`, `test_formateur_can_update_the_date_of_an_already_planned_lecon`, `test_formateur_cannot_update_the_date_of_a_lecon_of_another_centre` ; `ProgressionTest::test_formateur_can_correct_the_date_of_a_lecon_already_cochee_without_losing_the_validation` (vérifie explicitement `assertNotSoftDeleted` sur la `ProgressionModule`).

### Correctif du 2026-08-13 (suite, même jour) — formateur avec une seule formation : plus de filtre à choisir

Retour d'usage du porteur de projet : le formateur Nicolas, dont le compte n'a qu'une seule formation attachée (Alphabétisation), se voyait quand même demander de choisir sa formation sur la page **Présence** (`— Choisir —`, aucune donnée affichée tant que rien n'était sélectionné) — redondant avec un choix déjà fait une fois pour toutes à la création du compte (case à cocher "Formations enseignées", voir le chantier de restriction par formation plus haut). Corrigé sur **Présence** et **Rapports**, les deux écrans qui exigeaient de choisir une formation avant d'afficher quoi que ce soit :

- `PresenceController::index()`/`RapportController::index()` : quand l'acteur est Formateur et que `$user->formations()->count() === 1`, `formation_id` est auto-résolu à cette unique formation si non fourni dans la requête (`$request->merge()` côté Rapports pour que `lignesFiltrees()`, qui lit directement `$request`, en profite aussi) — la page affiche directement les données dès le premier chargement.
- `Presences/Index.jsx`/`Rapports/Index.jsx` : le `<select>` Formation est remplacé par un simple texte affichant le nom de la formation (rien à choisir) quand `auth.user.role === 'formateur' && formations.length === 1` ; inchangé pour tout autre cas (formateur avec plusieurs formations, ou tout autre rôle — dont le catalogue complet est amené à grandir, même raisonnement déjà appliqué au filtre centre).
- **Pages volontairement pas retouchées** : `Apprenants/Index.jsx` a un filtre Formation similaire mais purement optionnel (affiche déjà tous les apprenants par défaut, sans exiger de choix) — pas concerné par ce bug. `Historique` reste centre-large pour tous les rôles (gap déjà documenté plus haut, formation non filtrable du tout à cet écran).
- Tests : `PresenceScreenTest::test_formateur_with_a_single_formation_sees_the_roster_without_choosing_a_formation` (+ `test_formateur_with_several_formations_must_still_choose_one`, non-régression), `RapportTest::test_formateur_with_a_single_formation_sees_the_rapport_without_choosing_a_formation`.

### Passe de cohérence visuelle du 2026-08-13 — généralisation du style « Présence » à toute l'app

Retour d'usage du porteur de projet après la refonte de la page Présence (bandeau de filtres icône+libellé, tuiles de statistiques, avatar+badge dans le tableau) : « fait ça dans tout notre app » — le reste des pages gardait des filtres bruts (`<span>Filtrer par centre</span>` à côté d'un `<select>`), des `StatCard` sans icône, des `<table>` construits à la main, et des badges de statut en texte brut (`actif` au lieu de « Actif »). Généralisé à toutes les pages de liste/tableaux de bord :

- **3 nouveaux composants partagés**, extraits de la page Présence plutôt que dupliqués page par page : `Components/Badge.jsx` (pastille à puce colorée, tons `success`/`neutral`/`warning`/`info`/`danger`), `Components/FiltreChamp.jsx` (icône + libellé au-dessus d'un champ de filtre), `Components/StatTile.jsx` (icône + chiffre + libellé, remplace l'ancien `StatCard` sans icône dupliqué dans 4 fichiers).
- **`Components/DataTable.jsx`** gagne un alignement par colonne optionnel (`col.align: 'left'|'center'|'right'`, `'left'` par défaut — rétrocompatible) pour centrer proprement une colonne de badge, plutôt que du texte aligné à gauche par défaut.
- **Pages retouchées** : `Staff/{Centres,Formations,Utilisateurs,Apprenants,Rapports}/Index.jsx`, `Partenaire/{Dashboard,Formations}.jsx`, `Staff/Dashboard/{Gestionnaire,Formateur,Secretaire}.jsx`, `Staff/Historique/Index.jsx`, `Staff/Apprenants/Show.jsx` (tableau historique des inscriptions). Avatar (déjà utilisé côté apprenant) ajouté aux colonnes "Nom" qui listent des personnes (Apprenants, Utilisateurs, Rapports). `Utilisateurs/Index.jsx` gagne un badge de rôle coloré par ton (rouge=admin, bleu=gestionnaire, vert=formateur, ambre=secrétaire, gris=partenaire). `Rapports/Index.jsx` gagne une vraie barre de progression (pas juste un pourcentage en texte) pour la colonne Progression.
- **Décision respectée en la matière : les tableaux construits à la main dans les dashboards/Historique/Partenaire restent volontairement sans numérotation** (décision déjà actée le 2026-08-07, « pas des listes de référence à interroger par position ») — migrés vers `DataTable` quand même pour la cohérence visuelle (colonne sticky, style des lignes), mais avec `numerote={false}` pour ne pas réintroduire la colonne "#" qui avait été explicitement écartée pour ces écrans-là.
- **Piège Tailwind évité en cours de route** : une classe construite dynamiquement (`` `sm:grid-cols-${global ? 3 : 2}` ``) n'est jamais détectée par le scan statique de Tailwind au build — corrigé en écrivant les deux classes complètes en toutes lettres dans un ternaire (`global ? 'sm:grid-cols-3' : 'sm:grid-cols-2'`) avant que ça ne devienne un bug silencieux (classe absente du CSS généré).
- Aucun test PHPUnit n'exerce le rendu React (pas de framework de test JS dans ce projet, gap déjà documenté) — vérifié par build (`npm run build`) + rechargement HMR sans erreur + la suite `php artisan test` complète (129 tests, aucune régression, ces pages ne changent que du balisage/CSS).

### Suite de la passe de cohérence visuelle (2026-08-13, même jour) — tableaux de bord + page Profil

Retour d'usage du porteur de projet : « améliore un peu le dashboard, cet effet waouh applique-le là aussi, aussi sur le profil et même quand on clique dessus » (= la page Profil elle-même, atteinte en cliquant sur le bloc utilisateur de la sidebar — pas seulement son aperçu).

- **`Components/PageHero.jsx`** (nouveau) : bannière d'accueil en dégradé (`from-sky-600 via-sky-600 to-blue-700`, même palette que le panneau de marque de `GuestLayout`), avec deux cercles décoratifs en surimpression — ajoutée en tête des 4 tableaux de bord (`Partenaire/Dashboard.jsx`, `Staff/Dashboard/{Gestionnaire,Formateur,Secretaire}.jsx`) : « Bonjour {prénom} » + un résumé chiffré en sous-titre + la date du jour à droite. L'en-tête de page partagé (`AuthenticatedLayout` `header`) n'est pas touché : il reste sobre sur toutes les autres pages de l'app, cette bannière est un ajout propre aux tableaux de bord, pas un changement du gabarit commun.
- **`Components/StatTile.jsx`** gagne un léger effet de survol (`hover:-translate-y-0.5 hover:shadow-md`), profite automatiquement à toutes les pages qui l'utilisent déjà.
- **Page Profil entièrement refaite** (`Pages/Profile/Edit.jsx` + les 3 partiels `Update{ProfileInformation,Password}Form.jsx`/`DeleteUserForm.jsx`) : c'était le seul reste du scaffold Breeze par défaut, jamais retouché (gap documenté dans la section Recette depuis la Phase 6) — entièrement en anglais, sections plates sans icône. Traduit intégralement en français, cohérent avec le reste de l'app : carte d'en-tête avec `Avatar` (grand format) + nom + e-mail + badge de rôle coloré (mêmes tons que `Utilisateurs/Index.jsx`) ; chaque section (Informations du profil / Mot de passe / Supprimer le compte) gagne une icône dans un badge circulaire (`IconUserCircle`, `IconLock`, `IconTrash` — les deux derniers nouveaux dans `Components/Icons.jsx`, même style traits fins que le reste de la bibliothèque) au lieu d'un simple `<h2>` nu. Les cases à cocher "Formations enseignées" (formateur) sont regroupées dans un encadré avec surbrillance au survol plutôt que flottantes.
- **`Components/Badge.jsx`** gagne un `className` optionnel (passthrough, rétrocompatible) — nécessaire pour pousser le badge de rôle à droite de la carte d'en-tête du profil (`ms-auto`).
- Aucun test PHPUnit n'exerçait de contenu HTML de cette page (`ProfileTest` ne vérifie que des statuts HTTP et l'état en base) — aucune adaptation de test nécessaire, vérifié par `php artisan test` (129 tests, inchangé) + `npm run build`.

### Suite de la passe de cohérence visuelle (2026-08-13, encore le même jour) — sidebar

Retour d'usage du porteur de projet, sur la sidebar cette fois : « cette partie-là aussi doit être améliorée, je veux un effet waouh dans toute mon app ». `Layouts/AuthenticatedLayout.jsx` est le composant partagé par **toutes** les pages staff (desktop + tiroir mobile) — l'améliorer profite donc à l'app entière d'un coup, pas page par page :

- **Lien actif** : passe d'un simple fond bleu ciel très clair à une pastille pleine en dégradé (`from-sky-600 to-sky-500`, ombre `shadow-sky-200`) avec l'icône dans un badge circulaire `bg-white/15` — beaucoup plus visible qu'avant pour savoir où on se trouve dans l'app.
- **Liens inactifs** : icône dans un badge circulaire gris clair qui devient bleu ciel au survol (`group-hover`), léger déplacement horizontal (`hover:translate-x-0.5`) — un retour visuel discret mais présent au survol.
- **Bloc utilisateur** (bas de sidebar) : le rôle en texte gris devient un badge coloré (`Components/Badge`, mêmes tons que `Utilisateurs/Index.jsx`/`Profile/Edit.jsx` — cohérence de couleur par rôle dans toute l'app), l'avatar gagne un léger anneau blanc + ombre, tout le bloc réagit au survol (fond gris clair, nom qui passe en bleu), et le bouton de déconnexion devient rouge au survol (`hover:bg-rose-50 hover:text-rose-600`) au lieu d'un gris neutre — plus cohérent avec une action destructive.
- **Liseré dégradé** (`h-1 bg-gradient-to-r from-sky-600 via-sky-500 to-blue-600`, même dégradé que `PageHero`) ajouté en tout haut de la sidebar desktop, du tiroir mobile, et de la barre du haut mobile — une signature visuelle discrète mais présente sur toutes les pages, y compris avant même la connexion aux composants applicatifs (dès le chargement du châssis de page).
- Aucune nouvelle dépendance (pas de `framer-motion` pour une animation de pastille glissante — jugé disproportionné, les transitions CSS suffisent) ; vérifié par `npm run build` + `php artisan test` (129 tests, inchangé — ce composant n'est exercé par aucun test, uniquement du balisage/CSS).

### Correctifs du 2026-08-13 (suite, même jour) — alignement sur le skill de design TECHNIFORM

Retour d'usage du porteur de projet : « les animations de la page admin et de la page formateur sont un peu différentes... et pourquoi tu n'as pas revu la page apprenant, c'est le projet en entier que tu dois revoir. » En creusant, découverte d'un fichier existant mais jamais consulté jusqu'ici : **`Techniform designer skill.MD`** (racine du dépôt, non commité) — un skill de design complet qui verrouille une charte précise (couleurs sémantiques, typographie, espacements, budget d'animation) pour toute l'app. `resources/js/lib/statuts.js` (déjà présent avant ce chantier) applique déjà correctement cette charte — mais les composants `Badge`/`StatTile` créés plus tôt dans la journée avaient **réinventé leur propre palette, incompatible avec elle**. Corrigé :

- **`Components/Badge.jsx` réécrit pour consommer `SEMANTIQUE` de `lib/statuts.js`** au lieu d'une palette maison (emerald/amber/rose/**sky**) : succès=vert, attente=jaune, erreur=rouge, information=**violet**, neutre=slate. Le point le plus important : le ton `info` utilisait du bleu ciel (`sky`), alors que le skill est explicite — *« le bleu veut dire cliquable ou progression, rien d'autre ; ne réintroduis jamais de bleu dans la palette sémantique »*. Toutes les pages utilisant `Badge` en héritent automatiquement sans modification.
- **Statuts mal classés corrigés** (pas qu'une question de teinte, la sémantique elle-même était fausse par endroits) : présence « Absent » passe de `neutral` à `danger` (erreur, pas neutre — table du skill : « absent → erreur ») ; inscription « Abandonnée » passe de `neutral` à `danger` dans `Secretaire.jsx`/`Apprenants/Show.jsx`/`Rapports/Index.jsx` (le skill classe abandon **et** échec dans la même couleur erreur, pas deux couleurs différentes). Le badge « Statut » de `Apprenants/Index.jsx` (valeur d'une table de référence libre `apprenant_statuts`, extensible par l'admin) est figé en `neutral` : impossible de classer fiablement une étiquette arbitraire en succès/attente/erreur, mieux vaut une couleur honnête que devinée.
- **`Components/StatTile.jsx` corrige un vrai défaut d'affordance** relevé par le skill (« Une carte de statistique est cliquable et mène à la liste filtrée correspondante. Sans cela, elle est décorative ») : les tuiles réagissaient au survol comme un bouton sans être cliquables — un faux signal. `StatTile` accepte maintenant un `href` optionnel ; avec lui, la tuile devient un vrai lien animé au survol vers la liste filtrée (« Apprenants en cours » → `apprenants.index`, etc.) ; sans lui, plus d'animation du tout. Liens ajoutés seulement là où la destination est réellement accessible au rôle affiché — pas de lien « Membres du staff » pour un gestionnaire (`UserPolicy` réservée à l'admin), pas de lien sur `Partenaire/Dashboard.jsx` (page partagée admin/partenaire, l'accès à `apprenants.index`/`centres.index` diffère entre les deux — mieux vaut aucun lien qu'un lien parfois cassé).
- **Sections « Activité récente »/« Répartition des apprenants »** (`Formateur.jsx`/`Gestionnaire.jsx`/`Secretaire.jsx`) alignées sur le style des sections `DataTable` du dessus (`mb-3 text-sm font-bold text-slate-600` au lieu d'un `<h3 border-b font-semibold>` différent) + survol des lignes (`hover:bg-slate-50`, transition CSS 150ms — « changement de fond au survol des lignes de tableau », explicitement autorisé par le budget d'animation du skill). C'est très probablement la source du ressenti « animation différente entre admin et formateur ».
- **Espace apprenant (`Apprenant/Dashboard.jsx`) : traitement volontairement différent, pas un oubli.** Le skill est explicite sur ce point précis — pas de dégradé ni d'éléments décoratifs pour ce public (« Tu élimines le décoratif gratuit »), mais une exigence stricte d'**icônes systématiquement associées aux libellés** (« ce public inclut des apprenants en alphabétisation »), absente jusqu'ici. Corrigé sans toucher au reste : icône de chapitre (`IconBook`) à côté du titre, icône par état dans le badge (`IconClock`/`IconBook`/`IconClipboardCheck` pour à_venir/en_cours/terminé, en plus de la couleur déjà en place), icône sur les leçons « pas encore programmées ». Barre de progression réajustée aux tokens exacts du skill pour cet écran (hauteur 14px au lieu de 8px, remplissage `sky-500` au lieu de `sky-600`) — les cibles tactiles (case 32px, ligne 64px, titre 18px) étaient déjà conformes.
- Aucun test PHPUnit ne porte sur ces éléments (balisage/CSS uniquement) — vérifié par `php artisan test` (129 tests, inchangé) + `npm run build`.

### Correctifs du 2026-08-14 — retours d'usage en conditions réelles (sidebar trop sombre, connexion apprenant, dashboard formateur, Programme, vue globale)

Retour d'usage vocal du porteur de projet, après avoir utilisé l'app avec la charte de la veille : plusieurs points précis, clarifiés par deux questions avant d'agir (couleur du lien actif au survol/clic jugée trop sombre ; titre "Espace apprenant" jugé terne, un visuel/icône préféré à un simple texte en gras).

- **Sidebar — lien actif adouci.** Le dégradé plein (`from-sky-600 to-sky-500`, texte blanc) posé la veille était trop appuyé une fois en usage réel. Remplacé par le traitement que le skill de design prescrivait déjà pour cet élément et que je n'avais pas suivi à la lettre : fond clair `bg-sky-50`, texte/icône `sky-700`/`sky-600`, barre latérale de 3px (`bg-sky-500`) — cohérent avec le reste de la palette sémantique (fond clair + accent, jamais un aplat foncé).
- **`Layouts/GuestLayout.jsx`** gagne une prop `icon` optionnelle : un badge circulaire (`bg-sky-50` + icône) au-dessus du titre, à la place du simple `<h1>` en gras jugé austère. Activé sur `ApprenantAuth/Login.jsx` (`IconBook`) — les autres pages d'auth (staff) restent inchangées, la prop est disponible mais pas utilisée ailleurs pour l'instant, faute de demande explicite sur ces écrans.
- **Dashboard Formateur** : les liens « Présence du jour »/« Voir les apprenants », auparavant deux petits liens texte serrés dans l'en-tête du tableau « Mes formations », deviennent deux vrais boutons (icône + fond blanc + ombre) juste sous la bannière d'accueil — bien plus visibles, avant même de scroller jusqu'au tableau. L'action « Programme » de chaque ligne devient une pastille bleue cliquable plutôt qu'un lien texte discret.
- **`Staff/Formations/Programme.jsx`** repris dans son ensemble : bouton d'en-tête et filtre centre alignés sur le reste de l'app (`FiltreChamp`), cartes de chapitre avec icône (`IconBook`) et actions regroupées en boutons avec fond au survol (au lieu de liens texte nus), lignes de leçon avec survol, état vide avec icône + message d'action. Les 4 formulaires en modale (Chapitre/Module/Date/Planifier un cours) ne sont pas retouchés dans leur logique — seule l'enveloppe de la page a été reprise.
- **`Partenaire/Dashboard.jsx` (vue globale admin/partenaire)** : le tableau de centres, très clairsemé avec seulement 1 à 3 lignes, est remplacé par une grille de cartes (une carte par centre — alternative explicitement prévue par le skill de design pour cet écran), triées avec les centres en difficulté en premier (peu/pas d'activité aujourd'hui) plutôt qu'un ordre alphabétique qui les noierait. Chaque carte : icône + nom + ville, badge de statut, trois chiffres (apprenants/formations/présence) avec effet de survol léger.
- **`Hooks/usePolling.js`** étendu pour combler un gap déjà noté par le skill de design (section « Temps réel ») mais jamais implémenté : suspend le rechargement périodique quand l'onglet n'est plus visible (Page Visibility API, rattrape immédiatement au retour) et renvoie l'horodatage de la dernière mise à jour — affiché en toutes lettres (« Mis à jour à 14:32 ») sur `Partenaire/Dashboard.jsx`, seul consommateur actuel du hook, pour que l'utilisateur sache ce qu'il regarde plutôt qu'une illusion de direct.
- Aucun test PHPUnit ne porte sur ces éléments (balisage/CSS/JS client uniquement) — vérifié par `php artisan test` (129 tests, inchangé) + `npm run build`.

### Correctif du 2026-08-14 (suite, même jour) — en-tête « Tableau de bord — Rôle » redondant avec la bannière d'accueil

Retour d'usage du porteur de projet : sur les 4 tableaux de bord (Formateur/Gestionnaire/Secrétariat/vue globale), le titre de page partagé (`AuthenticatedLayout` `header`, ex. « Tableau de bord — Formateur ») affichait une information redondante avec la bannière d'accueil juste en dessous (`PageHero`, « Bonjour Nicolas »), qui dit déjà clairement où on se trouve. Corrigé en ne passant plus du tout de `header` à `AuthenticatedLayout` sur ces 4 pages — la prop est optionnelle (`{header && (...)}`), l'omettre supprime proprement toute la barre au lieu de la laisser vide. La bannière d'accueil devient donc le seul élément de titre en tête de page, sans doublon. Les autres pages de l'app (listes, fiches, formulaires) gardent leur en-tête habituel — cette suppression ne concerne que les 4 pages qui ont une `PageHero` propre.
- Vérifié par `php artisan test` (129 tests, inchangé — balisage uniquement) + `npm run build`.

### Correctif du 2026-08-14 (suite, même jour) — bannière d'accueil apprenant, en-tête Programme, formulaire de planification

Retour d'usage vocal du porteur de projet, insistant explicitement pour que l'espace apprenant ait aussi un effet visuel fort, malgré la sobriété que le skill de design recommande pour ce public — décision assumée du porteur de projet, qui prime sur la recommandation du skill dans ce cas précis.

- **`Apprenant/Dashboard.jsx` — bannière d'accueil en dégradé** (même palette `from-sky-600 via-sky-600 to-blue-700` que `GuestLayout`/`PageHero`), fusionnant ce qui était trois blocs séparés (barre du haut blanche, ligne « Bonjour », carte de progression) en un seul en-tête cohérent : logo sur plaque blanche, bouton de déconnexion toujours visible en haut à droite (jamais dans un menu, poste partagé), avatar en grand + nom + formation, barre de progression en blanc translucide sur le dégradé. Reste le **seul** endroit décoratif de cet espace — le corps de page (cartes de chapitre, cases à cocher) garde son traitement sobre existant, conforme au reste du skill.
- **`Staff/Formations/Programme.jsx` — en-tête de page repris.** Le titre plat « Programme — {formation} » dans la barre de titre partagée (`AuthenticatedLayout` `header`) est remplacé par une carte dédiée dans le corps de page (icône `IconAcademicCap` + titre + sous-titre chiffré « N chapitres · N leçons »), avec le bouton « Planifier un cours » à côté — même schéma que les tableaux de bord (en-tête de layout retiré, contenu enrichi à la place).
- **Formulaire « Planifier un cours »** : icône dans l'en-tête de la modale, icônes sur les libellés « Chapitre »/« Leçons »/« Date de départ » (`InputLabel` gagne une prop `icon` optionnelle, réutilisable partout), boutons « + Nouveau chapitre »/« + Nouvelle leçon » transformés en pastilles plutôt qu'en liens texte, liste de leçons à cocher avec les lignes sélectionnées mises en évidence (fond blanc + ombre légère sur fond gris clair).
- Vérifié par `php artisan test` (129 tests, inchangé — balisage uniquement) + `npm run build`.

### Correctif du 2026-08-29 — largeur du contenu incohérente d'une page à l'autre (« boxé », espace vide à gauche/droite)

Retour d'usage du porteur de projet, captures à l'appui (tableau de bord, liste Centres) : sur un écran large, le contenu de chaque page semblait « boxé » — les cartes et tableaux n'occupaient qu'une partie de la largeur disponible, laissant un espace vide inutile de part et d'autre. Cause : chaque page définissait son propre conteneur `mx-auto max-w-XXX` avec une valeur Tailwind choisie au coup par coup (`max-w-2xl` à `max-w-6xl`, soit 672px à 1152px selon l'écran) — jamais la même d'une page à l'autre, et toutes nettement plus étroites que la règle déjà écrite noir sur blanc dans `Techniform designer skill.MD` (« Contenu principal : max-width 1440px ») mais jamais appliquée dans le code.

- Nouveau token Tailwind **`max-w-content`** (1440px, `tailwind.config.js` → `theme.extend.maxWidth`), pour que toute page utilise désormais la même valeur nommée plutôt qu'une valeur ad hoc.
- Appliqué aux 15 pages concernées : les 4 tableaux de bord (Partenaire/Admin, Gestionnaire, Formateur, Secrétaire), toutes les listes (Centres, Utilisateurs, Formations, Statuts apprenant, Apprenants, Présence, Rapports, Historique, Formations par centre côté partenaire), la fiche détail apprenant et le Programme de formation.
- **Une page volontairement inchangée à l'époque** : `Profile/Edit.jsx` reste à `max-w-3xl` — c'est une page de formulaire à une seule colonne (les champs eux-mêmes plafonnent déjà à `max-w-xl`), l'élargir aurait juste étiré la carte blanche autour d'un formulaire resté étroit, déplaçant le vide au lieu de le supprimer. `Apprenant/Dashboard.jsx` était resté à `max-w-[640px]` (largeur imposée par le skill de design, alors encore en vigueur) — **revu le 2026-08-30** (voir plus bas) une fois ce skill devenu non contraignant : passé à `max-w-3xl`, comme `Profile/Edit.jsx`, sur retour explicite du porteur de projet ("les trucs sont boxés").
- **1440px reste un plafond volontaire, pas un remplissage bord à bord** : sur un très grand écran, une marge résiduelle modeste subsiste de part et d'autre, conforme au skill de design (évite des tableaux/cartes étirés à l'excès sur moniteur ultra-large) ; sur un écran de largeur usuelle (jusqu'à ~1700px avec la sidebar), le contenu remplit désormais l'intégralité de l'espace disponible. À revoir si le porteur de projet préfère un remplissage total même sur très grand écran.
- Vérifié par `npm run build` (aucune erreur) — changement purement visuel (classes Tailwind), aucun test PHPUnit concerné.

### Chantier du 2026-08-29 (suite, même jour) — graphiques, icônes, micro-animations, suite au revirement sur le skill de design

Suite directe du revirement documenté plus haut (le skill de design n'est plus la référence). Trois demandes du porteur de projet : ajouter des graphiques sur les Rapports/tableaux de bord, harmoniser les icônes maison, autoriser des micro-animations plus largement.

- **`Components/BarChart.jsx`** (nouveau) : graphique en barres horizontales maison, SVG/CSS pur, zéro dépendance ajoutée (même esprit que la bibliothèque d'icônes) — une seule teinte par graphique, valeur toujours écrite en clair à côté de la barre (jamais une couleur seule à interpréter), transition animée sur la largeur au changement de valeur. Props : `data` (`[{label, value}]`), `unit`, `color` (`sky`/`violet`/`emerald`/`amber`, mêmes tons que `StatTile`), `max`.
- **Graphiques ajoutés, tous à partir de données déjà présentes côté serveur sauf mention contraire** :
  - `Staff/Rapports/Index.jsx` : « Progression moyenne par formation », agrégée **côté client** à partir des lignes déjà chargées (aucune requête serveur supplémentaire).
  - `Partenaire/Dashboard.jsx` : « Taux de présence par centre » (affiché seulement si plus d'un centre — sans intérêt comparatif à un seul).
  - `Staff/Dashboard/Secretaire.jsx` : la liste texte « Répartition des apprenants » devient un graphique (même donnée `repartition_statuts`).
  - `Staff/Dashboard/{Gestionnaire,Formateur}.jsx` : « Progression moyenne par formation » — **seul cas nécessitant un ajout serveur** : `DashboardController::formationsAvecProgression()` (nouvelle méthode privée) reproduit exactement le calcul déjà utilisé par `RapportController::lignesFiltrees()` (leçons totales via `LeconDuJour::whereHas('moduleFormation.chapitre', ...)`, coché via `progressionModulesActives`), mais agrégé en moyenne par formation plutôt que par inscription individuelle — même garde de centre/formation que l'existant (réutilise les closures `$scopeCentre`/`$scopeFormateur` déjà définies dans chaque méthode).
- **Icônes harmonisées** (`Components/Icons.jsx`) : `IconBuilding` avait cinq petits traits (`M7 7h1M7 11h1...`) rendus comme des points par le `strokeLinecap: round` — visuellement plus « chargée » que le reste de la bibliothèque (2-4 traits par icône) ; remplacés par 3 petits rectangles (même motif `<rect>` que `IconGrid`, cohérent avec le reste). `IconTag` : rayon du petit cercle (trou de l'étiquette) élargi de 1.25 à 1.5 pour rester proportionné aux autres détails de la bibliothèque. Les 17 icônes continuent de partager le même objet `base` (viewBox, épaisseur de trait, angles arrondis) — aucune n'a été ajoutée depuis une librairie externe.
- **Micro-animations** (déverrouillées par le revirement du même jour, CSS uniquement — aucune librairie JS ajoutée) :
  - `tailwind.config.js` : nouvelle animation `fade-in` (250ms, légère translation + fondu), appliquée sur `<main>` de `AuthenticatedLayout.jsx` — rejoue à chaque navigation Inertia puisque ce layout est remonté à chaque changement de page (pas de layout persistant dans ce projet). Neutralisée sous `prefers-reduced-motion: reduce` (`resources/css/app.css`).
  - `PrimaryButton`/`SecondaryButton`/`DangerButton` : ajout de `active:scale-[0.98]` (retour visuel discret au clic).
  - Barre de progression de `Staff/Rapports/Index.jsx` : ajout de `transition-all duration-500` (manquait, contrairement à celle de l'espace apprenant qui l'avait déjà).
- Vérifié par `php artisan test` (129 tests, inchangé — les nouveaux graphiques sont purement additifs côté React ; la seule logique serveur ajoutée réutilise un calcul déjà couvert par `RapportTest`/`ProgressionTest`) + `npm run build`.

## Audit de sécurité de suivi du 2026-08-29

Audit complet (pas une revue de diff) demandé explicitement par le porteur de projet, dans la continuité du chantier graphiques/icônes/animations ci-dessus — rapport détaillé ajouté à la fin de `SECURITY_AUDIT.md` (même fichier que l'audit du 2026-08-10, non commité). Portée : les 12 mêmes catégories que le 2026-08-10, avec un focus sur ce qui a changé depuis (suppression Groupe/Période/Répartition du 2026-08-07, cloisonnement du formateur par formation, ajouts du jour même). Les 8 conclusions du 2026-08-10 ont été vérifiées non régressées. 2 nouvelles conclusions, toutes deux corrigées et testées le jour même (129 → 132 tests) :

- **CRITIQUE, corrigée — `InscriptionPolicy::viewAny()` n'excluait pas le Partenaire**, contrairement à `ApprenantPolicy::viewAny()` (corrigée spécifiquement pour ce rôle le 2026-08-10, conclusion #8). Trois écrans gardés uniquement par cette Policy (`RapportController::index()/export()/exportCsv()`, `PresenceController::index()`) exposaient donc à un compte Partenaire auto-inscrit (aucune validation admin, décision assumée du 2026-08-05) le nom, la progression individuelle et l'assiduité individuelle de chaque apprenant, **tous centres confondus** — puisque `Inscription::visibleTo()` ne filtre par aucun centre pour un rôle à portée globale. Corrigé en excluant `RoleStaff::Partenaire`, comme pour `ApprenantPolicy`. **Piège à retenir, déjà entrevu le 2026-08-10** : chaque nouvelle Policy `viewAny()` gardant un écran à données nominatives doit explicitement exclure Partenaire — ce n'est pas automatique via `isGlobalScope()`, qui reste vrai pour Admin **et** Partenaire indistinctement.
- **MOYENNE, corrigée — neutralisation CSV Injection incomplète sur 2 des 3 exports.** Le trait `EscapesCsvFormulas` (ajouté le 2026-08-10) n'était en réalité appliqué qu'à `apprenants.export-identifiants-csv` — `ApprenantController::exportListeCsv()` (colonnes formation/statut/centre) et `RapportController::exportCsv()` (colonne statut apprenant) laissaient passer ces champs texte libre créés par l'admin sans neutralisation. Corrigé par les mêmes appels à `neutraliserFormuleCsv()` déjà utilisés ailleurs dans ces mêmes méthodes.
- Verrouillé par `Tests\Feature\Metier\RapportTest::test_partenaire_cannot_view_rapports_or_historique`, `Tests\Feature\Metier\PresenceScreenTest::test_partenaire_cannot_view_presence_screen`, `Tests\Feature\Metier\IdentifiantsExportTest::test_export_liste_csv_neutralises_csv_formula_injection_in_formation_and_centre`.

### Correctifs du 2026-08-29 (suite, même jour) — export identifiants accessible, validation admin des comptes, menu déroulant du bloc utilisateur

Trois retours d'usage du porteur de projet, capture à l'appui (liste Apprenants + bloc utilisateur de la sidebar).

- **Export « Identifiants » rendu accessible sans choisir une formation.** Avant ce correctif, `apprenants.export-identifiants(-csv)` exigeait un `formation_id` (422 sinon) — les boutons n'apparaissaient donc sur `Apprenants/Index.jsx` que lorsqu'une formation précise était sélectionnée dans le filtre, ce qui les rendait quasiment invisibles en usage courant (filtre par défaut sur « Toutes les formations »). Clarifié avec le porteur de projet avant implémentation : la donnée à corriger n'était pas d'ajouter le mot de passe à l'export « liste générale » (`export-liste`, volontairement sans mot de passe ni journalisation depuis le 2026-08-07, pour rester distinct de l'export identifiants) mais de rendre ce dernier plus facilement atteignable. `formation_id` devient un filtre optionnel sur `ApprenantController::apprenantsPourExport()` (même pattern `centre_id`/`formation_id` optionnels que `lignesPourListe()`) : sans filtre, l'export couvre toutes les formations visibles par l'acteur, avec une colonne Formation ajoutée par ligne (PDF `pdf/identifiants-apprenants.blade.php`, CSV) — absente quand une formation précise est choisie, pour ne rien changer à la forme du fichier dans ce cas déjà existant. Chaque mot de passe reste journalisé individuellement (§2.6) quel que soit le périmètre. Verrouillé par `Tests\Feature\Metier\IdentifiantsExportTest::test_export_identifiants_without_a_formation_filter_covers_all_formations_and_logs_each_consultation`.
- **Validation admin des comptes auto-inscrits.** Revirement explicite du porteur de projet sur la décision du 2026-08-05 (auto-inscription entièrement libre, « à ne pas re-proposer de corriger sans qu'il le redemande ») — redemandé le 2026-08-29, donc traité. L'auto-inscription reste ouverte (rôle/centre toujours choisis librement par la personne, sans admin dans la boucle à ce stade), mais le compte créé ne peut plus se connecter tant qu'un admin ne l'a pas validé.
  - Nouvelle colonne `users.valide_le` (timestamp nullable, migration `add_valide_le_to_users_table` — migration incrémentale via `php artisan migrate`, pas une édition de la migration de base : contrairement aux tout premiers chantiers de ce projet, la base contient maintenant de vrais comptes, `migrate:fresh` n'est plus une option pour un changement de schéma courant). Les comptes déjà en base au moment de la migration sont rétroactivement considérés validés (`valide_le = now()` en backfill) pour ne bloquer personne d'existant.
  - `RegisteredUserController::store()` ne connecte plus automatiquement (`Auth::login()` retiré) — redirige vers `/login` avec un message flash expliquant qu'un admin doit valider le compte. `UserController::store()` (création directe par l'admin via `/utilisateurs`) définit `valide_le = now()` d'office : pas de double validation pour un compte que l'admin crée lui-même.
  - **La vraie garde vit dans `LoginRequest::authenticate()`** (pas seulement à l'inscription) : après un `Auth::attempt()` réussi, si `valide_le` est `null` (et rôle ≠ admin — l'admin n'a jamais de compte auto-inscrit, créé uniquement à la main), déconnexion immédiate + message « Ce compte n'a pas encore été validé par un administrateur », sans compter comme tentative échouée pour le rate limiter.
  - Nouvelle route `PATCH utilisateurs/{user}/valider` (`UserController::valider()`, même garde admin-only que le reste de `/utilisateurs` via `UserPolicy::update()`). `Utilisateurs/Index.jsx` gagne une colonne « Validation » (badge Validé/En attente) et une action « Valider » affichée seulement pour un compte encore en attente.
  - Verrouillé par `Tests\Feature\Auth\RegistrationTest::test_new_account_cannot_login_until_an_admin_validates_it` (parcours complet : inscription → connexion refusée → validation admin → connexion acceptée) et `Tests\Feature\Security\CentreIsolationTest::test_gestionnaire_cannot_validate_a_staff_user`.
- **Bloc utilisateur de la sidebar : bouton de déconnexion toujours visible remplacé par un menu déroulant.** Retour du porteur de projet : le bouton de déconnexion à côté du nom, visible en permanence, ne devait plus l'être — remplacé par un menu contenant « Profil » et « Se déconnecter ». `Components/Dropdown.jsx` gagne une prop `direction` (`'down'` par défaut, inchangé partout ailleurs) : `direction="up"` positionne le menu au-dessus du déclencheur plutôt qu'en dessous — nécessaire ici puisque ce bloc est tout en bas de la sidebar (un menu vers le bas sortirait de l'écran, raison pour laquelle ce bloc n'utilisait justement pas `Dropdown` jusqu'ici, voir la note du 2026-08-05 dans « Organisation du code »). `AuthenticatedLayout.jsx` : `BlocUtilisateur` devient le déclencheur d'un `Dropdown` (`align="left"`, `direction="up"`) contenant les deux actions ; le bouton de déconnexion n'est donc plus visible sans ouvrir le menu — changement purement visuel, non testé par `php artisan test` (comme le reste du balisage React), vérifié par `npm run build`.
- Suite complète : 135 tests / 711 assertions.

### Bug critique corrigé le 2026-08-29 (suite, même jour) — inscription/modification impossible pour tout rôle non-formateur

Signalé par le porteur de projet : « j'ai essayé de créer un compte en tant que partenaire mais ça n'a marché ». Diagnostic : **`formation_ids` portait un `'min:1'` inconditionnel** dans 4 `FormRequest` distincts (`Auth\StoreRegistrationRequest`, `ProfileUpdateRequest`, `StoreStaffUserRequest`, `UpdateStaffUserRequest`) — une règle censée s'appliquer seulement au formateur (`Rule::requiredIf(role === formateur)` juste à côté), mais `'array'`/`'min:1'` ne sont eux-mêmes jamais conditionnés par ce `requiredIf` : ils s'appliquent dès que le champ est *présent* dans la requête, peu importe s'il est requis. Or les 3 formulaires React concernés (`Auth/Register.jsx`, `Profile/Partials/UpdateProfileInformationForm.jsx`, `Staff/Utilisateurs/Form.jsx`) utilisent tous `useForm({ ..., formation_ids: [] })` — cette clé part donc **toujours** dans le payload, y compris pour un rôle qui n'affiche même pas ce champ à l'écran (gestionnaire/secrétaire/partenaire/admin). Résultat : un tableau vide mais présent échouait `'min:1'` (0 < 1) pour n'importe quel rôle non-formateur — **inscription, création par l'admin et mise à jour du profil personnel silencieusement impossibles** pour ces 4 rôles. Silencieux car `errors.formation_ids` n'est affiché dans aucun des 3 formulaires que dans le bloc conditionnel `role === 'formateur'`/`estFormateur` — l'utilisateur ne voyait littéralement aucune erreur, juste un formulaire qui semblait ne rien faire au clic.
- **Pourquoi les tests existants ne l'ont jamais détecté** : chaque test HTTP simulait un payload minimal omettant complètement la clé `formation_ids` pour les rôles non-formateur (`RegistrationTest::test_new_user_can_register_as_partenaire_without_a_centre`, etc.) — un champ **absent** de la requête ne déclenche pas les règles `'array'`/`'min:1'` (non implicites) chez Laravel, contrairement à un champ **présent mais vide**. Exactement le même piège « le contrat serveur est testé, pas le payload réel envoyé par le client » déjà rencontré le 2026-08-07 (résidus `chapitre_id`/`nouveau_chapitre_titre` dans `Programme.jsx`) — **à retenir une seconde fois : un test de FormRequest doit toujours reproduire la forme exacte du payload que `useForm()` envoie réellement (champs vides inclus), pas une version minimaliste construite à la main.**
- **Correctif** : simple suppression du `'min:1'` inconditionnel dans les 4 fichiers — `Rule::requiredIf(...)` suffit à lui seul, puisque Laravel traite un tableau vide comme absent pour ses règles `required`/`required_if` (`count($value) < 1` fait déjà échouer `required` en interne). Comportement inchangé pour un formateur (toujours obligé de cocher au moins une formation), corrigé pour tous les autres rôles.
- **Corrigé au passage, même cause profonde** : `Auth/Register.jsx` et `Staff/Utilisateurs/Form.jsx` ne réinitialisaient jamais `centre_id`/`formation_ids` en changeant de rôle dans le `<select>` — un partenaire sélectionné après un rôle avec centre pouvait hériter silencieusement d'un `centre_id` résiduel (aucune erreur de validation, `centre_id` accepte n'importe quel centre existant peu importe le rôle), contredisant « l'accès partenaire est global, sans filtrage par centre ». Les deux pages gagnent un handler `changeRole()` qui vide `centre_id`/`formation_ids` dès que le nouveau rôle n'en a plus besoin.
- Verrouillé par des tests qui reproduisent cette fois la vraie forme du payload (`formation_ids: []` explicite) : `RegistrationTest::test_new_user_can_register_as_partenaire_with_the_real_frontend_payload_shape`, `test_new_user_can_register_as_gestionnaire_with_the_real_frontend_payload_shape`, `ProfileTest::test_non_formateur_can_update_their_profile_with_the_real_frontend_payload_shape`, nouveau `Tests\Feature\Metier\UtilisateurAdminTest` (création/modification par l'admin d'un partenaire/secrétaire/gestionnaire).
- Suite complète : 141 tests / 726 assertions.

### Passe d'optimisation UI du 2026-08-29 (suite, même jour) — cartes formations partenaire, menu du bloc utilisateur, exports groupés, sélecteur de date maison

Quatre retours d'usage du porteur de projet, captures à l'appui.

- **`Partenaire/Formations.jsx`** : la liste de pastilles bleues par centre (« Centre X (N) ») jugée peu compréhensible est remplacée par `Components/BarChart` (même composant déjà utilisé ailleurs dans l'app pour ce type de répartition) — la proportion entre centres se lit d'un coup d'œil au lieu de comparer des nombres entre parenthèses. En-tête de carte enrichi d'une icône (`IconAcademicCap` dans un badge circulaire) et d'un `Badge tone="info"` pour le total, au lieu d'un texte gris plat.
- **Menu du bloc utilisateur de la sidebar** (`AuthenticatedLayout.jsx`) jugé « pas bien fait » : ajout d'icônes devant « Profil »/« Se déconnecter » (`Dropdown.Link` gagne une prop `icon` optionnelle, rétrocompatible — seul le second usage de `Dropdown.Link`, le menu « ••• » d'`Apprenants/Index.jsx`, n'en profite pas et reste inchangé), d'un séparateur entre les deux actions, et d'un chevron sur le déclencheur pour signaler que c'est un menu cliquable (rien ne l'indiquait auparavant).
- **Boutons d'export d'`Apprenants/Index.jsx`** (4 boutons à plat sur 2 lignes, jugés mal disposés) regroupés en 2 menus déroulants clairement étiquetés — « Exporter les apprenants » (icône groupe) et « Exporter les identifiants » (icône cadenas, pour bien marquer visuellement qu'il contient des mots de passe) — chacun proposant PDF/CSV en option via un nouveau composant local `ExportMenu`. **Piège évité** : ce sont des `<a href>` bruts dans le menu, pas des `Dropdown.Link` — un `Dropdown.Link` rend un `<Link>` Inertia, qui traiterait la réponse binaire (PDF/CSV) comme une visite Inertia plutôt qu'un téléchargement (même piège déjà documenté ailleurs dans ce fichier pour les exports).
- **Nouveau `Components/DatePicker.jsx`** : sélecteur de date maison (calendrier custom, SVG/CSS, zéro dépendance — même esprit que `BarChart`/`Icons.jsx`) remplaçant les 3 `<input type="date">` de l'app (`Presences/Index.jsx`, et les deux formulaires de `Staff/Formations/Programme.jsx` — planification d'un cours et correction de la date d'une leçon déjà planifiée) : le calendrier natif du navigateur ne peut pas être personnalisé et détonnait avec le reste de l'app. Même contrat qu'un champ contrôlé (`value` en chaîne `'YYYY-MM-DD'`, `onChange(valeur)` de même forme) — remplace un `<input type="date">` sans toucher au reste du formulaire appelant. Grille de 6 semaines lundi→dimanche avec jours hors-mois grisés, navigation mois précédent/suivant, jour sélectionné en plein bleu ciel, raccourcis « Effacer »/« Aujourd'hui ». Même piège UTC déjà documenté ailleurs dans ce fichier (`toISOString()` décale d'un jour) : construction de la date locale à la main. Deux nouvelles icônes `IconChevronLeft`/`IconChevronRight` dans `Icons.jsx`.
- Aucun de ces changements ne touche à la logique serveur (balisage/CSS/JS client uniquement) — vérifié par `php artisan test` (141 tests, inchangé) + `npm run build`. Pas de test JS (gap déjà documenté, aucun framework de test JS dans ce projet) — à confirmer visuellement par le porteur de projet.

## Audit du 2026-08-30 — sécurité, performance, tests, accessibilité

Audit complet demandé explicitement par le porteur de projet (« audite tout »), pas une revue de diff — mené en 3 passes parallèles (autorisation/scopes, requêtes/performance, couverture de tests + accessibilité des composants maison). 2 failles de sécurité trouvées et corrigées le jour même, le reste documenté comme pistes d'amélioration non bloquantes.

- **CRITIQUE, corrigée — « Planifier un cours » permettait de déplacer la date d'une leçon d'un autre centre/formation.** `StoreLeconDuJourRequest::withValidator()` ne vérifiait l'appartenance de `module_formation_ids` à la formation/au centre de l'acteur que si un chapitre **existant** était choisi (`$chapitre` non nul) — en passant par `nouveau_chapitre_titre` (créer un chapitre à la volée) à la place, cette vérification était entièrement sautée. Un formateur/gestionnaire pouvait glisser l'id d'un module d'un **autre centre ou d'une autre formation** dans `module_formation_ids` aux côtés d'un nouveau chapitre bidon : `LeconDuJourController::store()` déplaçait alors silencieusement la date planifiée de ce module étranger (`updateOrCreate` sur `module_formation_id`, sans aucun autre contrôle), visible sur le Programme et le tableau de bord apprenant d'un centre auquel l'acteur n'a pourtant aucun accès. Corrigé : la vérification (formation + centre, pour un acteur non global) s'applique désormais systématiquement à tout module choisi, que le chapitre visé soit nouveau ou existant. Verrouillé par `Tests\Feature\Metier\ChapitreProgrammeTest::test_cannot_plan_a_new_chapitre_with_a_module_of_another_centre` et `..._of_another_formation`.
- **BASSE, corrigée par prévention — `InscriptionPolicy::view()`/`ChapitrePolicy::view()` laissaient passer `isGlobalScope()`** (vrai pour Admin **et** Partenaire) au lieu d'exclure explicitement le partenaire, contrairement à `viewAny()` sur ces mêmes modèles (corrigé le 2026-08-29 pour `Inscription`). Aucune route n'invoque aujourd'hui ces deux méthodes `view()` (vérifié), donc pas d'exploit actif — mais un futur écran « fiche détail » par id aurait silencieusement redonné au partenaire un accès à des données nominatives, comme ça a déjà été le cas une fois. Retiré le raccourci `isGlobalScope()` (`before()` gère déjà l'admin, pas besoin de le répéter dans le corps de la méthode). Verrouillé par `Tests\Feature\Security\CentreIsolationTest::test_partenaire_cannot_view_a_single_inscription_or_chapitre_via_policy`.
- **Piège à généraliser** : toute nouvelle Policy `view()`/`viewAny()` sur une ressource nominative doit se fier uniquement à `before()` pour l'admin — jamais réintroduire `$user->role->isGlobalScope()` dans le corps de la méthode, ce raccourci laisse toujours passer le partenaire par la même occasion.
- **Performance, non corrigée (à faire si le besoin se confirme)** : `RapportController::lignesFiltrees()` recalcule `totalLecons` par une requête `LeconDuJour::whereHas(...)->count()` **par ligne** dans son `->map()`, alors que `ApprenantController::attacherActivite()` résout déjà exactement ce même calcul en une seule requête groupée mémorisée par `(formation_id, centre_id)` — un copier de ce pattern déjà en place réglerait le problème sans rien inventer. Même chose, moindre impact (borné par le nombre de formations d'un centre) dans `DashboardController::formationsAvecProgression()`.
- **Gaps déjà documentés, reconfirmés toujours vrais** : pas de pagination sur les listes (Apprenants, Utilisateurs...) — sans impact tant que le volume reste à quelques dizaines par centre, mais aggravé par le filtrage 100 % côté client (toute la liste non filtrée part dans le payload Inertia à chaque chargement, pas seulement à la recherche) ; les deux gaps se rejoignent et devraient être traités ensemble (recherche serveur + pagination) le jour où ça devient nécessaire. Pas d'index dédié sur `presences.date_session`/`inscriptions.statut` (filtrés en continu) — pas urgent aux volumes actuels, mais `presences` est la table qui grossit le plus vite (une ligne par inscription active par jour coché), à surveiller.
- **Couverture de tests, gaps identifiés (aucun test manquant n'est un risque de sécurité — juste des scénarios jamais exercés)** : `UserController::destroy`, `LeconDuJourController::destroy`, `ApprenantController::destroy`, `ApprenantAuth\AuthenticatedSessionController` (login/logout apprenant, aucune route jamais exercée directement), la branche mot de passe de `UserController::update()`, et la matrice Secrétaire/Partenaire sur `/utilisateurs` (seuls Gestionnaire/Formateur sont couverts par `CentreIsolationTest`).
- **Accessibilité clavier, composants maison** : ni `Components/Dropdown.jsx` ni `Components/DatePicker.jsx` (les deux nouveaux composants de la passe du 2026-08-29) ne se ferment avec Échap ni n'exposent d'attributs ARIA (`aria-haspopup`/`aria-expanded`/`role="menu"`/`role="dialog"`) — utilisables au clavier (boutons natifs, Tab fonctionne) mais sans les conventions d'accessibilité attendues pour un menu/calendrier. `Components/Modal.jsx` (Headless UI) gère déjà focus-trap/Échap/ARIA nativement, mais aucun appelant ne fournit de `Dialog.Title` — les modales affichent un titre visuel sans nom accessible programmatique pour un lecteur d'écran.
- Suite complète : 144 tests / 736 assertions.

### Correctif du 2026-08-30 (suite, même jour) — calendrier coupé dans une modale, champs élargis

Retour d'usage du porteur de projet, captures à l'appui : dans la modale « Planifier un cours », le calendrier de `DatePicker` (nouveau composant du 2026-08-29) s'ouvrait bien mais se retrouvait **coupé net** dès qu'il dépassait le bas de la modale. Cause identique à un bug déjà rencontré et corrigé une fois dans ce projet (`Dropdown.jsx`, 2026-08-06) : `Components/Modal.jsx` pose `overflow-hidden` sur le panneau (`DialogPanel`) pour ses propres besoins d'affichage, ce qui coupe tout enfant positionné en `absolute` qui déborderait visuellement de cette zone — exactement ce que faisait le calendrier. **Corrigé avec le même remède déjà établi** : `DatePicker` rend désormais son calendrier via `createPortal(document.body)`, en `position: fixed` avec des coordonnées calculées au clic (`getBoundingClientRect()` du déclencheur, même pattern que `Dropdown.jsx`) — il n'est donc plus jamais contraint par un ancêtre `overflow-hidden`/`overflow-x-auto`, dans une modale ou ailleurs. **Enseignement à généraliser** : tout futur composant avec un popup/dropdown doit par défaut être rendu en portail + position fixed plutôt qu'en `absolute` classique, dès qu'il pourrait un jour se retrouver dans un conteneur `overflow`-contraint (modale, tableau à défilement horizontal) — ce n'est pas visible en développement isolé, seulement en usage réel imbriqué.
- **Champs élargis** dans le même formulaire (« Planifier un cours ») : padding augmenté (`px-3.5 py-2.5` au lieu du défaut du plugin Tailwind Forms) sur le `<select>` chapitre/centre, les champs texte "nouveau chapitre"/"nouvelle leçon", et les lignes de la liste de leçons à cocher — pour un rendu plus spacieux, moins compact. Le bouton du `DatePicker` gagne le même padding, appliqué partout où le composant est utilisé (Présence, les deux formulaires de Programme) puisqu'il est partagé.
- Aucun test PHPUnit concerné (balisage/CSS/JS client uniquement) — vérifié par `php artisan test` (144 tests, inchangé) + `npm run build`.

### Nouveau (2026-08-30, suite, même jour) — confirmation avant déconnexion et généralisation des confirmations de suppression

Retour du porteur de projet : « dans certains cas on doit avoir un feedback, par exemple quand quelqu'un veut se déconnecter on doit demander de confirmer, et même pour la suppression ». Audit : la déconnexion (sidebar staff **et** tableau de bord apprenant) ne demandait strictement aucune confirmation — un clic déconnectait immédiatement. Les suppressions, elles, avaient déjà toutes une confirmation, mais via `window.confirm()` natif (11 occurrences trouvées, 8 fichiers) — la boîte de dialogue du navigateur ne peut pas être stylée et détonne avec le reste de l'app, même raisonnement déjà appliqué au calendrier natif (`DatePicker`, 2026-08-29).

- **Nouveau `Components/ConfirmDialog.jsx`** : boîte de confirmation partagée (au-dessus de `Modal.jsx`/Headless UI), icône dans un badge circulaire (rouge si `danger`, bleu ciel sinon), titre + message, boutons Annuler/Confirmer (`DangerButton` si `danger`, sinon `PrimaryButton`). Nouvelle icône `IconAlertTriangle` (icône par défaut si aucune n'est précisée).
- **Confirmation ajoutée avant toute déconnexion** : `Layouts/AuthenticatedLayout.jsx` (`BlocUtilisateur`, sidebar staff) et `Pages/Apprenant/Dashboard.jsx` — le clic ouvre désormais `ConfirmDialog` plutôt que de poster directement `/logout`.
- **Les 11 `window.confirm()` remplacés par `ConfirmDialog`**, un état local par page (`useState(null | cible)`, ou une variante `{ type, cible }` quand une même page a plusieurs actions à confirmer — `Programme.jsx` pour chapitre/module/leçon planifiée, `Apprenants/Index.jsx` et `Apprenants/Show.jsx` pour régénérer/supprimer/abandonner) : `Staff/{ApprenantStatuts,Centres,Utilisateurs,Formations,Apprenants}/Index.jsx`, `Staff/Apprenants/Show.jsx`, `Staff/Formations/Programme.jsx`. Le message reprend le nom de l'élément ciblé, comme le faisait le `confirm()` natif.
- **`Profile/Partials/DeleteUserForm.jsx` non touché** : la suppression de son propre compte demandait déjà une vraie confirmation en modale (avec ressaisie du mot de passe, plus stricte qu'un simple oui/non) — rien à améliorer là.
- Aucun test PHPUnit concerné (balisage/JS client uniquement, la requête serveur déclenchée reste strictement identique une fois confirmée) — vérifié par `php artisan test` (144 tests, inchangé) + `npm run build`.

### Correctif du 2026-08-30 (suite, même jour) — espace apprenant encore "boxé" sur grand écran

Retour d'usage du porteur de projet, capture à l'appui : sur `Apprenant/Dashboard.jsx`, la bannière d'accueil et le corps de page (« Mon programme ») restaient chacun contraints à `max-w-[640px]` — un reste de la largeur imposée par l'ancien skill de design (une seule colonne étroite, pensée pour un public en alphabétisation), devenu non contraignant depuis le revirement du 2026-08-29. Sur un écran large, ça laissait un vide important de part et d'autre, avec la même impression "boxée" déjà corrigée ailleurs le 2026-08-29 (`max-w-content`, 1440px, généralisé à 15 pages).

- Les 3 occurrences de `max-w-[640px]` (bannière : plaque logo + bouton déconnexion, avatar + progression ; corps : cartes de chapitre) remplacées par `max-w-3xl` (768px) — pas `max-w-content` (1440px, pensé pour des tableaux/grilles de cartes) : l'espace apprenant reste une lecture à une seule colonne (titre de chapitre + liste de leçons), l'élargir jusqu'à 1440px aurait étiré des lignes de texte au-delà d'une largeur de lecture confortable sans rien ajouter d'utile. Même largeur que `Profile/Edit.jsx`, l'autre page "personnelle" à une colonne de l'app.
- Vérifié par `npm run build` (aucune erreur) — changement purement visuel (classes Tailwind), aucun test PHPUnit concerné.

## Audit UX/UI + fonctionnel + performance du 2026-08-30

Audit complet demandé explicitement par le porteur de projet (« vérifie que chaque bouton est connecté à une base de données et que chaque action fonctionne vraiment ») — mené en 4 passes parallèles couvrant les 32 pages/formulaires React de l'app. Rapport détaillé dans `AUDIT_UX_FONCTIONNEL.md` à la racine (fichier de travail, non commité, même statut que `SECURITY_AUDIT.md`). **Aucun bouton mort ni route inexistante trouvé** — chaque action tracée pointe vers une route réelle et un contrôleur qui fait un vrai travail ; les problèmes trouvés sont des écarts entre ce que l'UI promet et ce qui se passe réellement, pas des liens cassés. Résumé des points à trancher (non corrigés à ce stade, en attente d'arbitrage du porteur de projet) :

- **Suppression Formation/Chapitre** : le message de confirmation promet une suppression en cascade du programme, mais le soft delete ne cascade jamais (même piège déjà connu pour Chapitre/Module, jamais traité au niveau Formation) — aucune garde contre la suppression d'une formation ayant des apprenants actifs.
- **Aucune garde contre l'auto-désactivation d'un admin** sur `/utilisateurs` — risque de verrouillage total si compte admin unique.
- **Bouton "Supprimer" un apprenant visible pour Formateur/Secrétaire** alors que `ApprenantPolicy::delete()` ne l'autorise qu'au Gestionnaire — échoue en 403 silencieusement (pas de `onError`, la confirmation se referme comme si ça avait marché).
- **Compteurs d'export "PDF (N)" trompeurs** sur Apprenants/Index (le nombre inclut la recherche client, jamais transmise au serveur qui génère le fichier).
- **Boutons d'export Rapports actifs malgré "Choisissez au moins un filtre"** — un clic dans cet état exporte quand même toutes les inscriptions visibles.
- **`User` n'implémente plus `MustVerifyEmail`** (`app/Models/User.php:5`, interface commentée) — 3 contrôleurs de vérification d'e-mail planteraient en 500 s'ils étaient atteints ; impact actuel nul (rien n'y mène dans l'UI), mais à trancher : réactiver proprement ou supprimer ce code mort.
- Confirmé toujours vrai, non corrigé : le N+1 de `RapportController::lignesFiltrees()` (déjà documenté le 2026-08-30 plus haut) et de `DashboardController::formationsAvecProgression()`.
- **Nouveau, pas documenté avant cet audit** : `Partenaire\DashboardController::index()` a le même pattern N+1 (2 requêtes par centre non groupées), aggravé par le polling 20s — à surveiller si le nombre de centres grandit.
- Gaps UX mineurs : 3 réimplémentations différentes de la palette de statut d'inscription (`Apprenants/Show.jsx`, `Rapports/Index.jsx`, `lib/statuts.js::ETAT_INSCRIPTION` — ce dernier jamais importé) ; pré-remplissage silencieux de `formation_id`/`centre_id` au premier élément dans `Apprenants/Form.jsx` ; `Auth/ConfirmPassword.jsx`/`Auth/VerifyEmail.jsx` orphelines et restées en anglais (scaffold Breeze jamais nettoyé) ; colonne Date non formatée dans l'Historique.
- Audit en lecture seule, aucun fichier applicatif modifié — corrections à faire au fil de l'eau selon les arbitrages du porteur de projet, comme pour l'audit de sécurité.

### Correction de l'audit UX/UI + fonctionnel + performance (2026-08-30, suite, même jour)

Tous les points 🔴 et 🟠 de l'audit ci-dessus corrigés et testés sur demande explicite du porteur de projet (« commence la correction »). Détail complet dans `AUDIT_UX_FONCTIONNEL.md` (mis à jour avec le statut de chaque point). 144 → 151 tests, 736 → 782 assertions ; `npm run build` vert.

- **Suppression Formation/Chapitre** : `FormationController::destroy()` bloque désormais (nouveau flash `error`, rouge, affiché par `FlashStatus.jsx`) si un `Apprenant` a encore `formation_id` sur cette formation — sinon cascade réellement en transaction (`ModuleFormation`→`Chapitre`→`Formation`, tous soft-supprimés), rendant vrai le message qui promettait déjà cette cascade. `ChapitreController::destroy()` cascade de même vers `modulesFormation()` — corrige au passage le badge "Leçons" de `Formations/Index.jsx` (les lignes désormais réellement soft-supprimées sont automatiquement exclues du `withCount` par le scope global d'Eloquent, sans toucher la requête). Nouveau canal `flash.error` ajouté à `HandleInertiaRequests::share()`/`FlashStatus.jsx` (rendu en rouge à côté du `status` vert existant) — premier cas d'usage de ce canal, réutilisable pour toute future garde bloquante de ce genre.
- **Auto-désactivation admin** : `UserPolicy::before()` ne court-circuite plus l'ability `delete` (fallthrough vers `delete()`, qui vérifie `$user->id !== $model->id`) — un admin ne peut plus se désactiver lui-même. Bouton "Désactiver" masqué côté React sur sa propre ligne.
- **Suppression apprenant** : bouton masqué pour Formateur/Secrétaire (`ApprenantPolicy::delete()` ne l'autorise qu'à Gestionnaire+Admin) dans `Apprenants/Index.jsx` et `Show.jsx` — éliminait un 403 silencieux après confirmation.
- **Exports Rapports** : les deux liens PDF/CSV se désactivent visuellement (`aria-disabled`, `cursor-not-allowed`, `onClick` neutralisé) tant que `lignes === null`, cohérent avec le message "Choisissez au moins un filtre" déjà affiché à l'écran.
- **Compteurs d'export Apprenants** : `nombreExportListe`/`nombreExportIdentifiants` recalculés indépendamment de la recherche client (jamais transmise au serveur), avec la même exclusion "inscription active" que `apprenantsPourExport()` pour le second.
- **N+1 corrigés** : nouveau `LeconDuJour::totalParFormationCentre()` (requête groupée par `"{formation_id}-{centre_id}"`, extrait de l'ancien calcul inline d'`ApprenantController::attacherActivite()`) désormais réutilisé par `RapportController::lignesFiltrees()` — élimine le `count()` par inscription dans son `->map()`. `Partenaire\DashboardController::index()` : les 2 requêtes par centre (formations actives, présence du jour) remplacées par 2 requêtes groupées calculées une fois avant le `->map()` sur les centres — pattern non factorisé avec `totalParFormationCentre()` (structure de jointure différente), mais même principe. `DashboardController::formationsAvecProgression()` laissé tel quel (impact jugé limité, borné par le nombre de formations d'un centre/formateur).
- **Faux positif corrigé dans le rapport d'audit, aucun code touché** : l'audit initial affirmait que les 3 contrôleurs de vérification d'e-mail plantaient (500) car `User` n'implémente plus l'interface `MustVerifyEmail` — **vérifié faux avant toute action** : `Illuminate\Foundation\Auth\User` (classe de base) inclut le trait `Illuminate\Auth\MustVerifyEmail` inconditionnellement, indépendamment de l'interface — confirmé par `tests/Feature/Auth/EmailVerificationTest.php` (déjà vert) et par réflexion PHP directe. Seul effet réel de l'interface commentée : le bloc "renvoyer l'e-mail de vérification" de `Profile` ne s'affiche jamais (fonctionnalité orpheline mais non cassée, même statut que `ConfirmPassword.jsx`). **Leçon à retenir : un finding d'audit produit par un agent doit être vérifié par une exécution réelle (ici `php artisan test --filter` + `tinker`) avant d'agir dessus, jamais appliqué tel quel** — cette fois-ci la vérification a évité de supprimer à tort du code fonctionnel.
- **Nouveaux tests** : `ReferenceDataCrudTest::test_admin_cannot_delete_a_formation_with_apprenants_still_attached`/`test_deleting_a_formation_cascades_to_its_chapitres_and_modules`, `ChapitreProgrammeTest::test_deleting_a_chapitre_cascades_to_its_modules`, `CentreIsolationTest::test_admin_cannot_deactivate_their_own_account`/`test_admin_can_deactivate_another_admin`, `RapportTest::test_progression_pourcentage_is_isolated_per_formation_after_the_grouped_query_refactor`, `PartenaireDashboardFiltreTest::test_dashboard_computes_formations_actives_and_taux_presence_per_centre`.
- **Non traité dans cette passe** (priorité basse, laissé pour un arbitrage ultérieur) : mismatch StatTile dashboards/liste non filtrée par statut ; 3 réimplémentations de la palette de statut d'inscription ; nettoyage de `ConfirmPassword.jsx`/`VerifyEmail.jsx` ; formatage de la colonne Date de l'Historique ; pré-remplissage silencieux de `Apprenants/Form.jsx`.

### Ré-audit du 2026-08-31 — vérification anti-régression des correctifs du 2026-08-30

Demande explicite du porteur de projet (« fait un audit », précisé « UX/UI + fonctionnel (refait) » face au choix proposé) : re-passer sur les mêmes 32 pages pour vérifier qu'aucune régression ne s'est glissée depuis le commit `5e844b5`. Mené en 4 passes parallèles (mêmes périmètres que le 2026-08-30), chaque agent relisant le code réel des correctifs plutôt que de se fier au rapport écrit, et re-vérifiant indépendamment (sans confiance aveugle) le diagnostic « faux positif MustVerifyEmail ». **Aucune régression trouvée** sur les 6 correctifs (`FormationController::destroy`, `ChapitreController::destroy`, `UserPolicy::delete`, canal `flash.error`, masquage "Supprimer" apprenant, désactivation des exports Rapports, `LeconDuJour::totalParFormationCentre()`, N+1 du dashboard partenaire) — tous fidèles à ce qu'ils prétendent faire, tests toujours alignés sur le code réel (151 tests / 782 assertions, `npm run build` vert).
- **Seule imprécision relevée dans le rapport d'audit initial, corrigée dans `AUDIT_UX_FONCTIONNEL.md`** : le point #6 (faux positif MustVerifyEmail) affirmait qu'« aucune route n'utilise le middleware `verified` » — **factuellement inexact**, `routes/web.php:29` (`/dashboard`) l'applique bien depuis le 2026-08-07. Sans conséquence pratique : `EnsureEmailIsVerified::handle()` ne bloque que si `$user instanceof MustVerifyEmail`, toujours `false` ici — le middleware reste un no-op pur, la conclusion (« fonctionnalité orpheline mais non cassée ») restait donc correcte, seule sa justification était fausse.
- Gaps déjà documentés comme non traités (StatTile mismatch, palettes de statut dupliquées, `Apprenants/Form.jsx`, Historique) reconfirmés inchangés — pas des régressions, des choix de priorité déjà actés le 2026-08-30.

## Audit de sécurité de suivi du 2026-08-31

Demandé explicitement (« audit de sécurité »), après le ré-audit UX/fonctionnel de la veille. Mené en 3 passes parallèles (authentification/sessions/autorisation ; validation serveur/Eloquent/props Inertia/upload ; secrets/config/dépendances/données personnelles). Rapport détaillé dans `SECURITY_AUDIT.md` (racine, non commité). **Aucune faille CRITIQUE ni HAUTE** — les 9 conclusions des audits du 2026-08-10/29 toutes reconfirmées corrigées, sans régression (vérifié par lecture de code + suite `Security`/`Auth`/`Registration`, 55 tests/142 assertions verts). 4 nouveaux points BASSE, non corrigés à ce stade (en attente d'arbitrage) :

- `CentrePolicy::view()` réintroduit le raccourci `isGlobalScope()` que la règle du 2026-08-30 interdit désormais — non exploitable aujourd'hui (`centres.show` n'existe pas), à corriger par cohérence.
- `password_visible` présent dans `Apprenant::$fillable` sans qu'aucun écrivain légitime ne l'assigne en masse — défense en profondeur manquante, pas une vulnérabilité active.
- Aucun rate limiting sur `POST /register` (staff) — spam d'inscriptions/abus d'e-mail de vérification possible en théorie, atténué par la garde `valide_le`.
- `ApprenantCredentialController` (consultation/régénération mot de passe apprenant) sans rate limiting — nécessite déjà un compte staff compromis, journalisation existante ne fait que tracer après coup.
- INFO : `inertiajs/inertia-laravel`/`@inertiajs/react` en retard d'une version majeure chacun, aucune CVE connue (`composer audit`/`npm audit` : 0 avis).

## Audit responsive/mobile du 2026-08-31

Demandé explicitement (« vois entièrement le mode responsive complet et optimise-le, UI/UX sur mobile »), captures d'écran mobile à l'appui pointant en particulier les barres de recherche. **Aucun outil de rendu visuel/navigateur n'est disponible dans cet environnement** (vérifié à nouveau : ni Playwright/Chromium ni capture d'écran) — audit mené par lecture de code (classes Tailwind responsive, comportement des popups en position fixed, grilles qui ne s'effondrent pas) plutôt que par un rendu réel, comme tous les précédents chantiers responsive de ce projet. Corrections appliquées directement (pas de rapport séparé, contrairement aux audits sécurité/UX) :

- **`Components/SearchInput.jsx` — cause du signalement initial.** Contrairement à tous les `<select>` voisins des mêmes bandeaux de filtre (`w-full`), ce champ utilisait `min-w-[240px] flex-1` — un `flex-1` sans effet depuis le passage des bandeaux de filtre en grille CSS (mort depuis, jamais nettoyé), et un `min-w` figé qui pouvait déborder du bandeau sur les écrans les plus étroits. Il manquait aussi l'icône que "Rôle"/"Centre"/"Formation" affichent tous à côté de leur libellé — particulièrement visible sur mobile où les champs s'empilent verticalement à la suite les uns des autres. Réécrit : `w-full` comme ses voisins, icône loupe (`IconSearch`, nouvelle) intégrée au champ + passée à `FiltreChamp` sur les deux pages qui l'utilisent (Apprenants, Utilisateurs), `type="search"` (clavier mobile adapté), bouton d'effacement rapide (`IconX`, nouvelle) visible dès qu'il y a du texte — plus pratique qu'un clavier tactile pour tout re-sélectionner à la main.
- **`Components/Modal.jsx` — largeur non garantie sur mobile.** `sm:w-full`/`sm:mx-auto` ne s'appliquaient qu'à partir de `sm:` (640px) ; en dessous, le panneau (élément flex sans `flex-basis`) se dimensionnait uniquement sur son contenu, sans garantie de largeur ni de centrage — fragile puisque ça dépend du contenu de chaque formulaire. Corrigé une fois pour toutes (composant partagé par **toutes** les modales de l'app) : `w-full` inconditionnel sur le panneau + `justify-center` sur le conteneur, `sm:max-w-*` continue de plafonner la largeur à partir de `sm:`.
- **`Components/Dropdown.jsx` et `Components/DatePicker.jsx` — popups en `position: fixed` sans clamp au viewport.** Les deux calculent leurs coordonnées à partir de `getBoundingClientRect()` du déclencheur mais ne vérifiaient jamais que le popup (largeur fixe : `w-48`/`w-56` pour Dropdown, `w-72` pour DatePicker) reste dans l'écran une fois affiché — un déclencheur proche d'un bord sur un téléphone étroit pouvait faire déborder le menu/calendrier hors viewport. Corrigé par un rattrapage post-affichage (`useLayoutEffect` + mesure réelle via `getBoundingClientRect()` du popup lui-même, décalage horizontal appliqué en `transform: translateX()` pour Dropdown / recalcul direct de `left` — et bascule au-dessus du déclencheur si le bas déborde aussi — pour DatePicker) plutôt que de deviner la largeur à l'avance : robuste quel que soit le contenu, sans jamais régresser le comportement existant (si la mesure échoue, aucun décalage n'est appliqué, comportement identique à avant).
- **`Staff/Rapports/Index.jsx` — bandeau de filtres en `flex flex-wrap` sans largeur définie.** Contrairement à Apprenants/Utilisateurs/Présences (grille CSS `grid-cols-1 sm:grid-cols-N`, chaque champ prend toute la largeur en une colonne sur mobile), ce bandeau utilisait `flex flex-wrap items-end gap-6` avec les `<select>` seulement `min-w-[10rem]` (160px) — une fois empilés sur mobile, chaque champ restait réduit à ~160px au lieu de remplir la ligne. Converti en grille (`grid-cols-1 sm:grid-cols-2`), boutons d'export PDF/CSV sortis du même bandeau vers leur propre ligne en dessous (même schéma qu'Apprenants/Index.jsx) plutôt que collés à droite via `ms-auto` sur une ligne qui peut se retrouver seule après un wrap.
- **`Staff/Presences/Index.jsx` — grille de statistiques en `grid-cols-3` fixe.** Seul endroit de l'app où la rangée de `StatTile` (icône 40px + chiffre + libellé, `p-4`) restait à 3 colonnes fixes même sur mobile, contrairement au même pattern déjà correct sur les tableaux de bord Formateur/Secrétaire/Partenaire (`grid-cols-1 sm:grid-cols-3`) — sur un écran étroit, 3 tuiles de ce gabarit dans la largeur disponible ne laissent quasiment plus de place au texte. Corrigé pour aligner sur le pattern déjà établi ailleurs.
- **Vérifié et jugé déjà correct, pas de changement** : `Apprenant/Dashboard.jsx` (`min-w-0`+`truncate` sur le nom, `flex-wrap` sur l'en-tête de chapitre, cibles tactiles 64px/32px déjà en place, texte du bouton déconnexion masqué sur mobile via `hidden sm:inline` en gardant l'icône) ; `DataTable.jsx` (colonne sticky + défilement horizontal déjà traités le 2026-08-05/06) ; `Staff/Formations/Programme.jsx` (tout en `flex-wrap`, pas de grille fixe) ; le filtre centre de `Programme.jsx` (`max-w-xs` en flux bloc normal, pas dans un conteneur flex — se comporte correctement, contrairement à `Modal.jsx`).
- **Non traité dans cette passe, jugé non prioritaire** : le padding fixe `p-6` (24px) des conteneurs de page et des cartes de filtre (répété à l'identique sur ~17 pages) n'est pas réduit sur mobile (`p-4 sm:p-6`) — resterait un gain marginal (le contenu tient déjà confortablement à partir de 360px de large, l'immense majorité du parc), pas la cause d'un bug visible ; à reconsidérer si un écran plus étroit que 360px pose un jour un vrai problème. Densité des petits boutons (`text-xs px-3 py-1.5`, ex. menus d'export) sous la cible tactile de 44px recommandée — cohérent avec le reste de l'app (pas une régression propre à cette page), pas retouché isolément.
- Vérifié par `npm run build` (aucune erreur) + `php artisan test` (151 tests, inchangé — balisage/CSS/JS client uniquement, aucun fichier PHP touché dans cette passe). Aucun test JS (gap déjà documenté) — à confirmer visuellement par le porteur de projet, en particulier le clamp de position de `Dropdown`/`DatePicker` qui ne peut pas être vérifié par lecture de code seule.

### Suite du même chantier (2026-08-31) — remplacement de tous les `<select>` natifs par un composant maison

Capture d'écran à l'appui : le menu déroulant natif d'un `<select>` (ex. "Statut" sur `Staff/Centres/Form.jsx`) s'affiche avec le rendu brut du système (rectangle carré, surbrillance grise, aucune ombre douce) et détonne avec le reste de l'app — **même cause exacte** que celle qui avait justifié de remplacer le calendrier natif par `DatePicker` le 2026-08-29 (« ne peut pas être personnalisé en CSS »). Ce n'est pas un bug de superposition/débordement de mon fait — le navigateur affiche toujours ce menu par-dessus tout, il n'est jamais coupé — juste l'esthétique par défaut, inévitable avec un `<select>` HTML brut. Question posée explicitement au porteur de projet (rester tel quel / composant maison partout / composant maison sur les champs les plus visibles seulement) : **choix du composant maison partout**.

- **Nouveau `Components/Select.jsx`** : remplace le `<select>` natif partout dans l'app. Construit sur `Listbox` de Headless UI (déjà utilisé pour `Modal`/`Menu` ailleurs dans l'app, v2.2.10 installée) avec son prop `anchor="bottom start"` — celui-ci gère lui-même le rendu en portail + le repositionnement dans le viewport (floating-ui intégré), donc **pas besoin de réinventer à la main le clamp bricolé pour `Dropdown`/`DatePicker` un peu plus haut dans ce même chantier** : ce composant ne peut pas se retrouver coupé par un `overflow-hidden` de modale ni déborder d'un écran étroit, par construction. Contrat volontairement proche d'un `<select>` contrôlé pour rester un remplacement quasi direct : `options` est un tableau `[{value, label}]` (l'entrée vide "Tous les centres"/"— Choisir —" est un élément normal du tableau, comme un `<option value="">` l'était), `onChange(valeur)` reçoit directement la nouvelle valeur en **chaîne** (comme `e.target.value` le faisait) plutôt qu'un évènement — un appelant passe donc de `(e) => setData('x', e.target.value)` à `(valeur) => setData('x', valeur)`. Style aligné sur `DatePicker` (`px-3.5 py-2.5`, mêmes bordures/focus) — complète au passage l'uniformisation des champs commencée le 2026-08-30 (jusque-là seulement appliquée aux formulaires de `Programme.jsx`), désormais valable pour tous les champs de sélection de l'app.
- **23 `<select>` natifs remplacés** dans 11 fichiers : `Staff/Centres/Form.jsx` (Statut — capture d'origine), `Partenaire/{Formations,Dashboard}.jsx` (filtre Centre), `Auth/Register.jsx` (Rôle, Centre), `Staff/Utilisateurs/{Index,Form}.jsx` (filtre Rôle/Centre, Rôle/Centre du formulaire), `Staff/Presences/Index.jsx` (Formation, Centre), `Staff/Apprenants/Form.jsx` (Formation, Sexe, Statut, Centre), `Staff/Apprenants/Index.jsx` (filtre Formation/Centre), `Staff/Rapports/Index.jsx` (filtre Centre/Formation), `Staff/Formations/Programme.jsx` (filtre Centre, centre d'un chapitre, centre d'un nouveau chapitre, choix du chapitre à planifier). Aucun `<select multiple>`/`<optgroup>` dans l'app (vérifié avant de commencer) — pas de cas particulier à gérer, tous remplacés par le même composant simple.
- Vérifié par `npm run build` (aucune erreur, `Select-*.js` ~70 Ko — inclut le moteur de positionnement de Headless UI, chargé une seule fois et partagé par tous les usages) + `php artisan test` (151 tests, inchangé — remplacement strictement côté React, aucun contrat serveur modifié : le champ envoyé reste la même chaîne qu'avant). Aucun test JS (gap déjà documenté) — à confirmer visuellement par le porteur de projet.

### Suite du même chantier (2026-08-31, encore le même jour) — `GuestLayout` vide sur les largeurs intermédiaires (tablette, petit écran d'ordinateur)

Deux captures comparées par le porteur de projet (mobile étroit vs. un écran plus large) : sur la plus large, la page de connexion (`GuestLayout`, partagée par toutes les pages d'auth staff) montre un formulaire minuscule centré dans un très grand vide gris, **sans le logo du tout** — ni le petit logo mobile, ni le panneau de marque desktop. Cause : le panneau de marque (dégradé bleu + logo, colonne de gauche) ne s'affichait qu'à partir de `lg:` (1024px, `hidden ... lg:flex`), et le petit logo mobile au-dessus du formulaire ne s'affichait qu'en dessous de ce même seuil (`lg:hidden`) — les deux se recouvrent en théorie sans laisser de trou, mais entre le mobile étroit et 1024px (toute la plage tablette/petit laptop, très courante), seul le petit logo mobile aurait dû s'afficher ; ce que montre la capture, c'est surtout que le formulaire reste plafonné à `max-w-md` (448px) **sans aucun contenu additionnel** pour occuper le reste d'un écran de ~900px de large — un vide au même titre que le "boxé" déjà corrigé ailleurs (2026-08-29, `max-w-content`), mais dans l'autre sens (pas assez de contenu pour l'espace disponible, plutôt qu'un contenu trop étroit).

- Seuil de bascule du panneau de marque abaissé de `lg:` (1024px) à `md:` (768px) — `Layouts/GuestLayout.jsx` uniquement (`hidden ... lg:flex` → `hidden ... md:flex`, `lg:hidden` → `md:hidden` sur le petit logo mobile), propagé automatiquement à toutes les pages qui utilisent ce layout partagé (Login, Register, mots de passe, `ApprenantAuth/Login.jsx`). Une tablette/petit écran (768px+) obtient désormais le panneau de marque en dégradé plutôt qu'un formulaire perdu dans le vide ; en dessous de 768px (tous les téléphones, y compris en paysage sur les plus grands modèles), le petit logo au-dessus du formulaire reste inchangé.
- Seuil de bascule du panneau de marque abaissé de `lg:` (1024px) à `md:` (768px) — `Layouts/GuestLayout.jsx` uniquement, propagé à toutes les pages d'auth.
- Vérifié par `npm run build` (aucune erreur) + `php artisan test` (151 tests, inchangé — balisage/CSS uniquement).

## Audit design / anti-slop IA + UX/UI du 2026-09-01

Demandé explicitement par le porteur de projet (« repère tout ce qui fait IA / généré par l'IA dans le design et l'UX/UI, toutes les couleurs à modifier, tout ce qu'il faut optimiser, et fais-moi un plan d'implémentation »). Mené avec le skill **`taste-skill`** (dépôt `Leonxlnx/taste-skill`, installé le 2026-09-01 dans `.agents/skills/` + liens `.claude/skills/`) — modules `redesign-existing-projects` (grille d'audit d'une app existante) et `design-taste-frontend` (catalogue des « AI tells »). Rapport détaillé dans **`AUDIT_DESIGN_TASTE.md`** à la racine (fichier de travail, non commité, même statut que `SECURITY_AUDIT.md` / `AUDIT_UX_FONCTIONNEL.md`). Audit en **lecture seule** : aucun fichier applicatif modifié, corrections à faire au fil de l'eau selon les arbitrages du porteur de projet.

Conclusions principales (détail + tableau de correspondance des couleurs + plan en 9 phases dans le rapport) :
- **Problème n°1 — la couleur d'accent (`sky` bleu ciel + dégradé `sky→blue-700` + ronds floutés) n'a aucun lien avec la marque TECHNIDEV, qui est ROUGE** (le « DEV » du logo). C'est l'empreinte visuelle n°1 d'un design généré par IA. Recommandation : token `brand` (rouge TECHNIDEV désaturé) comme accent d'identité, bouton primaire en `slate-900` (Option A, pattern Linear/Vercel — évite la collision rouge-primaire / rouge-destructif), **suppression de `violet-*`** (couleur « AI purple » bannie par le skill, utilisée comme `tone="info"` / `en_cours`), suppression des dégradés, fin de l'arc-en-ciel de `StatTile` (4 couleurs par rangée) et d'`Avatar` (6 pastels).
- **Autres tells** : bannières d'accueil `PageHero` en dégradé + cercles flous sur les 4 dashboards + espace apprenant ; « tout est une carte blanche à ombre » (bandeaux de filtres en `p-6 shadow` pour tenir 2 `<select>`, 7 rectangles empilés par dashboard) ; deux styles de bouton primaire concurrents (`bg-sky-600 rounded-lg` du composant `PrimaryButton` vs `bg-slate-800 rounded-md` écrit à la main dans 6 en-têtes de liste) ; actions de tableau = liens texte nus collés + `•••` en caractères ASCII ; glyphes Unicode (`×` `✕` `←`) mélangés à `Icons.jsx` alors qu'`IconX`/`IconChevronLeft` existent ; **tirets cadratins `—` et pluriels `mot(s)` dans les textes visibles** (~8 chaînes de prose, le `—` est « totalement banni » par le skill) ; **police `Figtree` chargée en 400/500/600 mais `font-bold` (700) utilisé partout → faux gras synthétisé** (charger `@fontsource/figtree/700.css` ou passer à `font-semibold`) ; favicon Laravel par défaut.
- **Optimisations UX/UI** : aucun état de chargement (pas de skeleton, `axios.get` sans `.catch()` → échec silencieux) ; focus visible manquant sur les liens d'action ; `tabular-nums` absent des colonnes numériques de `DataTable` ; contrastes limites (`text-slate-400` sur blanc ≈ 2.8:1 pour des placeholders/hints porteurs d'info, `text-sky-100` sur bannière) ; modales sans `<DialogTitle>` (pas de nom accessible) ; `Dropdown`/`DatePicker` sans `Échap` ni ARIA ; pas de bouton « afficher le mot de passe », `ApprenantAuth/Login` gagnerait un champ OTP 4 cases ; rayons d'angle non unifiés (`rounded-md` isolés), ombres noires non teintées ; `SelectionRole` ne passe pas par `GuestLayout` ; fausse affordance sur les cartes de centre du dashboard partenaire (`hover:-translate-y` sans être cliquables).
- **Plan** : 9 phases dans l'ordre « impact max / risque min » du skill. Chemin le plus court vers « ça ne fait plus IA » = Phases 0 (décisions) → 1 (tokens + police) → 2 (nettoyage palette) → 4 (dé-cardification + suppression des dégradés) ≈ 80 % du ressenti pour ~40 % de l'effort. Aucune phase ne touche de logique serveur (sauf favicon/blade en Phase 1). Contrainte : on ne migre pas la stack, on ne casse pas les fonctionnalités.
- **Déjà bien, à ne pas casser** : palette `slate` cohérente, `Badge` puce+libellé+couleur (accessibilité couleur non exclusive), `BarChart` maison (une teinte, valeur en clair), `Select`/`DatePicker` maison, `encryptHistory` + `clearHistory()` au logout, pas de `#000`/`#fff` purs.

### Implémentation du plan anti-slop (2026-09-01, suite directe — « oui démarre l'implémentation »)

Les 9 phases du plan ci-dessus ont été implémentées dans la foulée (build + `php artisan test` verts à chaque palier — **151 tests / 782 assertions inchangés**, aucune logique serveur touchée, uniquement du balisage/CSS/JS client + `tailwind.config.js` + `app.blade.php` (favicon) + `app.css` (police 700)). Détail par phase :

- **Phase 0 — décisions actées** (via questions au porteur de projet) : bouton primaire **`slate-900`** (Option A, évite la collision rouge-primaire / rouge-destructif) ; **en-têtes de dashboard sobres** (plus de bannière `PageHero`) ; **set d'icônes maison gardé et complété** (pas de librairie externe).
- **Phase 1 — tokens + police** : nouvelle échelle Tailwind **`brand`** (rouge TECHNIDEV `#e42422` désaturé, 50→950) + token `shadow-carte` (défini mais finalement peu utilisé, cf. Phase 4). `@fontsource/figtree/700.css` importé (`resources/css/app.css`) — corrige le faux gras (`font-bold` = 700 utilisé partout, jamais chargé jusque-là). `resources/js/app.jsx` : barre de progression Inertia passée au rouge de marque (`#e42422`). **Favicon** : `public/favicon.svg` maison (carré rouge arrondi + « F » blanc) + `<link rel="icon">`/`<meta name="theme-color">` dans `resources/views/app.blade.php` (remplace le favicon Laravel par défaut).
- **Phase 2 — nettoyage palette** : script `scratchpad/palette.mjs` (remplacement `sky-`→`brand-`, `rose-`→`red-`, `green-`→`emerald-` sur 33 fichiers, skip-list pour les fichiers réécrits à la main). **`violet` entièrement supprimé** : `resources/js/lib/statuts.js` (`SEMANTIQUE.information` passe de `sky` à `brand`), `Badge.jsx` (`info` = puce `brand-500`). `Avatar.jsx` : palette de 6 pastels → 2 gris `slate` (plus d'arc-en-ciel d'initiales). `BarChart.jsx` : `TEINTES` réduit à `brand` (défaut) + `emerald` (assiduité uniquement) ; `color="sky"` retiré des appelants.
- **Phase 3-4 — dé-cardification + suppression des dégradés + boutons** : `PrimaryButton.jsx` réécrit (`bg-slate-900`, `rounded-lg`, `font-semibold`, `focus:ring-brand-500`, `active:scale-[0.98]`). Les **6 en-têtes de liste** qui avaient un `<button class="bg-slate-800 rounded-md">` écrit à la main utilisent désormais `<PrimaryButton>` (Centres, Formations, Utilisateurs, Statuts, Apprenants, + bouton « Planifier un cours » de Programme). **Bandeaux de filtres dé-cardés** (`rounded-lg bg-white p-6 shadow` → grille nue `grid gap-4 border-b border-slate-200 pb-5`) sur Apprenants/Utilisateurs/Rapports/Présences/Programme/Partenaire-Formations. **Blocs de contenu** (`DataTable`, cartes de chapitre, cartes de formation partenaire, panneaux de la fiche apprenant, sections du Profil, `BarChart`, états vides) : `shadow` → `border border-slate-200`. **`PageHero.jsx` supprimé**, remplacé par `Components/EnteteBienvenue.jsx` (titre « Bonjour {prénom} » + date, sans dégradé ni cercles flous) sur les 4 dashboards. Liserés dégradés de la sidebar (`bg-gradient-to-r from-brand-600 via-brand-500 to-blue-600`) → aplat `bg-brand-600`. `GuestLayout` : panneau de marque `bg-slate-900` (plus de dégradé bleu/violet ni de cercles flous). Espace apprenant (`Apprenant/Dashboard.jsx`) : bannière dégradée retirée, en-tête blanc sobre + barre de progression `brand-600`.
- **Phase 5 — `MenuActions` + icônes + auth** : nouveau **`Components/MenuActions.jsx`** (sur `Menu` de Headless UI — clavier + `Échap` + ARIA natifs, `anchor`/`triggerClassName` en props) remplace **`Components/Dropdown.jsx` (supprimé)**. Déployé sur les menus « ••• » de fin de ligne (Apprenants, Centres, Formations, Utilisateurs, Statuts, Programme chapitres/leçons), les 2 menus d'export d'`Apprenants/Index`, la fiche `Apprenants/Show`, et le bloc utilisateur de la sidebar (`AuthenticatedLayout`, `anchor="top start"`). Glyphes Unicode → icônes maison : `×`/`✕` → `IconX` (Programme, `IdentifiantsBanner`), `←` → `IconChevronLeft` (`Apprenants/Show`), `<svg>` inline hamburger/croix → `IconMenu`/`IconX` (`AuthenticatedLayout` mobile). Nouvelles icônes : `IconDots`, `IconMenu`, `IconEye`, `IconEyeOff` ; `base.strokeWidth` `1.75` → `1.5` (cohérence de toute la bibliothèque). `Modal.jsx` : prop `title` → `<DialogTitle>` (nom accessible) — les formulaires en modale qui affichaient leur propre `<h2>` ont été dénudés en conséquence (Apprenants, Statuts, les 4 modales de Programme, DeleteUserForm). Nouveau **`Components/PasswordInput.jsx`** (bouton afficher/masquer, `IconEye`/`IconEyeOff`) sur les 5 écrans de saisie de mot de passe (Login staff, Login apprenant, Register, ResetPassword, UpdatePasswordForm, Utilisateurs/Form, DeleteUserForm). `SelectionRole.jsx` : tuiles dé-cardées, double affordance de survol (ring + shadow) → un seul état `border`+`bg`. `Register`/`ResetPassword` : bouton de soumission passé en pleine largeur, comme `Login`.
- **Phase 7 — cohérence typo/contraste/prose** : `tabular-nums` sur `<table>` (`DataTable`) + colonnes chiffrées ponctuelles ; en-têtes de page uniformisés `text-xl font-bold tracking-tight text-slate-900` (10 pages) ; en-têtes de section `text-sm font-semibold text-slate-900` ; tirets cadratins `—` retirés de la prose visible (`Apprenants/Form`, `Apprenant/Dashboard`, `Programme` planif + `<Head>` title, `IdentifiantsBanner`) ; `— Choisir —` → `Choisir…` ; placeholders `'—'` de `<Select>` optionnels → `'Non précisé'` ; colonne Date de l'Historique formatée (`toLocaleDateString('fr-FR', …)`).
- **Phase 8 — accessibilité** : `MenuActions` (Headless UI) apporte `Échap` + ARIA au menu utilisateur et aux menus de ligne. `DatePicker` clavier / `Modal` sans `DialogTitle` restants : `Modal` a désormais sa prop `title` (utilisée là où les formulaires ont été dénudés) ; `DatePicker` (navigation flèches / `Échap`) reste à faire.
- **Restes explicitement non traités (à faire au fil de l'eau)** : composant `EtatVide` partagé + skeletons de chargement (Phase 6, seul le `.catch()` sur les `axios.get`/`axios.post` d'`Apprenants/Index` + `Show` a été ajouté) ; champ OTP 4 cases pour `ApprenantAuth/Login` (le bouton afficher/masquer suffit pour l'instant) ; navigation clavier de `DatePicker` ; `Auth/ConfirmPassword.jsx` + `Auth/VerifyEmail.jsx` (orphelins Breeze restés en anglais) ; dark mode espace apprenant (Phase 9, optionnel) ; `Rapports/Index.jsx` garde son `STATUT_TONS` local (clés = libellés FR renvoyés par le serveur, pas les valeurs d'enum de `lib/statuts.js`).

### Barre de navigation basse mobile + boutons d'export (2026-09-01, suite, même jour)

Retour d'usage du porteur de projet, captures à l'appui : les deux boutons d'export de `Staff/Apprenants/Index.jsx`, empilés en vrac sur mobile (pastilles à bordure non pleine largeur, alignées à gauche avec du vide à droite), et demande d'ajouter une barre de navigation basse type app mobile (icône + libellé, façon capture Fixoria fournie).

- **`Layouts/AuthenticatedLayout.jsx` — nouvelle `BarreBasMobile`** (`lg:hidden`, `fixed inset-x-0 bottom-0 z-30`, `pb-[env(safe-area-inset-bottom)]` pour l'encoche iOS) : 4 emplacements en `flex-1` — **Tableau de bord** + les **2 premiers liens du rôle** (`liens.slice(0, 2)`, `LIENS_PAR_ROLE`) + **« Plus »** (`IconMenu`) qui ouvre le tiroir existant (`setMenuMobileOuvert(true)`, inchangé — contient déjà tous les liens + Profil/Déconnexion). État actif via `route().current()` (même logique que la sidebar) → `text-brand-600`, sinon `text-slate-400` ; « Plus » s'allume aussi quand aucun des 3 raccourcis n'est la page courante (ex. Rapports pour un formateur). Choix « auto `slice(0,2)` » plutôt qu'un trio curé par rôle : déterministe, zéro maintenance si les rôles changent.
- **Hamburger de la barre du haut mobile retiré** — « Plus » le remplace ; la barre du haut ne garde que le logo. Le `<main>` gagne `pb-20 lg:pb-0` pour ne pas passer sous la barre. `IconMenu` toujours importé (réutilisé par « Plus »).
- **`Staff/Apprenants/Index.jsx` — les deux `BoutonExport` sur une seule ligne, même sur mobile** (retour du porteur de projet juste après la première passe qui les empilait) : conteneur `flex flex-wrap gap-2`, chaque bouton `flex-1 sm:flex-none` (moitié de ligne sur mobile, largeur naturelle sur desktop). Le libellé se raccourcit sur mobile via une prop `labelCourt` (« Apprenants » / « Identifiants », `sm:hidden` vs `hidden sm:inline`) — sinon « Exporter les identifiants » ne tiendrait pas sur une demi-ligne de téléphone. Icône + chevron en `shrink-0`, libellé `truncate`. Desktop strictement inchangé.
- **Même traitement propagé à `Staff/Rapports/Index.jsx` et `Staff/Presences/Index.jsx`** (retour du porteur de projet, captures à l'appui) :
  - Rapports — les deux liens « Télécharger le PDF/CSV » passent en `flex-1 sm:flex-none`, libellé court « PDF » / « CSV » sur mobile (`sm:hidden` vs `hidden sm:inline`), padding réduit `px-3 py-2 sm:px-4 sm:py-2.5`. L'état désactivé (`exportsDisponibles`, audit du 2026-08-30) est conservé tel quel.
  - Présence — le bandeau de filtres passe de `grid-cols-1` à `grid-cols-2` sur mobile **pour les rôles non-globaux uniquement** (Formation + Date côte à côte ; un acteur global garde ses 3 selects empilés, trop serrés à mi-largeur). Le bouton « Aujourd'hui » du champ Date passe sous le `DatePicker` si l'espace manque (`flex-wrap` sur le conteneur interne, `DatePicker` en `min-w-0 flex-1`).
- N'affecte que les pages staff (l'espace apprenant a son propre layout). Aucun changement serveur, aucune nouvelle icône/dépendance. Vérifié par `npm run build` + `php artisan test` (151 tests / 782 assertions, inchangés — balisage/CSS uniquement). Aucun test JS (gap déjà documenté) — barre basse, encoche et disposition mobile à confirmer visuellement par le porteur de projet.

### Densification mobile des tableaux de bord + `StatTile` + sélecteurs « boxés » (2026-09-01, suite, même jour)

Retour d'usage du porteur de projet, capture du tableau de bord partenaire sur mobile : `space-y-8` (32px) entre sections trop aéré, et les tuiles de stats empilées une par une (`grid-cols-1`) laissent tout le côté droit vide.

- **Les 4 tableaux de bord** (`Partenaire/Dashboard`, `Staff/Dashboard/{Gestionnaire,Formateur,Secretaire}`) passent de `space-y-8` à `space-y-6` — aligné sur le reste de l'app (`Partenaire/Formations` et toutes les listes étaient déjà en `space-y-5/6`).
- **Grilles de `StatTile` en 2 colonnes sur mobile** : `grid-cols-1 sm:grid-cols-3` → `grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4` (Partenaire, Formateur, Secrétaire, + `Presences/Index`), et `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` → `grid-cols-2 … lg:grid-cols-4` (Gestionnaire, 4 tuiles). Pour 3 tuiles → disposition 2+1, standard sur mobile. **Revient sur la décision du 2026-08-31** qui avait mis les `StatTile` de Présence en `grid-cols-1 sm:grid-cols-3` : cette décision n'avait évalué que `grid-cols-3` (jugé trop serré à 3 tuiles/ligne sur téléphone), pas le compromis `grid-cols-2`.
- **`Components/StatTile.jsx`** (partagé, seulement utilisé par ces écrans) : `p-4` → `p-3 sm:p-4`, valeur `text-2xl` → `text-xl sm:text-2xl`, `min-w-0` sur le bloc texte — pour qu'une valeur type « 12 / 15 » ne déborde pas d'une tuile à mi-largeur sur un écran de 360px. Desktop strictement inchangé (`sm:` restaure tout).
- **Le wrapper `max-w-xs` du `<select>` Centre passe partout à `sm:max-w-xs`** (`Partenaire/Dashboard.jsx`, `Partenaire/Formations.jsx`, `Staff/Formations/Programme.jsx`) : il était plafonné à 320px quelle que soit la largeur d'écran, donc plus étroit que le contenu pleine largeur juste en dessous (« boxé » sur mobile, captures du porteur de projet). Désormais pleine largeur sur mobile, plafonné à 320px seulement à partir de `sm:` (un select à 1440px de large serait absurde). **Règle à retenir : tout wrapper `max-w-*` autour d'un champ de filtre doit être préfixé `sm:` — jamais un plafond de largeur qui s'applique aussi au mobile.**
- Vérifié par `npm run build` + `php artisan test` (151 tests / 782 assertions, inchangés — balisage/CSS uniquement). Scan complet des `.jsx` de `Pages/` : plus aucun `max-w-*` non préfixé sur un filtre, plus aucun `min-w-[...]` / `w-[...]` figé sur un champ.

### Filtre de date sur le tableau de bord partenaire (2026-09-01, suite, même jour)

Demande du porteur de projet : « ajoute un filtre de date dans l'espace partenaire ». Tranché avec lui avant implémentation (questions) : **un seul jour** (pas une plage), et le filtre pilote **tout le tableau de bord « à cette date »** (pas seulement la présence).

- **`Partenaire/DashboardController::index()`** accepte un paramètre `date` (`?date=YYYY-MM-DD`) :
  - Absent ou illisible (`rescue()` autour de `$request->date()`) → aujourd'hui. Date **future** → ramenée à aujourd'hui.
  - **Présence exacte** : les 3 requêtes `whereDate('date_session', today())` deviennent `whereDate(..., $date)`.
  - **Effectifs « à la date »** (approximation assumée — la base ne stocke ni date de fin d'inscription ni historique de statut de centre) : une inscription compte à la date D si `created_at <= fin(D)` **et** (`statut = en_cours` **ou** `updated_at > fin(D)`). `updated_at` sert de proxy de date de clôture — fiable ici, une inscription n'étant jamais modifiée hors de sa clôture dans cette app. Closure `$actifALaDate` appliquée via `->tap()` à `apprenants_actifs` et `formationsActivesParCentre` ; `apprenants_count` par centre filtré sur `apprenants.created_at <= fin(D)`.
  - **La liste des centres n'est PAS filtrée par `created_at`** : sur une base récemment vidée, toute date passée viderait sinon le tableau de bord entier. Un centre sans activité à la date affiche « — » / 0. Le statut actif/inactif reste le statut actuel (jamais historisé).
  - `stats.taux_presence_aujourdhui` → **`stats.taux_presence`** (et `centres.*.taux_presence_aujourdhui` → `taux_presence`) — la valeur n'est plus « aujourd'hui » par nature. `filtres` gagne `date` (toujours renseigné, ISO).
- **`Partenaire/Dashboard.jsx`** : `DatePicker` (composant maison) à côté du filtre Centre (bandeau en 2 colonnes `sm:`, empilé sur mobile), défaut = aujourd'hui, bouton inline « Aujourd'hui » quand la date ≠ aujourd'hui (comme `Presences/Index`). Libellés dynamiques : « Assiduité aujourd'hui » / « Assiduité le JJ/MM/AAAA », titre du graphe idem, en-tête « Mis à jour à HH:MM » remplacé par « Données du JJ/MM/AAAA » sur une date passée. `naviguer()` centralise la navigation en conservant `centre_id` + `date`.
- **`Hooks/usePolling.js`** gagne un 3ᵉ paramètre `actif` (défaut `true`, rétrocompatible) — le dashboard passe `estAujourdhui` : consulté à une date passée, les données sont figées, aucune requête de polling n'est lancée.
- Tests (TDD) : `PartenaireDashboardFiltreTest` — présence calculée pour une date passée, inscription créée après D exclue, inscription clôturée après D encore comptée à D, date future ramenée à aujourd'hui, paramètre illisible → aujourd'hui, défaut (`filtres.date`) verrouillé. 151 → **156 tests / 856 assertions**. `npm run build` vert.

### Comptes staff : réactivation + liste en mode carte sur mobile + audit du flux (2026-09-01, suite, même jour)

Retour d'usage du porteur de projet (capture de `/utilisateurs` sur mobile) : liste peu élégante sur téléphone (tableau qui déborde), un compte désactivé **disparaît** de la liste, aucune façon de le réactiver, et pas de séparation actifs / désactivés. Tranché avec lui : mode carte sur le `DataTable` partagé, tout afficher avec les désactivés groupés en bas, audit ciblé du flux comptes staff.

- **`Components/DataTable.jsx` — mode carte sur mobile (`< sm`), tableau inchangé sur `≥ sm`.** Chaque ligne devient une carte : 1ʳᵉ colonne en tête (nom + avatar) + actions en haut à droite, les autres colonnes en `<dl>` « étiquette : valeur » alignées à droite. Le `#` n'apparaît pas sur les cartes (l'ordre visuel suffit). **Bénéficie aux 7 listes de l'app** (Apprenants, Centres, Formations, Statuts, Rapports, Présence, Utilisateurs) — rendu à confirmer visuellement page par page (aucun test JS).
- **Réactivation d'un compte désactivé.** « Désactiver » a toujours été un **soft delete** (`User` utilise `SoftDeletes`) — le compte ne pouvait plus se connecter (`Auth::attempt()` ne trouve pas un modèle soft-supprimé) mais disparaissait aussi de la liste (scope Eloquent). Désormais :
  - `UserController::index()` : `User::withTrashed()->visibleTo(...)` — les comptes désactivés reviennent dans la liste (avec `deleted_at`).
  - Nouvelle route `PATCH utilisateurs/{user}/reactiver` (`->withTrashed()` pour résoudre un id soft-supprimé) → `UserController::reactiver()` → `$user->restore()`. `UserPolicy::restore()` : admin uniquement (pas de garde d'auto-identité comme `delete` — un compte désactivé ne peut pas être connecté). `valide_le` est préservé par delete/restore : un compte déjà validé, désactivé puis réactivé, se reconnecte immédiatement.
  - `Staff/Utilisateurs/Index.jsx` : après les filtres (recherche/rôle/centre), la liste est scindée — **comptes actifs** dans le tableau principal, puis une section **« Comptes désactivés (N) »** grisée (`opacity-70`, `numerote={false}`) en dessous, action **Réactiver** (confirmation `ConfirmDialog` non-danger). Colonne « Validation » seulement sur les actifs.
- **Tests (TDD)** : `UtilisateurAdminTest` — compte désactivé toujours listé (`withTrashed`), réactivation par l'admin, gestionnaire ne peut pas réactiver (403), désactivé ne peut pas se connecter, réactivé se reconnecte. 156 → **161 tests / 877 assertions**. `npm run build` vert.

**Audit ciblé du cycle de vie des comptes staff** (auto-inscription → validation → connexion → désactivation → réactivation) — **aucune faille trouvée** :
- Soft delete bloque la connexion à deux niveaux : `Auth::attempt()` ne trouve pas le modèle, et une session déjà ouverte est invalidée à la requête suivante (`retrieveById()` applique le scope → utilisateur traité comme invité). Un compte désactivé qui tente de se connecter voit « identifiants incorrects » (générique) et non « compte désactivé » — pas d'énumération de comptes, plutôt un bon défaut.
- Auto-désactivation d'un admin toujours bloquée (`UserPolicy::delete`, `$user->id !== $model->id`) → au moins un admin reste toujours actif, pas besoin de garde « dernier admin » distincte.
- `reactiver`/`valider` réservés à l'admin (`before()` + méthode). `valider`/`update` sur un compte soft-supprimé → 404 (route-model-binding sans `withTrashed`) : intentionnel, réactiver d'abord. Un compte en attente **et** désactivé demande 2 gestes admin (réactiver, puis valider) — rare, accepté.
- Aucun `forceDelete()` nulle part — « pas de suppression physique » respecté, l'historique lié (`inscrit_par_id`, `rapports_generes`, consultations) survit à une désactivation.
- **Points pré-existants, hors périmètre de ce chantier, toujours ouverts** (audit du 2026-08-31) : pas de rate limiting sur `POST /register` — spam de comptes en attente possible, atténué par la validation admin obligatoire.

### Revue de la page Programme + réordonnancement + date à la création/édition d'une leçon (2026-09-01, suite, même jour)

Retour d'usage du porteur de projet, 4 captures à l'appui (modales « Modifier la leçon »/« Modifier le chapitre »/« Nouvelle leçon ») : « revois tout sur cette page », plus la demande explicite de pouvoir planifier une leçon dès sa création.

- **Cause du bord rouge des champs en focus, corrigée app-wide.** `TextInput`/`Select`/`DatePicker`/`Checkbox`/`SearchInput`/`PasswordInput` (+ les 2 `<textarea>` écrits à la main de `Formations/Form.jsx`/`Apprenant/Dashboard.jsx`, + la case à cocher inline de `PlanifierCoursForm`) utilisaient tous `focus:border-brand-500 focus:ring-brand-500` — le même rouge (désaturé) que `InputError` (`text-red-600`), une leçon du skill anti-slop du 2026-09-01 (« un rouge pour l'accent, pas pour deux usages différents ») qui n'avait pas été appliquée à l'état de focus. Un champ actif et un champ en erreur devenaient indiscernables au premier coup d'œil — exactement ce que le porteur de projet a repéré sur les captures. Anneau de focus repassé en neutre (`slate-400`) sur les 9 fichiers concernés ; les focus rings de boutons/liens/menus (`focus-visible:ring-brand-500`, action ponctuelle sans message d'erreur adjacent) ne sont pas concernés — seuls les champs de formulaire porteurs potentiels d'une `InputError` juste en dessous l'étaient.
- **Réordonnancement manuel remplacé** : le champ « Ordre » en mode édition (`ChapitreForm`/`ModuleForm`, flèches natives du navigateur jugées peu élégantes) est retiré des deux modales — remplacé par des boutons ↑/↓ (`BoutonsDeplacer`, nouveau composant local à `Programme.jsx`, icônes `IconChevronUp`/`IconChevronDown`) directement dans la liste, à côté de chaque chapitre et de chaque leçon, désactivés en bout de liste. Nouveau trait partagé **`App\Http\Controllers\Concerns\DeplaceDansUnOrdre`** (`deplacerDansLOrdre($scope, $element, $direction)`) : échange l'`ordre` de l'élément avec son voisin immédiat dans une portée donnée (formation+centre pour un chapitre, chapitre pour une leçon), en transaction — pas de no-op silencieux qui planterait si on est déjà en première/dernière position. Deux nouvelles routes `PATCH chapitres/{chapitre}/deplacer` / `PATCH modules/{module}/deplacer` (`ChapitreController::deplacer()`/`ModuleFormationController::deplacer()`), autorisées via l'ability `update` déjà existante de chaque Policy (Gestionnaire/Formateur du centre+formation concernés) — aucune nouvelle règle d'autorisation à écrire.
- **Date à la création ET à l'édition d'une leçon** : `ModuleForm` gagne un `DatePicker` facultatif, affiché dans les deux modes.
  - **Création** (« Nouvelle leçon ») : `StoreModuleFormationRequest` valide `date_prevue` (`nullable`, `date`) ; `ModuleFormationController::store()` crée la `LeconDuJour` via la relation `leconsDuJour()` du module tout juste créé si une date est fournie.
  - **Édition** (« Modifier la leçon », retour d'usage du même jour, capture à l'appui — la modale ne montrait que le titre) : `UpdateModuleFormationRequest` gagne aussi `date_prevue` (`nullable`, `date`) ; `ModuleFormationController::update()` — date fournie → crée la planification si absente, sinon la **déplace en place** (`$lecon->update()`, l'id de la LeconDuJour ne change pas, donc une leçon déjà cochée par un apprenant garde sa validation, `progression_modules.lecon_du_jour_id` la référençant par cet id). Champ pré-rempli avec la date planifiée actuelle ; **laisser vide ne retire jamais une planification existante** (le retrait passe par le « × » du badge, avec sa confirmation `ConfirmDialog`) — un petit texte sous le champ le précise en mode édition.
  - `Staff/Formations/Programme.jsx` charge déjà `modulesFormation.leconsDuJour` (`ChapitreController::index()`), donc `module.lecons_du_jour[0].date_prevue` est disponible pour pré-remplir sans requête supplémentaire.
  - Le badge « Planifiée le… » garde son raccourci inline « Modifier » (`ModifierDateLeconForm` → `PUT lecons-du-jour/{id}`) : chemin rapide pour une correction de date seule sans ouvrir la modale complète — volontairement conservé (redondance de confort assumée, même esprit que « Nouvelle leçon » vs « Planifier un cours »).
  - « Planifier un cours » reste le point d'entrée pour planifier plusieurs leçons d'un coup.
- **Dates au format français** sur toute la page (`formatDateFr`, même helper local que `Partenaire/Dashboard.jsx` — découpage de chaîne ISO, pas d'objet `Date` pour éviter tout risque de décalage de fuseau) : le badge « Planifiée le… », le texte « déjà planifiée le… » de la liste à cocher de « Planifier un cours », et le message de confirmation de suppression d'une planification affichaient jusqu'ici la date ISO brute (`2026-09-01`) au lieu de `01/09/2026`.
- Tests (TDD) : `ChapitreProgrammeTest::test_formateur_can_move_a_chapitre_down_then_back_up`, `test_moving_the_first_chapitre_up_does_nothing`, `test_formateur_can_move_a_module_within_a_chapitre`, `test_formateur_cannot_move_a_chapitre_of_another_centre`, `test_creating_a_module_with_a_date_also_plans_it`, `test_creating_a_module_without_a_date_does_not_plan_it`, `test_editing_a_module_can_add_a_planning_date`, `test_editing_a_module_moves_its_existing_planning_date_in_place`, `test_editing_a_module_without_a_date_leaves_an_existing_plan_untouched` ; `ProgressionTest::test_editing_a_module_date_keeps_the_apprenant_validation`. 161 → **171 tests / 905 assertions**. `npm run build` vert.

### Validation de la présence par le formateur à la place de l'apprenant (2026-09-24)

Demande du porteur de projet (capture de la page Présence) : un apprenant qui ne sait pas encore utiliser l'outil ne peut pas cocher lui-même — le formateur doit pouvoir valider à sa place. Tranché avec lui avant implémentation : **présence + leçons du jour** (pas seulement la présence), et **rattrapage des jours passés autorisé** (jamais un jour futur).

- **Nouvelle route `PUT presences/{inscription}`** (`PresenceController::update()`, `presences.update`) : `date` (`Y-m-d`, `before_or_equal:today`) + `present` (booléen). Garde `InscriptionPolicy::update` (Admin, Gestionnaire/Formateur du centre + de la formation — **pas la secrétaire**, pas le partenaire), inscription `en_cours` uniquement (404 sinon).
- **Nouvelle action `App\Actions\Presence\ValiderPresenceParFormateur`** (transaction) : *présent* → coche via `CocherLecon` toutes les leçons planifiées à cette date dans le programme de la formation **pour le centre de l'apprenant** (programmes propres à chaque centre), sans retoucher une leçon déjà cochée (heure de coche/commentaire conservés), puis écrit la présence à la date choisie (même un jour sans leçon planifiée) ; *absent* (correction d'une erreur) → présence repassée à `false` + leçons de ce jour décochées (soft delete, historique conservé).
- **`CocherLecon::execute()`** gagne un 4ᵉ paramètre optionnel `?string $dateSession` (défaut : aujourd'hui, comportement apprenant inchangé) — nécessaire pour qu'un rattrapage enregistre la présence au jour rattrapé, pas au jour de la saisie.
- **`PresenceController::index()`** expose par ligne `inscription_id`, `lecons_prevues`, `lecons_cochees` (leçons du jour choisi, calculées en 2 requêtes groupées, pas une par ligne) + `peutValider` (Admin/Gestionnaire/Formateur).
- **`Staff/Presences/Index.jsx`** : colonne « Leçons du jour » (`x/y validées` ou « Aucune planifiée ») ; action de ligne « Marquer présent » (un clic, sans confirmation — cas courant, rapide pour toute une classe) ou « Annuler » (confirmation `ConfirmDialog` danger, précise combien de leçons seront décochées) ; actions masquées sur une date future ou pour un rôle non autorisé ; petite note explicative sous le tableau.
- Tests : `Tests\Feature\Metier\PresenceValidationFormateurTest` (8 tests — aujourd'hui, rattrapage d'hier, jour futur refusé, annulation, idempotence, leçons d'un autre centre non cochées, matrice d'autorisation formateur d'une autre formation / gestionnaire d'un autre centre / secrétaire → 403, props de l'écran). 171 → **179 tests / 946 assertions**. `npm run build` vert.

### Période de formation, formateurs rattachés, fin de l'auto-inscription, connexion par identifiant (2026-09-24)

Demande du porteur de projet (capture de la modale « Nouvelle formation »), tranchée avec lui avant implémentation : période **unique pour tous les centres** qui **bloque la planification** ; formateurs rattachés dès la création de la formation ; **suppression de l'auto-inscription** ; connexion staff par **identifiant ou téléphone libre**, e-mail facultatif ; mot de passe oublié **par e-mail ou en contactant l'admin**.

- **Période de la formation** : `formations.date_debut`/`date_fin` (migration `add_periode_to_formations_table`, `date` nullable, casts `date:Y-m-d`). Facultative mais complète (`required_with` croisé, fin `after_or_equal` début). Nouvelle règle **`App\Rules\DansLaPeriodeDeLaFormation(?Formation, int $nombreJours = 1)`** : no-op sans période ; appliquée à `StoreLeconDuJourRequest` (avec `$nombreJours` = nombre de leçons planifiées d'un coup, la **dernière** des dates consécutives doit aussi tomber dans la période), `Store/UpdateModuleFormationRequest` (date à la création/édition d'une leçon) et `LeconDuJourController::update()` (correction rapide de date). `Components/DatePicker.jsx` gagne des props `min`/`max` (jours hors bornes grisés, ouverture sur la période si aujourd'hui en sort) ; `Programme.jsx` les passe à ses 3 calendriers (`bornes`) et ramène la date par défaut de « Planifier un cours » dans la période. Affichage « Du … au … » : colonne Période de `Formations/Index.jsx`, en-tête de `Programme.jsx`, en-tête de `Apprenant/Dashboard.jsx` (`inscription.date_debut/date_fin`, déjà formatés `d/m/Y` côté serveur).
- **Formateurs rattachés à la formation** (remplacé le jour même par une liste déroulante à formateur unique, voir « Formulaires en modale sur mobile ») : `Store/UpdateFormationRequest` acceptaient `formateur_ids` (existence restreinte à `role = formateur` non désactivé) ; `FormationController::store()/update()` font `formateurs()->sync()` — même pivot `formateur_formations` que partout ailleurs, donc la restriction du formateur par formation s'applique immédiatement. `FormationController::index()` charge `formateurs` par formation et passe la liste des formateurs (avec centre) à l'admin uniquement ; `Formations/Form.jsx` affiche des cases à cocher « Nom · Centre », `Formations/Index.jsx` une colonne Formateurs. Le formateur garde la gestion de ses formations depuis son profil.
- **Fin de l'auto-inscription** : `RegisteredUserController`, `StoreRegistrationRequest`, `Auth/Register.jsx` et les routes `register` supprimés ; `Login.jsx` affiche « Pas encore de compte ? Demandez-le à l'administrateur de votre centre. » La garde `valide_le` de `LoginRequest` est conservée pour d'anciens comptes auto-inscrits jamais validés ; la colonne « Validation » de `Utilisateurs/Index.jsx` n'apparaît plus que s'il en reste un.
- **Connexion par identifiant ou téléphone** : migration `add_identifiant_to_users_table` (`identifiant` string(50) nullable unique, `email` passé nullable). `User::identifiant()` (mutator) normalise en minuscules sans espaces ; `Store/UpdateStaffUserRequest` : `identifiant` `required_without:email`, regex `[\pL\pN._+-]` (**jamais de `@`**, c'est ce qui distingue un identifiant d'un e-mail à la connexion), unique, même normalisation en `prepareForValidation()`, messages en français. `LoginRequest` : le champ s'appelle désormais **`login`** (plus `email`) — `credentials()` bascule sur `email` si la saisie contient un `@`, sinon sur `identifiant` ; erreurs/throttle sur `login`. `ProfileUpdateRequest` : e-mail requis seulement si le compte n'a pas d'identifiant, et l'identifiant n'est pas modifiable par l'utilisateur (absent des règles, affiché en lecture seule sur le Profil). `Utilisateurs/Form.jsx` : champs « Identifiant ou téléphone » + « E-mail (facultatif) » ; `Utilisateurs/Index.jsx` : colonne « Connexion » (identifiant + e-mail), recherche sur l'identifiant. **Piège pour tout futur test HTTP de connexion** : poster `login`, plus `email`.
- **Mot de passe oublié par e-mail ou via l'admin** : `Auth/ForgotPassword.jsx` propose deux onglets. « Par e-mail » = flux Breeze existant (inchangé). « Contacter l'administrateur » → `POST forgot-password/admin` (`password.demande-admin`, `throttle:5,1`, `Auth\DemandeReinitialisationController`) : crée une ligne `demandes_reinitialisation` (`user_id`, `traitee_le`, une seule demande ouverte par compte) et renvoie **toujours le même message**, que le compte existe ou non (pas d'énumération). Côté admin, un bandeau en tête de `Utilisateurs/Index.jsx` liste les demandes ouvertes (`UserController::index()` → `demandesReinitialisation`) avec « Changer le mot de passe » (ouvre la modale Modifier du compte) et « Marquer traitée » (`PATCH demandes-reinitialisation/{demande}/traiter`, `UserController::traiterDemande()`, garde `UserPolicy::update` = admin). Enregistrer un nouveau mot de passe dans `UserController::update()` ferme automatiquement les demandes ouvertes du compte.
- Tests : `RegistrationTest` réécrit (`/register` → 404, la garde `valide_le` toujours testée), nouveaux `Auth\ConnexionIdentifiantTest` (8), `Auth\DemandeReinitialisationTest` (6), `Metier\FormationPeriodeEtFormateursTest` (16) ; `AuthenticationTest`/`UtilisateurAdminTest` passés au champ `login` ; les 2 tests d'inscription de `FormationScopeTest` convertis en création par l'admin. 179 → **201 tests / 1015 assertions**. `npm run build` vert. Migrations appliquées sur la base MySQL locale (6 comptes existants, tous avec e-mail, inchangés).

### Formulaires en modale sur mobile (2026-09-24, suite)

Retour du porteur de projet (capture iPhone 16 de « Nouvelle formation ») : le haut de la modale (titre) était coupé et inatteignable dès que le formulaire dépassait la hauteur de l'écran. Cause : `Components/Modal.jsx` centrait le panneau avec `flex items-center` sur un conteneur `overflow-y-auto` : un élément flex plus haut que son conteneur déborde des deux côtés, et la partie haute ne peut plus être atteinte par le défilement.

- **`Components/Modal.jsx` réécrit** : mobile = panneau qui monte du bas (« bottom sheet », `rounded-t-2xl`, poignée, pleine largeur), plafonné à `max-h-[92dvh]` avec **son propre défilement** (`overflow-y-auto overscroll-contain`) ; à partir de `sm:` = fenêtre centrée plafonnée à `100dvh - 3rem`. Le titre (`DialogTitle`) est `sticky top-0`. Plus de `overflow-hidden` sur le panneau (Select/DatePicker/MenuActions passent déjà par un portail).
- **Nouveau `Components/ModalActions.jsx`** : rangée de boutons collée en bas du panneau sur mobile (`sticky bottom-0`, boutons à parts égales, marge `safe-area-inset-bottom`), rangée classique alignée à droite à partir de `sm:`. Suppose un formulaire en `p-4 sm:p-6` (marges négatives `-mx-4 -mb-4`). Remplace les `<div className="flex justify-end gap-3">` de **tous** les formulaires en modale : Centres, Formations, Utilisateurs, Apprenants, Statuts apprenant, les 4 formulaires de Programme (chapitre, leçon, date, planifier un cours), suppression de compte (Profil). **Tout nouveau formulaire en modale doit l'utiliser**, avec `p-4 sm:p-6`.
- Les titres internes `<h2>` de `Centres/Form.jsx`, `Formations/Form.jsx`, `Utilisateurs/Form.jsx` sont passés en prop `title` de `Modal` (pour profiter du titre collé en haut) ; `ConfirmDialog` passe aussi en `p-4 sm:p-6`. Début/Fin de la période d'une formation côte à côte dès le mobile.
- **Champs harmonisés** : `TextInput`, `PasswordInput`, `SearchInput`, `Select`, `DatePicker` et le `<textarea>` de `Formations/Form.jsx` partagent `px-3.5 py-2.5 text-base sm:text-sm`. `text-base` (16px) sur mobile évite le **zoom automatique d'iOS** au focus d'un champ en moins de 16px (le champ de recherche était en `text-sm`) ; les hauteurs sont désormais identiques entre champs texte et listes déroulantes.
- Balisage/CSS uniquement (aucun test PHPUnit concerné, 201 tests inchangés) ; `npm run build` vert. Rendu à confirmer visuellement (pas d'outil de capture dans cet environnement).
- **Formateur d'une formation en liste déroulante simple** (retour du porteur de projet : les cases à cocher puis une liste multiple à pastilles ont été jugées « pas professionnelles ») : un seul `Select` classique, `formateur_id` (+ `formateur_initial_id` en modification) remplace `formateur_ids` dans `Store/UpdateFormationRequest`. Création : `attach()` du formateur choisi. Modification : **seul le formateur affiché à l'ouverture (`formateur_initial_id`) est remplacé/retiré**, ceux rattachés pour d'autres centres (depuis leur compte ou leur profil) ne sont jamais touchés ; ils sont listés en texte sous le champ. Libellé « Nom · Centre » uniquement, sans identifiant ni e-mail (choix explicite du porteur de projet, même si la base contient des homonymes, « nick » ×2 à Gerreda). Tests : `FormationPeriodeEtFormateursTest` (16).

### Mot de passe provisoire à personnaliser à la première connexion (2026-09-24)

Demande du porteur de projet : un mot de passe choisi par l'admin ne doit pas rester celui de la personne. Validé avant implémentation : forcé à la **création** du compte et quand l'admin **change** le mot de passe via « Modifier » ; jamais pour les comptes existants, l'admin, ni un changement fait par la personne elle-même.

- Colonne **`users.doit_changer_mot_de_passe`** (boolean, défaut `false`, migration `add_doit_changer_mot_de_passe_to_users_table` : les comptes existants ne sont pas forcés). Mise à `true` par `UserController::store()` et par `UserController::update()` quand un mot de passe est saisi ; remise à `false` par la personnalisation et par la réinitialisation via lien e-mail (`NewPasswordController`).
- **Middleware `App\Http\Middleware\ExigerMotDePassePersonnalise`**, ajouté au groupe `web` (`bootstrap/app.php`) : tant que le drapeau est vrai pour l'utilisateur du guard `web`, **toute** requête est redirigée vers `mot-de-passe.personnaliser`, sauf cette page, son `PUT` et `logout`. Ne concerne jamais le guard `apprenant`. **Toute nouvelle route qui doit rester accessible pendant cette étape doit être ajoutée à `ROUTES_AUTORISEES`.**
- `Auth\PersonnaliserMotDePasseController` (`GET`/`PUT mot-de-passe/personnaliser`, `routes/auth.php`, groupe `auth`) : nouveau mot de passe `Password::defaults()` + confirmation, **refusé s'il est identique au provisoire** ; redirige vers le tableau de bord. Un compte non concerné qui ouvre la page est renvoyé au tableau de bord. Page `Auth/PersonnaliserMotDePasse.jsx` (`GuestLayout`, icône cadenas, bouton « Se déconnecter »). `Utilisateurs/Form.jsx` précise à l'admin que le mot de passe saisi est provisoire.
- Tests : `Tests\Feature\Auth\PersonnaliserMotDePasseTest` (8). 203 → **211 tests / 1042 assertions**. `npm run build` vert, migration appliquée sur la base MySQL locale.

### Séances formateur : pointage, cahier de séance, suivi et rapport (2026-09-25)

Demande du porteur de projet : un « traqueur de présence » du formateur (il pointe à l'arrivée avec ce qu'il prévoit, clôture avec les heures, l'appel et un bilan), suivi par l'admin, et un rapport de fin de formation enrichi. Spécification : [docs/superpowers/specs/2026-09-24-seances-formateur-design.md](docs/superpowers/specs/2026-09-24-seances-formateur-design.md) ; plan : [docs/superpowers/plans/2026-09-24-seances-formateur.md](docs/superpowers/plans/2026-09-24-seances-formateur.md). Pas de pauses ni de reprises (choix explicite : « ça va juste surcharger l'application »). Une relecture critique avant implémentation a retiré le choix manuel des leçons, le nombre de présents figé, l'appel dans la modification et le rattrapage, et une seconde page de suivi (§ 10 de la spécification).

- **Table `seances`** (migration `2026_09_25_000001_create_seances_table`) : `formateur_id`, `formation_id`, `centre_id` (instantané du centre du formateur), `date`, `ouverte_le` (null pour un rattrapage), `prevision`, `cloturee_le` (null = en cours), `duree_minutes` (1 à 720), `bilan`, `nb_inscrits` (instantané à la clôture), `saisie_a_posteriori`, `cloture_tardive`, `corrigee_par_id`/`corrigee_le`, softDeletes. Unique `(formateur_id, formation_id, date)`. Invariants : une seule séance ouverte à la fois par formateur, jamais dans le futur, dans la période de la formation (`DansLaPeriodeDeLaFormation`).
- **Leçons et présents d'une séance ne sont jamais stockés** : dérivés en direct par `App\Actions\Seances\SerialiserSeances::leconsPar()` (leçons planifiées ce jour-là pour la formation dans le centre) et `presentsPar()` (`presences` à vrai ce jour-là), une requête groupée chacun, clé `SerialiserSeances::cle()` = « formation-centre-date ». Une correction faite ensuite sur la page Présence est donc toujours reflétée. **Règle pour tout nouvel écran : passer par ces deux méthodes, ne jamais ajouter de colonne de présents ou de leçons sur `seances`.** Limite connue : la présence est par jour et par formation ; deux formateurs d'une même formation, même centre, même jour, affichent les mêmes présents.
- **Actions** (`app/Actions/Seances/`) : `OuvrirSeance` (heure d'arrivée = clic), `FaireAppel` (pour chaque apprenant dont l'état change, `ValiderPresenceParFormateur` : présence + leçons du jour ; identifiants étrangers ignorés ; renvoie `nb_inscrits`), `CloturerSeance` (appel + durée + bilan, `cloture_tardive` si clôturée un autre jour que `date`), `SaisirSeancePassee` (rattrapage J-7 à J-1, sans heure d'arrivée), `SerialiserSeances` (format commun des écrans), `DonneesCarteSeance` (prop `seance` du tableau de bord formateur).
- **`SeancePolicy`** : `before()` laisse passer l'admin sauf pour `create`/`cloturer`/`update` ; un admin ne pointe jamais et ne corrige qu'une séance clôturée. Correction = **durée + bilan seulement** : formateur le jour de la clôture, admin toujours (tracé `corrigee_par_id`/`corrigee_le`). Gestionnaire : lecture de son centre. Secrétaire et partenaire : aucun accès (le partenaire n'a que des heures agrégées). `Seance::visibleTo()` porte le cloisonnement.
- **Routes** (`routes/staff.php`, littéraux avant `{seance}`) : `GET seances` (`seances.index`), `POST seances` (`seances.store`), `POST seances/rattrapage` (`seances.rattrapage`, redirige vers `presences.index?formation_id=…&date=…` pour faire l'appel de ce jour-là), `PATCH seances/{seance}/cloturer`, `PUT seances/{seance}` ; `GET rapports/formation` et `rapports/formation-csv`.
- **Écrans** : carte « Ma séance du jour » en tête de `Staff/Dashboard/Formateur.jsx` (`Staff/Seances/Partials/CarteSeance.jsx` + modales Ouvrir/Clôturer/Rattrapage/Détail, `ChampDuree`, `ListeAppel` avec « Tous présents ») ; **page unique `Staff/Seances/Index.jsx`** adaptée au rôle (navigation par mois, jamais au-delà du mois courant ; admin et gestionnaire : filtres centre/formateur, tuiles « en séance maintenant » et « à surveiller », tableau par formateur avec assiduité et **jours sans séance** = couples (formation, jour) du mois **jusqu'à la veille** avec une leçon planifiée et sans séance). Les props lourdes du contrôleur sont des closures : le rafraîchissement 30 s de `en_seance` (`usePolling`) ne recalcule qu'elle. Entrée de menu « Séances » pour admin, gestionnaire, formateur. Utilitaires `resources/js/lib/seances.js`.
- **Tableau de bord partenaire/admin** : tuile « Heures dispensées en {mois} » (`stats.minutes_mois`, du 1er du mois de la date consultée jusqu'à elle) et une ligne « Heures en {mois} » sur chaque carte de centre (ligne séparée plutôt qu'une 4ᵉ colonne, trop serrée sur téléphone). Aucun texte libre.
- **Rapport de formation** (`RapportController::exportFormation()`/`exportFormationCsv()`, boutons sur `Staff/Rapports/Index.jsx` quand une formation est choisie) : synthèse (heures, séances, leçons traitées en séance / prévues à ce jour, assiduité et progression moyennes), journal des séances, tableau des apprenants. CSV neutralisé (`EscapesCsvFormulas`), export journalisé dans `rapports_generes`.
- **Relecture indépendante finale (corrigé)** : (1) **l'appel de clôture ne déduit jamais une absence d'un oubli** — le client envoie `presents` (cochés) et `absents` (affichés présents puis décochés), `FaireAppel` ne retire que les `absents` ; sans ça, un apprenant ayant coché lui-même pendant la séance (après l'affichage de l'appel) était effacé si le formateur ne cochait que les non autonomes. La carte recharge aussi `seance` (`router.reload({ only: ['seance'] })`) juste avant d'ouvrir la clôture. (2) L'assiduité moyenne de la synthèse du rapport = moyenne présents / inscrits des séances du journal (même calcul que la page Séances) : l'ancien calcul par ligne de présence ignorait les absents sans ligne et affichait 100 % au lieu de 33 %. 11 points mineurs notés et laissés à arbitrer (assiduité pouvant dépasser 100 %, séance d'un mois précédent restée ouverte absente de « À surveiller », `ChampDuree` jusqu'à 12 h 45, période du rapport non choisissable dans l'interface, etc.).
- **Bug pré-existant corrigé au passage** : `Staff/Presences/Index.jsx` n'affichait pas `FlashStatus` — le message « Présence annulée pour X » (2026-09-24) ne s'affichait jamais. Ajouté, ainsi que sur le tableau de bord formateur.
- Tests : `tests/Feature/Seances/` (trait `ScenarioSeance` + `SeancePolicyTest`, `SeanceOuvertureTest`, `SeanceClotureTest`, `SeanceCorrectionTest`, `SeanceRattrapageTest`, `PageSeancesTest`, `SeanceCarteTest`, `RapportFormationTest`) + un test dans `PartenaireDashboardFiltreTest`. 211 → **264 tests / 1437 assertions**. `npm run build` vert.
- **Vérification réelle du 2026-09-25 (après un 500 « table seances inexistante » signalé par le porteur de projet)** : les tests tournent sur SQLite en mémoire, ils ne prouvent donc pas que la base MySQL locale est migrée. Migration appliquée, puis parcours complet rejoué sur la vraie base MySQL avec les vrais comptes (script PHPUnit hors dépôt, dans une transaction annulée), et rendu réel dans Edge headless piloté par CDP (sessions créées directement en base, captures desktop et mobile, erreurs console/réseau relevées) : tableau de bord formateur, modales commencer/clôturer/rattrapage/détail, page Séances des 3 rôles, tableau de bord partenaire, rapports — aucune erreur, aucun débordement. Données de test supprimées ensuite. **Règle : avant d'annoncer qu'une fonctionnalité marche, la vérifier sur la base locale réelle et dans un navigateur, pas seulement par la suite de tests.**
- **Défaut trouvé par ce rendu et corrigé** : la saisie d'une séance passée proposait J-1 par défaut même hors de la période de la formation (ex. formation commençant aujourd'hui), ce qui garantissait un refus serveur. `DonneesCarteSeance` transmet désormais `date_debut`/`date_fin` de chaque formation ; `RattrapageSeanceForm` borne le calendrier à l'intersection (J-7..J-1) ∩ période, et affiche « Aucun jour à rattraper : la formation … commence le / s'est terminée le … » avec le bouton désactivé quand elle est vide. Test : `SeanceCarteTest::test_card_gives_each_formation_its_period_for_the_date_pickers`.
- **Tableau de bord formateur repris (2026-09-25, retour du porteur de projet)** : (1) **démarrage de séance très visible** — tant qu'aucune séance n'est pointée aujourd'hui, `CarteSeance` affiche un encadré « Nouvelle journée, nouvelle séance » avec un grand bouton « Commencer la séance d'aujourd'hui » (icône lecture, `IconPlay`) ; une fois la séance du jour clôturée, un bandeau vert « Séance du jour terminée — demain, le bouton réapparaîtra ici » ; une séance de la veille restée ouverte affiche « Clôturer la séance du JJ/MM/AAAA » et précise qu'on pourra ensuite commencer celle du jour. (2) **Graphique « Progression moyenne par formation » retiré** de ce tableau de bord (seulement pour le formateur, le gestionnaire le garde) ; `DashboardController::formateur()` ne calcule plus `progression_moyenne` et renvoie `id`/`titre`/`effectif`/`date_debut`/`date_fin`. (3) **« Mes formations » en cartes** (remplace le petit tableau), placées juste sous la carte séance : icône, titre en gros, badge En cours / À venir / Terminée selon la période, période, effectif, leçons du jour (tirées de `seance.lecons_du_jour`), boutons Programme / Présence / Apprenants (liens préfiltrés `?formation_id=` ; `Apprenants/Index.jsx` lit désormais ce paramètre pour initialiser son filtre Formation). Une formation seule prend toute la largeur, plusieurs se rangent en grille. Nouvelles icônes `IconPlay`, `IconCheckCircle`. Test : `DashboardRedirectTest::test_formateur_formations_carry_their_period_and_no_progression`. Vérifié dans Edge (3 états de la carte, 1 et 2 formations, mobile) sur la vraie base, données temporaires retirées.

### Vagues de formation (2026-09-25)

Demande du porteur de projet : diviser les apprenants d'une formation en **vagues** parallèles, chacune avec son formateur, pour qu'un formateur qui gère plusieurs vagues fasse plusieurs séances par jour, bien distinguées. Spécification : [docs/superpowers/specs/2026-09-25-vagues-design.md](docs/superpowers/specs/2026-09-25-vagues-design.md) ; plan : [docs/superpowers/plans/2026-09-25-vagues.md](docs/superpowers/plans/2026-09-25-vagues.md). Décisions (questions une par une) : vagues **en parallèle** (même période), **planning des leçons commun** (Programme inchangé), **un formateur attitré par vague**, **toujours une vague** (« Vague 1 » automatique), gestion par l'admin (partout), le gestionnaire (son centre) et le formateur (ses formations, il devient formateur de la vague qu'il crée), **approche A** : la vague organise le travail, la visibilité reste par formation (aucune Policy existante n'a changé de périmètre). Volontairement écarté : planning par vague, horaires, capacité, répartition automatique (ce qui avait rendu « Groupe » trop lourd, supprimé le 2026-08-07).

- **Données** (migration `2026_09_25_100000_create_vagues_table`) : table `vagues` (`formation_id`, `centre_id`, `formateur_id`, `nom`, softDeletes, unique `formation_id+centre_id+nom`) ; `apprenants.vague_id` (vague **actuelle**) ; `presences.vague_id` (vague **au moment où la présence est notée**) ; `seances.vague_id`. L'unicité des séances passe de `(formateur_id, formation_id, date)` à **`(vague_id, date)`** (`index('formateur_id')` ajouté d'abord : MySQL refuse de retirer un index porté par une clé étrangère). Colonnes **nullables en base** (anciens tests et lignes historiques), l'application garantit la vague à toute nouvelle inscription, présence et séance.
- **Reprise** (`App\Actions\Vagues\CreerVaguesInitiales`, appelée par la migration, idempotente) : une « Vague 1 » par couple formation × centre ayant des apprenants ; formateur = celui qui a déjà des séances sur ce couple, sinon le premier formateur du centre qui enseigne la formation ; apprenants, séances et présences rangés dedans. Appliquée sur la base locale le 2026-09-26 (sauvegarde préalable `storage/app/sauvegardes/techniform-2026-09-26-avant-vagues.sql`, ignorée par git) : ALPHABETISATION × Goz Beida → NICOLAS (19), × Gerreda → nick (16) ; 0 apprenant et 0 présence sans vague.
- **Modèle/règles** : `App\Models\Vague` (`scopeVisibleTo` : admin tout, gestionnaire/secrétaire leur centre, formateur son centre et ses formations ; `nomSuivant()` → « Vague N »). `VaguePolicy` (`create(User, ?Formation, ?int $centreId)`, `update` = gestionnaire du centre ou formateur attitré, `changerFormateur` = gestionnaire du centre, admin via `before()`). `App\Actions\Vagues\CreerVague` (+ `verifier()` statique) : le formateur attitré doit enseigner la formation **dans ce centre**, nom libre y compris parmi les vagues retirées ; partagé par la gestion des vagues et l'inscription. `VagueController` (`POST vagues`, `PUT vagues/{vague}`, `DELETE vagues/{vague}` — retrait refusé avec flash `error` s'il reste des apprenants). Un formateur qui envoie un autre `formateur_id` reçoit un 403 (pas un changement silencieux).
- **Inscription** : `App\Actions\Vagues\VagueDInscription::resoudre()` dans une transaction avec la création du compte : `vague_id` existant (même formation + centre), ou `nouvelle_vague_nom` (+ `nouvelle_vague_formateur_id` pour admin/gestionnaire ; la secrétaire ne crée pas), jamais les deux (422) ; rien de fourni → « Vague 1 » automatique **si la formation n'a encore aucune vague ici** et un seul formateur possible (le formateur qui inscrit est prioritaire), sinon erreur explicite. **Conséquence assumée : on ne peut plus inscrire dans une formation qui n'a aucun formateur dans le centre.** En modification, la vague n'est résolue que si elle change (autre formation, autre vague, nouvelle vague, apprenant sans vague) ; changer de vague garde l'inscription et la progression. `Apprenants/Partials/ChampVague.jsx` (sous Formation) : liste des vagues avec effectif et formateur, présélection s'il n'y en a qu'une, bascule « + Nouvelle vague » qui vide l'autre champ.
- **Présence** : **seul point d'écriture `Inscription::noterPresence($date, $present)`** (utilisé par `CocherLecon` et `ValiderPresenceParFormateur`), qui mémorise la vague de l'apprenant. **Toute nouvelle écriture de présence doit passer par elle.**
- **Séances** : ouvertes pour une vague (`vague_id` requis ; seul le formateur attitré, `SeancePolicy::create(User, ?Vague)`), une par vague et par jour, toujours une seule ouverte à la fois par formateur ; formation et centre de la séance = ceux de la vague. Appel (`FaireAppel::inscriptionsDeLaSeance`) = inscriptions actives dont l'apprenant est dans la vague. Présents d'une séance lus par **`SerialiserSeances::clePresents()`** (« v{vague}-{date} ») ; les leçons gardent `cle()` (formation-centre-date, planning commun). Séance ou présence sans vague (d'avant la migration) → repli sur l'ancienne clé. « Jours sans séance » calculés **par vague** du formateur (`User::vaguesAnimees()`) : deux formateurs d'une même formation ne se comptent plus les jours l'un de l'autre. Rattrapage par vague, redirection vers Présence avec `vague_id`.
- **Écrans** : carte « Ma séance du jour » (`vagues`, `vagues_disponibles` à la place de `formations_disponibles` ; « Commencer la séance · Vague 2 » quand le formateur a plusieurs vagues, « Il reste … aujourd'hui », « Séances du jour terminées » ; message si aucune vague ne lui est attribuée) ; formulaires Ouvrir/Rattrapage par vague (`libelleVague()` dans `lib/seances.js`) ; cartes « Mes formations » avec les vagues en pastilles (les siennes en rouge, effectif, formateur des autres) + « Nouvelle vague » ; bloc « Vagues » en haut de la page Programme (`Formations/Partials/BlocVagues.jsx` + `VagueForm.jsx`, renommer/changer de formateur/retirer si vide) ; Apprenants (colonne + filtre Vague une fois la formation choisie, fiche) ; Présence (filtre + colonne Vague, remis à zéro au changement de formation/centre) ; Séances (colonne, détail) ; Rapports (colonne, seulement pour l'inscription de la formation actuelle) ; rapport de formation PDF/CSV (vague par séance) ; exports liste et identifiants (colonne Vague) ; espace apprenant « Formation · Vague ».
- Tests : `tests/Feature/Vagues/` (`OutilsVague`, `VaguesInitialesTest`, `VagueGestionTest`, `InscriptionVagueTest` — forme réelle du payload —, `PresenceVagueTest`, `EcransVagueTest`), `tests/Feature/Seances/SeanceVagueTest` ; scénario `ScenarioSeance` avec une Vague 1 (et une vague propre à tout autre formateur dans `seanceOuverte()`) ; `InscriptionTest` gagne un formateur dans son centre. 264 → **316 tests / 1690 assertions**. `npm run build` vert.
- **Relecture indépendante finale (corrigé, chaque point par un test rouge puis vert)** : (1) **cloisonnement** — un formateur transféré dans un autre centre ou à qui la formation a été retirée gardait ses vagues (séance, appel et présences d'apprenants hors de sa portée) : `SeancePolicy::create`, `VaguePolicy::update` et la carte exigent désormais que la vague soit de son centre et d'une formation qu'il enseigne ; (2) la « Vague 1 » automatique échouait si une ancienne « Vague 1 » avait été retirée (nom calculé par `nomSuivant()`, erreurs rattachées au champ Vague) ; (3) le bouton « Nouvelle vague » du Programme n'est plus montré au partenaire (`peutCreerVague` passe par la Policy) ; (4) « jours sans séance » ne compte plus les jours antérieurs à la création d'une vague, ni les vagues vides ou d'un autre centre — les vagues reprises sont donc datées de leur premier apprenant (`CreerVaguesInitiales`), et les 2 vagues réelles ont été redatées (13/08 et 29/08) ; (5) colonne Vague ajoutée aux rapports d'apprenants (CSV, PDF, PDF du rapport de formation) ; (6) corriger une présence passée ne réécrit plus sa vague mémorisée (`noterPresence` garde la vague de la première écriture). Points mineurs laissés à arbitrer : nom « Vague N » proposé côté client, effectif incluant les abandons, exports Apprenants ignorant le filtre Vague, libellés ambigus pour l'admin sans centre, cas limites de la modification d'un apprenant, message de la secrétaire, deux cas de reprise impossibles sur une autre base. 316 → **324 tests / 1763 assertions**.
- **Vérification réelle** : migration appliquée sur MySQL, parcours complet rejoué avec les vrais comptes dans une transaction annulée (création de vagues, inscription dans une vague existante et nouvelle, refus d'une seconde séance ouverte, clôture puis séance de la Vague 2, appel limité, présence avec la bonne vague, refus d'un formateur d'un autre centre, 24 pages des 4 rôles, rapports, exports, espace apprenant), puis rendu Edge desktop et mobile (tableau de bord à 1 et 3 vagues, modales, Programme des 3 rôles, inscription formateur/gestionnaire, Présence, Séances, espace apprenant). Données et sessions temporaires retirées.

## Audit complet du 2026-09-26 — vérification réelle de bout en bout

Demandé par le porteur de projet (« fait un audit complet et vérifie qu'il n'y a pas d'erreur »), après le chantier Vagues. Tout a été exécuté, pas seulement relu :

- **Contrôles statiques** : `php -l` sur tout le PHP (0 erreur) ; les 92 routes pointent toutes vers une méthode existante ; `npm audit` 0 faille ; migrations toutes appliquées sur MySQL.
- **Journaux** (`storage/logs`, 25 et 26/09) : 8 erreurs, toutes expliquées et déjà résolues (MySQL éteint le 25 au matin, tables/colonnes `seances`/`vague_id` absentes avant migration, `use RoleStaff` manquant corrigé pendant les tests).
- **Balayage des pages sur la vraie base** (script PHPUnit hors dépôt, transaction annulée) : 61 URL × 7 comptes réels + 3 apprenants + 6 pages publiques = 436 requêtes, **aucune 500**, matrice d'accès cohérente (partenaire bloqué sur toute donnée nominative, formateur limité à son centre et sa formation, `/utilisateurs` admin seul).
- **Balayage des écritures** : 39 actions × 7 comptes, chaque requête annulée par savepoint. Catalogue et comptes : admin seul ; aucune action possible sur un autre centre ; `centre_id`/`formateur_id` forcés côté serveur pour les non-admins (chapitre, vague).
- **Cohérence des données** (21 requêtes SQL) : aucune incohérence entre apprenants, vagues, inscriptions, séances et présences. Constats sans correction : 7 leçons d'ALPHABETISATION planifiées avant le début de sa période (13/08 au 03/09, formation du 25/09 au 31/10) ; deux formations nommées « INFORMATIQUE » (ids 1 et 3) ; deux formateurs nommés « nick » à Gerreda (16 et 20).
- **Rendu Edge** (headless, CDP) : 47 couples (compte, page) et 7 modales, chacun sur ordinateur et téléphone = 108 rendus, **aucune exception JS, aucun avertissement console, aucune requête en échec, aucun débordement horizontal**.
- **Corrigé** : (1) **« jours sans séance » comptait des jours hors de la période de la formation**, où aucune séance n'est permise — alertes impossibles à résoudre (nick : 3 en septembre, NICOLAS : 2 en août). `SeanceController::parFormateur()` écarte désormais les leçons hors période ; test `SeanceVagueTest::test_days_outside_the_formation_period_are_not_counted` (rouge puis vert). (2) `league/commonmark` 2.9.0 → 2.10.3 (4 avis de sécurité `composer audit` ; bibliothèque non utilisée directement par l'app, seulement par les e-mails Laravel). (3) Carte formation du tableau de bord formateur : « Aucune leçon prévue » tronqué sur téléphone → `line-clamp-2`.
- **Laissé à arbitrer** : la carte séance propose « Commencer la séance · Vague 2 » même pour une vague sans apprenant ; désactiver un formateur ne prévient pas qu'il est attitré à des vagues (elles restent sans formateur actif jusqu'à ce qu'un gestionnaire en change).
- 324 → **325 tests / 1773 assertions**, `npm run build` vert. Sessions temporaires de rendu supprimées de la base.
- **Suite (même jour) — avertissement VS Code sur `jsconfig.json`** : « Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0 », émis par le TypeScript 6 intégré à VS Code (TypeScript n'est pas installé dans le projet ; ce fichier ne sert qu'à l'éditeur, Vite prend l'alias `@` de `laravel-vite-plugin`). Corrigé en retirant `baseUrl` et en préfixant `paths` par `./` (résolus depuis le fichier lui-même, possible depuis TypeScript 4.1). Vérifié avec `npx -p typescript@6 tsc -p jsconfig.json --traceResolution` : plus d'erreur TS5101, les 35 imports `@/…` du code résolus.

## Recette (cahier des charges §8)

Revue point par point, avec couverture automatisée (`php artisan test`, 129 tests / 686 assertions) sauf mention contraire :

- Recette fonctionnelle par rôle (admin/gestionnaire/formateur/secrétaire/partenaire/apprenant) : vérifiée manuellement à chaque phase + `Tests\Feature\Security\*`.
- Isolation par centre + portée apprenant : `CentreIsolationTest`, `ApprenantScopeTest`.
- Génération identifiant/mot de passe sans collision : `Tests\Unit\GenerateApprenantIdentifiantTest` (y compris non-recyclage après soft delete).
- Saisie libre conditionnelle + coche immédiate + décoche formateur : `Tests\Feature\Metier\ProgressionTest`.
- Traçabilité de l'acteur d'une inscription (`inscrit_par_id`), inscription automatique à la création, changement de formation (clôture + réouverture d'inscription) : `Tests\Feature\Metier\InscriptionTest`.
- Présence quotidienne, écrite en temps réel à la coche : `Tests\Feature\Metier\ProgressionTest::test_cocher_marks_the_apprenant_present_today`, écran de consultation : `Tests\Feature\Metier\PresenceScreenTest`.
- Aucune auto-inscription apprenant : garanti structurellement (aucune route d'inscription dans `routes/apprenant.php`).
- Consultation du mot de passe apprenant journalisée : `Tests\Feature\Metier\ApprenantCredentialTest` (consultation **et** régénération).
- Auto-inscription staff (rôle + centre choisis librement, admin exclu) : `Tests\Feature\Auth\RegistrationTest`.
- Isolation par formation pour le formateur (au-delà du centre) : `Tests\Feature\Security\FormationScopeTest`.

**Gaps connus, non bloquants** : pas de pagination sur les listes (Apprenants, Utilisateurs...) — acceptable pour le volume de données actuel (dizaines par centre), à surveiller si le nombre d'apprenants par centre grossit significativement. `MAIL_MAILER=log` en dev : les e-mails (mot de passe oublié) ne sont jamais réellement délivrés, seulement écrits dans `storage/logs/laravel.log` — à reconfigurer avec un vrai SMTP avant mise en production.

**Responsive — audit du 2026-08-05** : revue systématique de toutes les pages React pour repérer les en-têtes/lignes `flex items-center justify-between` sans `flex-wrap` (risque de débordement horizontal sur mobile si le contenu est long — nom d'apprenant, titre de page + bouton d'action, etc.) — corrigé sur une dizaine de pages (`Centres/Formations/Périodes Index`, les 3 dashboards par rôle, `Apprenant/Dashboard`, `Login`, `VerifyEmail`). **Limite assumée** : cet audit est une revue de code (classes Tailwind), pas une vérification visuelle — aucun outil de capture d'écran/navigateur headless n'est disponible dans cet environnement pour rendre les pages à différentes largeurs d'écran et confirmer à l'œil.
- **Responsive — correctif du 2026-08-05 (suite), à partir de captures d'écran mobile réelles fournies par l'utilisateur.** Deux problèmes concrets identifiés (pas visibles par simple lecture du code) : (1) tout tableau à plusieurs colonnes déborde forcément sur mobile (`overflow-x-auto`, comportement voulu) mais **la première colonne (nom/identifiant) scrollait avec le reste** — en faisant défiler pour voir les colonnes de droite (Actions...), on perdait de vue à quelle ligne elles appartenaient. Corrigé une fois pour toutes dans **`Components/DataTable.jsx`** (couvre Centres/Formations/Apprenants/Utilisateurs/Statuts apprenant d'un coup) : première colonne `sticky left-0` avec fond + `border-r`, reste toujours visible pendant le défilement horizontal. Même traitement appliqué manuellement aux tableaux qui n'utilisent pas `DataTable` (construits à la main) : les 3 `Staff/Dashboard/*.jsx` (colonne "Formation"/"Apprenant"), `Partenaire/Dashboard.jsx` (colonne "Centre"). (2) "Profile"/"Log Out" apparaissaient en anglais dans le menu (desktop **et** mobile) sur toutes les pages staff — repéré sur les captures du menu hamburger — traduits en "Profil"/"Déconnexion" dans `AuthenticatedLayout.jsx` (les deux rendus, desktop `Dropdown.Link` et mobile `ResponsiveNavLink`), plus le titre/en-tête de `Profile/Edit.jsx` par cohérence (le contenu des formulaires internes — Profile Information, Update Password, Delete Account — reste en anglais, gap déjà documenté, non traité ici). **Enseignement** : certains problèmes de mise en page ne se voient vraiment qu'en capture d'écran réelle, pas en lisant les classes Tailwind — continuer à privilégier les captures d'écran de l'utilisateur pour ce type de retour plutôt que deviner.

## Commandes

- `php artisan serve` — serveur de dev (Laravel)
- `npm run dev` — Vite en mode watch (à lancer en parallèle de `artisan serve` pour le hot-reload React)
- `npm run build` — build de production des assets
- `php artisan migrate` — jouer les migrations (base MySQL `techniform`, XAMPP)
- `php artisan migrate:fresh --seed` — tout recréer + rejouer tous les seeders (rôles, centres, formations+chapitres+leçons, staff, apprenants — chacun avec son inscription créée automatiquement —, leçons du jour, progression — données de démo complètes et cohérentes)
- `php artisan db:seed` — rejouer les seeders sans toucher aux migrations
- `php artisan test` — suite de tests (PHPUnit)
- `php artisan route:list` — inspecter les routes enregistrées (staff, apprenant, partenaire)

Comptes de démo créés par le seeder (mot de passe : `password`) :
- `admin@techniform.test` — admin
- `partenaire@techniform.test` — partenaire
- `gestionnaire.<ville>@techniform.test`, `formateur1.<ville>@techniform.test`, `formateur2.<ville>@techniform.test`, `secretaire.<ville>@techniform.test` — par centre (`goz-beida`, `n-djamena`, `abeche`)

## Maintenir ce fichier à jour

L'utilisateur a explicitement demandé que ce fichier soit mis à jour à chaque étape significative du projet (pas seulement à la fin). Le mettre à jour — section commandes, notes d'architecture, statut — dès que : le projet est initialisé, un nouveau module/une nouvelle migration est ajouté, une décision structurante change, ou les choix de stack/outillage évoluent. Considérer ce fichier périmé comme un bug.

## Langue de travail

Travailler en français avec l'utilisateur : réponses, documents et commentaires de code en français, sauf noms techniques (variables, classes, commandes) qui restent en anglais par convention du langage.
