# Séances formateur : plan d'implémentation (révisé)

> **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:** Permettre au formateur de pointer sa séance (ouverture, puis clôture avec appel et bilan), à l'admin et au gestionnaire de suivre ces séances sur une page unique, et enrichir le rapport de fin de formation.

**Architecture:** Une table `seances` (une ligne par formateur × formation × jour). Leçons et présents d'une séance ne sont jamais stockés : ils sont dérivés en direct de `lecons_du_jour` et `presences` (source unique, aucune contradiction possible). Les écritures passent par des Actions (`app/Actions/Seances/`) appelées par `Staff\SeanceController` ; l'appel réutilise `Actions\Presence\ValiderPresenceParFormateur`. Autorisation dans `SeancePolicy`, portée par `Seance::visibleTo()`. Une seule page « Séances » adaptée au rôle.

**Tech Stack:** Laravel 12, Inertia.js + React 18, Tailwind CSS v3, PHPUnit (SQLite en mémoire), barryvdh/laravel-dompdf.

**Spec:** `docs/superpowers/specs/2026-09-24-seances-formateur-design.md` (révisée le 2026-09-24, voir son § 10)

## Global Constraints

- Langue : tout texte visible, commentaire de code et message d'erreur en **français** ; noms métier en français comme dans le reste du projet (`Seance`, `OuvrirSeance`).
- Pas de suppression physique : `SoftDeletes` sur `Seance`.
- Colonnes `date` : écrire avec `->toDateString()`, comparer avec `whereDate()` (piège documenté dans CLAUDE.md : SQLite des tests ne tronque pas l'heure).
- Cloisonnement : tout identifiant venant de l'URL ou du corps de requête est revérifié (Policy, `visibleTo()`, filtres forcés pour un acteur non admin).
- Partenaire : jamais de texte libre (prévision, bilan) ni de donnée nominative ; seulement des heures agrégées.
- Formulaires en modale : `Modal` + formulaire `p-4 sm:p-6` + boutons dans `ModalActions` (convention du 2026-09-24).
- Champs : `TextInput`, `Select` (renvoie une **chaîne**), `DatePicker` (`min`/`max`), `Checkbox` existants ; aucune nouvelle dépendance npm/composer.
- Pas de glyphes Unicode décoratifs ni de tirets cadratins `—` dans la prose visible ; `'—'` reste accepté comme valeur vide dans une cellule de tableau (usage existant).
- Durée d'une séance : 1 à 720 minutes. Rattrapage : de J-7 à J-1 inclus.
- Leçons et présents d'une séance : **toujours dérivés** (`SerialiserSeances::leconsPar()` / `presentsPar()`), jamais stockés.
- Commits : **uniquement si le porteur de projet les a autorisés** pour ce chantier ; sinon sauter les étapes « Commit ».
- CLAUDE.md mis à jour à la fin (Task 11).

## Review Focus

1. **Séance restée ouverte la veille** : le lendemain, la carte propose de la clôturer (pas d'en ouvrir une nouvelle), durée à saisir, `cloture_tardive = true`, appel appliqué au jour de la séance. Testé Task 3 (`test_closing_the_next_day_is_flagged_late`) et Task 7 (`test_card_shows_yesterdays_open_session`).
2. **Présences corrigées après la clôture depuis la page Présence** : la séance affiche les présents à jour, jamais un chiffre figé contradictoire. Testé Task 6 (`test_presents_and_lessons_are_read_live`).
3. **Formateur avec plusieurs formations** : deux séances le même jour (une par formation) oui, deux ouvertes en même temps non, deux sur la même formation non. Testé Task 2.
4. **Gestionnaire qui force `centre_id` / `formateur_id` ou l'URL d'une séance d'un autre centre** : aucune donnée d'un autre centre, correction refusée. Testé Task 4 et Task 6.
5. **Rattrapage** : jour déjà saisi, hors fenêtre ou hors période refusé ; après l'enregistrement, arrivée sur la page Présence du bon jour et de la bonne formation. Testé Task 5.

---

## Structure des fichiers

Créés :
- `database/migrations/2026_09_25_000001_create_seances_table.php`
- `app/Models/Seance.php`
- `app/Policies/SeancePolicy.php`
- `app/Actions/Seances/OuvrirSeance.php`, `FaireAppel.php`, `CloturerSeance.php`, `SaisirSeancePassee.php`, `SerialiserSeances.php`, `DonneesCarteSeance.php`
- `app/Http/Requests/StoreSeanceRequest.php`, `CloturerSeanceRequest.php`, `UpdateSeanceRequest.php`, `StoreRattrapageSeanceRequest.php`
- `app/Http/Controllers/Staff/SeanceController.php`
- `resources/views/pdf/rapport-formation.blade.php`
- `resources/js/lib/seances.js`
- `resources/js/Pages/Staff/Seances/Index.jsx`
- `resources/js/Pages/Staff/Seances/Partials/ChampDuree.jsx`, `ListeAppel.jsx`, `OuvrirSeanceForm.jsx`, `CloturerSeanceForm.jsx`, `RattrapageSeanceForm.jsx`, `DetailSeance.jsx`, `CarteSeance.jsx`
- `tests/Feature/Seances/ScenarioSeance.php` (trait), `SeancePolicyTest.php`, `SeanceOuvertureTest.php`, `SeanceClotureTest.php`, `SeanceCorrectionTest.php`, `SeanceRattrapageTest.php`, `PageSeancesTest.php`, `SeanceCarteTest.php`, `RapportFormationTest.php`

Modifiés :
- `app/Models/User.php` (relation `seances()`)
- `routes/staff.php`
- `app/Http/Controllers/Staff/DashboardController.php` (`formateur()`)
- `app/Http/Controllers/Partenaire/DashboardController.php`
- `app/Http/Controllers/Staff/RapportController.php`
- `resources/js/Pages/Staff/Dashboard/Formateur.jsx`, `resources/js/Pages/Staff/Presences/Index.jsx`, `resources/js/Pages/Partenaire/Dashboard.jsx`, `resources/js/Pages/Staff/Rapports/Index.jsx`
- `resources/js/Layouts/AuthenticatedLayout.jsx` (`LIENS_PAR_ROLE`)
- `tests/Feature/Metier/PartenaireDashboardFiltreTest.php`
- `CLAUDE.md`

Routes finales (dans le groupe `auth:web` de `routes/staff.php`, sous `presences.update`, **dans cet ordre**) :

```php
    Route::get('seances', [SeanceController::class, 'index'])->name('seances.index');
    Route::post('seances', [SeanceController::class, 'store'])->name('seances.store');
    Route::post('seances/rattrapage', [SeanceController::class, 'rattrapage'])->name('seances.rattrapage');
    Route::patch('seances/{seance}/cloturer', [SeanceController::class, 'cloturer'])->name('seances.cloturer');
    Route::put('seances/{seance}', [SeanceController::class, 'update'])->name('seances.update');
```

---

### Task 1: Données, modèle, Policy et scénario de test

**Files:**
- Create: `database/migrations/2026_09_25_000001_create_seances_table.php`
- Create: `app/Models/Seance.php`
- Create: `app/Policies/SeancePolicy.php`
- Modify: `app/Models/User.php` (relation `seances()` après `formations()`)
- Create: `tests/Feature/Seances/ScenarioSeance.php`
- Test: `tests/Feature/Seances/SeancePolicyTest.php`

**Interfaces:**
- Produces: modèle `App\Models\Seance` (relations `formateur()`, `formation()`, `centre()`, `corrigeePar()`, scope `visibleTo(User)`, statique `formaterDuree(?int): string`) ; `SeancePolicy` abilities `viewAny(User)`, `view(User, Seance)`, `create(User, ?Formation)`, `cloturer(User, Seance)`, `update(User, Seance)` ; trait de test `ScenarioSeance` (propriétés `centre`, `formation`, `formateur`, `chapitre`, `module1`, `module2`, `module3`, `leconDuJour`, `inscriptions[0..2]` ; méthodes `preparerScenario()`, `seanceOuverte(array $attrs = []): Seance`, `formateurDUnAutreCentre(): User`).

- [ ] **Step 1: Écrire la migration**

`database/migrations/2026_09_25_000001_create_seances_table.php` :

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
 * Séances formateur (2026-09-25) : pointage ouverture/clôture + cahier de
 * séance. Une séance par formateur × formation × jour. Leçons et présents ne
 * sont pas stockés ici : ils sont dérivés de lecons_du_jour et presences.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('seances', function (Blueprint $table) {
            $table->id();
            $table->foreignId('formateur_id')->constrained('users');
            $table->foreignId('formation_id')->constrained('formations');
            $table->foreignId('centre_id')->constrained('centres');
            $table->date('date');
            $table->dateTime('ouverte_le')->nullable();
            $table->text('prevision')->nullable();
            $table->dateTime('cloturee_le')->nullable();
            $table->unsignedSmallInteger('duree_minutes')->nullable();
            $table->text('bilan')->nullable();
            $table->unsignedSmallInteger('nb_inscrits')->nullable();
            $table->boolean('saisie_a_posteriori')->default(false);
            $table->boolean('cloture_tardive')->default(false);
            $table->foreignId('corrigee_par_id')->nullable()->constrained('users')->nullOnDelete();
            $table->dateTime('corrigee_le')->nullable();
            $table->timestamps();
            $table->softDeletes();

            $table->unique(['formateur_id', 'formation_id', 'date']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('seances');
    }
};
```

- [ ] **Step 2: Écrire le modèle `Seance`**

`app/Models/Seance.php` :

```php
<?php

namespace App\Models;

use App\Enums\RoleStaff;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * Séance d'un formateur (2026-09-25) : ouverture (arrivée + prévision), puis
 * clôture (durée, bilan, appel). Leçons et présents sont dérivés, voir
 * App\Actions\Seances\SerialiserSeances.
 */
class Seance extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'formateur_id',
        'formation_id',
        'centre_id',
        'date',
        'ouverte_le',
        'prevision',
        'cloturee_le',
        'duree_minutes',
        'bilan',
        'nb_inscrits',
        'saisie_a_posteriori',
        'cloture_tardive',
        'corrigee_par_id',
        'corrigee_le',
    ];

    protected function casts(): array
    {
        return [
            'formateur_id' => 'integer',
            'formation_id' => 'integer',
            'centre_id' => 'integer',
            'date' => 'date:Y-m-d',
            'ouverte_le' => 'datetime',
            'cloturee_le' => 'datetime',
            'corrigee_le' => 'datetime',
            'duree_minutes' => 'integer',
            'nb_inscrits' => 'integer',
            'saisie_a_posteriori' => 'boolean',
            'cloture_tardive' => 'boolean',
        ];
    }

    public function formateur(): BelongsTo
    {
        return $this->belongsTo(User::class, 'formateur_id');
    }

    public function formation(): BelongsTo
    {
        return $this->belongsTo(Formation::class);
    }

    public function centre(): BelongsTo
    {
        return $this->belongsTo(Centre::class);
    }

    public function corrigeePar(): BelongsTo
    {
        return $this->belongsTo(User::class, 'corrigee_par_id');
    }

    /**
     * Admin : tout ; gestionnaire : son centre ; formateur : ses séances ;
     * autres rôles : rien (le partenaire n'a que des agrégats).
     */
    public function scopeVisibleTo(Builder $query, User $user): Builder
    {
        return match ($user->role) {
            RoleStaff::Admin => $query,
            RoleStaff::Gestionnaire => $query->where('centre_id', $user->centre_id),
            RoleStaff::Formateur => $query->where('formateur_id', $user->id),
            default => $query->whereRaw('1 = 0'),
        };
    }

    /** « 3 h 15 », « 45 min », « 2 h », ou '—' si inconnue. */
    public static function formaterDuree(?int $minutes): string
    {
        if ($minutes === null) {
            return '—';
        }

        $heures = intdiv($minutes, 60);
        $reste = $minutes % 60;

        if ($heures === 0) {
            return "{$reste} min";
        }

        return $reste === 0 ? "{$heures} h" : sprintf('%d h %02d', $heures, $reste);
    }
}
```

Dans `app/Models/User.php` (l'import `HasMany` existe déjà), après la méthode `formations()` :

```php
    /** Séances pointées par ce formateur (2026-09-25). */
    public function seances(): HasMany
    {
        return $this->hasMany(Seance::class, 'formateur_id');
    }
```

- [ ] **Step 3: Écrire la Policy**

`app/Policies/SeancePolicy.php` (découverte automatique Laravel `App\Models\Seance` → `App\Policies\SeancePolicy`, rien à enregistrer) :

```php
<?php

namespace App\Policies;

use App\Enums\RoleStaff;
use App\Models\Formation;
use App\Models\Seance;
use App\Models\User;

/**
 * Séances formateur (2026-09-25). L'admin consulte tout, corrige toute séance
 * clôturée, mais ne pointe jamais. Le gestionnaire consulte son centre. Le
 * formateur gère les siennes et les corrige le jour de la clôture.
 */
class SeancePolicy
{
    public function before(User $user, string $ability): ?bool
    {
        if ($user->role !== RoleStaff::Admin) {
            return null;
        }

        // create / cloturer / update : décidés par les méthodes (un admin ne
        // pointe jamais et ne corrige qu'une séance clôturée).
        return in_array($ability, ['create', 'cloturer', 'update'], true) ? null : true;
    }

    public function viewAny(User $user): bool
    {
        return in_array($user->role, [RoleStaff::Gestionnaire, RoleStaff::Formateur], true);
    }

    public function view(User $user, Seance $seance): bool
    {
        return match ($user->role) {
            RoleStaff::Gestionnaire => $seance->centre_id === $user->centre_id,
            RoleStaff::Formateur => $seance->formateur_id === $user->id,
            default => false,
        };
    }

    public function create(User $user, ?Formation $formation = null): bool
    {
        return $user->role === RoleStaff::Formateur
            && $user->centre_id !== null
            && (! $formation || $user->formations()->where('formations.id', $formation->id)->exists());
    }

    public function cloturer(User $user, Seance $seance): bool
    {
        return $user->role === RoleStaff::Formateur
            && $seance->formateur_id === $user->id
            && $seance->cloturee_le === null;
    }

    /** Durée et bilan : admin sur toute séance clôturée ; formateur la sienne, le jour de la clôture. */
    public function update(User $user, Seance $seance): bool
    {
        if ($seance->cloturee_le === null) {
            return false;
        }

        if ($user->role === RoleStaff::Admin) {
            return true;
        }

        return $user->role === RoleStaff::Formateur
            && $seance->formateur_id === $user->id
            && $seance->cloturee_le->isToday();
    }
}
```

- [ ] **Step 4: Écrire le trait de scénario de test**

`tests/Feature/Seances/ScenarioSeance.php` :

```php
<?php

namespace Tests\Feature\Seances;

use App\Enums\RoleStaff;
use App\Enums\StatutCentre;
use App\Enums\StatutInscription;
use App\Models\Apprenant;
use App\Models\Centre;
use App\Models\Chapitre;
use App\Models\Formation;
use App\Models\Inscription;
use App\Models\LeconDuJour;
use App\Models\ModuleFormation;
use App\Models\Seance;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt;

/**
 * Scénario commun aux tests de séances : lundi 05/10/2026 08:00, un centre,
 * une formation sans période, un formateur (« Nicolas »), trois leçons dont
 * une planifiée aujourd'hui (« Les voyelles »), trois apprenants inscrits
 * (Awa, Brahim, Chantal).
 */
trait ScenarioSeance
{
    protected Centre $centre;

    protected Formation $formation;

    protected User $formateur;

    protected Chapitre $chapitre;

    protected ModuleFormation $module1;

    protected ModuleFormation $module2;

    protected ModuleFormation $module3;

    protected LeconDuJour $leconDuJour;

    /** @var array<int, Inscription> */
    protected array $inscriptions = [];

    protected function preparerScenario(): void
    {
        $this->travelTo(Carbon::parse('2026-10-05 08:00:00'));

        $this->centre = Centre::create(['nom' => 'Goz Beida', 'ville' => 'Goz Beida', 'statut' => StatutCentre::Actif]);
        $this->formation = Formation::create(['titre' => 'Alphabétisation', 'saisie_libre_autorisee' => false]);
        $this->formateur = User::factory()->create(['role' => RoleStaff::Formateur, 'centre_id' => $this->centre->id, 'name' => 'Nicolas']);
        $this->formateur->formations()->attach($this->formation->id);

        $this->chapitre = Chapitre::create(['formation_id' => $this->formation->id, 'centre_id' => $this->centre->id, 'titre' => 'Chapitre 1', 'ordre' => 1]);
        $this->module1 = ModuleFormation::create(['formation_id' => $this->formation->id, 'chapitre_id' => $this->chapitre->id, 'titre' => 'Les voyelles', 'ordre' => 1]);
        $this->module2 = ModuleFormation::create(['formation_id' => $this->formation->id, 'chapitre_id' => $this->chapitre->id, 'titre' => 'Les consonnes', 'ordre' => 2]);
        $this->module3 = ModuleFormation::create(['formation_id' => $this->formation->id, 'chapitre_id' => $this->chapitre->id, 'titre' => 'Les syllabes', 'ordre' => 3]);
        $this->leconDuJour = LeconDuJour::create(['module_formation_id' => $this->module1->id, 'date_prevue' => '2026-10-05']);

        foreach (['Awa', 'Brahim', 'Chantal'] as $index => $nom) {
            $apprenant = Apprenant::create([
                'identifiant' => strtolower($nom).'.test',
                'password_visible' => Crypt::encryptString('1234'),
                'nom_prenom' => $nom,
                'formation_id' => $this->formation->id,
                'centre_id' => $this->centre->id,
                'inscrit_par_id' => $this->formateur->id,
            ]);
            $this->inscriptions[$index] = Inscription::create([
                'apprenant_id' => $apprenant->id,
                'formation_id' => $this->formation->id,
                'statut' => StatutInscription::EnCours,
                'inscrit_par_id' => $this->formateur->id,
            ]);
        }
    }

    /** Séance ouverte aujourd'hui à 08:00 par le formateur du scénario. */
    protected function seanceOuverte(array $attrs = []): Seance
    {
        return Seance::create(array_merge([
            'formateur_id' => $this->formateur->id,
            'formation_id' => $this->formation->id,
            'centre_id' => $this->centre->id,
            'date' => '2026-10-05',
            'ouverte_le' => Carbon::parse('2026-10-05 08:00:00'),
            'prevision' => 'Révision des voyelles',
        ], $attrs));
    }

    protected function formateurDUnAutreCentre(): User
    {
        $autre = Centre::create(['nom' => 'Gerreda', 'ville' => 'Gerreda', 'statut' => StatutCentre::Actif]);
        $formateur = User::factory()->create(['role' => RoleStaff::Formateur, 'centre_id' => $autre->id, 'name' => 'Zakaria']);
        $formateur->formations()->attach($this->formation->id);

        return $formateur;
    }
}
```

Note : une leçon (`module_formation_id`) n'a qu'**une** date planifiée (contrainte d'unicité du 2026-08-13). Pour planifier un autre jour, utiliser `module2` ou `module3`, jamais deux fois le même module.

- [ ] **Step 5: Écrire le test de la Policy**

`tests/Feature/Seances/SeancePolicyTest.php` :

```php
<?php

namespace Tests\Feature\Seances;

use App\Enums\RoleStaff;
use App\Models\Formation;
use App\Models\Seance;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class SeancePolicyTest extends TestCase
{
    use RefreshDatabase, ScenarioSeance;

    protected function setUp(): void
    {
        parent::setUp();
        $this->preparerScenario();
    }

    public function test_only_a_formateur_of_the_formation_can_open(): void
    {
        $admin = User::factory()->create(['role' => RoleStaff::Admin]);
        $gestionnaire = User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $this->centre->id]);
        $autreFormation = Formation::create(['titre' => 'Informatique']);

        $this->assertTrue($this->formateur->can('create', [Seance::class, $this->formation]));
        $this->assertFalse($this->formateur->can('create', [Seance::class, $autreFormation]));
        $this->assertFalse($admin->can('create', [Seance::class, $this->formation]));
        $this->assertFalse($gestionnaire->can('create', [Seance::class, $this->formation]));
    }

    public function test_view_any_and_view_are_scoped_by_role(): void
    {
        $seance = $this->seanceOuverte();
        $gestionnaire = User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $this->centre->id]);
        $gestionnaireAutre = User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $this->formateurDUnAutreCentre()->centre_id]);
        $secretaire = User::factory()->create(['role' => RoleStaff::Secretaire, 'centre_id' => $this->centre->id]);
        $partenaire = User::factory()->create(['role' => RoleStaff::Partenaire]);
        $admin = User::factory()->create(['role' => RoleStaff::Admin]);

        foreach ([$this->formateur, $gestionnaire, $admin] as $acteur) {
            $this->assertTrue($acteur->can('viewAny', Seance::class));
            $this->assertTrue($acteur->can('view', $seance));
        }
        $this->assertFalse($gestionnaireAutre->can('view', $seance));
        foreach ([$secretaire, $partenaire] as $acteur) {
            $this->assertFalse($acteur->can('viewAny', Seance::class));
            $this->assertFalse($acteur->can('view', $seance));
        }
    }

    public function test_update_rules(): void
    {
        $admin = User::factory()->create(['role' => RoleStaff::Admin]);
        $ouverte = $this->seanceOuverte();
        $this->assertFalse($this->formateur->can('update', $ouverte));
        $this->assertFalse($admin->can('update', $ouverte));

        $ouverte->update(['cloturee_le' => Carbon::parse('2026-10-05 11:00:00'), 'duree_minutes' => 180]);
        $this->assertTrue($this->formateur->can('update', $ouverte->fresh()));

        $this->travelTo(Carbon::parse('2026-10-06 08:00:00'));
        $this->assertFalse($this->formateur->can('update', $ouverte->fresh()));
        $this->assertTrue($admin->can('update', $ouverte->fresh()));
    }

    public function test_visible_to_scope(): void
    {
        $this->seanceOuverte();
        $autre = $this->formateurDUnAutreCentre();
        Seance::create([
            'formateur_id' => $autre->id, 'formation_id' => $this->formation->id, 'centre_id' => $autre->centre_id,
            'date' => '2026-10-05', 'ouverte_le' => now(),
        ]);
        $gestionnaire = User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $this->centre->id]);
        $secretaire = User::factory()->create(['role' => RoleStaff::Secretaire, 'centre_id' => $this->centre->id]);

        $this->assertSame(1, Seance::visibleTo($this->formateur)->count());
        $this->assertSame(1, Seance::visibleTo($gestionnaire)->count());
        $this->assertSame(2, Seance::visibleTo(User::factory()->create(['role' => RoleStaff::Admin]))->count());
        $this->assertSame(0, Seance::visibleTo($secretaire)->count());
    }

    public function test_formater_duree(): void
    {
        $this->assertSame('3 h 15', Seance::formaterDuree(195));
        $this->assertSame('2 h', Seance::formaterDuree(120));
        $this->assertSame('45 min', Seance::formaterDuree(45));
        $this->assertSame('—', Seance::formaterDuree(null));
    }
}
```

- [ ] **Step 6: Lancer le test**

Run: `php artisan test --filter=SeancePolicyTest`
Expected: PASS (5 tests).

- [ ] **Step 7: Commit (si autorisé)**

```bash
git add database/migrations/2026_09_25_000001_create_seances_table.php app/Models/Seance.php app/Models/User.php app/Policies/SeancePolicy.php tests/Feature/Seances/ScenarioSeance.php tests/Feature/Seances/SeancePolicyTest.php
git commit -m "Ajoute le modele Seance et sa Policy"
```

---

### Task 2: Ouvrir une séance

**Files:**
- Create: `app/Http/Requests/StoreSeanceRequest.php`
- Create: `app/Actions/Seances/OuvrirSeance.php`
- Create: `app/Http/Controllers/Staff/SeanceController.php` (méthode `store`)
- Modify: `routes/staff.php`
- Test: `tests/Feature/Seances/SeanceOuvertureTest.php`

**Interfaces:**
- Consumes: `Seance`, `SeancePolicy::create`, `App\Rules\DansLaPeriodeDeLaFormation(?Formation, int $nombreJours = 1)` (existante).
- Produces: route `POST seances` → `seances.store` (champs `formation_id`, `prevision`) ; `OuvrirSeance::execute(User $formateur, Formation $formation, ?string $prevision): Seance` ; classe `SeanceController`.

- [ ] **Step 1: Écrire les tests (échouent)**

`tests/Feature/Seances/SeanceOuvertureTest.php` :

```php
<?php

namespace Tests\Feature\Seances;

use App\Enums\RoleStaff;
use App\Models\Formation;
use App\Models\Seance;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SeanceOuvertureTest extends TestCase
{
    use RefreshDatabase, ScenarioSeance;

    protected function setUp(): void
    {
        parent::setUp();
        $this->preparerScenario();
    }

    private function ouvrir(User $acteur, array $surcharge = [])
    {
        return $this->actingAs($acteur)->post(route('seances.store'), array_merge([
            'formation_id' => $this->formation->id,
            'prevision' => 'Révision des voyelles',
        ], $surcharge));
    }

    private function informatiqueEnseignee(): Formation
    {
        $informatique = Formation::create(['titre' => 'Informatique']);
        $this->formateur->formations()->attach($informatique->id);

        return $informatique;
    }

    public function test_formateur_opens_a_session_with_arrival_time(): void
    {
        $this->ouvrir($this->formateur)->assertSessionHasNoErrors()->assertRedirect();

        $seance = Seance::firstOrFail();
        $this->assertSame('2026-10-05', $seance->date->toDateString());
        $this->assertSame('2026-10-05 08:00:00', $seance->ouverte_le->format('Y-m-d H:i:s'));
        $this->assertNull($seance->cloturee_le);
        $this->assertSame($this->centre->id, $seance->centre_id);
        $this->assertSame('Révision des voyelles', $seance->prevision);
    }

    public function test_cannot_open_a_second_session_while_one_is_open(): void
    {
        $informatique = $this->informatiqueEnseignee();
        $this->seanceOuverte();

        $this->ouvrir($this->formateur, ['formation_id' => $informatique->id])
            ->assertSessionHasErrors('formation_id');

        $this->assertSame(1, Seance::count());
    }

    public function test_can_chain_a_second_formation_after_closing_the_first(): void
    {
        $informatique = $this->informatiqueEnseignee();
        $this->seanceOuverte(['cloturee_le' => now(), 'duree_minutes' => 60]);

        $this->ouvrir($this->formateur, ['formation_id' => $informatique->id])->assertSessionHasNoErrors();

        $this->assertSame(2, Seance::count());
    }

    public function test_cannot_open_the_same_formation_twice_the_same_day(): void
    {
        $this->seanceOuverte(['cloturee_le' => now(), 'duree_minutes' => 60]);

        $this->ouvrir($this->formateur)->assertSessionHasErrors('formation_id');
    }

    public function test_cannot_open_for_a_formation_not_taught(): void
    {
        $informatique = Formation::create(['titre' => 'Informatique']);

        $this->ouvrir($this->formateur, ['formation_id' => $informatique->id])->assertForbidden();
    }

    public function test_cannot_open_outside_the_formation_period(): void
    {
        $this->formation->update(['date_debut' => '2026-11-01', 'date_fin' => '2026-11-30']);

        $this->ouvrir($this->formateur)->assertSessionHasErrors('formation_id');
    }

    public function test_prevision_is_limited(): void
    {
        $this->ouvrir($this->formateur, ['prevision' => str_repeat('a', 2001)])->assertSessionHasErrors('prevision');
    }

    public function test_admin_and_gestionnaire_cannot_open(): void
    {
        $this->ouvrir(User::factory()->create(['role' => RoleStaff::Admin]))->assertForbidden();
        $this->ouvrir(User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $this->centre->id]))->assertForbidden();
    }
}
```

- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**

Run: `php artisan test --filter=SeanceOuvertureTest`
Expected: FAIL avec « Route [seances.store] not defined ».

- [ ] **Step 3: Écrire la Form Request**

`app/Http/Requests/StoreSeanceRequest.php` :

```php
<?php

namespace App\Http\Requests;

use App\Models\Formation;
use App\Models\Seance;
use App\Rules\DansLaPeriodeDeLaFormation;
use Illuminate\Foundation\Http\FormRequest;

/** Ouverture d'une séance par le formateur (2026-09-25). */
class StoreSeanceRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', [Seance::class, Formation::find($this->integer('formation_id'))]);
    }

    public function rules(): array
    {
        return [
            'formation_id' => ['required', 'integer', 'exists:formations,id'],
            'prevision' => ['nullable', 'string', 'max:2000'],
        ];
    }

    public function withValidator($validator): void
    {
        $validator->after(function ($validator) {
            if ($validator->errors()->isNotEmpty()) {
                return;
            }

            $user = $this->user();
            $formation = Formation::findOrFail($this->integer('formation_id'));

            if (Seance::where('formateur_id', $user->id)->whereNull('cloturee_le')->exists()) {
                $validator->errors()->add('formation_id', "Vous avez déjà une séance en cours : clôturez-la avant d'en commencer une autre.");

                return;
            }

            if (Seance::where('formateur_id', $user->id)->where('formation_id', $formation->id)->whereDate('date', today())->exists()) {
                $validator->errors()->add('formation_id', "Vous avez déjà pointé une séance pour cette formation aujourd'hui.");

                return;
            }

            (new DansLaPeriodeDeLaFormation($formation))->validate(
                'formation_id',
                today()->toDateString(),
                fn () => $validator->errors()->add('formation_id', sprintf(
                    "Aujourd'hui est en dehors de la période de cette formation (du %s au %s).",
                    $formation->date_debut->format('d/m/Y'),
                    $formation->date_fin->format('d/m/Y'),
                )),
            );
        });
    }
}
```

- [ ] **Step 4: Écrire l'Action**

`app/Actions/Seances/OuvrirSeance.php` :

```php
<?php

namespace App\Actions\Seances;

use App\Models\Formation;
use App\Models\Seance;
use App\Models\User;

/** Ouverture d'une séance : l'heure d'arrivée est celle du clic. */
class OuvrirSeance
{
    public function execute(User $formateur, Formation $formation, ?string $prevision): Seance
    {
        return Seance::create([
            'formateur_id' => $formateur->id,
            'formation_id' => $formation->id,
            'centre_id' => $formateur->centre_id,
            'date' => now()->toDateString(),
            'ouverte_le' => now(),
            'prevision' => $prevision,
        ]);
    }
}
```

- [ ] **Step 5: Écrire le contrôleur (méthode `store`) et la route**

`app/Http/Controllers/Staff/SeanceController.php` :

```php
<?php

namespace App\Http\Controllers\Staff;

use App\Actions\Seances\OuvrirSeance;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSeanceRequest;
use App\Models\Formation;
use Illuminate\Http\RedirectResponse;

/**
 * Séances formateur (2026-09-25) : page « Séances » (portée selon le rôle),
 * ouverture, clôture avec appel, correction, rattrapage.
 */
class SeanceController extends Controller
{
    public function store(StoreSeanceRequest $request, OuvrirSeance $ouvrir): RedirectResponse
    {
        $ouvrir->execute(
            $request->user(),
            Formation::findOrFail($request->integer('formation_id')),
            $request->input('prevision'),
        );

        return back()->with('status', 'Séance commencée. Bonne séance !');
    }
}
```

Dans `routes/staff.php` : ajouter `use App\Http\Controllers\Staff\SeanceController;` entre `RapportController` et `UserController`, puis sous la ligne `Route::put('presences/{inscription}', ...)` :

```php

    // Séances formateur (2026-09-25) : pointage, cahier de séance, appel.
    Route::post('seances', [SeanceController::class, 'store'])->name('seances.store');
```

- [ ] **Step 6: Lancer les tests**

Run: `php artisan test --filter=SeanceOuvertureTest`
Expected: PASS (8 tests).

- [ ] **Step 7: Commit (si autorisé)**

```bash
git add app/Http/Requests/StoreSeanceRequest.php app/Actions/Seances/OuvrirSeance.php app/Http/Controllers/Staff/SeanceController.php routes/staff.php tests/Feature/Seances/SeanceOuvertureTest.php
git commit -m "Permet au formateur d'ouvrir une seance"
```

---

### Task 3: Clôturer une séance avec l'appel

**Files:**
- Create: `app/Actions/Seances/FaireAppel.php`
- Create: `app/Actions/Seances/CloturerSeance.php`
- Create: `app/Http/Requests/CloturerSeanceRequest.php`
- Modify: `app/Http/Controllers/Staff/SeanceController.php` (méthode `cloturer`)
- Modify: `routes/staff.php`
- Test: `tests/Feature/Seances/SeanceClotureTest.php`

**Interfaces:**
- Consumes: `App\Actions\Presence\ValiderPresenceParFormateur::execute(Inscription, string $date, bool $present): void` (existante).
- Produces: `FaireAppel::execute(Seance, array $inscriptionIdsPresents): int` (renvoie le nombre d'inscrits), `FaireAppel::inscriptionsDeLaSeance(Seance): Builder`, `FaireAppel::listePourAppel(Seance): array` (`[['inscription_id' => int, 'nom_prenom' => string, 'present' => bool], ...]` trié par nom) ; `CloturerSeance::execute(Seance, int $dureeMinutes, ?string $bilan, array $presents): Seance` ; route `PATCH seances/{seance}/cloturer` → `seances.cloturer` (champs `duree_minutes`, `bilan`, `presents[]`).

- [ ] **Step 1: Écrire les tests (échouent)**

`tests/Feature/Seances/SeanceClotureTest.php` :

```php
<?php

namespace Tests\Feature\Seances;

use App\Models\Presence;
use App\Models\ProgressionModule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class SeanceClotureTest extends TestCase
{
    use RefreshDatabase, ScenarioSeance;

    protected function setUp(): void
    {
        parent::setUp();
        $this->preparerScenario();
    }

    private function cloturer($seance, array $surcharge = [])
    {
        return $this->actingAs($this->formateur)->patch(route('seances.cloturer', $seance), array_merge([
            'duree_minutes' => 195,
            'bilan' => 'Voyelles révisées, bonne participation.',
            'presents' => [$this->inscriptions[0]->id, $this->inscriptions[1]->id],
        ], $surcharge));
    }

    private function present(int $index, string $date): ?bool
    {
        $valeur = $this->inscriptions[$index]->presences()->whereDate('date_session', $date)->value('present');

        return $valeur === null ? null : (bool) $valeur;
    }

    public function test_closing_records_duration_bilan_and_enrolled_snapshot(): void
    {
        $seance = $this->seanceOuverte();
        $this->travelTo(Carbon::parse('2026-10-05 11:15:00'));

        $this->cloturer($seance)->assertSessionHasNoErrors()->assertRedirect();

        $seance->refresh();
        $this->assertSame('2026-10-05 11:15:00', $seance->cloturee_le->format('Y-m-d H:i:s'));
        $this->assertSame(195, $seance->duree_minutes);
        $this->assertSame('Voyelles révisées, bonne participation.', $seance->bilan);
        $this->assertSame(3, $seance->nb_inscrits);
        $this->assertFalse($seance->cloture_tardive);
    }

    public function test_roll_call_marks_present_and_checks_the_lessons_of_the_day(): void
    {
        $this->cloturer($this->seanceOuverte());

        $this->assertTrue($this->present(0, '2026-10-05'));
        $this->assertTrue(ProgressionModule::where('inscription_id', $this->inscriptions[0]->id)
            ->where('lecon_du_jour_id', $this->leconDuJour->id)->exists());
        $this->assertNull($this->present(2, '2026-10-05'));
    }

    public function test_unchecking_a_present_apprenant_marks_them_absent(): void
    {
        Presence::create(['inscription_id' => $this->inscriptions[2]->id, 'date_session' => '2026-10-05', 'present' => true]);

        $this->cloturer($this->seanceOuverte());

        $this->assertFalse($this->present(2, '2026-10-05'));
    }

    public function test_closing_the_next_day_is_flagged_late(): void
    {
        $seance = $this->seanceOuverte();
        $this->travelTo(Carbon::parse('2026-10-06 07:30:00'));

        $this->cloturer($seance, ['duree_minutes' => 180])->assertSessionHasNoErrors();

        $seance->refresh();
        $this->assertTrue($seance->cloture_tardive);
        $this->assertSame(180, $seance->duree_minutes);
        // L'appel s'applique au jour de la séance, pas au jour de la clôture.
        $this->assertTrue($this->present(0, '2026-10-05'));
        $this->assertNull($this->present(0, '2026-10-06'));
    }

    public function test_cannot_close_twice_or_someone_elses_session(): void
    {
        $seance = $this->seanceOuverte();
        $this->cloturer($seance);
        $this->cloturer($seance)->assertForbidden();

        $autre = $this->formateurDUnAutreCentre();
        $seanceAutre = $this->seanceOuverte(['formateur_id' => $autre->id, 'centre_id' => $autre->centre_id]);
        $this->cloturer($seanceAutre)->assertForbidden();
    }

    public function test_duration_is_bounded(): void
    {
        $seance = $this->seanceOuverte();

        $this->cloturer($seance, ['duree_minutes' => 721])->assertSessionHasErrors('duree_minutes');
        $this->cloturer($seance, ['duree_minutes' => 0])->assertSessionHasErrors('duree_minutes');
        $this->assertNull($seance->fresh()->cloturee_le);
    }

    public function test_foreign_ids_in_the_roll_call_are_ignored(): void
    {
        $this->cloturer($this->seanceOuverte(), ['presents' => [$this->inscriptions[0]->id, 999999]])
            ->assertSessionHasNoErrors();

        $this->assertSame(1, Presence::where('present', true)->count());
    }
}
```

- [ ] **Step 2: Lancer pour vérifier l'échec**

Run: `php artisan test --filter=SeanceClotureTest`
Expected: FAIL avec « Route [seances.cloturer] not defined ».

- [ ] **Step 3: Écrire `FaireAppel`**

`app/Actions/Seances/FaireAppel.php` :

```php
<?php

namespace App\Actions\Seances;

use App\Actions\Presence\ValiderPresenceParFormateur;
use App\Enums\StatutInscription;
use App\Models\Inscription;
use App\Models\Seance;
use Illuminate\Database\Eloquent\Builder;

/**
 * Appel de la classe à la clôture d'une séance (2026-09-25) : pour chaque
 * apprenant dont l'état change, même mécanisme que « Marquer présent » /
 * « Annuler » de la page Présence (présence + leçons du jour). Un identifiant
 * qui n'est pas une inscription active de la formation dans le centre de la
 * séance est ignoré.
 */
class FaireAppel
{
    public function __construct(private ValiderPresenceParFormateur $valider) {}

    /** Applique l'appel et renvoie le nombre d'inscrits (instantané nb_inscrits). */
    public function execute(Seance $seance, array $inscriptionIdsPresents): int
    {
        $date = $seance->date->toDateString();
        $presents = array_map('intval', $inscriptionIdsPresents);

        $inscriptions = self::inscriptionsDeLaSeance($seance)
            ->with(['apprenant', 'presences' => fn ($q) => $q->whereDate('date_session', $date)])
            ->get();

        foreach ($inscriptions as $inscription) {
            $etaitPresent = (bool) $inscription->presences->first()?->present;
            $doitEtrePresent = in_array($inscription->id, $presents, true);

            if ($etaitPresent !== $doitEtrePresent) {
                $this->valider->execute($inscription, $date, $doitEtrePresent);
            }
        }

        return $inscriptions->count();
    }

    /** Inscriptions actives de la formation dans le centre de la séance. */
    public static function inscriptionsDeLaSeance(Seance $seance): Builder
    {
        return Inscription::query()
            ->where('statut', StatutInscription::EnCours)
            ->where('formation_id', $seance->formation_id)
            ->whereHas('apprenant', fn ($q) => $q->where('centre_id', $seance->centre_id));
    }

    /** Liste pour l'écran d'appel, présents du jour cochés d'office. */
    public static function listePourAppel(Seance $seance): array
    {
        $date = $seance->date->toDateString();

        return self::inscriptionsDeLaSeance($seance)
            ->with(['apprenant', 'presences' => fn ($q) => $q->whereDate('date_session', $date)])
            ->get()
            ->map(fn (Inscription $i) => [
                'inscription_id' => $i->id,
                'nom_prenom' => $i->apprenant->nom_prenom,
                'present' => (bool) $i->presences->first()?->present,
            ])
            ->sortBy('nom_prenom', SORT_NATURAL | SORT_FLAG_CASE)
            ->values()
            ->all();
    }
}
```

- [ ] **Step 4: Écrire `CloturerSeance`**

`app/Actions/Seances/CloturerSeance.php` :

```php
<?php

namespace App\Actions\Seances;

use App\Models\Seance;
use Illuminate\Support\Facades\DB;

/**
 * Clôture : durée (proposée côté client, corrigeable), bilan, appel. Une
 * clôture un autre jour que celui de la séance est marquée tardive.
 */
class CloturerSeance
{
    public function __construct(private FaireAppel $appel) {}

    public function execute(Seance $seance, int $dureeMinutes, ?string $bilan, array $presents): Seance
    {
        return DB::transaction(function () use ($seance, $dureeMinutes, $bilan, $presents) {
            $nbInscrits = $this->appel->execute($seance, $presents);

            $seance->update([
                'cloturee_le' => now(),
                'duree_minutes' => $dureeMinutes,
                'bilan' => $bilan,
                'nb_inscrits' => $nbInscrits,
                'cloture_tardive' => now()->toDateString() !== $seance->date->toDateString(),
            ]);

            return $seance;
        });
    }
}
```

- [ ] **Step 5: Écrire la Form Request**

`app/Http/Requests/CloturerSeanceRequest.php` :

```php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CloturerSeanceRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('cloturer', $this->route('seance'));
    }

    public function rules(): array
    {
        return [
            'duree_minutes' => ['required', 'integer', 'min:1', 'max:720'],
            'bilan' => ['nullable', 'string', 'max:4000'],
            'presents' => ['nullable', 'array'],
            'presents.*' => ['integer'],
        ];
    }

    public function messages(): array
    {
        return [
            'duree_minutes.required' => 'Indiquez la durée de la séance.',
            'duree_minutes.min' => 'Indiquez la durée de la séance.',
            'duree_minutes.max' => 'Une séance ne peut pas dépasser 12 heures.',
        ];
    }
}
```

- [ ] **Step 6: Ajouter la méthode `cloturer` et la route**

Dans `SeanceController`, imports `use App\Actions\Seances\CloturerSeance;`, `use App\Http\Requests\CloturerSeanceRequest;`, `use App\Models\Seance;`, puis :

```php
    public function cloturer(CloturerSeanceRequest $request, Seance $seance, CloturerSeance $cloturer): RedirectResponse
    {
        $data = $request->validated();

        $cloturer->execute($seance, (int) $data['duree_minutes'], $data['bilan'] ?? null, $data['presents'] ?? []);

        return back()->with('status', 'Séance clôturée.');
    }
```

Dans `routes/staff.php`, sous `seances.store` :

```php
    Route::patch('seances/{seance}/cloturer', [SeanceController::class, 'cloturer'])->name('seances.cloturer');
```

- [ ] **Step 7: Lancer les tests**

Run: `php artisan test --filter="SeanceClotureTest|PresenceValidationFormateurTest"`
Expected: PASS (7 + 8 tests).

- [ ] **Step 8: Commit (si autorisé)**

```bash
git add app/Actions/Seances/FaireAppel.php app/Actions/Seances/CloturerSeance.php app/Http/Requests/CloturerSeanceRequest.php app/Http/Controllers/Staff/SeanceController.php routes/staff.php tests/Feature/Seances/SeanceClotureTest.php
git commit -m "Permet de cloturer une seance avec l'appel de la classe"
```

---

### Task 4: Corriger une séance (durée, bilan)

**Files:**
- Create: `app/Http/Requests/UpdateSeanceRequest.php`
- Modify: `app/Http/Controllers/Staff/SeanceController.php` (méthode `update`)
- Modify: `routes/staff.php`
- Test: `tests/Feature/Seances/SeanceCorrectionTest.php`

**Interfaces:**
- Consumes: `SeancePolicy::update`.
- Produces: route `PUT seances/{seance}` → `seances.update` (champs `duree_minutes`, `bilan`), même contrat pour le formateur et l'admin.

- [ ] **Step 1: Écrire les tests (échouent)**

`tests/Feature/Seances/SeanceCorrectionTest.php` :

```php
<?php

namespace Tests\Feature\Seances;

use App\Enums\RoleStaff;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class SeanceCorrectionTest extends TestCase
{
    use RefreshDatabase, ScenarioSeance;

    protected function setUp(): void
    {
        parent::setUp();
        $this->preparerScenario();
    }

    private function seanceCloturee()
    {
        return $this->seanceOuverte([
            'cloturee_le' => Carbon::parse('2026-10-05 11:00:00'),
            'duree_minutes' => 180,
            'bilan' => 'Bilan initial',
            'nb_inscrits' => 3,
        ]);
    }

    private function corriger(User $acteur, $seance, array $donnees = ['duree_minutes' => 150, 'bilan' => 'Bilan corrigé'])
    {
        return $this->actingAs($acteur)->put(route('seances.update', $seance), $donnees);
    }

    public function test_formateur_corrects_the_same_day(): void
    {
        $seance = $this->seanceCloturee();

        $this->corriger($this->formateur, $seance)->assertSessionHasNoErrors()->assertRedirect();

        $seance->refresh();
        $this->assertSame(150, $seance->duree_minutes);
        $this->assertSame('Bilan corrigé', $seance->bilan);
        $this->assertNull($seance->corrigee_par_id);
    }

    public function test_formateur_cannot_correct_the_next_day(): void
    {
        $seance = $this->seanceCloturee();
        $this->travelTo(Carbon::parse('2026-10-06 09:00:00'));

        $this->corriger($this->formateur, $seance)->assertForbidden();
    }

    public function test_admin_corrects_any_day_and_it_is_traced(): void
    {
        $seance = $this->seanceCloturee();
        $this->travelTo(Carbon::parse('2026-10-10 09:00:00'));
        $admin = User::factory()->create(['role' => RoleStaff::Admin]);

        $this->corriger($admin, $seance, ['duree_minutes' => 120, 'bilan' => 'Corrigé par admin'])->assertSessionHasNoErrors();

        $seance->refresh();
        $this->assertSame(120, $seance->duree_minutes);
        $this->assertSame('Corrigé par admin', $seance->bilan);
        $this->assertSame($admin->id, $seance->corrigee_par_id);
        $this->assertSame('2026-10-10', $seance->corrigee_le->toDateString());
        $this->assertSame('Révision des voyelles', $seance->prevision);
    }

    public function test_an_open_session_cannot_be_corrected(): void
    {
        $seance = $this->seanceOuverte();

        $this->corriger(User::factory()->create(['role' => RoleStaff::Admin]), $seance)->assertForbidden();
        $this->corriger($this->formateur, $seance)->assertForbidden();
    }

    public function test_gestionnaire_and_other_formateur_cannot_correct(): void
    {
        $seance = $this->seanceCloturee();

        $this->corriger(User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $this->centre->id]), $seance)->assertForbidden();
        $this->corriger($this->formateurDUnAutreCentre(), $seance)->assertForbidden();
    }

    public function test_duration_is_bounded(): void
    {
        $this->corriger($this->formateur, $this->seanceCloturee(), ['duree_minutes' => 0, 'bilan' => 'x'])
            ->assertSessionHasErrors('duree_minutes');
    }
}
```

- [ ] **Step 2: Lancer pour vérifier l'échec**

Run: `php artisan test --filter=SeanceCorrectionTest`
Expected: FAIL avec « Route [seances.update] not defined ».

- [ ] **Step 3: Écrire la Form Request**

`app/Http/Requests/UpdateSeanceRequest.php` :

```php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

/** Correction d'une séance clôturée : durée et bilan (2026-09-25). */
class UpdateSeanceRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('seance'));
    }

    public function rules(): array
    {
        return [
            'duree_minutes' => ['required', 'integer', 'min:1', 'max:720'],
            'bilan' => ['nullable', 'string', 'max:4000'],
        ];
    }

    public function messages(): array
    {
        return [
            'duree_minutes.required' => 'Indiquez la durée de la séance.',
            'duree_minutes.min' => 'Indiquez la durée de la séance.',
            'duree_minutes.max' => 'Une séance ne peut pas dépasser 12 heures.',
        ];
    }
}
```

- [ ] **Step 4: Ajouter la méthode `update` et la route**

Dans `SeanceController`, imports `use App\Enums\RoleStaff;`, `use App\Http\Requests\UpdateSeanceRequest;`, puis :

```php
    /** Durée et bilan. Formateur : le jour de la clôture ; admin : toujours, tracé. */
    public function update(UpdateSeanceRequest $request, Seance $seance): RedirectResponse
    {
        $admin = $request->user()->role === RoleStaff::Admin;

        $seance->update([
            'duree_minutes' => (int) $request->validated('duree_minutes'),
            'bilan' => $request->validated('bilan'),
            ...($admin ? ['corrigee_par_id' => $request->user()->id, 'corrigee_le' => now()] : []),
        ]);

        return back()->with('status', $admin ? 'Séance corrigée.' : 'Séance mise à jour.');
    }
```

Route, sous `seances.cloturer` :

```php
    Route::put('seances/{seance}', [SeanceController::class, 'update'])->name('seances.update');
```

- [ ] **Step 5: Lancer les tests**

Run: `php artisan test --filter=SeanceCorrectionTest`
Expected: PASS (6 tests).

- [ ] **Step 6: Commit (si autorisé)**

```bash
git add app/Http/Requests/UpdateSeanceRequest.php app/Http/Controllers/Staff/SeanceController.php routes/staff.php tests/Feature/Seances/SeanceCorrectionTest.php
git commit -m "Permet de corriger la duree et le bilan d'une seance"
```

---

### Task 5: Rattrapage d'une séance passée

**Files:**
- Create: `app/Http/Requests/StoreRattrapageSeanceRequest.php`
- Create: `app/Actions/Seances/SaisirSeancePassee.php`
- Modify: `app/Http/Controllers/Staff/SeanceController.php` (méthode `rattrapage`)
- Modify: `routes/staff.php`
- Test: `tests/Feature/Seances/SeanceRattrapageTest.php`

**Interfaces:**
- Consumes: `FaireAppel::inscriptionsDeLaSeance`, `DansLaPeriodeDeLaFormation`, route existante `presences.index` (lit `formation_id` et `date` en query).
- Produces: route `POST seances/rattrapage` → `seances.rattrapage` (champs `formation_id`, `date`, `duree_minutes`, `bilan`) ; `SaisirSeancePassee::execute(User $formateur, Formation $formation, string $date, int $dureeMinutes, ?string $bilan): Seance`.

- [ ] **Step 1: Écrire les tests (échouent)**

`tests/Feature/Seances/SeanceRattrapageTest.php` :

```php
<?php

namespace Tests\Feature\Seances;

use App\Enums\RoleStaff;
use App\Models\Seance;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SeanceRattrapageTest extends TestCase
{
    use RefreshDatabase, ScenarioSeance;

    protected function setUp(): void
    {
        parent::setUp();
        $this->preparerScenario();
    }

    private function rattraper(array $surcharge = [], ?User $acteur = null)
    {
        return $this->actingAs($acteur ?? $this->formateur)->post(route('seances.rattrapage'), array_merge([
            'formation_id' => $this->formation->id,
            'date' => '2026-10-02',
            'duree_minutes' => 210,
            'bilan' => 'Séance oubliée, rattrapée.',
        ], $surcharge));
    }

    public function test_past_session_is_recorded_flagged_and_leads_to_the_roll_call(): void
    {
        $this->rattraper()
            ->assertSessionHasNoErrors()
            ->assertRedirect(route('presences.index', ['formation_id' => $this->formation->id, 'date' => '2026-10-02']));

        $seance = Seance::firstOrFail();
        $this->assertTrue($seance->saisie_a_posteriori);
        $this->assertFalse($seance->cloture_tardive);
        $this->assertSame('2026-10-02', $seance->date->toDateString());
        $this->assertNull($seance->ouverte_le);
        $this->assertNotNull($seance->cloturee_le);
        $this->assertSame(210, $seance->duree_minutes);
        $this->assertSame(3, $seance->nb_inscrits);
    }

    public function test_date_window_is_seven_days_before_today(): void
    {
        $this->rattraper(['date' => '2026-09-27'])->assertSessionHasErrors('date');
        $this->rattraper(['date' => '2026-10-05'])->assertSessionHasErrors('date');
        $this->rattraper(['date' => '2026-09-28'])->assertSessionHasNoErrors();
    }

    public function test_cannot_catch_up_a_day_already_recorded(): void
    {
        $this->rattraper()->assertSessionHasNoErrors();
        $this->rattraper()->assertSessionHasErrors('date');
        $this->assertSame(1, Seance::count());
    }

    public function test_catch_up_outside_the_formation_period_is_refused(): void
    {
        $this->formation->update(['date_debut' => '2026-10-03', 'date_fin' => '2026-10-31']);

        $this->rattraper()->assertSessionHasErrors('date');
    }

    public function test_formateur_can_correct_a_catch_up_the_day_it_was_entered(): void
    {
        $this->rattraper();

        $this->assertTrue($this->formateur->can('update', Seance::firstOrFail()));
    }

    public function test_only_a_formateur_can_catch_up(): void
    {
        $this->rattraper([], User::factory()->create(['role' => RoleStaff::Admin]))->assertForbidden();
        $this->rattraper([], User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $this->centre->id]))->assertForbidden();
    }
}
```

- [ ] **Step 2: Lancer pour vérifier l'échec**

Run: `php artisan test --filter=SeanceRattrapageTest`
Expected: FAIL avec « Route [seances.rattrapage] not defined ».

- [ ] **Step 3: Écrire la Form Request**

`app/Http/Requests/StoreRattrapageSeanceRequest.php` :

```php
<?php

namespace App\Http\Requests;

use App\Models\Formation;
use App\Models\Seance;
use App\Rules\DansLaPeriodeDeLaFormation;
use Illuminate\Foundation\Http\FormRequest;

/** Saisie après coup d'une séance oubliée, de J-7 à J-1 (2026-09-25). */
class StoreRattrapageSeanceRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', [Seance::class, Formation::find($this->integer('formation_id'))]);
    }

    public function rules(): array
    {
        return [
            'formation_id' => ['required', 'integer', 'exists:formations,id'],
            'date' => [
                'required',
                'date_format:Y-m-d',
                'after_or_equal:'.today()->subDays(7)->toDateString(),
                'before:'.today()->toDateString(),
                new DansLaPeriodeDeLaFormation(Formation::find($this->integer('formation_id'))),
            ],
            'duree_minutes' => ['required', 'integer', 'min:1', 'max:720'],
            'bilan' => ['nullable', 'string', 'max:4000'],
        ];
    }

    public function messages(): array
    {
        return [
            'date.after_or_equal' => 'Une séance ne peut être rattrapée que dans les 7 derniers jours.',
            'date.before' => "Pour aujourd'hui, utilisez « Commencer la séance ».",
            'duree_minutes.required' => 'Indiquez la durée de la séance.',
            'duree_minutes.min' => 'Indiquez la durée de la séance.',
            'duree_minutes.max' => 'Une séance ne peut pas dépasser 12 heures.',
        ];
    }

    public function withValidator($validator): void
    {
        $validator->after(function ($validator) {
            if ($validator->errors()->isNotEmpty()) {
                return;
            }

            $dejaSaisie = Seance::where('formateur_id', $this->user()->id)
                ->where('formation_id', $this->integer('formation_id'))
                ->whereDate('date', $this->input('date'))
                ->exists();

            if ($dejaSaisie) {
                $validator->errors()->add('date', 'Une séance existe déjà pour cette formation ce jour-là.');
            }
        });
    }
}
```

- [ ] **Step 4: Écrire l'Action**

`app/Actions/Seances/SaisirSeancePassee.php` :

```php
<?php

namespace App\Actions\Seances;

use App\Models\Formation;
use App\Models\Seance;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * Rattrapage d'une séance oubliée : créée d'emblée clôturée, marquée
 * « saisie a posteriori », sans heure d'arrivée (non vérifiable après coup).
 * L'appel de ce jour-là se fait ensuite sur la page Présence.
 */
class SaisirSeancePassee
{
    public function execute(User $formateur, Formation $formation, string $date, int $dureeMinutes, ?string $bilan): Seance
    {
        return DB::transaction(function () use ($formateur, $formation, $date, $dureeMinutes, $bilan) {
            $seance = Seance::create([
                'formateur_id' => $formateur->id,
                'formation_id' => $formation->id,
                'centre_id' => $formateur->centre_id,
                'date' => Carbon::parse($date)->toDateString(),
                'ouverte_le' => null,
                'cloturee_le' => now(),
                'duree_minutes' => $dureeMinutes,
                'bilan' => $bilan,
                'saisie_a_posteriori' => true,
            ]);

            $seance->update(['nb_inscrits' => FaireAppel::inscriptionsDeLaSeance($seance)->count()]);

            return $seance;
        });
    }
}
```

- [ ] **Step 5: Ajouter la méthode et la route**

Dans `SeanceController`, imports `use App\Actions\Seances\SaisirSeancePassee;`, `use App\Http\Requests\StoreRattrapageSeanceRequest;`, puis :

```php
    /** Séance oubliée : enregistrée, puis direction la page Présence de ce jour pour l'appel. */
    public function rattrapage(StoreRattrapageSeanceRequest $request, SaisirSeancePassee $saisir): RedirectResponse
    {
        $data = $request->validated();

        $seance = $saisir->execute(
            $request->user(),
            Formation::findOrFail($data['formation_id']),
            $data['date'],
            (int) $data['duree_minutes'],
            $data['bilan'] ?? null,
        );

        return redirect()
            ->route('presences.index', ['formation_id' => $seance->formation_id, 'date' => $seance->date->toDateString()])
            ->with('status', 'Séance du '.$seance->date->format('d/m/Y')." enregistrée. Faites maintenant l'appel de ce jour-là.");
    }
```

Route, juste sous `seances.store` (littéral avant `seances/{seance}`) :

```php
    Route::post('seances/rattrapage', [SeanceController::class, 'rattrapage'])->name('seances.rattrapage');
```

- [ ] **Step 6: Lancer les tests**

Run: `php artisan test --filter=SeanceRattrapageTest`
Expected: PASS (6 tests).

- [ ] **Step 7: Commit (si autorisé)**

```bash
git add app/Http/Requests/StoreRattrapageSeanceRequest.php app/Actions/Seances/SaisirSeancePassee.php app/Http/Controllers/Staff/SeanceController.php routes/staff.php tests/Feature/Seances/SeanceRattrapageTest.php
git commit -m "Permet de saisir une seance passee (rattrapage 7 jours)"
```

---

### Task 6: Page « Séances » (serveur) et format commun

**Files:**
- Create: `app/Actions/Seances/SerialiserSeances.php`
- Modify: `app/Http/Controllers/Staff/SeanceController.php` (méthodes `index`, `parFormateur`)
- Modify: `routes/staff.php`
- Test: `tests/Feature/Seances/PageSeancesTest.php`

**Interfaces:**
- Consumes: `Seance::visibleTo`, `SeancePolicy::viewAny` / `update`.
- Produces:
  - `SerialiserSeances::pour(Collection $seances, User $user): Collection` d'éléments `SeanceListe = {id, date, formateur_id, formateur, formation_id, formation, centre, ouverte_le (ISO|null), cloturee_le (ISO|null), duree_minutes, prevision, bilan, presents: string[], nb_inscrits, lecons: string[], saisie_a_posteriori, cloture_tardive, corrigee_le, corrigee_par, modifiable: bool}` ;
  - statiques `SerialiserSeances::cle(Seance): string` (`"formation-centre-date"`), `presentsPar(Collection): Collection` et `leconsPar(Collection): Collection` (clé → liste de noms / titres) ;
  - route `GET seances` → `seances.index`, query `mois=YYYY-MM`, `centre_id` (admin), `formateur_id` (admin, gestionnaire) ; props `{mois, mois_courant, filtres: {centre_id, formateur_id}, stats: {minutes, seances, a_surveiller}, seances: SeanceListe[]}` + pour admin/gestionnaire `{en_seance: string[], par_formateur: [{id, nom, centre, minutes, seances, assiduite, jours_sans_seance}], centres, formateurs: [{id, nom, centre}]}`.

- [ ] **Step 1: Écrire les tests (échouent)**

`tests/Feature/Seances/PageSeancesTest.php` :

```php
<?php

namespace Tests\Feature\Seances;

use App\Enums\RoleStaff;
use App\Models\LeconDuJour;
use App\Models\Presence;
use App\Models\Seance;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;

class PageSeancesTest extends TestCase
{
    use RefreshDatabase, ScenarioSeance;

    private User $admin;

    protected function setUp(): void
    {
        parent::setUp();
        $this->preparerScenario();
        $this->admin = User::factory()->create(['role' => RoleStaff::Admin]);
    }

    private function seanceCloturee(string $date, int $minutes, array $attrs = []): Seance
    {
        return $this->seanceOuverte(array_merge([
            'date' => $date,
            'ouverte_le' => Carbon::parse($date.' 08:00'),
            'cloturee_le' => Carbon::parse($date.' 08:00')->addMinutes($minutes),
            'duree_minutes' => $minutes,
            'nb_inscrits' => 3,
        ], $attrs));
    }

    private function seanceDeZakaria(string $date, int $minutes, array $attrs = []): Seance
    {
        $autre = $this->formateurDUnAutreCentre();

        return $this->seanceCloturee($date, $minutes, array_merge(['formateur_id' => $autre->id, 'centre_id' => $autre->centre_id], $attrs));
    }

    public function test_formateur_sees_only_his_sessions_of_the_month(): void
    {
        $this->seanceCloturee('2026-10-05', 180);
        $this->seanceCloturee('2026-10-01', 120);
        $this->seanceCloturee('2026-09-30', 120);
        $this->seanceDeZakaria('2026-10-05', 60);

        $this->actingAs($this->formateur)->get(route('seances.index'))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->component('Staff/Seances/Index')
                ->where('mois', '2026-10')
                ->has('seances', 2)
                ->where('seances.0.date', '2026-10-05')
                ->where('stats.minutes', 300)
                ->missing('par_formateur')
                ->missing('en_seance')
            );
    }

    public function test_month_parameter_and_future_months(): void
    {
        $this->seanceCloturee('2026-09-30', 120);

        $this->actingAs($this->formateur)->get(route('seances.index', ['mois' => '2026-09']))
            ->assertInertia(fn (Assert $page) => $page->where('mois', '2026-09')->has('seances', 1));
        $this->actingAs($this->formateur)->get(route('seances.index', ['mois' => 'n-importe-quoi']))
            ->assertInertia(fn (Assert $page) => $page->where('mois', '2026-10'));
        $this->actingAs($this->formateur)->get(route('seances.index', ['mois' => '2026-11']))
            ->assertInertia(fn (Assert $page) => $page->where('mois', '2026-10'));
    }

    public function test_presents_and_lessons_are_read_live(): void
    {
        $this->seanceCloturee('2026-10-05', 180);
        // Présences corrigées après la clôture, depuis la page Présence.
        Presence::create(['inscription_id' => $this->inscriptions[2]->id, 'date_session' => '2026-10-05', 'present' => true]);
        Presence::create(['inscription_id' => $this->inscriptions[0]->id, 'date_session' => '2026-10-05', 'present' => true]);
        Presence::create(['inscription_id' => $this->inscriptions[1]->id, 'date_session' => '2026-10-05', 'present' => false]);

        $this->actingAs($this->formateur)->get(route('seances.index'))
            ->assertInertia(fn (Assert $page) => $page
                ->where('seances.0.presents', ['Awa', 'Chantal'])
                ->where('seances.0.nb_inscrits', 3)
                ->where('seances.0.lecons', ['Les voyelles'])
                ->where('seances.0.modifiable', true)
            );
    }

    public function test_admin_sees_everything_and_who_is_in_session(): void
    {
        $this->seanceCloturee('2026-10-02', 180);
        $this->seanceOuverte();
        $this->seanceDeZakaria('2026-10-05', 60, ['cloture_tardive' => true]);

        $this->actingAs($this->admin)->get(route('seances.index'))
            ->assertInertia(fn (Assert $page) => $page
                ->where('stats.minutes', 240)
                ->where('stats.seances', 3)
                ->where('stats.a_surveiller', 1)
                ->where('en_seance', ['Nicolas'])
                ->has('par_formateur', 2)
                ->has('centres', 2)
                ->has('formateurs', 2)
                ->where('seances.0.modifiable', true)
            );
    }

    public function test_gestionnaire_is_limited_to_his_centre_even_with_forced_filters(): void
    {
        $this->seanceCloturee('2026-10-05', 180);
        $zakaria = $this->seanceDeZakaria('2026-10-05', 60);
        $gestionnaire = User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $this->centre->id]);

        $this->actingAs($gestionnaire)->get(route('seances.index', ['centre_id' => $zakaria->centre_id]))
            ->assertInertia(fn (Assert $page) => $page
                ->has('seances', 1)
                ->where('stats.minutes', 180)
                ->where('seances.0.modifiable', false)
                ->has('par_formateur', 1)
                ->where('centres', [])
            );

        $this->actingAs($gestionnaire)->get(route('seances.index', ['formateur_id' => $zakaria->formateur_id]))
            ->assertInertia(fn (Assert $page) => $page->has('seances', 0)->has('par_formateur', 0));
    }

    public function test_days_without_session_and_attendance_per_formateur(): void
    {
        // Leçons planifiées le 01/10, le 02/10 et aujourd'hui (05/10) ; séance le 02/10 seulement.
        LeconDuJour::create(['module_formation_id' => $this->module2->id, 'date_prevue' => '2026-10-01']);
        LeconDuJour::create(['module_formation_id' => $this->module3->id, 'date_prevue' => '2026-10-02']);
        $this->seanceCloturee('2026-10-02', 120, ['nb_inscrits' => 2]);
        Presence::create(['inscription_id' => $this->inscriptions[0]->id, 'date_session' => '2026-10-02', 'present' => true]);

        $this->actingAs($this->admin)->get(route('seances.index'))
            ->assertInertia(fn (Assert $page) => $page
                ->where('par_formateur.0.nom', 'Nicolas')
                ->where('par_formateur.0.minutes', 120)
                // Le 01/10 manque ; aujourd'hui n'est pas compté (journée en cours).
                ->where('par_formateur.0.jours_sans_seance', 1)
                ->where('par_formateur.0.assiduite', 50)
            );
    }

    public function test_other_roles_are_forbidden(): void
    {
        foreach ([RoleStaff::Secretaire, RoleStaff::Partenaire] as $role) {
            $this->actingAs(User::factory()->create(['role' => $role, 'centre_id' => $this->centre->id]))
                ->get(route('seances.index'))
                ->assertForbidden();
        }
    }
}
```

- [ ] **Step 2: Lancer pour vérifier l'échec**

Run: `php artisan test --filter=PageSeancesTest`
Expected: FAIL avec « Route [seances.index] not defined ».

- [ ] **Step 3: Écrire `SerialiserSeances`**

`app/Actions/Seances/SerialiserSeances.php` :

```php
<?php

namespace App\Actions\Seances;

use App\Models\LeconDuJour;
use App\Models\Presence;
use App\Models\Seance;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;

/**
 * Format commun d'une séance pour les écrans (carte du tableau de bord, page
 * Séances) et sources des données dérivées (rapport). Présents et leçons ne
 * sont jamais stockés sur la séance : ils sont lus en direct dans presences
 * et lecons_du_jour, en une requête groupée chacun pour toute la liste.
 */
class SerialiserSeances
{
    /** @param Collection<int, Seance> $seances */
    public function pour(Collection $seances, User $user): Collection
    {
        // Une collection de base (ex. issue d'un merge) n'a pas loadMissing().
        $seances = EloquentCollection::make($seances->all());
        $seances->loadMissing(['formateur:id,name', 'formation:id,titre', 'centre:id,nom', 'corrigeePar:id,name']);
        $presents = self::presentsPar($seances);
        $lecons = self::leconsPar($seances);

        return $seances->map(fn (Seance $s) => [
            'id' => $s->id,
            'date' => $s->date->toDateString(),
            'formateur_id' => $s->formateur_id,
            'formateur' => $s->formateur?->name,
            'formation_id' => $s->formation_id,
            'formation' => $s->formation?->titre,
            'centre' => $s->centre?->nom,
            'ouverte_le' => $s->ouverte_le?->toIso8601String(),
            'cloturee_le' => $s->cloturee_le?->toIso8601String(),
            'duree_minutes' => $s->duree_minutes,
            'prevision' => $s->prevision,
            'bilan' => $s->bilan,
            'presents' => $presents->get(self::cle($s), collect())->values()->all(),
            'nb_inscrits' => $s->nb_inscrits,
            'lecons' => $lecons->get(self::cle($s), collect())->values()->all(),
            'saisie_a_posteriori' => $s->saisie_a_posteriori,
            'cloture_tardive' => $s->cloture_tardive,
            'corrigee_le' => $s->corrigee_le?->toIso8601String(),
            'corrigee_par' => $s->corrigeePar?->name,
            'modifiable' => $user->can('update', $s),
        ])->values();
    }

    /** Clé de regroupement « formation-centre-date » (présence et planning sont par jour). */
    public static function cle(Seance $s): string
    {
        return "{$s->formation_id}-{$s->centre_id}-{$s->date->toDateString()}";
    }

    /** Noms des apprenants présents, par clé, triés. */
    public static function presentsPar(Collection $seances): Collection
    {
        if ($seances->isEmpty()) {
            return collect();
        }

        $dates = $seances->map(fn (Seance $s) => $s->date->toDateString());

        return Presence::query()
            ->where('present', true)
            ->whereDate('date_session', '>=', $dates->min())
            ->whereDate('date_session', '<=', $dates->max())
            ->whereHas('inscription', fn ($q) => $q
                ->whereIn('formation_id', $seances->pluck('formation_id')->unique())
                ->whereHas('apprenant', fn ($q2) => $q2->whereIn('centre_id', $seances->pluck('centre_id')->unique())))
            ->with('inscription.apprenant:id,nom_prenom,centre_id')
            ->get()
            ->filter(fn (Presence $p) => $p->inscription?->apprenant)
            ->groupBy(fn (Presence $p) => "{$p->inscription->formation_id}-{$p->inscription->apprenant->centre_id}-{$p->date_session->toDateString()}")
            ->map(fn (Collection $groupe) => $groupe
                ->pluck('inscription.apprenant.nom_prenom')
                ->sort(SORT_NATURAL | SORT_FLAG_CASE)
                ->values());
    }

    /** Titres des leçons planifiées, par clé, dans l'ordre du programme. */
    public static function leconsPar(Collection $seances): Collection
    {
        if ($seances->isEmpty()) {
            return collect();
        }

        $dates = $seances->map(fn (Seance $s) => $s->date->toDateString());

        return LeconDuJour::query()
            ->whereDate('date_prevue', '>=', $dates->min())
            ->whereDate('date_prevue', '<=', $dates->max())
            ->whereHas('moduleFormation.chapitre', fn ($q) => $q
                ->whereIn('formation_id', $seances->pluck('formation_id')->unique())
                ->whereIn('centre_id', $seances->pluck('centre_id')->unique()))
            ->with(['moduleFormation:id,titre,ordre,chapitre_id', 'moduleFormation.chapitre:id,formation_id,centre_id,ordre'])
            ->get()
            ->sortBy(fn (LeconDuJour $l) => sprintf('%05d-%05d', $l->moduleFormation->chapitre->ordre, $l->moduleFormation->ordre))
            ->groupBy(fn (LeconDuJour $l) => "{$l->moduleFormation->chapitre->formation_id}-{$l->moduleFormation->chapitre->centre_id}-{$l->date_prevue->toDateString()}")
            ->map(fn (Collection $groupe) => $groupe->pluck('moduleFormation.titre')->values());
    }
}
```

- [ ] **Step 4: Méthode `index` et synthèse par formateur**

Dans `SeanceController`, imports `use App\Actions\Seances\SerialiserSeances;`, `use App\Models\Centre;`, `use App\Models\LeconDuJour;`, `use App\Models\User;`, `use Illuminate\Http\Request;`, `use Illuminate\Support\Carbon;`, `use Illuminate\Support\Collection;`, `use Inertia\Inertia;`, `use Inertia\Response;`, puis :

```php
    /**
     * Page « Séances » : le formateur voit les siennes, le gestionnaire son
     * centre (lecture), l'admin tout (correction). Navigation par mois.
     * Les props lourdes sont des closures : le rafraîchissement périodique
     * de « en séance maintenant » ne recalcule qu'elle.
     */
    public function index(Request $request, SerialiserSeances $serialiser): Response
    {
        $this->authorize('viewAny', Seance::class);

        $user = $request->user();
        $admin = $user->role === RoleStaff::Admin;
        $suivi = $user->role !== RoleStaff::Formateur;

        $moisCourant = today()->format('Y-m');
        $demande = (string) $request->query('mois');
        $mois = preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $demande) && $demande <= $moisCourant ? $demande : $moisCourant;
        $debut = Carbon::createFromFormat('Y-m-d', $mois.'-01')->startOfDay();
        $fin = $debut->copy()->endOfMonth()->startOfDay();

        $centreId = $admin ? ($request->integer('centre_id') ?: null) : null;
        $formateurId = $suivi ? ($request->integer('formateur_id') ?: null) : null;

        $lignes = null;
        $chargerLignes = function () use (&$lignes, $serialiser, $user, $debut, $fin, $centreId, $formateurId): Collection {
            return $lignes ??= $serialiser->pour(
                Seance::visibleTo($user)
                    ->whereDate('date', '>=', $debut)
                    ->whereDate('date', '<=', $fin)
                    ->when($centreId, fn ($q) => $q->where('centre_id', $centreId))
                    ->when($formateurId, fn ($q) => $q->where('formateur_id', $formateurId))
                    ->orderByDesc('date')
                    ->orderByDesc('id')
                    ->get(),
                $user,
            );
        };

        $props = [
            'mois' => $mois,
            'mois_courant' => $moisCourant,
            'filtres' => ['centre_id' => $centreId, 'formateur_id' => $formateurId],
            'stats' => fn () => [
                'minutes' => (int) $chargerLignes()->sum('duree_minutes'),
                'seances' => $chargerLignes()->count(),
                'a_surveiller' => $chargerLignes()->filter(fn (array $s) => $s['cloture_tardive']
                    || $s['saisie_a_posteriori']
                    || ($s['cloturee_le'] === null && $s['date'] < today()->toDateString()))->count(),
            ],
            'seances' => fn () => $chargerLignes()->all(),
        ];

        if ($suivi) {
            $formateurs = User::where('role', RoleStaff::Formateur)
                ->when(! $admin, fn ($q) => $q->where('centre_id', $user->centre_id))
                ->when($centreId, fn ($q) => $q->where('centre_id', $centreId))
                ->with(['centre:id,nom', 'formations' => fn ($q) => $q->select('formations.id')])
                ->orderBy('name')
                ->get(['id', 'name', 'centre_id']);

            $props += [
                'en_seance' => fn () => Seance::visibleTo($user)
                    ->whereNull('cloturee_le')
                    ->whereDate('date', today())
                    ->when($centreId, fn ($q) => $q->where('centre_id', $centreId))
                    ->with('formateur:id,name')
                    ->get()
                    ->pluck('formateur.name')
                    ->filter()
                    ->sort(SORT_NATURAL | SORT_FLAG_CASE)
                    ->values()
                    ->all(),
                'par_formateur' => fn () => $this->parFormateur(
                    $formateurs->when($formateurId, fn (Collection $c) => $c->where('id', $formateurId)),
                    $chargerLignes(),
                    $debut,
                    $fin,
                ),
                'centres' => $admin ? Centre::orderBy('nom')->get(['id', 'nom']) : [],
                'formateurs' => $formateurs->map(fn (User $f) => ['id' => $f->id, 'nom' => $f->name, 'centre' => $f->centre?->nom])->values(),
            ];
        }

        return Inertia::render('Staff/Seances/Index', $props);
    }

    /**
     * Heures, séances, assiduité moyenne (présents / inscrits) et jours sans
     * séance : couples (formation, jour) du mois, jusqu'à la veille, où une
     * leçon était planifiée dans son centre pour une de ses formations sans
     * séance de sa part pour cette formation. Une seule requête de leçons.
     */
    private function parFormateur(Collection $formateurs, Collection $lignes, Carbon $debut, Carbon $fin): array
    {
        $jusqua = $fin->lt(today()->subDay()) ? $fin : today()->subDay();

        $planifies = $formateurs->isEmpty() || $jusqua->lt($debut) ? collect() : LeconDuJour::query()
            ->whereDate('date_prevue', '>=', $debut)
            ->whereDate('date_prevue', '<=', $jusqua)
            ->whereHas('moduleFormation.chapitre', fn ($q) => $q->whereIn('centre_id', $formateurs->pluck('centre_id')->filter()->unique()))
            ->with('moduleFormation.chapitre:id,formation_id,centre_id')
            ->get()
            ->map(fn (LeconDuJour $l) => [
                'centre_id' => $l->moduleFormation->chapitre->centre_id,
                'formation_id' => $l->moduleFormation->chapitre->formation_id,
                'cle' => $l->moduleFormation->chapitre->formation_id.'|'.$l->date_prevue->toDateString(),
            ]);

        return $formateurs->map(function (User $f) use ($lignes, $planifies) {
            $siennes = $lignes->where('formateur_id', $f->id);
            $avecAppel = $siennes->filter(fn (array $s) => $s['cloturee_le'] !== null && $s['nb_inscrits'] > 0);
            $formationIds = $f->formations->pluck('id')->all();

            $attendus = $planifies
                ->filter(fn (array $p) => $p['centre_id'] == $f->centre_id && in_array($p['formation_id'], $formationIds))
                ->pluck('cle')
                ->unique();
            $pointes = $siennes->map(fn (array $s) => $s['formation_id'].'|'.$s['date'])->unique();

            return [
                'id' => $f->id,
                'nom' => $f->name,
                'centre' => $f->centre?->nom,
                'minutes' => (int) $siennes->sum('duree_minutes'),
                'seances' => $siennes->count(),
                'assiduite' => $avecAppel->isEmpty()
                    ? null
                    : (int) round($avecAppel->avg(fn (array $s) => count($s['presents']) / $s['nb_inscrits']) * 100),
                'jours_sans_seance' => $attendus->diff($pointes)->count(),
            ];
        })->values()->all();
    }
```

Route, **en tête** des routes séances (avant `seances.store`) :

```php
    Route::get('seances', [SeanceController::class, 'index'])->name('seances.index');
```

- [ ] **Step 5: Lancer les tests**

Run: `php artisan test --filter=PageSeancesTest`
Expected: PASS (7 tests). La page React n'existe pas encore : les tests Inertia n'en ont pas besoin (`assertInertia` ne rend pas le composant).

- [ ] **Step 6: Commit (si autorisé)**

```bash
git add app/Actions/Seances/SerialiserSeances.php app/Http/Controllers/Staff/SeanceController.php routes/staff.php tests/Feature/Seances/PageSeancesTest.php
git commit -m "Ajoute la page Seances (portee selon le role) cote serveur"
```

---

### Task 7: Données de la carte « Ma séance du jour »

**Files:**
- Create: `app/Actions/Seances/DonneesCarteSeance.php`
- Modify: `app/Http/Controllers/Staff/DashboardController.php` (méthode `formateur`)
- Test: `tests/Feature/Seances/SeanceCarteTest.php`

**Interfaces:**
- Consumes: `SerialiserSeances::pour`, `FaireAppel::listePourAppel`.
- Produces: prop Inertia `seance` sur `Staff/Dashboard/Formateur` :
  ```
  {
    en_cours: (SeanceListe & {appel: [{inscription_id, nom_prenom, present}]}) | null,
    du_jour: SeanceListe[],                      // clôturées aujourd'hui
    formations: [{id, titre}],                   // toutes les siennes (rattrapage)
    formations_disponibles: [{id, titre}],       // pas encore pointées aujourd'hui, [] si une séance est ouverte
    lecons_du_jour: {[formation_id]: string[]},  // titres planifiés aujourd'hui dans son centre
  }
  ```

- [ ] **Step 1: Écrire les tests (échouent)**

`tests/Feature/Seances/SeanceCarteTest.php` :

```php
<?php

namespace Tests\Feature\Seances;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;

class SeanceCarteTest extends TestCase
{
    use RefreshDatabase, ScenarioSeance;

    protected function setUp(): void
    {
        parent::setUp();
        $this->preparerScenario();
    }

    public function test_card_offers_to_start_when_nothing_is_open(): void
    {
        $this->actingAs($this->formateur)->get(route('dashboard'))
            ->assertOk()
            ->assertInertia(fn (Assert $page) => $page
                ->where('seance.en_cours', null)
                ->has('seance.formations', 1)
                ->has('seance.formations_disponibles', 1)
                ->where('seance.lecons_du_jour.'.$this->formation->id, ['Les voyelles'])
            );
    }

    public function test_card_shows_the_open_session_with_its_roll_call(): void
    {
        $seance = $this->seanceOuverte();

        $this->actingAs($this->formateur)->get(route('dashboard'))
            ->assertInertia(fn (Assert $page) => $page
                ->where('seance.en_cours.id', $seance->id)
                ->where('seance.en_cours.prevision', 'Révision des voyelles')
                ->where('seance.en_cours.lecons', ['Les voyelles'])
                ->has('seance.en_cours.appel', 3)
                ->where('seance.en_cours.appel.0.nom_prenom', 'Awa')
                ->has('seance.formations_disponibles', 0)
            );
    }

    public function test_card_shows_yesterdays_open_session(): void
    {
        $seance = $this->seanceOuverte();
        $this->travelTo(Carbon::parse('2026-10-06 08:00:00'));

        $this->actingAs($this->formateur)->get(route('dashboard'))
            ->assertInertia(fn (Assert $page) => $page
                ->where('seance.en_cours.id', $seance->id)
                ->where('seance.en_cours.date', '2026-10-05')
                ->has('seance.formations_disponibles', 0)
            );
    }

    public function test_closed_sessions_of_the_day_are_listed(): void
    {
        $this->seanceOuverte(['cloturee_le' => now(), 'duree_minutes' => 60, 'nb_inscrits' => 3]);

        $this->actingAs($this->formateur)->get(route('dashboard'))
            ->assertInertia(fn (Assert $page) => $page
                ->where('seance.en_cours', null)
                ->has('seance.du_jour', 1)
                ->where('seance.du_jour.0.modifiable', true)
                ->has('seance.formations_disponibles', 0)
            );
    }
}
```

- [ ] **Step 2: Lancer pour vérifier l'échec**

Run: `php artisan test --filter=SeanceCarteTest`
Expected: FAIL (prop `seance` absente).

- [ ] **Step 3: Écrire `DonneesCarteSeance`**

`app/Actions/Seances/DonneesCarteSeance.php` :

```php
<?php

namespace App\Actions\Seances;

use App\Models\LeconDuJour;
use App\Models\Seance;
use App\Models\User;

/**
 * Données de la carte « Ma séance du jour » du tableau de bord formateur
 * (2026-09-25). Une séance restée ouverte un jour précédent est renvoyée en
 * `en_cours` pour être clôturée (clôture tardive).
 */
class DonneesCarteSeance
{
    public function __construct(private SerialiserSeances $serialiser) {}

    public function pour(User $formateur): array
    {
        $aujourdhui = today()->toDateString();
        $formations = $formateur->formations()->orderBy('titre')->get(['formations.id', 'formations.titre'])
            ->map(fn ($f) => ['id' => $f->id, 'titre' => $f->titre])
            ->values();

        $enCours = Seance::where('formateur_id', $formateur->id)->whereNull('cloturee_le')->first();
        $duJour = Seance::where('formateur_id', $formateur->id)
            ->whereDate('date', $aujourdhui)
            ->whereNotNull('cloturee_le')
            ->orderBy('id')
            ->get();

        $serialisees = $this->serialiser
            ->pour(collect([$enCours])->filter()->merge($duJour), $formateur)
            ->keyBy('id');

        $dejaPointees = Seance::where('formateur_id', $formateur->id)->whereDate('date', $aujourdhui)->pluck('formation_id');

        $leconsDuJour = LeconDuJour::query()
            ->whereDate('date_prevue', $aujourdhui)
            ->whereHas('moduleFormation.chapitre', fn ($q) => $q
                ->whereIn('formation_id', $formations->pluck('id'))
                ->where('centre_id', $formateur->centre_id))
            ->with(['moduleFormation:id,titre,ordre,chapitre_id', 'moduleFormation.chapitre:id,formation_id,ordre'])
            ->get()
            ->sortBy(fn (LeconDuJour $l) => sprintf('%05d-%05d', $l->moduleFormation->chapitre->ordre, $l->moduleFormation->ordre))
            ->groupBy(fn (LeconDuJour $l) => $l->moduleFormation->chapitre->formation_id)
            ->map(fn ($lecons) => $lecons->pluck('moduleFormation.titre')->values());

        return [
            'en_cours' => $enCours
                ? array_merge($serialisees[$enCours->id], ['appel' => FaireAppel::listePourAppel($enCours)])
                : null,
            'du_jour' => $duJour->map(fn (Seance $s) => $serialisees[$s->id])->values(),
            'formations' => $formations,
            'formations_disponibles' => $enCours ? [] : $formations->whereNotIn('id', $dejaPointees)->values(),
            'lecons_du_jour' => $leconsDuJour,
        ];
    }
}
```

- [ ] **Step 4: Brancher la carte dans `DashboardController::formateur()`**

Ajouter l'import `use App\Actions\Seances\DonneesCarteSeance;` et, dans le tableau de `Inertia::render('Staff/Dashboard/Formateur', [...])`, après `'activite_recente' => ...` :

```php
            // Carte « Ma séance du jour » (2026-09-25).
            'seance' => app(DonneesCarteSeance::class)->pour($user),
```

- [ ] **Step 5: Lancer les tests**

Run: `php artisan test --filter="SeanceCarteTest|DashboardRedirectTest"`
Expected: PASS.

- [ ] **Step 6: Commit (si autorisé)**

```bash
git add app/Actions/Seances/DonneesCarteSeance.php app/Http/Controllers/Staff/DashboardController.php tests/Feature/Seances/SeanceCarteTest.php
git commit -m "Prepare les donnees de la carte Ma seance du jour"
```

---

### Task 8: Interface (carte, modales, page Séances, menu, messages)

**Files:**
- Create: `resources/js/lib/seances.js`
- Create: `resources/js/Pages/Staff/Seances/Partials/ChampDuree.jsx`, `ListeAppel.jsx`, `OuvrirSeanceForm.jsx`, `CloturerSeanceForm.jsx`, `RattrapageSeanceForm.jsx`, `DetailSeance.jsx`, `CarteSeance.jsx`
- Create: `resources/js/Pages/Staff/Seances/Index.jsx`
- Modify: `resources/js/Pages/Staff/Dashboard/Formateur.jsx`
- Modify: `resources/js/Pages/Staff/Presences/Index.jsx` (afficher `FlashStatus`)
- Modify: `resources/js/Layouts/AuthenticatedLayout.jsx` (`LIENS_PAR_ROLE`)

**Interfaces:**
- Consumes: props des Tasks 6 et 7 ; routes `seances.index`, `seances.store`, `seances.cloturer`, `seances.update`, `seances.rattrapage` ; `usePolling(only, intervalMs, actif)` ; composants `Modal` (`show`, `onClose`, `title`), `ModalActions`, `Select` (valeur renvoyée en chaîne), `DatePicker` (`min`, `max`), `DataTable` (`columns`, `rows`, `actions`, `empty`, `numerote`), `StatTile` (`icon`, `valeur`, `libelle`, `couleur`, `fond`), `Badge` (`tone`), `FiltreChamp` (`icon`, `label`), `FlashStatus`.
- Produces: fonctions de `resources/js/lib/seances.js` : `formaterDuree`, `minutesDepuis`, `arrondiQuartDHeure`, `heureLocale`, `dateLocaleISO`, `decalerJours`, `formatDateFr`, `etatsSeance`, `decalerMois`, `libelleMois`.

Aucun test JS dans ce projet : la vérification est le build + la suite PHP + contrôle visuel par le porteur de projet.

- [ ] **Step 1: Utilitaires**

`resources/js/lib/seances.js` :

```js
// Utilitaires d'affichage des séances formateur (2026-09-25).

export function formaterDuree(minutes) {
    if (minutes === null || minutes === undefined) return '—';
    const heures = Math.floor(minutes / 60);
    const reste = minutes % 60;
    if (heures === 0) return `${reste} min`;
    return reste === 0 ? `${heures} h` : `${heures} h ${String(reste).padStart(2, '0')}`;
}

export function minutesDepuis(iso, maintenant = new Date()) {
    return Math.max(0, Math.floor((maintenant - new Date(iso)) / 60000));
}

// Durée proposée à la clôture : arrondie au quart d'heure, entre 15 min et 12 h.
export function arrondiQuartDHeure(minutes) {
    return Math.min(720, Math.max(15, Math.round(minutes / 15) * 15));
}

export function heureLocale(iso) {
    return new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}

// Date locale 'YYYY-MM-DD' (toISOString() convertit en UTC : piège documenté).
export function dateLocaleISO(date = new Date()) {
    const mois = String(date.getMonth() + 1).padStart(2, '0');
    const jour = String(date.getDate()).padStart(2, '0');
    return `${date.getFullYear()}-${mois}-${jour}`;
}

export function decalerJours(jours) {
    const d = new Date();
    d.setDate(d.getDate() + jours);
    return dateLocaleISO(d);
}

export function formatDateFr(iso) {
    const [annee, mois, jour] = iso.slice(0, 10).split('-');
    return `${jour}/${mois}/${annee}`;
}

// Badges d'état d'une séance, tons de Components/Badge.
export function etatsSeance(seance) {
    if (!seance.cloturee_le) return [{ label: 'En cours', tone: 'info' }];
    const etats = [];
    if (seance.cloture_tardive) etats.push({ label: 'Tardive', tone: 'warning' });
    if (seance.saisie_a_posteriori) etats.push({ label: 'A posteriori', tone: 'neutral' });
    if (seance.corrigee_le) etats.push({ label: 'Corrigée', tone: 'neutral' });
    if (etats.length === 0) etats.push({ label: 'Clôturée', tone: 'success' });
    return etats;
}

export function decalerMois(mois, delta) {
    const [annee, m] = mois.split('-').map(Number);
    const d = new Date(annee, m - 1 + delta, 1);
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}

export function libelleMois(mois) {
    const [annee, m] = mois.split('-').map(Number);
    const texte = new Date(annee, m - 1, 1).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
    return texte.charAt(0).toUpperCase() + texte.slice(1);
}
```

- [ ] **Step 2: Champs partagés**

`resources/js/Pages/Staff/Seances/Partials/ChampDuree.jsx` :

```jsx
import Select from '@/Components/Select';
import TextInput from '@/Components/TextInput';

// Durée saisie en heures + minutes, renvoyée en minutes (2026-09-25).
export default function ChampDuree({ id = 'duree', minutes, onChange }) {
    const total = minutes || 0;
    const heures = Math.floor(total / 60);
    const reste = total % 60;
    const quarts = [0, 15, 30, 45];
    const options = (quarts.includes(reste) ? quarts : [...quarts, reste].sort((a, b) => a - b))
        .map((m) => ({ value: m, label: String(m).padStart(2, '0') }));

    const maj = (h, m) => onChange(Math.min(12, Math.max(0, h)) * 60 + m);

    return (
        <div className="flex items-center gap-2">
            <TextInput
                id={id}
                type="number"
                inputMode="numeric"
                min="0"
                max="12"
                className="w-20 text-center"
                value={heures}
                onChange={(e) => maj(Number(e.target.value) || 0, reste)}
            />
            <span className="text-sm text-slate-600">h</span>
            <Select className="w-24" value={reste} onChange={(v) => maj(heures, Number(v))} options={options} />
            <span className="text-sm text-slate-600">min</span>
        </div>
    );
}
```

`resources/js/Pages/Staff/Seances/Partials/ListeAppel.jsx` :

```jsx
import Checkbox from '@/Components/Checkbox';

// Appel de la classe : présents cochés, « Tous présents » en un clic.
export default function ListeAppel({ apprenants, presents, onChange }) {
    const basculer = (id) => onChange(presents.includes(id) ? presents.filter((x) => x !== id) : [...presents, id]);

    if (apprenants.length === 0) {
        return <p className="text-sm text-slate-500">Aucun apprenant inscrit à cette formation.</p>;
    }

    return (
        <div>
            <div className="mb-2 flex items-center justify-between text-sm">
                <span className="tabular-nums text-slate-600">
                    {presents.length} présent{presents.length > 1 ? 's' : ''} sur {apprenants.length}
                </span>
                <button
                    type="button"
                    onClick={() => onChange(apprenants.map((a) => a.inscription_id))}
                    className="font-semibold text-brand-700 hover:text-brand-800"
                >
                    Tous présents
                </button>
            </div>
            <ul className="max-h-64 divide-y divide-slate-100 overflow-y-auto rounded-lg border border-slate-200">
                {apprenants.map((a) => (
                    <li key={a.inscription_id}>
                        <label className="flex cursor-pointer items-center gap-3 px-3 py-2.5 hover:bg-slate-50">
                            <Checkbox checked={presents.includes(a.inscription_id)} onChange={() => basculer(a.inscription_id)} />
                            <span className="text-sm text-slate-800">{a.nom_prenom}</span>
                        </label>
                    </li>
                ))}
            </ul>
            <p className="mt-2 text-xs text-slate-500">
                Un apprenant coché est marqué présent et ses leçons du jour sont validées. Décocher un apprenant
                déjà présent annule sa présence et ses leçons du jour.
            </p>
        </div>
    );
}
```

- [ ] **Step 3: Modales d'ouverture, de clôture et de rattrapage**

`resources/js/Pages/Staff/Seances/Partials/OuvrirSeanceForm.jsx` :

```jsx
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import ModalActions from '@/Components/ModalActions';
import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';
import Select from '@/Components/Select';
import { useForm } from '@inertiajs/react';

const zoneTexte =
    'mt-1 block w-full rounded-lg border-slate-300 px-3.5 py-2.5 text-base shadow-sm focus:border-slate-400 focus:ring-slate-400 sm:text-sm';

export default function OuvrirSeanceForm({ carte, onClose }) {
    const formations = carte.formations_disponibles;
    const { data, setData, post, processing, errors } = useForm({
        formation_id: formations[0]?.id ?? '',
        prevision: '',
    });
    const lecons = carte.lecons_du_jour[data.formation_id] ?? [];

    const submit = (e) => {
        e.preventDefault();
        post(route('seances.store'), { preserveScroll: true, onSuccess: onClose });
    };

    return (
        <form onSubmit={submit} className="space-y-4 p-4 sm:p-6">
            <div>
                <InputLabel htmlFor="formation_id" value="Formation" />
                {formations.length > 1 ? (
                    <Select
                        id="formation_id"
                        className="mt-1"
                        value={data.formation_id}
                        onChange={(v) => setData('formation_id', v)}
                        options={formations.map((f) => ({ value: f.id, label: f.titre }))}
                    />
                ) : (
                    <p className="mt-1 text-sm font-semibold text-slate-900">{formations[0]?.titre}</p>
                )}
                <InputError message={errors.formation_id} className="mt-2" />
            </div>

            <div>
                <InputLabel value="Au programme aujourd'hui" />
                {lecons.length > 0 ? (
                    <ul className="mt-1 divide-y divide-slate-100 rounded-lg border border-slate-200">
                        {lecons.map((titre, index) => (
                            <li key={`${index}-${titre}`} className="px-3 py-2 text-sm text-slate-800">{titre}</li>
                        ))}
                    </ul>
                ) : (
                    <p className="mt-1 text-sm text-slate-500">Aucune leçon planifiée aujourd'hui pour cette formation.</p>
                )}
            </div>

            <div>
                <InputLabel htmlFor="prevision" value="Ce que je prévois aujourd'hui" />
                <textarea
                    id="prevision"
                    rows={3}
                    className={zoneTexte}
                    placeholder="Exemple : révision des voyelles, exercices de lecture en binôme"
                    value={data.prevision}
                    onChange={(e) => setData('prevision', e.target.value)}
                />
                <InputError message={errors.prevision} className="mt-2" />
            </div>

            <ModalActions>
                <SecondaryButton type="button" onClick={onClose}>Annuler</SecondaryButton>
                <PrimaryButton disabled={processing}>Commencer</PrimaryButton>
            </ModalActions>
        </form>
    );
}
```

`resources/js/Pages/Staff/Seances/Partials/CloturerSeanceForm.jsx` :

```jsx
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import ModalActions from '@/Components/ModalActions';
import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';
import { arrondiQuartDHeure, dateLocaleISO, formatDateFr, minutesDepuis } from '@/lib/seances';
import { useForm } from '@inertiajs/react';
import ChampDuree from './ChampDuree';
import ListeAppel from './ListeAppel';

const zoneTexte =
    'mt-1 block w-full rounded-lg border-slate-300 px-3.5 py-2.5 text-base shadow-sm focus:border-slate-400 focus:ring-slate-400 sm:text-sm';

export default function CloturerSeanceForm({ seance, onClose }) {
    const tardive = seance.date !== dateLocaleISO();

    const { data, setData, patch, processing, errors } = useForm({
        duree_minutes: tardive ? 0 : arrondiQuartDHeure(minutesDepuis(seance.ouverte_le)),
        bilan: '',
        presents: seance.appel.filter((a) => a.present).map((a) => a.inscription_id),
    });

    const submit = (e) => {
        e.preventDefault();
        patch(route('seances.cloturer', seance.id), { preserveScroll: true, onSuccess: onClose });
    };

    return (
        <form onSubmit={submit} className="space-y-5 p-4 sm:p-6">
            <div>
                <InputLabel htmlFor="duree" value="Durée de la séance" />
                {tardive && (
                    <p className="mt-1 text-xs text-amber-700">
                        Séance du {formatDateFr(seance.date)} : indiquez la durée réelle.
                    </p>
                )}
                <div className="mt-1">
                    <ChampDuree minutes={data.duree_minutes} onChange={(m) => setData('duree_minutes', m)} />
                </div>
                <InputError message={errors.duree_minutes} className="mt-2" />
            </div>

            <div>
                <InputLabel value="Appel" />
                {seance.lecons.length > 0 && (
                    <p className="mt-1 text-xs text-slate-500">Leçons du jour : {seance.lecons.join(', ')}</p>
                )}
                <div className="mt-2">
                    <ListeAppel
                        apprenants={seance.appel}
                        presents={data.presents}
                        onChange={(ids) => setData('presents', ids)}
                    />
                </div>
            </div>

            <div>
                <InputLabel htmlFor="bilan" value="Bilan de la séance" />
                <textarea
                    id="bilan"
                    rows={3}
                    className={zoneTexte}
                    placeholder="Ce qui a été fait, difficultés, observations"
                    value={data.bilan}
                    onChange={(e) => setData('bilan', e.target.value)}
                />
                <InputError message={errors.bilan} className="mt-2" />
            </div>

            <ModalActions>
                <SecondaryButton type="button" onClick={onClose}>Annuler</SecondaryButton>
                <PrimaryButton disabled={processing}>Clôturer</PrimaryButton>
            </ModalActions>
        </form>
    );
}
```

`resources/js/Pages/Staff/Seances/Partials/RattrapageSeanceForm.jsx` :

```jsx
import DatePicker from '@/Components/DatePicker';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import ModalActions from '@/Components/ModalActions';
import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';
import Select from '@/Components/Select';
import { decalerJours } from '@/lib/seances';
import { useForm } from '@inertiajs/react';
import ChampDuree from './ChampDuree';

const zoneTexte =
    'mt-1 block w-full rounded-lg border-slate-300 px-3.5 py-2.5 text-base shadow-sm focus:border-slate-400 focus:ring-slate-400 sm:text-sm';

// Séance oubliée, de J-7 à J-1. Le serveur renvoie ensuite vers la page
// Présence de ce jour pour faire l'appel.
export default function RattrapageSeanceForm({ carte, onClose }) {
    const { data, setData, post, processing, errors } = useForm({
        formation_id: carte.formations[0]?.id ?? '',
        date: decalerJours(-1),
        duree_minutes: 0,
        bilan: '',
    });

    const submit = (e) => {
        e.preventDefault();
        post(route('seances.rattrapage'), { onSuccess: onClose });
    };

    return (
        <form onSubmit={submit} className="space-y-4 p-4 sm:p-6">
            <p className="text-sm text-slate-600">
                Pour une séance oubliée des 7 derniers jours. Elle sera signalée comme saisie après coup, puis vous
                ferez l'appel de ce jour-là sur la page Présence.
            </p>

            {carte.formations.length > 1 && (
                <div>
                    <InputLabel htmlFor="formation_id" value="Formation" />
                    <Select
                        id="formation_id"
                        className="mt-1"
                        value={data.formation_id}
                        onChange={(v) => setData('formation_id', v)}
                        options={carte.formations.map((f) => ({ value: f.id, label: f.titre }))}
                    />
                    <InputError message={errors.formation_id} className="mt-2" />
                </div>
            )}

            <div>
                <InputLabel htmlFor="date" value="Jour de la séance" />
                <DatePicker
                    id="date"
                    className="mt-1"
                    value={data.date}
                    min={decalerJours(-7)}
                    max={decalerJours(-1)}
                    onChange={(v) => setData('date', v)}
                />
                <InputError message={errors.date} className="mt-2" />
            </div>

            <div>
                <InputLabel htmlFor="duree" value="Durée" />
                <div className="mt-1">
                    <ChampDuree minutes={data.duree_minutes} onChange={(m) => setData('duree_minutes', m)} />
                </div>
                <InputError message={errors.duree_minutes} className="mt-2" />
            </div>

            <div>
                <InputLabel htmlFor="bilan" value="Bilan de la séance" />
                <textarea
                    id="bilan"
                    rows={3}
                    className={zoneTexte}
                    value={data.bilan}
                    onChange={(e) => setData('bilan', e.target.value)}
                />
                <InputError message={errors.bilan} className="mt-2" />
            </div>

            <ModalActions>
                <SecondaryButton type="button" onClick={onClose}>Annuler</SecondaryButton>
                <PrimaryButton disabled={processing}>Enregistrer et faire l'appel</PrimaryButton>
            </ModalActions>
        </form>
    );
}
```

- [ ] **Step 4: Détail d'une séance (avec correction)**

`resources/js/Pages/Staff/Seances/Partials/DetailSeance.jsx` :

```jsx
import Badge from '@/Components/Badge';
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import ModalActions from '@/Components/ModalActions';
import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';
import { etatsSeance, formatDateFr, formaterDuree, heureLocale } from '@/lib/seances';
import { useForm } from '@inertiajs/react';
import ChampDuree from './ChampDuree';

function Ligne({ libelle, children }) {
    return (
        <div className="grid grid-cols-3 gap-2 py-2 text-sm">
            <dt className="text-slate-500">{libelle}</dt>
            <dd className="col-span-2 whitespace-pre-line text-slate-800">{children}</dd>
        </div>
    );
}

// Détail d'une séance ; si `seance.modifiable` (formateur le jour de la
// clôture, admin toujours), durée et bilan sont corrigeables ici.
export default function DetailSeance({ seance, onClose }) {
    const { data, setData, put, processing, errors } = useForm({
        duree_minutes: seance.duree_minutes ?? 0,
        bilan: seance.bilan ?? '',
    });

    const enregistrer = (e) => {
        e.preventDefault();
        put(route('seances.update', seance.id), { preserveScroll: true, onSuccess: onClose });
    };

    const presents = seance.nb_inscrits !== null
        ? `${seance.presents.length} sur ${seance.nb_inscrits}`
        : seance.cloturee_le ? '—' : "À l'appel de la clôture";

    return (
        <div className="p-4 sm:p-6">
            <div className="flex flex-wrap gap-1.5">
                {etatsSeance(seance).map((e) => <Badge key={e.label} tone={e.tone}>{e.label}</Badge>)}
            </div>
            <dl className="mt-3 divide-y divide-slate-100">
                <Ligne libelle="Date">{formatDateFr(seance.date)}</Ligne>
                {seance.formateur && <Ligne libelle="Formateur">{seance.formateur}</Ligne>}
                <Ligne libelle="Formation">{seance.formation}</Ligne>
                <Ligne libelle="Arrivée">
                    {seance.ouverte_le ? heureLocale(seance.ouverte_le) : 'Non renseignée (saisie après coup)'}
                </Ligne>
                <Ligne libelle="Durée">{formaterDuree(seance.duree_minutes)}</Ligne>
                <Ligne libelle="Présents">
                    {presents}
                    {seance.presents.length > 0 && (
                        <span className="mt-1 block text-xs text-slate-500">{seance.presents.join(', ')}</span>
                    )}
                </Ligne>
                <Ligne libelle="Leçons">{seance.lecons.length ? seance.lecons.join(', ') : 'Aucune leçon planifiée ce jour-là'}</Ligne>
                <Ligne libelle="Prévision">{seance.prevision || '—'}</Ligne>
                <Ligne libelle="Bilan">{seance.bilan || '—'}</Ligne>
                {seance.corrigee_le && (
                    <Ligne libelle="Correction">
                        Par {seance.corrigee_par ?? 'un administrateur'} le {formatDateFr(seance.corrigee_le)}
                    </Ligne>
                )}
            </dl>

            {seance.modifiable ? (
                <form onSubmit={enregistrer} className="mt-4 space-y-4 border-t border-slate-200 pt-4">
                    <div>
                        <InputLabel htmlFor="duree_corrigee" value="Durée" />
                        <div className="mt-1">
                            <ChampDuree id="duree_corrigee" minutes={data.duree_minutes} onChange={(m) => setData('duree_minutes', m)} />
                        </div>
                        <InputError message={errors.duree_minutes} className="mt-2" />
                    </div>
                    <div>
                        <InputLabel htmlFor="bilan_corrige" value="Bilan" />
                        <textarea
                            id="bilan_corrige"
                            rows={3}
                            className="mt-1 block w-full rounded-lg border-slate-300 px-3.5 py-2.5 text-base shadow-sm focus:border-slate-400 focus:ring-slate-400 sm:text-sm"
                            value={data.bilan}
                            onChange={(e) => setData('bilan', e.target.value)}
                        />
                        <InputError message={errors.bilan} className="mt-2" />
                    </div>
                    <p className="text-xs text-slate-500">Pour corriger l'appel, utilisez la page Présence à cette date.</p>
                    <ModalActions>
                        <SecondaryButton type="button" onClick={onClose}>Fermer</SecondaryButton>
                        <PrimaryButton disabled={processing}>Enregistrer</PrimaryButton>
                    </ModalActions>
                </form>
            ) : (
                <div className="mt-4 flex justify-end">
                    <SecondaryButton type="button" onClick={onClose}>Fermer</SecondaryButton>
                </div>
            )}
        </div>
    );
}
```

- [ ] **Step 5: Carte du tableau de bord**

`resources/js/Pages/Staff/Seances/Partials/CarteSeance.jsx` :

```jsx
import { IconClock } from '@/Components/Icons';
import Modal from '@/Components/Modal';
import PrimaryButton from '@/Components/PrimaryButton';
import { dateLocaleISO, formatDateFr, formaterDuree, heureLocale, minutesDepuis } from '@/lib/seances';
import { Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import CloturerSeanceForm from './CloturerSeanceForm';
import DetailSeance from './DetailSeance';
import OuvrirSeanceForm from './OuvrirSeanceForm';
import RattrapageSeanceForm from './RattrapageSeanceForm';

// Carte « Ma séance du jour » du tableau de bord formateur (2026-09-25).
export default function CarteSeance({ carte }) {
    const [modale, setModale] = useState(null); // null | {type, seance?}
    const [, setTic] = useState(0);
    const enCours = carte.en_cours;
    const tardive = enCours && enCours.date !== dateLocaleISO();

    // Rafraîchit la durée écoulée affichée, une fois par minute.
    useEffect(() => {
        if (!enCours || tardive) return undefined;
        const id = setInterval(() => setTic((t) => t + 1), 60000);
        return () => clearInterval(id);
    }, [enCours, tardive]);

    const fermer = () => setModale(null);

    if (carte.formations.length === 0) {
        return null;
    }

    return (
        <section className="rounded-lg border border-slate-200 bg-white p-4 sm:p-5">
            <div className="flex items-center gap-2">
                <span className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50">
                    <IconClock className="h-5 w-5 text-brand-600" />
                </span>
                <h2 className="text-base font-semibold text-slate-900">Ma séance du jour</h2>
            </div>

            {enCours ? (
                <div className="mt-4 space-y-3">
                    {tardive ? (
                        <p className="text-sm font-semibold text-amber-700">
                            Séance du {formatDateFr(enCours.date)} non clôturée
                        </p>
                    ) : (
                        <p className="text-sm text-slate-700">
                            <span className="font-semibold text-slate-900">En cours depuis {heureLocale(enCours.ouverte_le)}</span>
                            {' · '}
                            <span className="tabular-nums">{formaterDuree(minutesDepuis(enCours.ouverte_le))}</span>
                        </p>
                    )}
                    <p className="text-sm text-slate-600">
                        {enCours.formation}
                        {enCours.lecons.length > 0 && ` · ${enCours.lecons.join(', ')}`}
                    </p>
                    {enCours.prevision && (
                        <p className="whitespace-pre-line rounded-lg bg-slate-50 px-3 py-2 text-sm text-slate-700">{enCours.prevision}</p>
                    )}
                    <PrimaryButton className="w-full justify-center sm:w-auto" onClick={() => setModale({ type: 'cloturer', seance: enCours })}>
                        Clôturer la séance
                    </PrimaryButton>
                </div>
            ) : (
                <div className="mt-4 space-y-3">
                    {carte.du_jour.length === 0 && (
                        <p className="text-sm text-slate-600">Pas encore de séance aujourd'hui.</p>
                    )}
                    {carte.du_jour.map((s) => (
                        <div key={s.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-slate-50 px-3 py-2 text-sm">
                            <span className="text-slate-700">
                                <span className="font-semibold text-slate-900">{s.formation}</span>
                                {' · '}{formaterDuree(s.duree_minutes)}
                                {s.nb_inscrits !== null && ` · ${s.presents.length}/${s.nb_inscrits} présents`}
                            </span>
                            {s.modifiable && (
                                <button
                                    type="button"
                                    onClick={() => setModale({ type: 'detail', seance: s })}
                                    className="text-sm font-semibold text-brand-700 hover:text-brand-800"
                                >
                                    Modifier
                                </button>
                            )}
                        </div>
                    ))}
                    {carte.formations_disponibles.length > 0 && (
                        <PrimaryButton className="w-full justify-center sm:w-auto" onClick={() => setModale({ type: 'ouvrir' })}>
                            {carte.du_jour.length > 0 ? 'Commencer une autre séance' : 'Commencer la séance'}
                        </PrimaryButton>
                    )}
                </div>
            )}

            <div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 border-t border-slate-100 pt-3 text-sm">
                <button
                    type="button"
                    onClick={() => setModale({ type: 'rattrapage' })}
                    className="font-medium text-slate-600 hover:text-slate-900"
                >
                    Saisir une séance passée
                </button>
                <Link href={route('seances.index')} className="font-medium text-slate-600 hover:text-slate-900">
                    Mes séances
                </Link>
            </div>

            <Modal show={modale?.type === 'ouvrir'} onClose={fermer} title="Commencer la séance">
                {modale?.type === 'ouvrir' && <OuvrirSeanceForm carte={carte} onClose={fermer} />}
            </Modal>
            <Modal show={modale?.type === 'cloturer'} onClose={fermer} title="Clôturer la séance">
                {modale?.type === 'cloturer' && <CloturerSeanceForm seance={modale.seance} onClose={fermer} />}
            </Modal>
            <Modal show={modale?.type === 'detail'} onClose={fermer} title="Modifier la séance">
                {modale?.type === 'detail' && <DetailSeance seance={modale.seance} onClose={fermer} />}
            </Modal>
            <Modal show={modale?.type === 'rattrapage'} onClose={fermer} title="Saisir une séance passée">
                {modale?.type === 'rattrapage' && <RattrapageSeanceForm carte={carte} onClose={fermer} />}
            </Modal>
        </section>
    );
}
```

- [ ] **Step 6: Page « Séances »**

`resources/js/Pages/Staff/Seances/Index.jsx` :

```jsx
import Badge from '@/Components/Badge';
import DataTable from '@/Components/DataTable';
import FiltreChamp from '@/Components/FiltreChamp';
import FlashStatus from '@/Components/FlashStatus';
import { IconAlertTriangle, IconBuilding, IconCalendar, IconChevronLeft, IconChevronRight, IconClock, IconUsers } from '@/Components/Icons';
import Modal from '@/Components/Modal';
import Select from '@/Components/Select';
import StatTile from '@/Components/StatTile';
import usePolling from '@/Hooks/usePolling';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { decalerMois, etatsSeance, formatDateFr, formaterDuree, heureLocale, libelleMois } from '@/lib/seances';
import DetailSeance from '@/Pages/Staff/Seances/Partials/DetailSeance';
import { Head, router, usePage } from '@inertiajs/react';
import { useState } from 'react';

const boutonMois =
    'flex h-9 w-9 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40';

export default function Index({ mois, mois_courant, filtres, stats, seances, en_seance = [], par_formateur = [], centres = [], formateurs = [] }) {
    const { auth } = usePage().props;
    const suivi = auth.user.role !== 'formateur';
    const [detail, setDetail] = useState(null);

    usePolling(['en_seance'], 30000, suivi && mois === mois_courant);

    const naviguer = (changements) => {
        const params = Object.fromEntries(
            Object.entries({ mois, ...filtres, ...changements }).filter(([, v]) => v !== null && v !== undefined && v !== ''),
        );
        router.get(route('seances.index'), params, { preserveState: true, preserveScroll: true, replace: true });
    };

    const colonnes = [
        { key: 'date', label: 'Date', render: (s) => formatDateFr(s.date) },
        ...(suivi ? [{ key: 'formateur', label: 'Formateur' }] : []),
        { key: 'formation', label: 'Formation' },
        { key: 'ouverte_le', label: 'Arrivée', render: (s) => (s.ouverte_le ? heureLocale(s.ouverte_le) : '—') },
        { key: 'duree_minutes', label: 'Durée', render: (s) => formaterDuree(s.duree_minutes) },
        {
            key: 'presents',
            label: 'Présents',
            align: 'center',
            render: (s) => (s.nb_inscrits !== null ? `${s.presents.length}/${s.nb_inscrits}` : '—'),
        },
        {
            key: 'etat',
            label: 'État',
            render: (s) => (
                <div className="flex flex-wrap gap-1">
                    {etatsSeance(s).map((e) => <Badge key={e.label} tone={e.tone}>{e.label}</Badge>)}
                </div>
            ),
        },
    ];

    return (
        <AuthenticatedLayout header={<h2 className="text-xl font-bold tracking-tight text-slate-900">Séances</h2>}>
            <Head title="Séances" />

            <div className="mx-auto max-w-content space-y-6 p-6">
                <FlashStatus />

                <div className="flex items-center justify-between gap-3 border-b border-slate-200 pb-5">
                    <button type="button" className={boutonMois} onClick={() => naviguer({ mois: decalerMois(mois, -1) })} aria-label="Mois précédent">
                        <IconChevronLeft className="h-4 w-4" />
                    </button>
                    <span className="text-base font-semibold text-slate-900">{libelleMois(mois)}</span>
                    <button
                        type="button"
                        className={boutonMois}
                        disabled={mois >= mois_courant}
                        onClick={() => naviguer({ mois: decalerMois(mois, 1) })}
                        aria-label="Mois suivant"
                    >
                        <IconChevronRight className="h-4 w-4" />
                    </button>
                </div>

                {suivi && (
                    <div className={`grid grid-cols-1 gap-4 ${centres.length > 0 ? 'sm:grid-cols-2' : ''}`}>
                        {centres.length > 0 && (
                            <FiltreChamp icon={IconBuilding} label="Centre">
                                <Select
                                    value={filtres.centre_id ?? ''}
                                    onChange={(v) => naviguer({ centre_id: v, formateur_id: null })}
                                    options={[{ value: '', label: 'Tous les centres' }, ...centres.map((c) => ({ value: c.id, label: c.nom }))]}
                                />
                            </FiltreChamp>
                        )}
                        <FiltreChamp icon={IconUsers} label="Formateur">
                            <Select
                                value={filtres.formateur_id ?? ''}
                                onChange={(v) => naviguer({ formateur_id: v })}
                                options={[
                                    { value: '', label: 'Tous les formateurs' },
                                    ...formateurs.map((f) => ({ value: f.id, label: f.centre ? `${f.nom} · ${f.centre}` : f.nom })),
                                ]}
                            />
                        </FiltreChamp>
                    </div>
                )}

                <div className={`grid grid-cols-2 gap-3 sm:gap-4 ${suivi ? 'lg:grid-cols-4' : ''}`}>
                    <StatTile icon={IconClock} valeur={formaterDuree(stats.minutes)} libelle="Heures dispensées" />
                    <StatTile icon={IconCalendar} valeur={stats.seances} libelle="Séances" />
                    {suivi && <StatTile icon={IconUsers} valeur={en_seance.length} libelle="En séance maintenant" />}
                    {suivi && (
                        <StatTile
                            icon={IconAlertTriangle}
                            valeur={stats.a_surveiller}
                            libelle="À surveiller"
                            couleur={stats.a_surveiller > 0 ? 'text-amber-600' : undefined}
                            fond={stats.a_surveiller > 0 ? 'bg-amber-50' : undefined}
                        />
                    )}
                </div>
                {suivi && en_seance.length > 0 && (
                    <p className="-mt-3 text-sm text-slate-600">En séance : {en_seance.join(', ')}</p>
                )}

                {suivi && (
                    <section>
                        <h3 className="mb-3 text-sm font-semibold text-slate-900">Par formateur</h3>
                        <DataTable
                            numerote={false}
                            columns={[
                                { key: 'nom', label: 'Formateur' },
                                ...(centres.length > 0 ? [{ key: 'centre', label: 'Centre', render: (f) => f.centre ?? '—' }] : []),
                                { key: 'minutes', label: 'Heures', render: (f) => formaterDuree(f.minutes) },
                                { key: 'seances', label: 'Séances', align: 'center' },
                                { key: 'assiduite', label: 'Assiduité', align: 'center', render: (f) => (f.assiduite !== null ? `${f.assiduite}%` : '—') },
                                {
                                    key: 'jours_sans_seance',
                                    label: 'Jours sans séance',
                                    align: 'center',
                                    render: (f) => (
                                        <Badge tone={f.jours_sans_seance > 0 ? 'warning' : 'success'}>{f.jours_sans_seance}</Badge>
                                    ),
                                },
                            ]}
                            rows={par_formateur}
                            empty="Aucun formateur."
                        />
                        <p className="mt-2 text-xs text-slate-500">
                            Jours sans séance : jours passés du mois où une leçon était planifiée, sans séance pointée.
                        </p>
                    </section>
                )}

                <section>
                    {suivi && <h3 className="mb-3 text-sm font-semibold text-slate-900">Séances</h3>}
                    <DataTable
                        numerote={false}
                        columns={colonnes}
                        rows={seances}
                        actions={(s) => (
                            <button
                                type="button"
                                onClick={() => setDetail(s)}
                                className="rounded-md px-2.5 py-1.5 text-sm font-semibold text-brand-700 transition-colors hover:bg-brand-50"
                            >
                                Voir
                            </button>
                        )}
                        empty="Aucune séance ce mois-ci."
                    />
                </section>
            </div>

            <Modal show={detail !== null} onClose={() => setDetail(null)} title="Détail de la séance">
                {detail && <DetailSeance seance={detail} onClose={() => setDetail(null)} />}
            </Modal>
        </AuthenticatedLayout>
    );
}
```

- [ ] **Step 7: Tableau de bord formateur, page Présence, menu**

Dans `resources/js/Pages/Staff/Dashboard/Formateur.jsx` :
- imports `import FlashStatus from '@/Components/FlashStatus';` et `import CarteSeance from '@/Pages/Staff/Seances/Partials/CarteSeance';` ;
- signature `export default function Formateur({ stats, formations, activite_recente, seance }) {` ;
- juste après `<div className="mx-auto max-w-content space-y-6 p-6">` : `<FlashStatus />` ;
- juste après la balise fermante `</EnteteBienvenue>` : `<CarteSeance carte={seance} />`.

Dans `resources/js/Pages/Staff/Presences/Index.jsx` (les messages « Présence annulée pour X » et celui du rattrapage ne s'affichaient pas) :
- import `import FlashStatus from '@/Components/FlashStatus';` ;
- juste après `<div className="mx-auto max-w-content space-y-6 p-6">` : `<FlashStatus />`.

Dans `resources/js/Layouts/AuthenticatedLayout.jsx` : ajouter `IconCalendar,` à la liste d'imports d'icônes (ordre alphabétique, après `IconBuilding,`), puis ajouter la ligne suivante **juste après** `['presences.index', 'Présence', IconClock],` dans les rôles `admin`, `gestionnaire` et `formateur` :

```jsx
        ['seances.index', 'Séances', IconCalendar],
```

- [ ] **Step 8: Build + tests**

Run: `npm run build && php artisan test --filter=Seance`
Expected: build sans erreur ; tous les tests de `tests/Feature/Seances` passent.

- [ ] **Step 9: Commit (si autorisé)**

```bash
git add resources/js/lib/seances.js resources/js/Pages/Staff/Seances resources/js/Pages/Staff/Dashboard/Formateur.jsx resources/js/Pages/Staff/Presences/Index.jsx resources/js/Layouts/AuthenticatedLayout.jsx
git commit -m "Ajoute l'interface des seances formateur"
```

---

### Task 9: Heures dispensées sur le tableau de bord partenaire / admin

**Files:**
- Modify: `app/Http/Controllers/Partenaire/DashboardController.php`
- Modify: `resources/js/Pages/Partenaire/Dashboard.jsx`
- Test: `tests/Feature/Metier/PartenaireDashboardFiltreTest.php`

**Interfaces:**
- Produces: `stats.minutes_mois` (int) et, par centre, `minutes_mois` (int) : somme des `duree_minutes` des séances clôturées du 1er du mois de la date consultée jusqu'à cette date. Aucun texte libre.

- [ ] **Step 1: Écrire le test (échoue)**

Ajouter dans `tests/Feature/Metier/PartenaireDashboardFiltreTest.php` les imports `use App\Models\Seance;`, `use Illuminate\Support\Carbon;`, `use Inertia\Testing\AssertableInertia as Assert;` (les autres existent déjà), puis le test ci-dessous. Il utilise `$this->centreA` du `setUp()` existant : c'est le premier centre par ordre alphabétique (« Centre A », « Centre B »), donc `centres.0`.

```php
    public function test_dashboard_shows_hours_taught_this_month_without_free_text(): void
    {
        $this->travelTo(Carbon::parse('2026-10-05 12:00:00'));
        $formation = Formation::create(['titre' => 'Alphabétisation']);
        $formateur = User::factory()->create(['role' => RoleStaff::Formateur, 'centre_id' => $this->centreA->id]);
        $base = ['formateur_id' => $formateur->id, 'formation_id' => $formation->id, 'centre_id' => $this->centreA->id, 'bilan' => 'secret'];
        Seance::create($base + ['date' => '2026-10-01', 'ouverte_le' => now(), 'cloturee_le' => now(), 'duree_minutes' => 120]);
        Seance::create($base + ['date' => '2026-10-05', 'ouverte_le' => now(), 'cloturee_le' => now(), 'duree_minutes' => 90]);
        Seance::create($base + ['date' => '2026-09-30', 'ouverte_le' => now(), 'cloturee_le' => now(), 'duree_minutes' => 60]);
        $partenaire = User::factory()->create(['role' => RoleStaff::Partenaire]);

        $this->actingAs($partenaire)->get(route('partenaire.dashboard'))
            ->assertInertia(fn (Assert $page) => $page
                ->where('stats.minutes_mois', 210)
                ->where('centres.0.minutes_mois', 210)
            )
            ->assertDontSee('secret');
    }
```

- [ ] **Step 2: Lancer pour vérifier l'échec**

Run: `php artisan test --filter=test_dashboard_shows_hours_taught_this_month_without_free_text`
Expected: FAIL (`stats.minutes_mois` absent).

- [ ] **Step 3: Calcul serveur**

Dans `Partenaire\DashboardController::index()`, import `use App\Models\Seance;`. Juste après le bloc `$presencesParCentre = ...;` :

```php
        // Heures dispensées (séances formateur, 2026-09-25) : du 1er du mois de
        // la date consultée jusqu'à cette date. Agrégat uniquement : le
        // partenaire ne voit jamais les bilans ni les prévisions.
        $minutesParCentre = Seance::query()
            ->whereNotNull('cloturee_le')
            ->whereDate('date', '>=', $date->copy()->startOfMonth())
            ->whereDate('date', '<=', $date)
            ->selectRaw('centre_id, sum(duree_minutes) as total')
            ->groupBy('centre_id')
            ->pluck('total', 'centre_id');
```

Ajouter `$minutesParCentre` à la liste `use (...)` du `->map(function (Centre $centre) use (...)`, et la clé dans le tableau renvoyé :

```php
                    'minutes_mois' => (int) ($minutesParCentre->get($centre->id) ?? 0),
```

Dans `'stats' => [...]` :

```php
                'minutes_mois' => (int) ($centreId
                    ? ($minutesParCentre->get($centreId) ?? 0)
                    : $minutesParCentre->sum()),
```

- [ ] **Step 4: Affichage**

Dans `resources/js/Pages/Partenaire/Dashboard.jsx` :
- import `import { formaterDuree } from '@/lib/seances';` (`IconClock` est déjà importé) ;
- après `const suffixeDate = ...;`, ajouter :

```jsx
    const nomMois = new Date(`${filtres.date}T00:00`).toLocaleDateString('fr-FR', { month: 'long' });
```

- la grille de tuiles `grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4` devient `grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4`, et ajouter après la tuile d'assiduité :

```jsx
                    <StatTile icon={IconClock} valeur={formaterDuree(stats.minutes_mois)} libelle={`Heures dispensées en ${nomMois}`} />
```

- dans chaque carte de centre, **après** la `div` `grid grid-cols-3 gap-2 border-t ...` (une ligne à part plutôt qu'une 4ᵉ colonne, trop serrée sur téléphone) :

```jsx
                                    <p className="mt-3 flex items-center justify-between border-t border-slate-100 pt-3 text-sm">
                                        <span className="text-slate-500">Heures en {nomMois}</span>
                                        <span className="font-semibold tabular-nums text-slate-900">{formaterDuree(c.minutes_mois)}</span>
                                    </p>
```

- [ ] **Step 5: Lancer tests + build**

Run: `php artisan test --filter=PartenaireDashboardFiltreTest && npm run build`
Expected: PASS ; build sans erreur.

- [ ] **Step 6: Commit (si autorisé)**

```bash
git add app/Http/Controllers/Partenaire/DashboardController.php resources/js/Pages/Partenaire/Dashboard.jsx tests/Feature/Metier/PartenaireDashboardFiltreTest.php
git commit -m "Affiche les heures dispensees sur le tableau de bord partenaire"
```

---

### Task 10: Rapport de formation enrichi (PDF / CSV)

**Files:**
- Modify: `app/Http/Controllers/Staff/RapportController.php`
- Modify: `routes/staff.php`
- Create: `resources/views/pdf/rapport-formation.blade.php`
- Modify: `resources/js/Pages/Staff/Rapports/Index.jsx`
- Test: `tests/Feature/Seances/RapportFormationTest.php`

**Interfaces:**
- Consumes: `RapportController::lignesFiltrees(Request): array` et `journaliser(Request, int)` (privées, même classe ; clés de ligne `apprenant`, `centre`, `statut_inscription`, `progression_pourcentage`, `taux_presence`), `Seance::formaterDuree`, `SerialiserSeances::cle`, `presentsPar`, `leconsPar`, trait `EscapesCsvFormulas::neutraliserFormuleCsv` (préfixe `'` devant `=`, `+`, `-`, `@`).
- Produces: routes `GET rapports/formation` (`rapports.formation`) et `GET rapports/formation-csv` (`rapports.formation-csv`), query `formation_id` (requis), `centre_id`, `du`, `au`.

- [ ] **Step 1: Écrire les tests (échouent)**

`tests/Feature/Seances/RapportFormationTest.php` :

```php
<?php

namespace Tests\Feature\Seances;

use App\Enums\RoleStaff;
use App\Models\Formation;
use App\Models\LeconDuJour;
use App\Models\Presence;
use App\Models\RapportGenere;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\View;
use Tests\TestCase;

class RapportFormationTest extends TestCase
{
    use RefreshDatabase, ScenarioSeance;

    private User $admin;

    protected function setUp(): void
    {
        parent::setUp();
        $this->preparerScenario();
        $this->admin = User::factory()->create(['role' => RoleStaff::Admin]);

        // Séance du 05/10 (leçon « Les voyelles ») ; leçon du 02/10 sans séance.
        $this->seanceOuverte([
            'cloturee_le' => Carbon::parse('2026-10-05 11:15'),
            'duree_minutes' => 195,
            'nb_inscrits' => 3,
            'bilan' => '=HYPERLINK("x") Bonne séance',
        ]);
        LeconDuJour::create(['module_formation_id' => $this->module2->id, 'date_prevue' => '2026-10-02']);
        Presence::create(['inscription_id' => $this->inscriptions[0]->id, 'date_session' => '2026-10-05', 'present' => true]);
        Presence::create(['inscription_id' => $this->inscriptions[1]->id, 'date_session' => '2026-10-05', 'present' => true]);
    }

    private function capturerVue(): \Closure
    {
        $vue = null;
        View::composer('pdf.rapport-formation', function ($view) use (&$vue) {
            $vue = $view->getData();
        });

        return function () use (&$vue) {
            return $vue;
        };
    }

    public function test_pdf_is_downloaded_and_logged(): void
    {
        $response = $this->actingAs($this->admin)
            ->get(route('rapports.formation', ['formation_id' => $this->formation->id]));

        $response->assertOk();
        $this->assertSame('application/pdf', $response->headers->get('content-type'));
        $this->assertSame(1, RapportGenere::count());
    }

    public function test_view_data_contains_summary_and_journal(): void
    {
        $vue = $this->capturerVue();

        $this->actingAs($this->admin)->get(route('rapports.formation', ['formation_id' => $this->formation->id]))->assertOk();

        $this->assertSame('3 h 15', $vue()['synthese']['heures']);
        $this->assertSame(1, $vue()['synthese']['seances']);
        $this->assertSame('1 / 2', $vue()['synthese']['lecons']);
        $this->assertCount(1, $vue()['journal']);
        $this->assertSame(2, $vue()['journal'][0]['nb_presents']);
        $this->assertSame('Les voyelles', $vue()['journal'][0]['lecons']);
        $this->assertCount(3, $vue()['lignes']);
    }

    public function test_csv_is_neutralised(): void
    {
        $response = $this->actingAs($this->admin)
            ->get(route('rapports.formation-csv', ['formation_id' => $this->formation->id]));

        $response->assertOk();
        $csv = $response->streamedContent();
        $this->assertStringContainsString("'=HYPERLINK", $csv);
        $this->assertStringContainsString('3 h 15', $csv);
    }

    public function test_access_rules(): void
    {
        $url = route('rapports.formation', ['formation_id' => $this->formation->id]);
        $autreFormation = Formation::create(['titre' => 'Informatique']);

        $this->actingAs($this->formateur)->get($url)->assertOk();
        $this->actingAs($this->formateur)->get(route('rapports.formation', ['formation_id' => $autreFormation->id]))->assertForbidden();
        $this->actingAs(User::factory()->create(['role' => RoleStaff::Secretaire, 'centre_id' => $this->centre->id]))->get($url)->assertForbidden();
        $this->actingAs(User::factory()->create(['role' => RoleStaff::Partenaire]))->get($url)->assertForbidden();
        $this->actingAs($this->admin)->get(route('rapports.formation'))->assertSessionHasErrors('formation_id');
    }

    public function test_gestionnaire_of_another_centre_gets_no_session(): void
    {
        $autre = $this->formateurDUnAutreCentre();
        $vue = $this->capturerVue();

        $this->actingAs(User::factory()->create(['role' => RoleStaff::Gestionnaire, 'centre_id' => $autre->centre_id]))
            ->get(route('rapports.formation', ['formation_id' => $this->formation->id, 'centre_id' => $this->centre->id]))
            ->assertOk();

        $this->assertCount(0, $vue()['journal']);
        $this->assertCount(0, $vue()['lignes']);
    }
}
```

- [ ] **Step 2: Lancer pour vérifier l'échec**

Run: `php artisan test --filter=RapportFormationTest`
Expected: FAIL avec « Route [rapports.formation] not defined ».

- [ ] **Step 3: Données du rapport et exports**

Dans `RapportController`, imports `use App\Actions\Seances\SerialiserSeances;`, `use App\Models\Seance;`, `use Illuminate\Support\Carbon;`, puis ajouter :

```php
    /** Rapport de formation enrichi (2026-09-25) : synthèse + journal des séances + apprenants. */
    public function exportFormation(Request $request)
    {
        $donnees = $this->donneesRapportFormation($request);
        $this->journaliser($request, count($donnees['lignes']));

        return Pdf::loadView('pdf.rapport-formation', $donnees)
            ->download('rapport-formation-'.now()->format('Y-m-d').'.pdf');
    }

    /** Journal des séances en CSV, une ligne par séance. */
    public function exportFormationCsv(Request $request): StreamedResponse
    {
        $donnees = $this->donneesRapportFormation($request);
        $this->journaliser($request, count($donnees['journal']));

        return response()->streamDownload(function () use ($donnees) {
            $flux = fopen('php://output', 'w');
            fputcsv($flux, ['Date', 'Formateur', 'Centre', 'Arrivée', 'Durée', 'Présents', 'Inscrits', 'Leçons', 'Prévision', 'Bilan', 'État']);

            foreach ($donnees['journal'] as $s) {
                fputcsv($flux, [
                    $s['date'],
                    $this->neutraliserFormuleCsv($s['formateur']),
                    $this->neutraliserFormuleCsv($s['centre']),
                    $s['arrivee'],
                    $s['duree'],
                    $s['nb_presents'],
                    $s['nb_inscrits'] ?? '',
                    $this->neutraliserFormuleCsv($s['lecons']),
                    $this->neutraliserFormuleCsv($s['prevision']),
                    $this->neutraliserFormuleCsv($s['bilan']),
                    $s['etat'],
                ]);
            }

            fclose($flux);
        }, 'rapport-formation-'.now()->format('Y-m-d').'.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
    }

    private function donneesRapportFormation(Request $request): array
    {
        $this->authorize('viewAny', Inscription::class);

        $user = Auth::user();
        abort_if($user->role === RoleStaff::Secretaire, 403);

        $request->validate([
            'formation_id' => ['required', 'integer', 'exists:formations,id'],
            'centre_id' => ['nullable', 'integer', 'exists:centres,id'],
            'du' => ['nullable', 'date_format:Y-m-d'],
            'au' => ['nullable', 'date_format:Y-m-d'],
        ]);

        $formation = Formation::findOrFail($request->integer('formation_id'));

        if ($user->role === RoleStaff::Formateur) {
            abort_unless($user->formations()->where('formations.id', $formation->id)->exists(), 403);
        }

        // Un acteur non global est toujours ramené à son propre centre.
        $centreId = $user->role->isGlobalScope() ? ($request->integer('centre_id') ?: null) : $user->centre_id;
        $request->merge(['centre_id' => $centreId]);

        $du = $request->input('du') ?: $formation->date_debut?->toDateString();
        $au = $request->input('au') ?: $formation->date_fin?->toDateString();

        $seances = Seance::query()
            ->whereNotNull('cloturee_le')
            ->where('formation_id', $formation->id)
            ->when($centreId, fn ($q) => $q->where('centre_id', $centreId))
            ->when($du, fn ($q) => $q->whereDate('date', '>=', $du))
            ->when($au, fn ($q) => $q->whereDate('date', '<=', $au))
            ->with(['formateur:id,name', 'centre:id,nom'])
            ->orderBy('date')
            ->orderBy('id')
            ->get();

        $presents = SerialiserSeances::presentsPar($seances);
        $lecons = SerialiserSeances::leconsPar($seances);

        $journal = $seances->map(fn (Seance $s) => [
            'date' => $s->date->format('d/m/Y'),
            'formateur' => $s->formateur?->name ?? '',
            'centre' => $s->centre?->nom ?? '',
            'arrivee' => $s->ouverte_le?->format('H:i') ?? '',
            'duree' => Seance::formaterDuree($s->duree_minutes),
            'nb_presents' => $presents->get(SerialiserSeances::cle($s), collect())->count(),
            'nb_inscrits' => $s->nb_inscrits,
            'lecons' => $lecons->get(SerialiserSeances::cle($s), collect())->join(', '),
            'prevision' => $s->prevision ?? '',
            'bilan' => $s->bilan ?? '',
            'etat' => collect([
                $s->cloture_tardive ? 'Clôture tardive' : null,
                $s->saisie_a_posteriori ? 'Saisie a posteriori' : null,
                $s->corrigee_le ? 'Corrigée' : null,
            ])->filter()->join(', '),
        ])->values()->all();

        // Leçons traitées : leçons prévues jusqu'à aujourd'hui (dans la
        // période) dont le jour a eu une séance clôturée de la formation.
        $jusqua = $au && $au < today()->toDateString() ? $au : today()->toDateString();
        $prevues = LeconDuJour::query()
            ->whereHas('moduleFormation.chapitre', fn ($q) => $q
                ->where('formation_id', $formation->id)
                ->when($centreId, fn ($q2) => $q2->where('centre_id', $centreId)))
            ->when($du, fn ($q) => $q->whereDate('date_prevue', '>=', $du))
            ->whereDate('date_prevue', '<=', $jusqua)
            ->with('moduleFormation.chapitre:id,formation_id,centre_id')
            ->get();
        $joursAvecSeance = $seances->map(fn (Seance $s) => SerialiserSeances::cle($s))->unique();
        $traitees = $prevues->filter(fn (LeconDuJour $l) => $joursAvecSeance->contains(
            "{$l->moduleFormation->chapitre->formation_id}-{$l->moduleFormation->chapitre->centre_id}-{$l->date_prevue->toDateString()}"
        ))->count();

        $lignes = $this->lignesFiltrees($request);
        $assiduites = collect($lignes)->pluck('taux_presence')->filter(fn ($t) => $t !== null);

        return [
            'formation' => $formation,
            'centre' => $centreId ? Centre::find($centreId)?->nom : 'Tous les centres',
            'periode' => $du || $au
                ? 'du '.($du ? Carbon::parse($du)->format('d/m/Y') : '…').' au '.($au ? Carbon::parse($au)->format('d/m/Y') : '…')
                : 'toute la formation',
            'formateurs' => $seances->pluck('formateur.name')->filter()->unique()->values()->join(', ') ?: 'Aucun',
            'synthese' => [
                'heures' => Seance::formaterDuree((int) $seances->sum('duree_minutes')),
                'seances' => $seances->count(),
                'lecons' => $traitees.' / '.$prevues->count(),
                'assiduite' => $assiduites->isEmpty() ? '—' : round($assiduites->avg()).' %',
                'progression' => count($lignes) === 0 ? '—' : round(collect($lignes)->avg('progression_pourcentage')).' %',
            ],
            'journal' => $journal,
            'lignes' => $lignes,
            'genereLe' => now()->format('d/m/Y H:i'),
        ];
    }
```

Remarques :
- `lignesFiltrees()` lit `formation_id`/`centre_id` dans la requête (centre fusionné juste avant) et applique `Inscription::visibleTo($user)` : le cloisonnement des apprenants est assuré.
- `journaliser()` lit aussi `centre_id` depuis la requête.

Routes, dans `routes/staff.php`, sous `rapports.export-csv` :

```php
    // Rapport de formation enrichi des séances (2026-09-25).
    Route::get('rapports/formation', [RapportController::class, 'exportFormation'])->name('rapports.formation');
    Route::get('rapports/formation-csv', [RapportController::class, 'exportFormationCsv'])->name('rapports.formation-csv');
```

- [ ] **Step 4: Vue PDF**

`resources/views/pdf/rapport-formation.blade.php` :

```blade
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <title>Rapport de formation</title>
    <style>
        body { font-family: DejaVu Sans, sans-serif; font-size: 10px; color: #1f2937; }
        h1 { font-size: 16px; margin-bottom: 2px; }
        h2 { font-size: 12px; margin: 18px 0 6px; }
        .meta { color: #6b7280; font-size: 9px; margin-bottom: 12px; }
        table { width: 100%; border-collapse: collapse; }
        th, td { border: 1px solid #d1d5db; padding: 5px 6px; text-align: left; vertical-align: top; }
        th { background-color: #f3f4f6; }
        .synthese td { width: 20%; text-align: center; }
        .synthese .valeur { font-size: 14px; font-weight: bold; }
    </style>
</head>
<body>
    <h1>TECHNIFORM · Rapport de formation</h1>
    <div class="meta">
        {{ $formation->titre }} · {{ $centre }} · {{ $periode }}<br>
        Formateurs : {{ $formateurs }} · Généré le {{ $genereLe }}
    </div>

    <h2>Synthèse</h2>
    <table class="synthese">
        <tr>
            <td><div class="valeur">{{ $synthese['heures'] }}</div>Heures dispensées</td>
            <td><div class="valeur">{{ $synthese['seances'] }}</div>Séances</td>
            <td><div class="valeur">{{ $synthese['lecons'] }}</div>Leçons traitées en séance / prévues à ce jour</td>
            <td><div class="valeur">{{ $synthese['assiduite'] }}</div>Assiduité moyenne</td>
            <td><div class="valeur">{{ $synthese['progression'] }}</div>Progression moyenne</td>
        </tr>
    </table>

    <h2>Journal des séances</h2>
    <table>
        <thead>
            <tr>
                <th>Date</th>
                <th>Formateur</th>
                <th>Durée</th>
                <th>Présents</th>
                <th>Leçons</th>
                <th>Bilan</th>
            </tr>
        </thead>
        <tbody>
            @forelse ($journal as $seance)
                <tr>
                    <td>{{ $seance['date'] }}@if ($seance['etat'])<br><small>{{ $seance['etat'] }}</small>@endif</td>
                    <td>{{ $seance['formateur'] }}</td>
                    <td>{{ $seance['duree'] }}</td>
                    <td>{{ $seance['nb_inscrits'] !== null ? $seance['nb_presents'].' / '.$seance['nb_inscrits'] : '—' }}</td>
                    <td>{{ $seance['lecons'] ?: '—' }}</td>
                    <td>{{ \Illuminate\Support\Str::limit($seance['bilan'], 600) ?: '—' }}</td>
                </tr>
            @empty
                <tr><td colspan="6">Aucune séance clôturée sur cette période.</td></tr>
            @endforelse
        </tbody>
    </table>

    <h2>Apprenants</h2>
    <table>
        <thead>
            <tr>
                <th>#</th>
                <th>Apprenant</th>
                <th>Centre</th>
                <th>Statut inscription</th>
                <th>Progression</th>
                <th>Assiduité</th>
            </tr>
        </thead>
        <tbody>
            @forelse ($lignes as $ligne)
                <tr>
                    <td>{{ $loop->iteration }}</td>
                    <td>{{ $ligne['apprenant'] }}</td>
                    <td>{{ $ligne['centre'] }}</td>
                    <td>{{ $ligne['statut_inscription'] }}</td>
                    <td>{{ $ligne['progression_pourcentage'] }} %</td>
                    <td>{{ $ligne['taux_presence'] !== null ? $ligne['taux_presence'].' %' : '—' }}</td>
                </tr>
            @empty
                <tr><td colspan="6">Aucun apprenant.</td></tr>
            @endforelse
        </tbody>
    </table>
</body>
</html>
```

- [ ] **Step 5: Lancer les tests**

Run: `php artisan test --filter="RapportFormationTest|RapportTest"`
Expected: PASS.

- [ ] **Step 6: Boutons dans la page Rapports**

Dans `resources/js/Pages/Staff/Rapports/Index.jsx`, juste après la balise fermante `</div>` du bloc `<div className="flex items-center gap-2">` qui contient les deux liens « Télécharger le PDF / CSV », ajouter :

```jsx
                {filtres.formation_id && (
                    <div className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 bg-white p-3">
                        <span className="w-full text-sm text-slate-600 sm:w-auto sm:flex-1">
                            Rapport de formation : heures, journal des séances et apprenants.
                        </span>
                        <a
                            href={route('rapports.formation', filtres)}
                            className="flex-1 whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-center text-sm font-semibold text-white hover:bg-slate-800 sm:flex-none sm:px-4"
                        >
                            Rapport de formation (PDF)
                        </a>
                        <a
                            href={route('rapports.formation-csv', filtres)}
                            className="flex-1 whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-center text-sm font-medium text-slate-700 hover:bg-slate-50 sm:flex-none sm:px-4"
                        >
                            Journal (CSV)
                        </a>
                    </div>
                )}
```

(Liens `<a href>` bruts, pas `Link` Inertia : téléchargements binaires, piège documenté.)

- [ ] **Step 7: Build**

Run: `npm run build`
Expected: sans erreur.

- [ ] **Step 8: Commit (si autorisé)**

```bash
git add app/Http/Controllers/Staff/RapportController.php routes/staff.php resources/views/pdf/rapport-formation.blade.php resources/js/Pages/Staff/Rapports/Index.jsx tests/Feature/Seances/RapportFormationTest.php
git commit -m "Ajoute le rapport de formation enrichi des seances"
```

---

### Task 11: Vérification finale, migration locale, CLAUDE.md

**Files:**
- Modify: `CLAUDE.md`

- [ ] **Step 1: Suite complète + build**

Run: `php artisan test && npm run build`
Expected: tous les tests passent (211 existants + environ 50 nouveaux) ; build sans erreur.

- [ ] **Step 2: Migrer la base locale**

Run: `php artisan migrate --force`
Expected: `2026_09_25_000001_create_seances_table` en `DONE`.

- [ ] **Step 3: Vérification HTTP rapide**

Run: `curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8001/login`
Expected: `200` (serveur de dev sur le port 8001).

- [ ] **Step 4: Mettre à jour CLAUDE.md**

Ajouter une section `### Séances formateur : pointage, cahier de séance, suivi et rapport (2026-09-25)` juste avant `## Recette (cahier des charges §8)`, et mettre à jour le compte de tests dans le paragraphe « Statut » en haut du fichier. Contenu minimal :
- table `seances` et invariants (une par formateur × formation × jour, une seule ouverte à la fois, pas de futur, période de la formation) ;
- **leçons et présents jamais stockés** : dérivés par `SerialiserSeances::leconsPar()` / `presentsPar()` (règle à respecter pour tout nouvel écran) ;
- Actions `OuvrirSeance`, `FaireAppel`, `CloturerSeance`, `SaisirSeancePassee`, `SerialiserSeances`, `DonneesCarteSeance` ;
- `SeancePolicy` (admin ne pointe jamais ; correction durée + bilan : formateur le jour de la clôture, admin toujours et tracé ; gestionnaire lecture de son centre) ;
- routes `seances.*`, `rapports.formation(-csv)` ; page unique `Staff/Seances/Index.jsx` adaptée au rôle, props lourdes en closures pour le rafraîchissement de `en_seance` ;
- rattrapage → redirection vers la page Présence du jour ; `FlashStatus` ajouté à la page Présence (messages jamais affichés jusque-là) ;
- « jours sans séance » = couples (formation, jour) jusqu'à la veille avec leçon planifiée et sans séance ;
- nouveaux tests et nouveau total.

- [ ] **Step 5: Commit (si autorisé)**

```bash
git add CLAUDE.md
git commit -m "Documente le chantier des seances formateur"
```
