# Vagues de formation — plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Diviser les apprenants d'une formation en vagues parallèles, chacune avec son formateur, et faire séances, appel, présence et suivi par vague.

**Architecture:** Nouvelle table `vagues` (formation × centre × formateur). `apprenants.vague_id` = vague actuelle ; `presences.vague_id` = instantané au moment où la présence est notée ; `seances.vague_id` remplace l'unicité formateur × formation × jour par vague × jour. La visibilité reste par formation (approche A) : aucune Policy existante ne change de périmètre.

**Tech Stack:** Laravel 12, Inertia, React 18, Tailwind, PHPUnit (SQLite en mémoire pour les tests), MySQL (XAMPP) en local.

**Spec:** [docs/superpowers/specs/2026-09-25-vagues-design.md](../specs/2026-09-25-vagues-design.md)

## Global Constraints

- Tout en français (libellés, messages, commentaires) ; noms techniques en anglais seulement s'ils le sont déjà.
- **Pas de commit** sans demande explicite du porteur de projet (les étapes « commit » du modèle sont remplacées par « suite complète verte »).
- Colonnes `date` : écrire avec `toDateString()`, comparer avec `whereDate()`.
- `Select` (composant maison) renvoie une **chaîne** ; comparer les ids avec `String(...)`.
- Formulaires en modale : `p-4 sm:p-6` + `ModalActions`.
- Tout wrapper `max-w-*` d'un filtre est préfixé `sm:`.
- Les tests HTTP de formulaire envoient **la forme réelle du payload** de `useForm()` (champs vides compris).
- Colonnes `vague_id` nullables en base ; l'application garantit une vague à toute nouvelle inscription, présence et séance.
- CLAUDE.md mis à jour à la fin (section dédiée + compteur de tests).

## Review Focus

1. Formulaire d'inscription réel : `vague_id: ''` et `nouvelle_vague_nom: ''` envoyés ensemble → traités comme absents (Vague 1 automatique ou « Choisissez une vague »), jamais une erreur de type. Test : Task 3 `test_real_payload_with_empty_vague_fields_creates_vague_1`.
2. `vague_id` **et** `nouvelle_vague_nom` remplis ensemble (piège de « Planifier un cours ») → 422 explicite, rien créé. Test : Task 3 `test_existing_and_new_vague_together_are_refused`.
3. Changement de formation à l'édition avec l'ancienne `vague_id` → refus, jamais un apprenant rangé dans une vague d'une autre formation. Test : Task 3 `test_changing_formation_requires_a_vague_of_the_new_formation`.
4. Formateur qui tente d'ouvrir la séance de la vague d'un collègue → 403. Test : Task 5 `test_cannot_open_a_session_for_a_colleagues_vague`.
5. Présence notée pour un apprenant sans vague (données anciennes) → aucune erreur, `vague_id` null. Test : Task 4 `test_presence_of_an_apprenant_without_vague_is_still_recorded`.

---

## Fichiers

- Créer : `database/migrations/2026_09_25_100000_create_vagues_table.php` (table + colonnes + index + reprise)
- Créer : `app/Models/Vague.php`, `app/Policies/VaguePolicy.php`
- Créer : `app/Actions/Vagues/CreerVaguesInitiales.php`, `app/Actions/Vagues/VagueDInscription.php`
- Créer : `app/Http/Controllers/Staff/VagueController.php`, `app/Http/Requests/StoreVagueRequest.php`, `app/Http/Requests/UpdateVagueRequest.php`
- Créer : `resources/js/Components/NouvelleVagueForm.jsx`, `resources/js/Pages/Staff/Formations/Partials/BlocVagues.jsx`
- Créer tests : `tests/Feature/Vagues/{VaguesInitialesTest,VagueGestionTest,InscriptionVagueTest,PresenceVagueTest,SeanceVagueTest,EcransVagueTest}.php`
- Modifier : `app/Models/{Apprenant,Presence,Seance,Inscription,Formation,User}.php`, `app/Actions/Progression/CocherLecon.php`, `app/Actions/Presence/ValiderPresenceParFormateur.php`, `app/Actions/Seances/*`, `app/Http/Requests/{StoreSeanceRequest,StoreRattrapageSeanceRequest,StoreApprenantRequest,UpdateApprenantRequest}.php`, `app/Policies/SeancePolicy.php`, contrôleurs `Staff/{Seance,Apprenant,Presence,Rapport,Chapitre,Dashboard}Controller.php`, `Apprenant/DashboardController.php`, `routes/staff.php`, vues PDF, pages React listées par tâche, `tests/Feature/Seances/ScenarioSeance.php`.

---

### Task 1 : table, modèle et reprise des données

**Interfaces — Produces :**
- `App\Models\Vague` : fillable `formation_id, centre_id, formateur_id, nom` ; relations `formation()`, `centre()`, `formateur()`, `apprenants()` ; `scopeVisibleTo(Builder, User)` ; `static nomSuivant(int $formationId, int $centreId): string`.
- `Apprenant::vague()`, `Seance::vague()`, `Presence` fillable `vague_id`.
- `App\Actions\Vagues\CreerVaguesInitiales::execute(): int` (nombre de vagues créées).

- [ ] **Step 1 : test de reprise** — `tests/Feature/Vagues/VaguesInitialesTest.php`

```php
public function test_creates_one_vague_per_formation_and_centre_and_fills_everything(): void
{
    // 2 apprenants F×C1 (formateurs 15 et 19, 19 a une séance), 1 apprenant F×C2 (formateur 16)
    // → 2 vagues « Vague 1 » ; formateur C1 = celui qui a la séance ; apprenants, présences, séances renseignés
    $this->assertSame(2, app(CreerVaguesInitiales::class)->execute());
    $v1 = Vague::where('centre_id', $c1->id)->sole();
    $this->assertSame('Vague 1', $v1->nom);
    $this->assertSame($avecSeance->id, $v1->formateur_id);
    $this->assertSame($v1->id, $apprenantC1->fresh()->vague_id);
    $this->assertSame($v1->id, $presence->fresh()->vague_id);
    $this->assertSame($v1->id, $seance->fresh()->vague_id);
    $this->assertSame(0, app(CreerVaguesInitiales::class)->execute()); // idempotent
}
public function test_single_formateur_of_the_centre_becomes_the_formateur(): void
public function test_couple_without_any_formateur_takes_no_vague(): void  // formateur_id obligatoire : on saute
```

- [ ] **Step 2 : lancer, doit échouer** — `php artisan test tests/Feature/Vagues/VaguesInitialesTest.php`
- [ ] **Step 3 : migration** — crée `vagues` (unique `formation_id, centre_id, nom`), ajoute `vague_id` nullable (FK `vagues`) à `apprenants`, `presences`, `seances` ; sur `seances` : `index('formateur_id')` **avant** `dropUnique(['formateur_id','formation_id','date'])` (MySQL refuse de retirer un index porté par une FK), puis `unique(['vague_id','date'])` ; en fin de `up()` : `app(CreerVaguesInitiales::class)->execute()`.
- [ ] **Step 4 : modèle + action**

```php
// CreerVaguesInitiales::execute()
$couples = Apprenant::query()->whereNull('vague_id')->select('formation_id', 'centre_id')->distinct()->get();
foreach ($couples as $c) {
    $vague = Vague::where(['formation_id' => $c->formation_id, 'centre_id' => $c->centre_id])->orderBy('id')->first();
    if (! $vague) {
        $formateurId = Seance::where('formation_id', $c->formation_id)->where('centre_id', $c->centre_id)->value('formateur_id')
            ?? User::where('role', RoleStaff::Formateur)->where('centre_id', $c->centre_id)
                ->whereHas('formations', fn ($q) => $q->where('formations.id', $c->formation_id))->orderBy('id')->value('id');
        if (! $formateurId) continue;
        $vague = Vague::create([... 'nom' => 'Vague 1']); $crees++;
    }
    Apprenant::where(...couple)->whereNull('vague_id')->update(['vague_id' => $vague->id]);
    Seance::where(...couple)->whereNull('vague_id')->update(['vague_id' => $vague->id]);
    Presence::whereNull('vague_id')->whereHas('inscription.apprenant', fn ($q) => $q->where('vague_id', $vague->id))->update(['vague_id' => $vague->id]);
}
```

`Vague::scopeVisibleTo` : admin tout ; gestionnaire/secrétaire son centre ; formateur son centre **et** ses formations ; autres rien. `nomSuivant` : « Vague N » avec N = nombre de vagues (soft-supprimées comprises) + 1, incrémenté tant que le nom existe.
- [ ] **Step 5 : tests verts + suite complète verte.**

### Task 2 : gestion des vagues (créer, renommer, changer de formateur, retirer)

**Interfaces — Consumes :** `Vague`. **Produces :** routes `vagues.store` (POST `vagues`), `vagues.update` (PUT `vagues/{vague}`), `vagues.destroy` (DELETE `vagues/{vague}`) ; `VaguePolicy::{create(User, Formation, int $centreId), update, changerFormateur, delete}`.

- [ ] **Step 1 : tests** — `VagueGestionTest` :
  - formateur crée une vague de sa formation → il en est le formateur, centre = le sien, nom proposé s'il est vide ;
  - formateur ne crée pas pour une formation qu'il n'enseigne pas (403) ;
  - gestionnaire crée dans son centre avec un formateur de la formation ; refus d'un formateur d'un autre centre ou n'enseignant pas la formation (422 `formateur_id`) ;
  - gestionnaire d'un autre centre : 403 sur update/destroy ;
  - nom en double dans formation × centre : 422 `nom` ;
  - formateur attitré renomme ; il ne peut pas changer le formateur (champ ignoré ou 403) ;
  - secrétaire ne crée pas (403) ;
  - retrait d'une vague non vide refusé (flash `error`, vague intacte) ; vide → soft delete.
- [ ] **Step 2 : échec attendu.**
- [ ] **Step 3 : implémentation** — `StoreVagueRequest` (`formation_id` requis, `centre_id` forcé au centre de l'acteur hors admin, `nom` nullable max:100, `formateur_id` forcé à l'acteur s'il est formateur, requis sinon ; `withValidator` : formateur de ce centre enseignant la formation, nom unique) ; `UpdateVagueRequest` (`nom` requis, `formateur_id` pris en compte seulement si `can('changerFormateur')`) ; `VagueController::{store,update,destroy}` avec messages `status`/`error`.
- [ ] **Step 4 : tests verts + suite complète.**

### Task 3 : inscription et modification par vague

**Interfaces — Consumes :** `Vague`, `VaguePolicy`. **Produces :** `App\Actions\Vagues\VagueDInscription::resoudre(User $acteur, int $formationId, int $centreId, array $donnees): Vague` (lève `ValidationException` sur `vague_id` / `nouvelle_vague_nom` / `nouvelle_vague_formateur_id`) ; props `vagues` (`id, nom, formation_id, centre_id, formateur, effectif`) et `formateursVague` (`id, nom, centre_id, formation_ids`, admin/gestionnaire seulement) dans `ApprenantController::formOptions()`.

- [ ] **Step 1 : tests** — `InscriptionVagueTest` (payload complet du formulaire, champs vides compris) :
  - `test_real_payload_with_empty_vague_fields_creates_vague_1` (première inscription, un seul formateur possible) ;
  - choix d'une vague existante ; vague d'une autre formation ou d'un autre centre → 422 ;
  - `test_existing_and_new_vague_together_are_refused` ;
  - « + Nouvelle vague » par un formateur (il en est le formateur) et par un gestionnaire (avec `nouvelle_vague_formateur_id`) ;
  - secrétaire avec `nouvelle_vague_nom` → 422 ; secrétaire, première inscription, un seul formateur → Vague 1 automatique ;
  - vagues existantes et aucun choix → 422 « Choisissez une vague » ; plusieurs formateurs possibles et aucune vague → 422 ;
  - changement de vague à l'édition : progression conservée (`progression_modules` intact) ;
  - `test_changing_formation_requires_a_vague_of_the_new_formation`.
- [ ] **Step 2 : échec attendu.**
- [ ] **Step 3 : implémentation** — règles `vague_id` nullable integer, `nouvelle_vague_nom` nullable string max:100, `nouvelle_vague_formateur_id` nullable integer dans `Store/UpdateApprenantRequest` (+ refus des deux ensemble dans `withValidator`) ; `ApprenantController::store()` : transaction → `resoudre()` → `CreateApprenantAccount` avec `vague_id` ; `update()` : `resoudre()` quand la vague ou la formation change ; `CreateApprenantAccount` accepte `vague_id` ; `Apprenant` fillable `vague_id`.
- [ ] **Step 4 : tests verts + suite complète.**

### Task 4 : présence mémorisée avec la vague

**Produces :** `Inscription::noterPresence(string $date, bool $present): Presence`.

- [ ] **Step 1 : tests** — `PresenceVagueTest` : l'apprenant qui coche → présence avec sa vague ; le formateur qui valide → idem ; après changement de vague, une présence passée garde l'ancienne vague ; `test_presence_of_an_apprenant_without_vague_is_still_recorded`.
- [ ] **Step 2 : échec attendu.**
- [ ] **Step 3 :** `noterPresence()` = `presences()->updateOrCreate(['date_session' => $date], ['present' => $present, 'vague_id' => $this->apprenant?->vague_id])` ; utilisé par `CocherLecon` et `ValiderPresenceParFormateur`.
- [ ] **Step 4 : tests verts + suite complète.**

### Task 5 : séances par vague (serveur)

**Produces :** `OuvrirSeance::execute(User, Vague, ?string)`, `SaisirSeancePassee::execute(User, Vague, string, int, ?string)`, `SeancePolicy::create(User, ?Vague)`, `SerialiserSeances::clePresents(Seance): string` (« vague-date »), clé `vague`/`vague_id` dans chaque séance sérialisée ; `DonneesCarteSeance` renvoie `vagues` (les siennes : `id, nom, formation_id, formation, effectif`) et `vagues_disponibles` (sans séance aujourd'hui ; vide si une séance est ouverte) à la place de `formations_disponibles`.

- [ ] **Step 1 : adapter `ScenarioSeance`** (une Vague 1 du formateur, apprenants dedans, `seanceOuverte()` avec `vague_id`) et les tests `Seances/*` qui postent `formation_id` → `vague_id`.
- [ ] **Step 2 : tests** — `SeanceVagueTest` : deux séances le même jour pour deux vagues ; une seule ouverte à la fois ; seconde séance de la même vague le même jour refusée ; `test_cannot_open_a_session_for_a_colleagues_vague` ; appel limité à la vague (un apprenant de la vague 2 n'y figure pas, `nb_inscrits` = vague) ; présents d'une séance = présences de sa vague ; rattrapage par vague ; carte : `vagues_disponibles` après la clôture de la vague 1 ; jours sans séance comptés par vague.
- [ ] **Step 3 : échec attendu.**
- [ ] **Step 4 : implémentation** — requêtes `vague_id` requis ; séance créée avec `formation_id`/`centre_id` de la vague ; `FaireAppel::inscriptionsDeLaSeance` filtre `apprenant.vague_id = seance.vague_id` ; `presentsPar` groupé par `vague_id-date` ; `SeanceController::parFormateur` par vague ; rapport de formation : colonne vague.
- [ ] **Step 5 : tests verts + suite complète.**

### Task 6 : écrans

- [ ] **Step 1 : tests de props** — `EcransVagueTest` : `apprenants.index` expose `vague` par apprenant et `vagues` ; `presences.index` accepte `vague_id` et filtre ; tableau de bord formateur : `formations.*.vagues` ; `formations.programme` expose `vagues` et `formateursPossibles` ; espace apprenant : `inscription.vague` ; rapports : colonne `vague`.
- [ ] **Step 2 : échec attendu, puis contrôleurs.**
- [ ] **Step 3 : React** — `Apprenants/Form.jsx` (champ Vague + bascule « + Nouvelle vague », remise à zéro de `vague_id` au changement de formation/centre) ; `Apprenants/Index.jsx` (colonne + filtre) ; `Components/NouvelleVagueForm.jsx` ; cartes formation du tableau de bord formateur (vagues + bouton) ; `CarteSeance`/`OuvrirSeanceForm`/`RattrapageSeanceForm` (vague) ; `Formations/Partials/BlocVagues.jsx` sur `Programme.jsx` ; `Presences/Index.jsx` (filtre + colonne) ; `Seances` (colonne, détail) ; `Rapports/Index.jsx` (colonne) ; `Apprenant/Dashboard.jsx` (« Formation · Vague ») ; exports apprenants et PDF.
- [ ] **Step 4 : `npm run build` + suite complète verte.**

### Task 7 : vérification réelle et documentation

- [ ] `php artisan migrate` sur MySQL ; contrôle des vagues reprises.
- [ ] Parcours PHPUnit hors dépôt sur MySQL (transaction annulée) : inscription avec nouvelle vague, deux séances dans la journée, appel par vague, pages de tous les rôles.
- [ ] Rendu Edge desktop + mobile des écrans touchés ; erreurs console/réseau ; données et sessions temporaires retirées.
- [ ] CLAUDE.md : section « Vagues » + compteur de tests.
