# ALDANA Phase 6 — Backoffice Admin Studio Implementation Plan

> **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:** Construire le backoffice admin permettant à Aurane (et un futur admin délégué) de programmer séances, salles, profs, tarifs/carnets, évènements depuis l'interface web, sans passer par phpMyAdmin. CRUD complets + récurrence hebdo pour les séances + annulation cascade (notif membres) + édition CMS des textes du site.

**Architecture:**
- Mur d'auth : `AuthMiddleware::checkAdmin()` **existe déjà** et est câblé via `'middleware' => 'admin'` dans `routes.php`. Aucune refonte infra.
- Pattern : un `Controllers/Admin/<X>Controller` par module + `Repositories/<X>Repository` + vues `views/pages/admin/<module>/{index,form}.php`, layout `views/layouts/admin.php` partagé.
- Récurrence séances : `ClassScheduleService::generateFromTemplate(weeklyTemplate, dateRange)` qui crée N cours en transaction.
- Annulation séance : réutilise `BookingService::cancelByAdmin(classID, reason)` qui rembourse sessions, log audit, envoie email aux réservés.
- Upload photos : différé en Phase 7 (médias). En P6, les champs `image_url`/`photo_url` sont des `<input type="url">` — Aurane uploade en FTP/Dropbox et colle l'URL.

**Tech Stack:** PHP 8.4 + PDO + sessions. Frontend admin : HTML/CSS/JS vanilla, datepicker natif `<input type="date">`, pas de framework. Templates PHP natifs.

**Pré-requis P4 livré** (BookingService + tests). Si P6 démarre avant P4, retirer Task 8 (annulation cascade) et adapter Tasks 12 (booking listing).

---

## File Structure

```
src/
├── Controllers/Admin/
│   ├── DashboardController.php       ← Task 3 (/admin home)
│   ├── RoomsController.php           ← Task 4
│   ├── TeachersController.php        ← Task 5
│   ├── PlansController.php           ← Task 6
│   ├── ClassesController.php         ← Task 7 (unitaire + édition)
│   ├── ScheduleController.php        ← Task 8 (récurrence + annulation)
│   ├── EventsController.php          ← Task 10
│   ├── BookingsController.php        ← Task 11 (listing + crédit manuel)
│   └── SettingsController.php        ← Task 12 (édition t_site_settings)
├── Repositories/
│   ├── RoomRepository.php            ← Task 4 (new)
│   ├── TeacherRepository.php         ← Task 5 (new)
│   ├── PlanRepository.php            ← Task 6 (new)
│   ├── ClassRepository.php           ← MODIF Task 7-8 (extension méthodes admin)
│   ├── EventRepository.php           ← Task 10 (new)
│   └── (BookingRepository, SubscriptionRepository, UserRepository déjà existants)
├── Services/
│   ├── ClassScheduleService.php      ← Task 8 (batch création récurrente)
│   └── BookingService.php            ← MODIF Task 8 (cancelByAdmin)
└── (existing helpers/middleware untouched)

views/
├── layouts/
│   └── admin.php                     ← Task 3 (sidebar + topbar)
├── partials/
│   └── admin/
│       ├── sidebar.php               ← Task 3
│       └── flash.php                 ← Task 3 (réutilise pattern public)
└── pages/admin/
    ├── dashboard.php                 ← Task 3
    ├── rooms/{index,form}.php        ← Task 4
    ├── teachers/{index,form}.php     ← Task 5
    ├── plans/{index,form}.php        ← Task 6
    ├── classes/{index,form}.php      ← Task 7
    ├── schedule/{template,batch}.php ← Task 8
    ├── events/{index,form}.php       ← Task 10
    ├── bookings/index.php            ← Task 11
    └── settings/index.php            ← Task 12

public/assets/
├── css/admin.css                     ← Task 3 (sidebar + tables + forms admin)
└── js/admin.js                       ← Task 3 (confirm delete, datepickers, etc.)

config/
└── routes.php                        ← MODIF Tasks 3-12 (ajout routes /admin/*)

database/migrations/
├── 014_create_rooms.sql              ← Task 1
├── 015_create_teachers.sql           ← Task 1
├── 016_alter_classes_add_fks.sql     ← Task 2
└── 017_alter_events_add_teacher_fk.sql ← Task 2

tests/
└── Feature/
    └── ClassScheduleServiceTest.php  ← Task 9 (récurrence batch + annulation cascade)
```

---

## Décisions de scope (taken, pas de question)

1. **1 lieu unique** (La Maison Amara) → `t_rooms` minimal, pas de table `t_sites`. Une salle = une room.
2. **Aurane = prof principale**, t_teachers permet 1-N invités. Pas de login obligatoire prof (FK `user_ID` nullable).
3. **Récurrence hebdo** = un "template" (jour de semaine + heure + pratique + room + teacher) projeté sur une plage de dates. Pas de RRULE complexe RFC 5545.
4. **Annulation séance** = soft (status='cancelled'). Pas de DELETE. Email automatique aux réservés.
5. **Tarifs** = édition `price_cents` local. Sync Stripe Price ID = champ texte rempli manuellement par Aurane (auto-sync = P5).
6. **Upload photo** = différé P7. En P6, champs `<input type="url">` pour `photo_url` / `image_url`.
7. **Bookings admin** = read-only + bouton "créditer 1 séance" manuel (cas force majeure). Pas de booking au nom du client (privilégié = workflow "demande privée").
8. **Settings** = édition libre des paires clé/valeur de `t_site_settings`. Pas de validation par type avancée — Aurane sait ce qu'elle édite.
9. **Audit log** = différé (P8). On log dans `storage/logs/admin.log` les actions sensibles (annulation cours, crédit forfait, modif tarif).

---

## Task 1 : Migration `t_rooms` + `t_teachers`

**Files:**
- Create: `database/migrations/014_create_rooms.sql`
- Create: `database/migrations/015_create_teachers.sql`

- [ ] **Step 1.1 : Créer `014_create_rooms.sql`**

```sql
-- ALDANA migration 014
-- Salles de pratique (1 lieu = N salles en théorie, 1 seule à La Maison Amara aujourd'hui)

CREATE TABLE IF NOT EXISTS t_rooms (
    room_ID         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name            VARCHAR(150) NOT NULL,
    slug            VARCHAR(150) NOT NULL UNIQUE,
    capacity        TINYINT UNSIGNED NOT NULL DEFAULT 12,
    surface_m2      SMALLINT UNSIGNED NULL,
    equipment_json  JSON NULL,
    ambiance        TEXT NULL,
    photo_url       VARCHAR(500) NULL,
    active          TINYINT(1) NOT NULL DEFAULT 1,
    display_order   SMALLINT UNSIGNED NOT NULL DEFAULT 100,
    created_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_active (active, display_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Seed : la salle unique actuelle de La Maison Amara
INSERT INTO t_rooms (name, slug, capacity, ambiance, display_order)
VALUES ('Salle principale', 'salle-principale', 12,
        'Salle lumineuse au parquet clair, vue sur cour intérieure.', 10)
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP;
```

- [ ] **Step 1.2 : Créer `015_create_teachers.sql`**

```sql
-- ALDANA migration 015
-- Profs (Aurane + futurs invités). FK vers t_users facultative (un invité peut ne pas avoir de compte).

CREATE TABLE IF NOT EXISTS t_teachers (
    teacher_ID      INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_ID         INT UNSIGNED NULL UNIQUE,
    full_name       VARCHAR(150) NOT NULL,
    slug            VARCHAR(150) NOT NULL UNIQUE,
    bio_short       VARCHAR(280) NULL,
    bio_long        TEXT NULL,
    specialties     VARCHAR(300) NULL,
    photo_url       VARCHAR(500) NULL,
    active          TINYINT(1) NOT NULL DEFAULT 1,
    is_principal    TINYINT(1) NOT NULL DEFAULT 0,
    display_order   SMALLINT UNSIGNED NOT NULL DEFAULT 100,
    created_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_ID) REFERENCES t_users(user_ID) ON DELETE SET NULL,
    INDEX idx_active (active, display_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Seed : Aurane comme prof principale, liée à son user_ID (aldana.aurane@hotmail.fr)
INSERT INTO t_teachers (user_ID, full_name, slug, bio_short, is_principal, display_order)
SELECT u.user_ID, u.full_name, 'aurane-aldana',
       'Professeure principale, fondatrice de Kinetic Silence.',
       1, 10
FROM t_users u
WHERE u.email = 'aldana.aurane@hotmail.fr'
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP;
```

- [ ] **Step 1.3 : Jouer la migration**

```powershell
php database/migrate.php
```

Expected : `014_create_rooms.sql OK`, `015_create_teachers.sql OK`. Vérifier dans phpMyAdmin que les 2 tables existent avec leur seed.

- [ ] **Step 1.4 : Commit**

```powershell
git add database/migrations/014_create_rooms.sql database/migrations/015_create_teachers.sql
git commit -m "feat(P6): tables t_rooms et t_teachers + seed Aurane salle principale"
```

---

## Task 2 : Migrations ALTER `t_classes` + `t_events` (ajout FK room + teacher)

**Files:**
- Create: `database/migrations/016_alter_classes_add_fks.sql`
- Create: `database/migrations/017_alter_events_add_teacher_fk.sql`

- [ ] **Step 2.1 : Créer `016_alter_classes_add_fks.sql`**

```sql
-- ALDANA migration 016
-- Ajout FK room + teacher sur t_classes (nullable pour rétrocompat).
-- Idempotent via vérification INFORMATION_SCHEMA.

SET @col_exists := (
    SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE() AND table_name = 't_classes' AND column_name = 'room_ID'
);
SET @sql := IF(@col_exists = 0,
    'ALTER TABLE t_classes ADD COLUMN room_ID INT UNSIGNED NULL AFTER practice_ID, ADD INDEX idx_room (room_ID), ADD CONSTRAINT fk_classes_room FOREIGN KEY (room_ID) REFERENCES t_rooms(room_ID) ON DELETE SET NULL',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

SET @col_exists := (
    SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE() AND table_name = 't_classes' AND column_name = 'teacher_ID'
);
SET @sql := IF(@col_exists = 0,
    'ALTER TABLE t_classes ADD COLUMN teacher_ID INT UNSIGNED NULL AFTER room_ID, ADD INDEX idx_teacher (teacher_ID), ADD CONSTRAINT fk_classes_teacher FOREIGN KEY (teacher_ID) REFERENCES t_teachers(teacher_ID) ON DELETE SET NULL',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

-- Backfill : tous les cours existants seed_dev rattachés à la salle 'salle-principale' et à Aurane (teacher principal)
UPDATE t_classes
SET room_ID = (SELECT room_ID FROM t_rooms WHERE slug = 'salle-principale' LIMIT 1),
    teacher_ID = (SELECT teacher_ID FROM t_teachers WHERE is_principal = 1 LIMIT 1)
WHERE room_ID IS NULL OR teacher_ID IS NULL;
```

- [ ] **Step 2.2 : Créer `017_alter_events_add_teacher_fk.sql`**

```sql
-- ALDANA migration 017
-- Ajout FK teacher sur t_events (un évènement = un prof principal).

SET @col_exists := (
    SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE() AND table_name = 't_events' AND column_name = 'teacher_ID'
);
SET @sql := IF(@col_exists = 0,
    'ALTER TABLE t_events ADD COLUMN teacher_ID INT UNSIGNED NULL AFTER subtitle, ADD INDEX idx_teacher (teacher_ID), ADD CONSTRAINT fk_events_teacher FOREIGN KEY (teacher_ID) REFERENCES t_teachers(teacher_ID) ON DELETE SET NULL',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

UPDATE t_events
SET teacher_ID = (SELECT teacher_ID FROM t_teachers WHERE is_principal = 1 LIMIT 1)
WHERE teacher_ID IS NULL;
```

- [ ] **Step 2.3 : Jouer + vérifier**

```powershell
php database/migrate.php
```

Vérifier phpMyAdmin : `t_classes` et `t_events` doivent contenir les nouvelles colonnes + FK. Les cours du seed_dev doivent avoir `room_ID` et `teacher_ID` peuplés.

- [ ] **Step 2.4 : Commit**

```powershell
git add database/migrations/016_alter_classes_add_fks.sql database/migrations/017_alter_events_add_teacher_fk.sql
git commit -m "feat(P6): FK room + teacher sur t_classes et t_events (backfill auto)"
```

---

## Task 3 : Infrastructure admin (layout + dashboard + base controller)

**Files:**
- Create: `src/Controllers/Admin/DashboardController.php`
- Create: `views/layouts/admin.php`
- Create: `views/partials/admin/sidebar.php`
- Create: `views/pages/admin/dashboard.php`
- Create: `public/assets/css/admin.css`
- Create: `public/assets/js/admin.js`
- Modify: `config/routes.php` (ajout `GET /admin`)

- [ ] **Step 3.1 : `DashboardController` (statistiques home admin)**

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Helpers\Database;

final class DashboardController extends BaseController
{
    public function index(): void
    {
        $pdo = Database::getInstance()->getConnection();

        $stats = [
            'classes_upcoming'   => (int) $pdo->query("SELECT COUNT(*) FROM t_classes WHERE class_date >= CURDATE() AND status = 'scheduled'")->fetchColumn(),
            'bookings_today'     => (int) $pdo->query("SELECT COUNT(*) FROM t_bookings b JOIN t_classes c ON b.class_ID = c.class_ID WHERE c.class_date = CURDATE() AND b.status = 'confirmed'")->fetchColumn(),
            'members_active'     => (int) $pdo->query("SELECT COUNT(DISTINCT user_ID) FROM t_subscriptions WHERE status = 'active' AND valid_until >= CURDATE()")->fetchColumn(),
            'events_published'   => (int) $pdo->query("SELECT COUNT(*) FROM t_events WHERE status = 'published' AND date_start >= CURDATE()")->fetchColumn(),
        ];

        echo view('layouts.admin', [
            'title'       => 'Tableau de bord',
            'currentPath' => '/admin',
            'content'     => view('pages.admin.dashboard', ['stats' => $stats]),
        ]);
    }
}
```

- [ ] **Step 3.2 : Layout admin avec sidebar**

```php
<!-- views/layouts/admin.php -->
<?php /** @var string $content */ /** @var string $currentPath */ /** @var string $title */ ?>
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex,nofollow">
    <title><?= e($title ?? 'Admin') ?> · ALDANA Admin</title>
    <link rel="stylesheet" href="/assets/css/aldana.css">
    <link rel="stylesheet" href="/assets/css/admin.css">
</head>
<body class="admin-body">
    <aside class="admin-sidebar">
        <?= view('partials.admin.sidebar', ['currentPath' => $currentPath ?? '/admin']) ?>
    </aside>
    <main class="admin-main">
        <header class="admin-topbar">
            <h1 class="admin-title"><?= e($title ?? '') ?></h1>
            <div class="admin-user">
                <span><?= e($_SESSION['user_name'] ?? '') ?></span>
                <form method="POST" action="/logout" style="display:inline">
                    <?= csrf_field() ?>
                    <button type="submit" class="btn btn-ghost btn-sm">Déconnexion</button>
                </form>
            </div>
        </header>
        <?= view('partials.flash') ?>
        <div class="admin-content">
            <?= $content ?>
        </div>
    </main>
    <script src="/assets/js/admin.js" defer></script>
</body>
</html>
```

- [ ] **Step 3.3 : Sidebar admin**

```php
<!-- views/partials/admin/sidebar.php -->
<?php /** @var string $currentPath */
$items = [
    ['/admin',              'Tableau de bord', 'home'],
    ['/admin/seances',      'Séances',         'calendar'],
    ['/admin/recurrence',   'Récurrence',      'repeat'],
    ['/admin/salles',       'Salles',          'box'],
    ['/admin/profs',        'Profs',           'user'],
    ['/admin/tarifs',       'Tarifs & carnets','tag'],
    ['/admin/evenements',   'Évènements',      'star'],
    ['/admin/reservations', 'Réservations',    'bookmark'],
    ['/admin/settings',     'Site & contenu',  'settings'],
];
?>
<nav class="admin-nav">
    <a href="/admin" class="admin-brand">ALDANA · Admin</a>
    <ul>
        <?php foreach ($items as [$href, $label, $icon]): ?>
            <li>
                <a href="<?= e($href) ?>"
                   class="admin-nav-item <?= str_starts_with($currentPath, $href) && ($href !== '/admin' || $currentPath === '/admin') ? 'is-active' : '' ?>">
                    <?= e($label) ?>
                </a>
            </li>
        <?php endforeach; ?>
    </ul>
    <a href="/" class="admin-back">← Retour site</a>
</nav>
```

- [ ] **Step 3.4 : Dashboard view (cartes statistiques)**

```php
<!-- views/pages/admin/dashboard.php -->
<?php /** @var array $stats */ ?>
<div class="admin-stats">
    <div class="stat-card">
        <span class="stat-label">Séances à venir</span>
        <span class="stat-value"><?= (int) $stats['classes_upcoming'] ?></span>
        <a href="/admin/seances" class="stat-link">Gérer</a>
    </div>
    <div class="stat-card">
        <span class="stat-label">Réservations aujourd'hui</span>
        <span class="stat-value"><?= (int) $stats['bookings_today'] ?></span>
        <a href="/admin/reservations" class="stat-link">Voir</a>
    </div>
    <div class="stat-card">
        <span class="stat-label">Membres actifs</span>
        <span class="stat-value"><?= (int) $stats['members_active'] ?></span>
    </div>
    <div class="stat-card">
        <span class="stat-label">Évènements publiés</span>
        <span class="stat-value"><?= (int) $stats['events_published'] ?></span>
        <a href="/admin/evenements" class="stat-link">Gérer</a>
    </div>
</div>

<section class="admin-quick">
    <h2>Actions rapides</h2>
    <div class="admin-quick-grid">
        <a href="/admin/seances/nouveau" class="btn btn-primary">+ Programmer une séance</a>
        <a href="/admin/recurrence" class="btn btn-secondary">Générer la semaine type</a>
        <a href="/admin/evenements/nouveau" class="btn btn-secondary">+ Créer un évènement</a>
    </div>
</section>
```

- [ ] **Step 3.5 : CSS admin (sidebar + tables + forms)**

```css
/* public/assets/css/admin.css */
.admin-body { margin: 0; display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: #f7f5f1; }
.admin-sidebar { background: #1a1a1a; color: #f7f5f1; padding: 2rem 1rem; }
.admin-brand { color: #fff; font-weight: 600; display: block; margin-bottom: 2rem; text-decoration: none; }
.admin-nav ul { list-style: none; padding: 0; margin: 0; }
.admin-nav-item { display: block; padding: .6rem 1rem; color: #ccc; text-decoration: none; border-radius: 6px; margin-bottom: .2rem; }
.admin-nav-item:hover { background: #2a2a2a; color: #fff; }
.admin-nav-item.is-active { background: #c9a96e; color: #1a1a1a; font-weight: 600; }
.admin-back { display: block; margin-top: 2rem; color: #888; font-size: .9rem; text-decoration: none; }

.admin-main { padding: 0; }
.admin-topbar { display: flex; justify-content: space-between; align-items: center; padding: 1.5rem 2rem; background: #fff; border-bottom: 1px solid #e5e0d8; }
.admin-title { margin: 0; font-size: 1.5rem; }
.admin-content { padding: 2rem; max-width: 1200px; }

.admin-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.stat-card { background: #fff; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }
.stat-label { color: #888; font-size: .85rem; }
.stat-value { display: block; font-size: 2.5rem; font-weight: 700; color: #1a1a1a; margin: .3rem 0; }
.stat-link { color: #c9a96e; font-size: .85rem; text-decoration: none; }

.admin-table { width: 100%; background: #fff; border-collapse: collapse; border-radius: 8px; overflow: hidden; }
.admin-table th, .admin-table td { padding: .8rem 1rem; text-align: left; border-bottom: 1px solid #eee; }
.admin-table th { background: #f7f5f1; font-weight: 600; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-table .actions { display: flex; gap: .4rem; }

.admin-form { background: #fff; padding: 2rem; border-radius: 8px; max-width: 720px; }
.admin-form .form-group { margin-bottom: 1.2rem; }
.admin-form label { display: block; font-weight: 600; margin-bottom: .3rem; }
.admin-form input, .admin-form select, .admin-form textarea { width: 100%; padding: .6rem; border: 1px solid #ddd; border-radius: 4px; font-family: inherit; }
.admin-form textarea { min-height: 120px; resize: vertical; }
.admin-form .form-actions { display: flex; gap: .8rem; margin-top: 1.5rem; }

.badge { display: inline-block; padding: .2rem .6rem; border-radius: 999px; font-size: .8rem; font-weight: 600; }
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-danger { background: #f8d7da; color: #721c24; }
.badge-muted { background: #e9ecef; color: #6c757d; }
```

- [ ] **Step 3.6 : JS admin (confirm delete, datepickers, sticky form)**

```js
// public/assets/js/admin.js
document.addEventListener('click', (e) => {
    const btn = e.target.closest('[data-confirm]');
    if (!btn) return;
    const msg = btn.dataset.confirm || 'Confirmer cette action ?';
    if (!confirm(msg)) {
        e.preventDefault();
        e.stopPropagation();
    }
});

// Auto-slug : convertit le 1er input "name" en slug pour le 1er input "slug" voisin
document.querySelectorAll('[data-slugify]').forEach((input) => {
    const target = document.querySelector(input.dataset.slugify);
    if (!target) return;
    input.addEventListener('input', () => {
        if (target.dataset.userEdited === '1') return;
        target.value = input.value
            .toLowerCase()
            .normalize('NFD').replace(/[̀-ͯ]/g, '')
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '');
    });
    target.addEventListener('input', () => { target.dataset.userEdited = '1'; });
});
```

- [ ] **Step 3.7 : Routes admin (dashboard uniquement)**

Ajouter à `config/routes.php` (avant la dernière `]` de fermeture) :

```php
    // ---- Admin (role 29) ----
    'GET /admin'                          => ['App\Controllers\Admin\DashboardController',  'index',  ['middleware' => 'admin']],
```

- [ ] **Step 3.8 : Test manuel dashboard**

- Démarrer WAMP, naviguer `http://localhost/aldana/public/admin`
- Connecté en non-admin → 403 attendu
- Connecté en Aurane (role 29) → dashboard s'affiche avec stats (peuvent être à 0)

- [ ] **Step 3.9 : Commit**

```powershell
git add src/Controllers/Admin/DashboardController.php views/layouts/admin.php views/partials/admin/sidebar.php views/pages/admin/dashboard.php public/assets/css/admin.css public/assets/js/admin.js config/routes.php
git commit -m "feat(P6): infrastructure admin (layout sidebar, dashboard, 403 si non-admin)"
```

---

## Task 4 : CRUD Salles (`/admin/salles`)

**Files:**
- Create: `src/Repositories/RoomRepository.php`
- Create: `src/Controllers/Admin/RoomsController.php`
- Create: `views/pages/admin/rooms/index.php`
- Create: `views/pages/admin/rooms/form.php`
- Modify: `config/routes.php`

- [ ] **Step 4.1 : `RoomRepository`**

```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use App\Helpers\Database;
use PDO;

final class RoomRepository
{
    private PDO $pdo;
    public function __construct() { $this->pdo = Database::getInstance()->getConnection(); }

    public function listAll(bool $onlyActive = false): array
    {
        $sql = 'SELECT * FROM t_rooms';
        if ($onlyActive) { $sql .= ' WHERE active = 1'; }
        $sql .= ' ORDER BY display_order, name';
        return $this->pdo->query($sql)->fetchAll();
    }

    public function findByID(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM t_rooms WHERE room_ID = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    public function findBySlug(string $slug): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM t_rooms WHERE slug = ?');
        $stmt->execute([$slug]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    public function create(array $data): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_rooms (name, slug, capacity, surface_m2, equipment_json, ambiance, photo_url, active, display_order)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $data['name'], $data['slug'], $data['capacity'],
            $data['surface_m2'] ?: null,
            $data['equipment_json'] ?: null,
            $data['ambiance'] ?: null,
            $data['photo_url'] ?: null,
            $data['active'] ? 1 : 0,
            $data['display_order'],
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, array $data): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE t_rooms SET name = ?, slug = ?, capacity = ?, surface_m2 = ?, equipment_json = ?, ambiance = ?, photo_url = ?, active = ?, display_order = ?
             WHERE room_ID = ?'
        );
        $stmt->execute([
            $data['name'], $data['slug'], $data['capacity'],
            $data['surface_m2'] ?: null,
            $data['equipment_json'] ?: null,
            $data['ambiance'] ?: null,
            $data['photo_url'] ?: null,
            $data['active'] ? 1 : 0,
            $data['display_order'],
            $id,
        ]);
    }

    /** Soft-delete : désactive plutôt que supprimer (préserve FK depuis t_classes). */
    public function deactivate(int $id): void
    {
        $this->pdo->prepare('UPDATE t_rooms SET active = 0 WHERE room_ID = ?')->execute([$id]);
    }
}
```

- [ ] **Step 4.2 : `RoomsController`**

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Repositories\RoomRepository;

final class RoomsController extends BaseController
{
    public function index(): void
    {
        $rooms = (new RoomRepository())->listAll();
        echo view('layouts.admin', [
            'title' => 'Salles', 'currentPath' => '/admin/salles',
            'content' => view('pages.admin.rooms.index', ['rooms' => $rooms]),
        ]);
    }

    public function form(): void
    {
        $id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
        $room = $id > 0 ? (new RoomRepository())->findByID($id) : null;
        if ($id > 0 && !$room) { http_response_code(404); echo 'Salle introuvable'; return; }

        echo view('layouts.admin', [
            'title' => $room ? 'Modifier ' . $room['name'] : 'Nouvelle salle',
            'currentPath' => '/admin/salles',
            'content' => view('pages.admin.rooms.form', ['room' => $room]),
        ]);
    }

    public function save(): void
    {
        $id = (int) ($_POST['room_ID'] ?? 0);
        $data = [
            'name'           => trim((string) ($_POST['name'] ?? '')),
            'slug'           => $this->slugify((string) ($_POST['slug'] ?? $_POST['name'] ?? '')),
            'capacity'       => max(1, (int) ($_POST['capacity'] ?? 12)),
            'surface_m2'     => $_POST['surface_m2'] !== '' ? (int) $_POST['surface_m2'] : null,
            'equipment_json' => trim((string) ($_POST['equipment_json'] ?? '')),
            'ambiance'       => trim((string) ($_POST['ambiance'] ?? '')),
            'photo_url'      => trim((string) ($_POST['photo_url'] ?? '')),
            'active'         => !empty($_POST['active']),
            'display_order'  => (int) ($_POST['display_order'] ?? 100),
        ];
        if ($data['name'] === '') { flash('error', 'Le nom est requis.'); redirect('/admin/salles/nouveau'); }
        if ($data['equipment_json'] !== '' && json_decode($data['equipment_json']) === null) {
            flash('error', 'JSON équipements invalide.'); redirect($id > 0 ? "/admin/salles/edit?id={$id}" : '/admin/salles/nouveau');
        }

        $repo = new RoomRepository();
        if ($id > 0) { $repo->update($id, $data); flash('success', 'Salle mise à jour.'); }
        else { $newID = $repo->create($data); flash('success', "Salle créée (#{$newID})."); }
        redirect('/admin/salles');
    }

    public function deactivate(): void
    {
        $id = (int) ($_POST['room_ID'] ?? 0);
        if ($id > 0) {
            (new RoomRepository())->deactivate($id);
            flash('success', 'Salle désactivée (non supprimée — préserve l\'historique).');
        }
        redirect('/admin/salles');
    }

    private function slugify(string $s): string
    {
        $s = strtolower($s);
        $s = preg_replace('/[^a-z0-9]+/', '-', iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s);
        return trim((string) $s, '-');
    }
}
```

- [ ] **Step 4.3 : Vue `index.php` (liste tableau)**

```php
<!-- views/pages/admin/rooms/index.php -->
<?php /** @var array $rooms */ ?>
<div class="admin-actions-row">
    <a href="/admin/salles/nouveau" class="btn btn-primary">+ Nouvelle salle</a>
</div>

<table class="admin-table">
    <thead>
        <tr>
            <th>Nom</th><th>Capacité</th><th>Surface</th><th>Statut</th><th>Ordre</th><th>Actions</th>
        </tr>
    </thead>
    <tbody>
    <?php if (empty($rooms)): ?>
        <tr><td colspan="6" class="text-muted">Aucune salle.</td></tr>
    <?php else: foreach ($rooms as $r): ?>
        <tr>
            <td><strong><?= e($r['name']) ?></strong><br><span class="text-muted"><?= e($r['slug']) ?></span></td>
            <td><?= (int) $r['capacity'] ?></td>
            <td><?= $r['surface_m2'] ? (int) $r['surface_m2'] . ' m²' : '—' ?></td>
            <td><?= $r['active'] ? '<span class="badge badge-success">Active</span>' : '<span class="badge badge-muted">Inactive</span>' ?></td>
            <td><?= (int) $r['display_order'] ?></td>
            <td class="actions">
                <a href="/admin/salles/edit?id=<?= (int) $r['room_ID'] ?>" class="btn btn-ghost btn-sm">Modifier</a>
                <?php if ($r['active']): ?>
                <form method="POST" action="/admin/salles/deactivate" style="display:inline">
                    <?= csrf_field() ?>
                    <input type="hidden" name="room_ID" value="<?= (int) $r['room_ID'] ?>">
                    <button type="submit" class="btn btn-danger btn-sm" data-confirm="Désactiver cette salle ?">Désactiver</button>
                </form>
                <?php endif; ?>
            </td>
        </tr>
    <?php endforeach; endif; ?>
    </tbody>
</table>
```

- [ ] **Step 4.4 : Vue `form.php` (création + édition unifié)**

```php
<!-- views/pages/admin/rooms/form.php -->
<?php /** @var ?array $room */ $r = $room ?? []; ?>
<form method="POST" action="/admin/salles/save" class="admin-form">
    <?= csrf_field() ?>
    <input type="hidden" name="room_ID" value="<?= (int) ($r['room_ID'] ?? 0) ?>">

    <div class="form-group">
        <label for="name">Nom *</label>
        <input id="name" name="name" type="text" required value="<?= e($r['name'] ?? '') ?>"
               data-slugify="#slug" autofocus>
    </div>

    <div class="form-group">
        <label for="slug">Slug (URL) *</label>
        <input id="slug" name="slug" type="text" required value="<?= e($r['slug'] ?? '') ?>"
               pattern="[a-z0-9\-]+" title="lettres minuscules, chiffres et tirets uniquement">
    </div>

    <div class="form-row">
        <div class="form-group">
            <label for="capacity">Capacité *</label>
            <input id="capacity" name="capacity" type="number" min="1" required value="<?= (int) ($r['capacity'] ?? 12) ?>">
        </div>
        <div class="form-group">
            <label for="surface_m2">Surface (m²)</label>
            <input id="surface_m2" name="surface_m2" type="number" min="0" value="<?= e($r['surface_m2'] ?? '') ?>">
        </div>
        <div class="form-group">
            <label for="display_order">Ordre d'affichage</label>
            <input id="display_order" name="display_order" type="number" min="0" value="<?= (int) ($r['display_order'] ?? 100) ?>">
        </div>
    </div>

    <div class="form-group">
        <label for="equipment_json">Équipements (JSON, optionnel)</label>
        <textarea id="equipment_json" name="equipment_json" rows="3" placeholder='{"tapis":12,"briques":24,"chauffage":"radiant"}'><?= e($r['equipment_json'] ?? '') ?></textarea>
    </div>

    <div class="form-group">
        <label for="ambiance">Ambiance (texte court pour vitrine)</label>
        <textarea id="ambiance" name="ambiance" rows="3"><?= e($r['ambiance'] ?? '') ?></textarea>
    </div>

    <div class="form-group">
        <label for="photo_url">URL photo</label>
        <input id="photo_url" name="photo_url" type="url" value="<?= e($r['photo_url'] ?? '') ?>"
               placeholder="https://...">
    </div>

    <div class="form-group">
        <label><input type="checkbox" name="active" value="1" <?= !isset($r['active']) || $r['active'] ? 'checked' : '' ?>> Active</label>
    </div>

    <div class="form-actions">
        <button type="submit" class="btn btn-primary">Enregistrer</button>
        <a href="/admin/salles" class="btn btn-ghost">Annuler</a>
    </div>
</form>
```

- [ ] **Step 4.5 : Routes salles**

Ajouter à `config/routes.php` :

```php
    'GET /admin/salles'             => ['App\Controllers\Admin\RoomsController',     'index',       ['middleware' => 'admin']],
    'GET /admin/salles/nouveau'     => ['App\Controllers\Admin\RoomsController',     'form',        ['middleware' => 'admin']],
    'GET /admin/salles/edit'        => ['App\Controllers\Admin\RoomsController',     'form',        ['middleware' => 'admin']],
    'POST /admin/salles/save'       => ['App\Controllers\Admin\RoomsController',     'save',        ['middleware' => 'admin']],
    'POST /admin/salles/deactivate' => ['App\Controllers\Admin\RoomsController',     'deactivate',  ['middleware' => 'admin']],
```

- [ ] **Step 4.6 : Smoke test manuel**

1. `/admin/salles` → liste affiche "Salle principale"
2. `/admin/salles/nouveau` → créer "Salle annexe", capacité 8 → revenir liste, 2 salles
3. Édition "Salle annexe" → changer capacité à 10 → vérifier persistance
4. Désactivation → badge passe à "Inactive"

- [ ] **Step 4.7 : Commit**

```powershell
git add src/Repositories/RoomRepository.php src/Controllers/Admin/RoomsController.php views/pages/admin/rooms/ config/routes.php
git commit -m "feat(P6): CRUD admin Salles (liste + form + soft-deactivate)"
```

---

## Task 5 : CRUD Profs (`/admin/profs`)

**Files:**
- Create: `src/Repositories/TeacherRepository.php`
- Create: `src/Controllers/Admin/TeachersController.php`
- Create: `views/pages/admin/teachers/index.php`
- Create: `views/pages/admin/teachers/form.php`
- Modify: `config/routes.php`

Pattern identique à Task 4 (Salles), adapté aux colonnes `t_teachers`.

- [ ] **Step 5.1 : `TeacherRepository`**

```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use App\Helpers\Database;
use PDO;

final class TeacherRepository
{
    private PDO $pdo;
    public function __construct() { $this->pdo = Database::getInstance()->getConnection(); }

    public function listAll(bool $onlyActive = false): array
    {
        $sql = 'SELECT t.*, u.email AS user_email
                FROM t_teachers t LEFT JOIN t_users u ON t.user_ID = u.user_ID';
        if ($onlyActive) { $sql .= ' WHERE t.active = 1'; }
        $sql .= ' ORDER BY t.is_principal DESC, t.display_order, t.full_name';
        return $this->pdo->query($sql)->fetchAll();
    }

    public function findByID(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM t_teachers WHERE teacher_ID = ?');
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }

    public function create(array $data): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_teachers (user_ID, full_name, slug, bio_short, bio_long, specialties, photo_url, active, is_principal, display_order)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $data['user_ID'] ?: null,
            $data['full_name'], $data['slug'],
            $data['bio_short'] ?: null,
            $data['bio_long'] ?: null,
            $data['specialties'] ?: null,
            $data['photo_url'] ?: null,
            $data['active'] ? 1 : 0,
            $data['is_principal'] ? 1 : 0,
            $data['display_order'],
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, array $data): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE t_teachers
             SET user_ID = ?, full_name = ?, slug = ?, bio_short = ?, bio_long = ?, specialties = ?, photo_url = ?, active = ?, is_principal = ?, display_order = ?
             WHERE teacher_ID = ?'
        );
        $stmt->execute([
            $data['user_ID'] ?: null,
            $data['full_name'], $data['slug'],
            $data['bio_short'] ?: null,
            $data['bio_long'] ?: null,
            $data['specialties'] ?: null,
            $data['photo_url'] ?: null,
            $data['active'] ? 1 : 0,
            $data['is_principal'] ? 1 : 0,
            $data['display_order'],
            $id,
        ]);
    }

    public function deactivate(int $id): void
    {
        $this->pdo->prepare('UPDATE t_teachers SET active = 0 WHERE teacher_ID = ?')->execute([$id]);
    }

    public function searchUsersForLink(string $q): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT user_ID, email, full_name FROM t_users
             WHERE (email LIKE ? OR full_name LIKE ?) AND role_ID IN (11, 29)
             ORDER BY full_name LIMIT 20"
        );
        $stmt->execute(["%{$q}%", "%{$q}%"]);
        return $stmt->fetchAll();
    }
}
```

- [ ] **Step 5.2 : `TeachersController`**

Structure copiée de `RoomsController` avec champs adaptés (full_name, bio_short, bio_long, specialties, is_principal, user_ID). Logique de slug, validation (full_name requis), soft-deactivate identique.

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Repositories\TeacherRepository;

final class TeachersController extends BaseController
{
    public function index(): void
    {
        $teachers = (new TeacherRepository())->listAll();
        echo view('layouts.admin', [
            'title' => 'Profs', 'currentPath' => '/admin/profs',
            'content' => view('pages.admin.teachers.index', ['teachers' => $teachers]),
        ]);
    }

    public function form(): void
    {
        $id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
        $teacher = $id > 0 ? (new TeacherRepository())->findByID($id) : null;
        if ($id > 0 && !$teacher) { http_response_code(404); echo 'Prof introuvable'; return; }
        echo view('layouts.admin', [
            'title' => $teacher ? 'Modifier ' . $teacher['full_name'] : 'Nouveau prof',
            'currentPath' => '/admin/profs',
            'content' => view('pages.admin.teachers.form', ['teacher' => $teacher]),
        ]);
    }

    public function save(): void
    {
        $id = (int) ($_POST['teacher_ID'] ?? 0);
        $data = [
            'user_ID'       => $_POST['user_ID'] !== '' ? (int) $_POST['user_ID'] : null,
            'full_name'     => trim((string) ($_POST['full_name'] ?? '')),
            'slug'          => $this->slugify((string) ($_POST['slug'] ?? $_POST['full_name'] ?? '')),
            'bio_short'     => trim((string) ($_POST['bio_short'] ?? '')),
            'bio_long'      => trim((string) ($_POST['bio_long'] ?? '')),
            'specialties'   => trim((string) ($_POST['specialties'] ?? '')),
            'photo_url'     => trim((string) ($_POST['photo_url'] ?? '')),
            'active'        => !empty($_POST['active']),
            'is_principal'  => !empty($_POST['is_principal']),
            'display_order' => (int) ($_POST['display_order'] ?? 100),
        ];
        if ($data['full_name'] === '') { flash('error', 'Le nom complet est requis.'); redirect('/admin/profs/nouveau'); }

        $repo = new TeacherRepository();
        if ($id > 0) { $repo->update($id, $data); flash('success', 'Prof mis à jour.'); }
        else { $newID = $repo->create($data); flash('success', "Prof créé (#{$newID})."); }
        redirect('/admin/profs');
    }

    public function deactivate(): void
    {
        $id = (int) ($_POST['teacher_ID'] ?? 0);
        if ($id > 0) { (new TeacherRepository())->deactivate($id); flash('success', 'Prof désactivé.'); }
        redirect('/admin/profs');
    }

    private function slugify(string $s): string
    {
        $s = strtolower($s);
        $s = preg_replace('/[^a-z0-9]+/', '-', iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s);
        return trim((string) $s, '-');
    }
}
```

- [ ] **Step 5.3 : Vues `index.php` et `form.php`**

```php
<!-- views/pages/admin/teachers/index.php -->
<?php /** @var array $teachers */ ?>
<div class="admin-actions-row">
    <a href="/admin/profs/nouveau" class="btn btn-primary">+ Nouveau prof</a>
</div>
<table class="admin-table">
    <thead><tr><th>Nom</th><th>Compte lié</th><th>Spécialités</th><th>Statut</th><th>Actions</th></tr></thead>
    <tbody>
    <?php if (empty($teachers)): ?>
        <tr><td colspan="5">Aucun prof.</td></tr>
    <?php else: foreach ($teachers as $t): ?>
        <tr>
            <td>
                <strong><?= e($t['full_name']) ?></strong>
                <?= $t['is_principal'] ? '<span class="badge badge-success">Principal</span>' : '' ?>
                <br><span class="text-muted"><?= e($t['slug']) ?></span>
            </td>
            <td><?= $t['user_email'] ? e($t['user_email']) : '<span class="text-muted">—</span>' ?></td>
            <td><?= e($t['specialties'] ?? '') ?></td>
            <td><?= $t['active'] ? '<span class="badge badge-success">Actif</span>' : '<span class="badge badge-muted">Inactif</span>' ?></td>
            <td class="actions">
                <a href="/admin/profs/edit?id=<?= (int) $t['teacher_ID'] ?>" class="btn btn-ghost btn-sm">Modifier</a>
                <?php if ($t['active']): ?>
                <form method="POST" action="/admin/profs/deactivate" style="display:inline">
                    <?= csrf_field() ?>
                    <input type="hidden" name="teacher_ID" value="<?= (int) $t['teacher_ID'] ?>">
                    <button type="submit" class="btn btn-danger btn-sm" data-confirm="Désactiver ce prof ?">Désactiver</button>
                </form>
                <?php endif; ?>
            </td>
        </tr>
    <?php endforeach; endif; ?>
    </tbody>
</table>
```

```php
<!-- views/pages/admin/teachers/form.php -->
<?php /** @var ?array $teacher */ $t = $teacher ?? []; ?>
<form method="POST" action="/admin/profs/save" class="admin-form">
    <?= csrf_field() ?>
    <input type="hidden" name="teacher_ID" value="<?= (int) ($t['teacher_ID'] ?? 0) ?>">

    <div class="form-group">
        <label for="full_name">Nom complet *</label>
        <input id="full_name" name="full_name" type="text" required value="<?= e($t['full_name'] ?? '') ?>" data-slugify="#slug">
    </div>
    <div class="form-group">
        <label for="slug">Slug *</label>
        <input id="slug" name="slug" type="text" required value="<?= e($t['slug'] ?? '') ?>" pattern="[a-z0-9\-]+">
    </div>

    <div class="form-group">
        <label for="user_ID">user_ID lié (optionnel — si le prof a un compte sur le site)</label>
        <input id="user_ID" name="user_ID" type="number" min="0" value="<?= e($t['user_ID'] ?? '') ?>" placeholder="ID utilisateur, vide si pas de compte">
        <small class="text-muted">Pour un guest, laisser vide. Pour Aurane : ID utilisateur dans t_users (probablement 1).</small>
    </div>

    <div class="form-group">
        <label for="bio_short">Bio courte (280 car. max)</label>
        <input id="bio_short" name="bio_short" type="text" maxlength="280" value="<?= e($t['bio_short'] ?? '') ?>">
    </div>
    <div class="form-group">
        <label for="bio_long">Bio longue (page profil prof public)</label>
        <textarea id="bio_long" name="bio_long" rows="6"><?= e($t['bio_long'] ?? '') ?></textarea>
    </div>
    <div class="form-group">
        <label for="specialties">Spécialités (texte libre)</label>
        <input id="specialties" name="specialties" type="text" value="<?= e($t['specialties'] ?? '') ?>" placeholder="Yoga, Power Yoga, Ayurveda…">
    </div>
    <div class="form-group">
        <label for="photo_url">URL photo</label>
        <input id="photo_url" name="photo_url" type="url" value="<?= e($t['photo_url'] ?? '') ?>">
    </div>

    <div class="form-row">
        <div class="form-group">
            <label><input type="checkbox" name="active" value="1" <?= !isset($t['active']) || $t['active'] ? 'checked' : '' ?>> Actif</label>
        </div>
        <div class="form-group">
            <label><input type="checkbox" name="is_principal" value="1" <?= !empty($t['is_principal']) ? 'checked' : '' ?>> Prof principal</label>
        </div>
        <div class="form-group">
            <label for="display_order">Ordre</label>
            <input id="display_order" name="display_order" type="number" value="<?= (int) ($t['display_order'] ?? 100) ?>">
        </div>
    </div>

    <div class="form-actions">
        <button type="submit" class="btn btn-primary">Enregistrer</button>
        <a href="/admin/profs" class="btn btn-ghost">Annuler</a>
    </div>
</form>
```

- [ ] **Step 5.4 : Routes profs**

```php
    'GET /admin/profs'              => ['App\Controllers\Admin\TeachersController',  'index',       ['middleware' => 'admin']],
    'GET /admin/profs/nouveau'      => ['App\Controllers\Admin\TeachersController',  'form',        ['middleware' => 'admin']],
    'GET /admin/profs/edit'         => ['App\Controllers\Admin\TeachersController',  'form',        ['middleware' => 'admin']],
    'POST /admin/profs/save'        => ['App\Controllers\Admin\TeachersController',  'save',        ['middleware' => 'admin']],
    'POST /admin/profs/deactivate'  => ['App\Controllers\Admin\TeachersController',  'deactivate',  ['middleware' => 'admin']],
```

- [ ] **Step 5.5 : Smoke test + commit**

Test : créer guest "Marie Dupont", éditer, désactiver. Vérifier qu'Aurane apparaît seed avec is_principal=1.

```powershell
git add src/Repositories/TeacherRepository.php src/Controllers/Admin/TeachersController.php views/pages/admin/teachers/ config/routes.php
git commit -m "feat(P6): CRUD admin Profs (Aurane principale + guests)"
```

---

## Task 6 : CRUD Tarifs & Carnets (`/admin/tarifs`)

**Files:**
- Create: `src/Repositories/PlanRepository.php`
- Create: `src/Controllers/Admin/PlansController.php`
- Create: `views/pages/admin/plans/index.php`
- Create: `views/pages/admin/plans/form.php`
- Modify: `config/routes.php`

Spécificité : `t_subscription_plans` mélange unitaire, carnets (pack), abonnements mensuels/annuels (`type` ENUM). Affichage groupé par type, le formulaire active/désactive les champs selon le type sélectionné.

- [ ] **Step 6.1 : `PlanRepository`**

```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use App\Helpers\Database;
use PDO;

final class PlanRepository
{
    private PDO $pdo;
    public function __construct() { $this->pdo = Database::getInstance()->getConnection(); }

    public function listAll(bool $onlyActive = false): array
    {
        $sql = 'SELECT * FROM t_subscription_plans';
        if ($onlyActive) { $sql .= ' WHERE active = 1'; }
        $sql .= ' ORDER BY type, display_order, name';
        return $this->pdo->query($sql)->fetchAll();
    }

    public function findByID(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM t_subscription_plans WHERE plan_ID = ?');
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }

    public function create(array $d): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_subscription_plans (name, slug, type, sessions_count, validity_days, price_cents, currency, stripe_price_id, description, display_order, active)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $d['name'], $d['slug'], $d['type'],
            $d['sessions_count'], $d['validity_days'],
            $d['price_cents'], $d['currency'] ?: 'EUR',
            $d['stripe_price_id'] ?: null,
            $d['description'] ?: null,
            $d['display_order'], $d['active'] ? 1 : 0,
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, array $d): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE t_subscription_plans
             SET name = ?, slug = ?, type = ?, sessions_count = ?, validity_days = ?, price_cents = ?, currency = ?, stripe_price_id = ?, description = ?, display_order = ?, active = ?
             WHERE plan_ID = ?'
        );
        $stmt->execute([
            $d['name'], $d['slug'], $d['type'],
            $d['sessions_count'], $d['validity_days'],
            $d['price_cents'], $d['currency'] ?: 'EUR',
            $d['stripe_price_id'] ?: null,
            $d['description'] ?: null,
            $d['display_order'], $d['active'] ? 1 : 0,
            $id,
        ]);
    }

    public function deactivate(int $id): void
    {
        $this->pdo->prepare('UPDATE t_subscription_plans SET active = 0 WHERE plan_ID = ?')->execute([$id]);
    }
}
```

- [ ] **Step 6.2 : `PlansController`**

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Repositories\PlanRepository;

final class PlansController extends BaseController
{
    public function index(): void
    {
        $plans = (new PlanRepository())->listAll();
        // Groupe par type pour affichage sections
        $grouped = [];
        foreach ($plans as $p) { $grouped[$p['type']][] = $p; }
        echo view('layouts.admin', [
            'title' => 'Tarifs & carnets', 'currentPath' => '/admin/tarifs',
            'content' => view('pages.admin.plans.index', ['grouped' => $grouped]),
        ]);
    }

    public function form(): void
    {
        $id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
        $plan = $id > 0 ? (new PlanRepository())->findByID($id) : null;
        if ($id > 0 && !$plan) { http_response_code(404); echo 'Tarif introuvable'; return; }
        echo view('layouts.admin', [
            'title' => $plan ? 'Modifier ' . $plan['name'] : 'Nouveau tarif',
            'currentPath' => '/admin/tarifs',
            'content' => view('pages.admin.plans.form', ['plan' => $plan]),
        ]);
    }

    public function save(): void
    {
        $id = (int) ($_POST['plan_ID'] ?? 0);
        $type = (string) ($_POST['type'] ?? 'unit');
        if (!in_array($type, ['unit', 'pack', 'monthly_unlimited', 'annual_unlimited'], true)) {
            flash('error', 'Type de tarif invalide.'); redirect('/admin/tarifs');
        }
        $sessionsCount = $_POST['sessions_count'] !== '' ? (int) $_POST['sessions_count'] : null;
        // Cohérence : unit = 1 séance, monthly/annual unlimited = NULL, pack = N séances
        if ($type === 'unit') { $sessionsCount = 1; }
        if (in_array($type, ['monthly_unlimited', 'annual_unlimited'], true)) { $sessionsCount = null; }
        if ($type === 'pack' && ($sessionsCount === null || $sessionsCount < 2)) {
            flash('error', 'Un carnet doit avoir au moins 2 séances.'); redirect($id > 0 ? "/admin/tarifs/edit?id={$id}" : '/admin/tarifs/nouveau');
        }
        $validityDays = $_POST['validity_days'] !== '' ? (int) $_POST['validity_days'] : null;
        if ($validityDays === null || $validityDays < 1) {
            flash('error', 'Durée de validité requise.'); redirect($id > 0 ? "/admin/tarifs/edit?id={$id}" : '/admin/tarifs/nouveau');
        }
        $priceEuros = (float) str_replace(',', '.', (string) ($_POST['price_euros'] ?? '0'));
        if ($priceEuros < 0) { flash('error', 'Prix négatif refusé.'); redirect('/admin/tarifs'); }

        $data = [
            'name'             => trim((string) ($_POST['name'] ?? '')),
            'slug'             => $this->slugify((string) ($_POST['slug'] ?? $_POST['name'] ?? '')),
            'type'             => $type,
            'sessions_count'   => $sessionsCount,
            'validity_days'    => $validityDays,
            'price_cents'      => (int) round($priceEuros * 100),
            'currency'         => 'EUR',
            'stripe_price_id'  => trim((string) ($_POST['stripe_price_id'] ?? '')),
            'description'      => trim((string) ($_POST['description'] ?? '')),
            'display_order'    => (int) ($_POST['display_order'] ?? 100),
            'active'           => !empty($_POST['active']),
        ];
        if ($data['name'] === '') { flash('error', 'Le nom est requis.'); redirect('/admin/tarifs'); }

        $repo = new PlanRepository();
        if ($id > 0) { $repo->update($id, $data); flash('success', 'Tarif mis à jour.'); }
        else { $newID = $repo->create($data); flash('success', "Tarif créé (#{$newID})."); }
        redirect('/admin/tarifs');
    }

    public function deactivate(): void
    {
        $id = (int) ($_POST['plan_ID'] ?? 0);
        if ($id > 0) { (new PlanRepository())->deactivate($id); flash('success', 'Tarif désactivé.'); }
        redirect('/admin/tarifs');
    }

    private function slugify(string $s): string
    {
        $s = strtolower($s);
        $s = preg_replace('/[^a-z0-9]+/', '-', iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s);
        return trim((string) $s, '-');
    }
}
```

- [ ] **Step 6.3 : Vues `index.php` (groupé par type) et `form.php`**

```php
<!-- views/pages/admin/plans/index.php -->
<?php /** @var array $grouped */
$typeLabels = [
    'unit' => 'Séance unitaire',
    'pack' => 'Carnets',
    'monthly_unlimited' => 'Abonnements mensuels',
    'annual_unlimited' => 'Abonnements annuels',
];
?>
<div class="admin-actions-row">
    <a href="/admin/tarifs/nouveau" class="btn btn-primary">+ Nouveau tarif</a>
</div>

<?php foreach ($typeLabels as $type => $label): ?>
    <h2 class="admin-section-title"><?= e($label) ?></h2>
    <table class="admin-table">
        <thead><tr><th>Nom</th><th>Séances</th><th>Validité</th><th>Prix</th><th>Stripe</th><th>Statut</th><th>Actions</th></tr></thead>
        <tbody>
        <?php $rows = $grouped[$type] ?? []; if (empty($rows)): ?>
            <tr><td colspan="7" class="text-muted">Aucun tarif dans cette catégorie.</td></tr>
        <?php else: foreach ($rows as $p): ?>
            <tr>
                <td><strong><?= e($p['name']) ?></strong><br><span class="text-muted"><?= e($p['slug']) ?></span></td>
                <td><?= $p['sessions_count'] === null ? 'Illimité' : (int) $p['sessions_count'] ?></td>
                <td><?= (int) $p['validity_days'] ?> jours</td>
                <td><strong><?= number_format($p['price_cents'] / 100, 2, ',', ' ') ?> €</strong></td>
                <td><?= $p['stripe_price_id'] ? '<span class="badge badge-success">'.e($p['stripe_price_id']).'</span>' : '<span class="badge badge-warning">non lié</span>' ?></td>
                <td><?= $p['active'] ? '<span class="badge badge-success">Actif</span>' : '<span class="badge badge-muted">Inactif</span>' ?></td>
                <td class="actions">
                    <a href="/admin/tarifs/edit?id=<?= (int) $p['plan_ID'] ?>" class="btn btn-ghost btn-sm">Modifier</a>
                    <?php if ($p['active']): ?>
                    <form method="POST" action="/admin/tarifs/deactivate" style="display:inline">
                        <?= csrf_field() ?>
                        <input type="hidden" name="plan_ID" value="<?= (int) $p['plan_ID'] ?>">
                        <button type="submit" class="btn btn-danger btn-sm" data-confirm="Désactiver ce tarif ?">Désactiver</button>
                    </form>
                    <?php endif; ?>
                </td>
            </tr>
        <?php endforeach; endif; ?>
        </tbody>
    </table>
<?php endforeach; ?>
```

```php
<!-- views/pages/admin/plans/form.php -->
<?php /** @var ?array $plan */ $p = $plan ?? []; ?>
<form method="POST" action="/admin/tarifs/save" class="admin-form">
    <?= csrf_field() ?>
    <input type="hidden" name="plan_ID" value="<?= (int) ($p['plan_ID'] ?? 0) ?>">

    <div class="form-row">
        <div class="form-group">
            <label for="name">Nom *</label>
            <input id="name" name="name" type="text" required value="<?= e($p['name'] ?? '') ?>" data-slugify="#slug">
        </div>
        <div class="form-group">
            <label for="slug">Slug *</label>
            <input id="slug" name="slug" type="text" required value="<?= e($p['slug'] ?? '') ?>">
        </div>
    </div>

    <div class="form-group">
        <label for="type">Type *</label>
        <select id="type" name="type" required>
            <option value="unit"               <?= ($p['type'] ?? '') === 'unit' ? 'selected' : '' ?>>Séance unitaire (1 séance)</option>
            <option value="pack"               <?= ($p['type'] ?? '') === 'pack' ? 'selected' : '' ?>>Carnet (N séances)</option>
            <option value="monthly_unlimited"  <?= ($p['type'] ?? '') === 'monthly_unlimited' ? 'selected' : '' ?>>Abonnement mensuel illimité</option>
            <option value="annual_unlimited"   <?= ($p['type'] ?? '') === 'annual_unlimited' ? 'selected' : '' ?>>Abonnement annuel illimité</option>
        </select>
    </div>

    <div class="form-row">
        <div class="form-group">
            <label for="sessions_count">Nombre de séances (laisser vide pour illimité)</label>
            <input id="sessions_count" name="sessions_count" type="number" min="1" value="<?= e($p['sessions_count'] ?? '') ?>">
        </div>
        <div class="form-group">
            <label for="validity_days">Validité (jours) *</label>
            <input id="validity_days" name="validity_days" type="number" min="1" required value="<?= (int) ($p['validity_days'] ?? 30) ?>">
        </div>
        <div class="form-group">
            <label for="price_euros">Prix (€) *</label>
            <input id="price_euros" name="price_euros" type="text" inputmode="decimal" required
                   value="<?= isset($p['price_cents']) ? number_format($p['price_cents'] / 100, 2, '.', '') : '' ?>">
        </div>
    </div>

    <div class="form-group">
        <label for="stripe_price_id">Stripe Price ID (rempli après création produit Stripe — P5)</label>
        <input id="stripe_price_id" name="stripe_price_id" type="text" value="<?= e($p['stripe_price_id'] ?? '') ?>" placeholder="price_1AbCdEfG...">
    </div>

    <div class="form-group">
        <label for="description">Description (page tarifs)</label>
        <textarea id="description" name="description" rows="3"><?= e($p['description'] ?? '') ?></textarea>
    </div>

    <div class="form-row">
        <div class="form-group">
            <label for="display_order">Ordre</label>
            <input id="display_order" name="display_order" type="number" value="<?= (int) ($p['display_order'] ?? 100) ?>">
        </div>
        <div class="form-group">
            <label><input type="checkbox" name="active" value="1" <?= !isset($p['active']) || $p['active'] ? 'checked' : '' ?>> Actif</label>
        </div>
    </div>

    <div class="form-actions">
        <button type="submit" class="btn btn-primary">Enregistrer</button>
        <a href="/admin/tarifs" class="btn btn-ghost">Annuler</a>
    </div>
</form>
```

- [ ] **Step 6.4 : Routes tarifs**

```php
    'GET /admin/tarifs'             => ['App\Controllers\Admin\PlansController', 'index',       ['middleware' => 'admin']],
    'GET /admin/tarifs/nouveau'     => ['App\Controllers\Admin\PlansController', 'form',        ['middleware' => 'admin']],
    'GET /admin/tarifs/edit'        => ['App\Controllers\Admin\PlansController', 'form',        ['middleware' => 'admin']],
    'POST /admin/tarifs/save'       => ['App\Controllers\Admin\PlansController', 'save',        ['middleware' => 'admin']],
    'POST /admin/tarifs/deactivate' => ['App\Controllers\Admin\PlansController', 'deactivate',  ['middleware' => 'admin']],
```

- [ ] **Step 6.5 : Smoke + commit**

Test : 3 tarifs seed apparaissent (Unitaire, Carnet 10, Abo mensuel). Créer "Carnet 5 séances" à 100€/180 jours. Vérifier que la vitrine `/tarifs` (P2) prend bien en compte le nouveau tarif (s'assurer que la page lit `active = 1`).

```powershell
git add src/Repositories/PlanRepository.php src/Controllers/Admin/PlansController.php views/pages/admin/plans/ config/routes.php
git commit -m "feat(P6): CRUD admin Tarifs et carnets (unitaire/pack/mensuel/annuel)"
```

---

## Task 7 : CRUD Séances unitaires (`/admin/seances`)

**Files:**
- Modify: `src/Repositories/ClassRepository.php` (méthodes admin)
- Create: `src/Controllers/Admin/ClassesController.php`
- Create: `views/pages/admin/classes/index.php`
- Create: `views/pages/admin/classes/form.php`
- Modify: `config/routes.php`

**Pré-requis** : `ClassRepository.php` créé en P4. Si P4 pas livré, créer ici un repo minimal (listAll, findByID, create, update).

- [ ] **Step 7.1 : Extension `ClassRepository` (méthodes admin)**

Ajouter à `ClassRepository.php` :

```php
public function listForAdmin(?string $from = null, ?string $to = null): array
{
    $from = $from ?: date('Y-m-d');
    $to   = $to   ?: date('Y-m-d', strtotime('+60 days'));
    $stmt = $this->pdo->prepare(
        'SELECT c.*, p.name AS practice_name, r.name AS room_name, t.full_name AS teacher_name,
                (c.capacity - c.spots_left) AS booked_count
         FROM t_classes c
         LEFT JOIN t_practices p ON c.practice_ID = p.practice_ID
         LEFT JOIN t_rooms r ON c.room_ID = r.room_ID
         LEFT JOIN t_teachers t ON c.teacher_ID = t.teacher_ID
         WHERE c.class_date BETWEEN ? AND ?
         ORDER BY c.class_date, c.class_time'
    );
    $stmt->execute([$from, $to]);
    return $stmt->fetchAll();
}

public function findAdminByID(int $id): ?array
{
    $stmt = $this->pdo->prepare('SELECT * FROM t_classes WHERE class_ID = ?');
    $stmt->execute([$id]);
    return $stmt->fetch() ?: null;
}

public function createClass(array $d): int
{
    $stmt = $this->pdo->prepare(
        'INSERT INTO t_classes (practice_ID, room_ID, teacher_ID, title, class_date, class_time, duration_min, capacity, spots_left, notes, status)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
    );
    $stmt->execute([
        $d['practice_ID'], $d['room_ID'] ?: null, $d['teacher_ID'] ?: null,
        $d['title'], $d['class_date'], $d['class_time'], $d['duration_min'],
        $d['capacity'], $d['capacity'], // spots_left = capacity à la création
        $d['notes'] ?: null,
        $d['status'] ?? 'scheduled',
    ]);
    return (int) $this->pdo->lastInsertId();
}

public function updateClass(int $id, array $d): void
{
    // Récupérer le booking count pour réajuster spots_left si capacity change
    $current = $this->findAdminByID($id);
    if (!$current) { throw new \RuntimeException('Cours introuvable'); }
    $bookedCount = (int) $current['capacity'] - (int) $current['spots_left'];
    $newSpotsLeft = max(0, (int) $d['capacity'] - $bookedCount);

    $stmt = $this->pdo->prepare(
        'UPDATE t_classes
         SET practice_ID = ?, room_ID = ?, teacher_ID = ?, title = ?, class_date = ?, class_time = ?, duration_min = ?, capacity = ?, spots_left = ?, notes = ?, status = ?
         WHERE class_ID = ?'
    );
    $stmt->execute([
        $d['practice_ID'], $d['room_ID'] ?: null, $d['teacher_ID'] ?: null,
        $d['title'], $d['class_date'], $d['class_time'], $d['duration_min'],
        $d['capacity'], $newSpotsLeft,
        $d['notes'] ?: null, $d['status'],
        $id,
    ]);
}
```

- [ ] **Step 7.2 : `ClassesController`**

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Repositories\ClassRepository;
use App\Repositories\RoomRepository;
use App\Repositories\TeacherRepository;
use App\Helpers\Database;

final class ClassesController extends BaseController
{
    public function index(): void
    {
        $from = $_GET['from'] ?? date('Y-m-d');
        $to   = $_GET['to']   ?? date('Y-m-d', strtotime('+30 days'));
        $classes = (new ClassRepository())->listForAdmin($from, $to);
        echo view('layouts.admin', [
            'title' => 'Séances', 'currentPath' => '/admin/seances',
            'content' => view('pages.admin.classes.index', ['classes' => $classes, 'from' => $from, 'to' => $to]),
        ]);
    }

    public function form(): void
    {
        $id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
        $repo = new ClassRepository();
        $class = $id > 0 ? $repo->findAdminByID($id) : null;
        if ($id > 0 && !$class) { http_response_code(404); echo 'Cours introuvable'; return; }

        $pdo = Database::getInstance()->getConnection();
        $practices = $pdo->query('SELECT practice_ID, name FROM t_practices ORDER BY display_order')->fetchAll();
        $rooms     = (new RoomRepository())->listAll(true);
        $teachers  = (new TeacherRepository())->listAll(true);

        echo view('layouts.admin', [
            'title' => $class ? 'Modifier le cours' : 'Nouvelle séance',
            'currentPath' => '/admin/seances',
            'content' => view('pages.admin.classes.form', [
                'class' => $class, 'practices' => $practices, 'rooms' => $rooms, 'teachers' => $teachers,
            ]),
        ]);
    }

    public function save(): void
    {
        $id = (int) ($_POST['class_ID'] ?? 0);
        $capacity = max(1, (int) ($_POST['capacity'] ?? 12));
        $data = [
            'practice_ID'  => (int) ($_POST['practice_ID'] ?? 0),
            'room_ID'      => (int) ($_POST['room_ID'] ?? 0),
            'teacher_ID'   => (int) ($_POST['teacher_ID'] ?? 0),
            'title'        => trim((string) ($_POST['title'] ?? '')),
            'class_date'   => (string) ($_POST['class_date'] ?? ''),
            'class_time'   => (string) ($_POST['class_time'] ?? ''),
            'duration_min' => max(15, (int) ($_POST['duration_min'] ?? 60)),
            'capacity'     => $capacity,
            'notes'        => trim((string) ($_POST['notes'] ?? '')),
            'status'       => in_array($_POST['status'] ?? 'scheduled', ['scheduled','cancelled','completed'], true) ? $_POST['status'] : 'scheduled',
        ];
        if ($data['practice_ID'] === 0) { flash('error', 'Pratique requise.'); redirect('/admin/seances/nouveau'); }
        if ($data['title'] === '')      { flash('error', 'Titre requis.'); redirect('/admin/seances/nouveau'); }
        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['class_date'])) { flash('error', 'Date invalide.'); redirect('/admin/seances/nouveau'); }
        if (!preg_match('/^\d{2}:\d{2}/', $data['class_time']))         { flash('error', 'Heure invalide.'); redirect('/admin/seances/nouveau'); }
        if (strlen($data['class_time']) === 5) { $data['class_time'] .= ':00'; }

        $repo = new ClassRepository();
        if ($id > 0) { $repo->updateClass($id, $data); flash('success', 'Cours mis à jour.'); }
        else { $newID = $repo->createClass($data); flash('success', "Cours créé (#{$newID})."); }
        redirect('/admin/seances');
    }
}
```

- [ ] **Step 7.3 : Vues `index.php` et `form.php`**

```php
<!-- views/pages/admin/classes/index.php -->
<?php /** @var array $classes */ /** @var string $from */ /** @var string $to */ ?>
<div class="admin-actions-row">
    <a href="/admin/seances/nouveau" class="btn btn-primary">+ Nouvelle séance</a>
    <a href="/admin/recurrence" class="btn btn-secondary">Générer en série</a>
</div>

<form method="GET" action="/admin/seances" class="admin-filter">
    <label>Du <input type="date" name="from" value="<?= e($from) ?>"></label>
    <label>au <input type="date" name="to" value="<?= e($to) ?>"></label>
    <button type="submit" class="btn btn-ghost btn-sm">Filtrer</button>
</form>

<table class="admin-table">
    <thead><tr><th>Date</th><th>Heure</th><th>Pratique</th><th>Titre</th><th>Salle</th><th>Prof</th><th>Réservés</th><th>Statut</th><th>Actions</th></tr></thead>
    <tbody>
    <?php if (empty($classes)): ?>
        <tr><td colspan="9" class="text-muted">Aucun cours sur cette période.</td></tr>
    <?php else: foreach ($classes as $c): ?>
        <tr class="<?= $c['status'] === 'cancelled' ? 'is-cancelled' : '' ?>">
            <td><?= e(date('d/m/Y', strtotime($c['class_date']))) ?></td>
            <td><?= e(substr($c['class_time'], 0, 5)) ?></td>
            <td><?= e($c['practice_name']) ?></td>
            <td><?= e($c['title']) ?></td>
            <td><?= e($c['room_name'] ?? '—') ?></td>
            <td><?= e($c['teacher_name'] ?? '—') ?></td>
            <td><?= (int) $c['booked_count'] ?> / <?= (int) $c['capacity'] ?></td>
            <td>
                <?php $b = ['scheduled' => 'success', 'cancelled' => 'danger', 'completed' => 'muted'][$c['status']] ?? 'muted'; ?>
                <span class="badge badge-<?= $b ?>"><?= e($c['status']) ?></span>
            </td>
            <td class="actions">
                <a href="/admin/seances/edit?id=<?= (int) $c['class_ID'] ?>" class="btn btn-ghost btn-sm">Modifier</a>
                <?php if ($c['status'] === 'scheduled'): ?>
                <form method="POST" action="/admin/seances/cancel" style="display:inline">
                    <?= csrf_field() ?>
                    <input type="hidden" name="class_ID" value="<?= (int) $c['class_ID'] ?>">
                    <button type="submit" class="btn btn-danger btn-sm"
                            data-confirm="Annuler ce cours ? Les <?= (int) $c['booked_count'] ?> personne(s) réservée(s) seront notifiée(s) et leurs séances re-créditées.">Annuler</button>
                </form>
                <?php endif; ?>
            </td>
        </tr>
    <?php endforeach; endif; ?>
    </tbody>
</table>
```

```php
<!-- views/pages/admin/classes/form.php -->
<?php /** @var ?array $class */ /** @var array $practices */ /** @var array $rooms */ /** @var array $teachers */
$c = $class ?? []; ?>
<form method="POST" action="/admin/seances/save" class="admin-form">
    <?= csrf_field() ?>
    <input type="hidden" name="class_ID" value="<?= (int) ($c['class_ID'] ?? 0) ?>">

    <div class="form-row">
        <div class="form-group">
            <label for="practice_ID">Pratique *</label>
            <select id="practice_ID" name="practice_ID" required>
                <option value="">—</option>
                <?php foreach ($practices as $p): ?>
                <option value="<?= (int) $p['practice_ID'] ?>" <?= (int) ($c['practice_ID'] ?? 0) === (int) $p['practice_ID'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
                <?php endforeach; ?>
            </select>
        </div>
        <div class="form-group">
            <label for="room_ID">Salle</label>
            <select id="room_ID" name="room_ID">
                <option value="">—</option>
                <?php foreach ($rooms as $r): ?>
                <option value="<?= (int) $r['room_ID'] ?>" <?= (int) ($c['room_ID'] ?? 0) === (int) $r['room_ID'] ? 'selected' : '' ?>><?= e($r['name']) ?> (cap. <?= (int) $r['capacity'] ?>)</option>
                <?php endforeach; ?>
            </select>
        </div>
        <div class="form-group">
            <label for="teacher_ID">Prof</label>
            <select id="teacher_ID" name="teacher_ID">
                <option value="">—</option>
                <?php foreach ($teachers as $t): ?>
                <option value="<?= (int) $t['teacher_ID'] ?>" <?= (int) ($c['teacher_ID'] ?? 0) === (int) $t['teacher_ID'] ? 'selected' : '' ?>><?= e($t['full_name']) ?></option>
                <?php endforeach; ?>
            </select>
        </div>
    </div>

    <div class="form-group">
        <label for="title">Titre du cours *</label>
        <input id="title" name="title" type="text" required value="<?= e($c['title'] ?? '') ?>" placeholder="Yoga aligné du matin">
    </div>

    <div class="form-row">
        <div class="form-group">
            <label for="class_date">Date *</label>
            <input id="class_date" name="class_date" type="date" required value="<?= e($c['class_date'] ?? '') ?>">
        </div>
        <div class="form-group">
            <label for="class_time">Heure *</label>
            <input id="class_time" name="class_time" type="time" required value="<?= e(substr($c['class_time'] ?? '', 0, 5)) ?>">
        </div>
        <div class="form-group">
            <label for="duration_min">Durée (min) *</label>
            <input id="duration_min" name="duration_min" type="number" min="15" step="5" required value="<?= (int) ($c['duration_min'] ?? 75) ?>">
        </div>
        <div class="form-group">
            <label for="capacity">Capacité *</label>
            <input id="capacity" name="capacity" type="number" min="1" required value="<?= (int) ($c['capacity'] ?? 12) ?>">
        </div>
    </div>

    <div class="form-group">
        <label for="status">Statut</label>
        <select id="status" name="status">
            <option value="scheduled" <?= ($c['status'] ?? 'scheduled') === 'scheduled' ? 'selected' : '' ?>>Programmé</option>
            <option value="completed" <?= ($c['status'] ?? '') === 'completed' ? 'selected' : '' ?>>Passé / terminé</option>
            <option value="cancelled" <?= ($c['status'] ?? '') === 'cancelled' ? 'selected' : '' ?>>Annulé</option>
        </select>
    </div>

    <div class="form-group">
        <label for="notes">Notes internes (non publique)</label>
        <textarea id="notes" name="notes" rows="3"><?= e($c['notes'] ?? '') ?></textarea>
    </div>

    <div class="form-actions">
        <button type="submit" class="btn btn-primary">Enregistrer</button>
        <a href="/admin/seances" class="btn btn-ghost">Annuler</a>
    </div>
</form>
```

- [ ] **Step 7.4 : Routes séances unitaires (sans cancel pour l'instant — Task 8)**

```php
    'GET /admin/seances'           => ['App\Controllers\Admin\ClassesController', 'index', ['middleware' => 'admin']],
    'GET /admin/seances/nouveau'   => ['App\Controllers\Admin\ClassesController', 'form',  ['middleware' => 'admin']],
    'GET /admin/seances/edit'      => ['App\Controllers\Admin\ClassesController', 'form',  ['middleware' => 'admin']],
    'POST /admin/seances/save'     => ['App\Controllers\Admin\ClassesController', 'save',  ['middleware' => 'admin']],
```

- [ ] **Step 7.5 : Smoke + commit**

Test : créer "Yoga aligné lundi 10h", capacité 8, salle principale, prof Aurane. Modifier titre. Vérifier que les cours du seed apparaissent avec leurs noms.

```powershell
git add src/Repositories/ClassRepository.php src/Controllers/Admin/ClassesController.php views/pages/admin/classes/ config/routes.php
git commit -m "feat(P6): CRUD admin Séances unitaires (filtre date, FK salle+prof)"
```

---

## Task 8 : `ClassScheduleService` (récurrence batch + annulation cascade)

**Files:**
- Create: `src/Services/ClassScheduleService.php`
- Modify: `src/Services/BookingService.php` (méthode `cancelByAdmin`)
- Create: `src/Controllers/Admin/ScheduleController.php`
- Create: `views/pages/admin/schedule/template.php`
- Modify: `config/routes.php`

**Récurrence — modèle conceptuel** : un "template hebdomadaire" est un ensemble de slots `[jour_semaine 1-7, heure, duree, pratique_ID, room_ID, teacher_ID, titre, capacite]`. L'utilisateur saisit ses N slots puis choisit une plage de dates (`from`, `to`). Le service projette le template sur la plage et crée les cours en transaction (rollback total si conflit).

- [ ] **Step 8.1 : `ClassScheduleService`**

```php
<?php
declare(strict_types=1);

namespace App\Services;

use App\Helpers\Database;
use App\Repositories\ClassRepository;
use PDO;

/**
 * Génère des cours en batch depuis un template hebdomadaire.
 * Transactionnel : 0 cours créé si un seul conflit (doublon date+heure+room).
 */
final class ClassScheduleService
{
    private PDO $pdo;
    public function __construct() { $this->pdo = Database::getInstance()->getConnection(); }

    /**
     * @param array $slots tableau d'éléments : [
     *   'weekday'      => 1..7 (1=lundi),
     *   'class_time'   => 'HH:MM:SS',
     *   'duration_min' => int,
     *   'practice_ID'  => int,
     *   'room_ID'      => ?int,
     *   'teacher_ID'   => ?int,
     *   'title'        => string,
     *   'capacity'     => int,
     * ]
     * @param string $from date YYYY-MM-DD incluse
     * @param string $to   date YYYY-MM-DD incluse
     * @param bool $skipExisting si true, ignore les conflits (skip silently). Si false, throw.
     * @return array ['created' => int, 'skipped' => int, 'dates' => string[]]
     */
    public function generateFromTemplate(array $slots, string $from, string $to, bool $skipExisting = true): array
    {
        if (empty($slots))                                 { throw new \InvalidArgumentException('Au moins 1 slot requis'); }
        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $from))   { throw new \InvalidArgumentException('Date from invalide'); }
        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $to))     { throw new \InvalidArgumentException('Date to invalide'); }
        if (strtotime($from) > strtotime($to))             { throw new \InvalidArgumentException('from > to'); }

        $created = 0; $skipped = 0; $dates = [];

        $this->pdo->beginTransaction();
        try {
            $repo = new ClassRepository();
            $checkStmt = $this->pdo->prepare(
                'SELECT COUNT(*) FROM t_classes
                 WHERE class_date = ? AND class_time = ? AND COALESCE(room_ID, 0) = ?'
            );

            $current = strtotime($from);
            $end     = strtotime($to);
            while ($current <= $end) {
                $weekday = (int) date('N', $current); // 1=lundi
                $dateStr = date('Y-m-d', $current);
                foreach ($slots as $slot) {
                    if ((int) $slot['weekday'] !== $weekday) { continue; }
                    $time = strlen($slot['class_time']) === 5 ? $slot['class_time'] . ':00' : $slot['class_time'];
                    $checkStmt->execute([$dateStr, $time, (int) ($slot['room_ID'] ?? 0)]);
                    if ((int) $checkStmt->fetchColumn() > 0) {
                        if ($skipExisting) { $skipped++; continue; }
                        throw new \RuntimeException("Conflit : un cours existe déjà le {$dateStr} à {$time}");
                    }
                    $repo->createClass([
                        'practice_ID'  => (int) $slot['practice_ID'],
                        'room_ID'      => $slot['room_ID']    ?: null,
                        'teacher_ID'   => $slot['teacher_ID'] ?: null,
                        'title'        => $slot['title'],
                        'class_date'   => $dateStr,
                        'class_time'   => $time,
                        'duration_min' => (int) $slot['duration_min'],
                        'capacity'     => (int) $slot['capacity'],
                        'notes'        => '[récurrence]',
                        'status'       => 'scheduled',
                    ]);
                    $created++;
                    $dates[] = "{$dateStr} {$time}";
                }
                $current = strtotime('+1 day', $current);
            }
            $this->pdo->commit();
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }

        return ['created' => $created, 'skipped' => $skipped, 'dates' => $dates];
    }
}
```

- [ ] **Step 8.2 : Extension `BookingService::cancelByAdmin`**

Ajouter à `BookingService.php` (créé en P4) :

```php
/**
 * Annule un cours côté admin : marque le cours 'cancelled', annule chaque booking confirmé,
 * recrédite la session sur le forfait du membre, envoie un email à chaque membre.
 *
 * Transactionnel. Si l'envoi email échoue, la transaction reste committée (le mail
 * peut être renvoyé manuellement, mais l'annulation est définitive côté BDD).
 */
public function cancelByAdmin(int $classID, string $reason = ''): array
{
    $pdo = Database::getInstance()->getConnection();
    $pdo->beginTransaction();
    try {
        $stmt = $pdo->prepare('SELECT * FROM t_classes WHERE class_ID = ? FOR UPDATE');
        $stmt->execute([$classID]);
        $class = $stmt->fetch();
        if (!$class)                            { throw new \RuntimeException('class_not_found'); }
        if ($class['status'] === 'cancelled')   { throw new \RuntimeException('already_cancelled'); }

        // Récupérer tous les bookings confirmés
        $stmt = $pdo->prepare(
            'SELECT b.*, u.email, u.full_name FROM t_bookings b
             JOIN t_users u ON b.user_ID = u.user_ID
             WHERE b.class_ID = ? AND b.status = "confirmed"'
        );
        $stmt->execute([$classID]);
        $bookings = $stmt->fetchAll();

        // Annuler chaque booking + recréditer
        foreach ($bookings as $b) {
            $pdo->prepare('UPDATE t_bookings SET status = "cancelled_by_admin", cancelled_at = NOW() WHERE booking_ID = ?')
                ->execute([$b['booking_ID']]);
            if (!empty($b['subscription_ID'])) {
                $pdo->prepare(
                    'UPDATE t_subscriptions SET sessions_left = sessions_left + 1
                     WHERE subscription_ID = ? AND sessions_left IS NOT NULL'
                )->execute([$b['subscription_ID']]);
            }
        }

        // Marquer le cours annulé
        $pdo->prepare('UPDATE t_classes SET status = "cancelled", notes = CONCAT(COALESCE(notes,""), ?, ?) WHERE class_ID = ?')
            ->execute(["\n[ANNULÉ ".date('Y-m-d H:i')."]: ", $reason, $classID]);

        $pdo->commit();
    } catch (\Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }

    // Envoi emails après commit (best effort, ne casse pas la transaction)
    foreach ($bookings as $b) {
        try {
            (new EmailService())->sendClassCancellationByAdmin(
                $b['email'], $b['full_name'], $class, $reason
            );
        } catch (\Throwable $e) {
            error_log("Email annulation échoué pour booking #{$b['booking_ID']}: {$e->getMessage()}");
        }
    }

    return ['cancelled_bookings' => count($bookings)];
}
```

Note : la colonne `cancelled_at` sur `t_bookings` doit exister (P4 plan en parle implicitement). Si elle n'existe pas, ajouter migration 018 idempotente :
```sql
SET @col := (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='t_bookings' AND column_name='cancelled_at');
SET @sql := IF(@col=0, 'ALTER TABLE t_bookings ADD COLUMN cancelled_at TIMESTAMP NULL AFTER status', 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
```

- [ ] **Step 8.3 : `ScheduleController`**

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Services\ClassScheduleService;
use App\Services\BookingService;
use App\Repositories\RoomRepository;
use App\Repositories\TeacherRepository;
use App\Helpers\Database;

final class ScheduleController extends BaseController
{
    public function template(): void
    {
        $pdo = Database::getInstance()->getConnection();
        $practices = $pdo->query('SELECT practice_ID, name FROM t_practices ORDER BY display_order')->fetchAll();
        $rooms     = (new RoomRepository())->listAll(true);
        $teachers  = (new TeacherRepository())->listAll(true);

        echo view('layouts.admin', [
            'title' => 'Récurrence — Semaine type', 'currentPath' => '/admin/recurrence',
            'content' => view('pages.admin.schedule.template', [
                'practices' => $practices, 'rooms' => $rooms, 'teachers' => $teachers,
            ]),
        ]);
    }

    public function generate(): void
    {
        $from = (string) ($_POST['from'] ?? '');
        $to   = (string) ($_POST['to'] ?? '');
        $slots = $_POST['slots'] ?? [];
        if (!is_array($slots) || empty($slots)) {
            flash('error', 'Aucun slot saisi.'); redirect('/admin/recurrence');
        }
        $cleanSlots = [];
        foreach ($slots as $s) {
            if (!is_array($s)) continue;
            $weekday = (int) ($s['weekday'] ?? 0);
            $time    = (string) ($s['class_time'] ?? '');
            $practice = (int) ($s['practice_ID'] ?? 0);
            if ($weekday < 1 || $weekday > 7 || $time === '' || $practice === 0) { continue; }
            $cleanSlots[] = [
                'weekday'      => $weekday,
                'class_time'   => $time,
                'duration_min' => max(15, (int) ($s['duration_min'] ?? 75)),
                'practice_ID'  => $practice,
                'room_ID'      => (int) ($s['room_ID'] ?? 0) ?: null,
                'teacher_ID'   => (int) ($s['teacher_ID'] ?? 0) ?: null,
                'title'        => trim((string) ($s['title'] ?? '')) ?: 'Cours',
                'capacity'     => max(1, (int) ($s['capacity'] ?? 12)),
            ];
        }
        if (empty($cleanSlots)) { flash('error', 'Slots invalides.'); redirect('/admin/recurrence'); }

        try {
            $result = (new ClassScheduleService())->generateFromTemplate($cleanSlots, $from, $to, true);
            flash('success', "Génération OK : {$result['created']} cours créés, {$result['skipped']} doublons ignorés.");
        } catch (\Throwable $e) {
            flash('error', 'Échec : ' . $e->getMessage());
        }
        redirect('/admin/seances?from=' . urlencode($from) . '&to=' . urlencode($to));
    }

    public function cancelClass(): void
    {
        $id = (int) ($_POST['class_ID'] ?? 0);
        $reason = trim((string) ($_POST['reason'] ?? 'Annulation par le studio'));
        if ($id === 0) { flash('error', 'ID manquant.'); redirect('/admin/seances'); }
        try {
            $res = (new BookingService())->cancelByAdmin($id, $reason);
            flash('success', "Cours annulé. {$res['cancelled_bookings']} membre(s) recrédité(s) et notifié(s).");
        } catch (\Throwable $e) {
            flash('error', 'Échec annulation : ' . $e->getMessage());
        }
        redirect('/admin/seances');
    }
}
```

- [ ] **Step 8.4 : Vue `template.php` (saisie semaine type)**

```php
<!-- views/pages/admin/schedule/template.php -->
<?php /** @var array $practices */ /** @var array $rooms */ /** @var array $teachers */
$weekdays = [1=>'Lundi',2=>'Mardi',3=>'Mercredi',4=>'Jeudi',5=>'Vendredi',6=>'Samedi',7=>'Dimanche']; ?>

<p class="lead">Saisis ta semaine type, choisis une plage de dates, le système crée tous les cours en série.</p>

<form method="POST" action="/admin/recurrence/generate" class="admin-form" id="schedule-form">
    <?= csrf_field() ?>

    <div class="form-row">
        <div class="form-group">
            <label for="from">Du *</label>
            <input id="from" name="from" type="date" required value="<?= e(date('Y-m-d')) ?>">
        </div>
        <div class="form-group">
            <label for="to">Au *</label>
            <input id="to" name="to" type="date" required value="<?= e(date('Y-m-d', strtotime('+30 days'))) ?>">
        </div>
    </div>

    <h2>Slots hebdomadaires</h2>
    <table class="admin-table" id="slots-table">
        <thead><tr><th>Jour</th><th>Heure</th><th>Durée</th><th>Pratique</th><th>Salle</th><th>Prof</th><th>Titre</th><th>Capa.</th><th></th></tr></thead>
        <tbody>
        <tr class="slot-row">
            <td>
                <select name="slots[0][weekday]">
                    <?php foreach ($weekdays as $n => $label): ?>
                    <option value="<?= $n ?>"><?= e($label) ?></option>
                    <?php endforeach; ?>
                </select>
            </td>
            <td><input name="slots[0][class_time]" type="time" value="10:00" required></td>
            <td><input name="slots[0][duration_min]" type="number" min="15" step="5" value="75" style="width:80px"></td>
            <td>
                <select name="slots[0][practice_ID]" required>
                    <option value="">—</option>
                    <?php foreach ($practices as $p): ?>
                    <option value="<?= (int) $p['practice_ID'] ?>"><?= e($p['name']) ?></option>
                    <?php endforeach; ?>
                </select>
            </td>
            <td>
                <select name="slots[0][room_ID]">
                    <option value="">—</option>
                    <?php foreach ($rooms as $r): ?>
                    <option value="<?= (int) $r['room_ID'] ?>"><?= e($r['name']) ?></option>
                    <?php endforeach; ?>
                </select>
            </td>
            <td>
                <select name="slots[0][teacher_ID]">
                    <option value="">—</option>
                    <?php foreach ($teachers as $t): ?>
                    <option value="<?= (int) $t['teacher_ID'] ?>"><?= e($t['full_name']) ?></option>
                    <?php endforeach; ?>
                </select>
            </td>
            <td><input name="slots[0][title]" type="text" value="Yoga" required></td>
            <td><input name="slots[0][capacity]" type="number" min="1" value="12" style="width:70px"></td>
            <td><button type="button" class="btn btn-danger btn-sm slot-remove">×</button></td>
        </tr>
        </tbody>
    </table>
    <button type="button" id="add-slot" class="btn btn-secondary">+ Ajouter un slot</button>

    <div class="form-actions">
        <button type="submit" class="btn btn-primary">Générer les cours</button>
        <a href="/admin/seances" class="btn btn-ghost">Annuler</a>
    </div>
</form>

<script>
(function() {
    let slotIdx = 1;
    const tbody = document.querySelector('#slots-table tbody');
    const tpl = tbody.querySelector('.slot-row');
    document.getElementById('add-slot').addEventListener('click', () => {
        const clone = tpl.cloneNode(true);
        clone.querySelectorAll('[name]').forEach(el => {
            el.name = el.name.replace(/slots\[\d+\]/, `slots[${slotIdx}]`);
        });
        tbody.appendChild(clone);
        slotIdx++;
    });
    tbody.addEventListener('click', (e) => {
        if (e.target.closest('.slot-remove')) {
            const rows = tbody.querySelectorAll('.slot-row');
            if (rows.length > 1) { e.target.closest('.slot-row').remove(); }
        }
    });
})();
</script>
```

- [ ] **Step 8.5 : Routes récurrence + annulation cours**

```php
    'GET /admin/recurrence'           => ['App\Controllers\Admin\ScheduleController', 'template',    ['middleware' => 'admin']],
    'POST /admin/recurrence/generate' => ['App\Controllers\Admin\ScheduleController', 'generate',    ['middleware' => 'admin']],
    'POST /admin/seances/cancel'      => ['App\Controllers\Admin\ScheduleController', 'cancelClass', ['middleware' => 'admin']],
```

- [ ] **Step 8.6 : Smoke test**

1. `/admin/recurrence` → ajouter 3 slots (Yoga lundi 10h, Yoga mercredi 18h30, Power Yoga vendredi 19h30), plage 30 jours
2. Soumettre → message success "X cours créés"
3. Vérifier `/admin/seances` : ~13 cours sur 30 jours (3 slots × ~4 semaines)
4. Annuler un cours qui a déjà des réservations (créer une résa de test via P4) → vérifier que la résa est marquée cancelled_by_admin et que la session est recréditée

- [ ] **Step 8.7 : Commit**

```powershell
git add src/Services/ClassScheduleService.php src/Services/BookingService.php src/Controllers/Admin/ScheduleController.php views/pages/admin/schedule/ config/routes.php database/migrations/018_*.sql
git commit -m "feat(P6): récurrence hebdo (batch transactionnel) + annulation cours (cascade resa + recréd. session + email)"
```

---

## Task 9 : Tests `ClassScheduleService`

**Files:**
- Create: `tests/Feature/ClassScheduleServiceTest.php`

- [ ] **Step 9.1 : Test de base — génération 1 slot sur 7 jours**

```php
<?php
declare(strict_types=1);

namespace Tests\Feature;

use App\Services\ClassScheduleService;
use App\Helpers\Database;
use PHPUnit\Framework\TestCase;

final class ClassScheduleServiceTest extends TestCase
{
    private \PDO $pdo;
    private int $practiceID;
    private int $roomID;

    protected function setUp(): void
    {
        $this->pdo = Database::getInstance()->getConnection();
        // Setup fixtures minimum
        $this->pdo->exec('DELETE FROM t_classes WHERE notes = "[récurrence]"');
        $this->practiceID = (int) $this->pdo->query("SELECT practice_ID FROM t_practices LIMIT 1")->fetchColumn();
        $this->roomID = (int) $this->pdo->query("SELECT room_ID FROM t_rooms LIMIT 1")->fetchColumn();
    }

    public function test_generates_one_class_per_matching_weekday(): void
    {
        $slots = [[
            'weekday' => 1, 'class_time' => '10:00:00', 'duration_min' => 75,
            'practice_ID' => $this->practiceID, 'room_ID' => $this->roomID, 'teacher_ID' => null,
            'title' => 'Yoga test récurrence', 'capacity' => 10,
        ]];
        $from = date('Y-m-d', strtotime('next monday'));
        $to   = date('Y-m-d', strtotime($from . ' +28 days')); // 4 lundis

        $res = (new ClassScheduleService())->generateFromTemplate($slots, $from, $to, true);

        $this->assertEquals(5, $res['created']); // lundi inclusif from + 4 suivants
        $this->assertEquals(0, $res['skipped']);
    }

    public function test_skips_existing_when_skipExisting_true(): void
    {
        $slot = [[
            'weekday' => 1, 'class_time' => '10:00:00', 'duration_min' => 75,
            'practice_ID' => $this->practiceID, 'room_ID' => $this->roomID, 'teacher_ID' => null,
            'title' => 'Yoga test dup', 'capacity' => 10,
        ]];
        $from = date('Y-m-d', strtotime('next monday'));
        $to   = date('Y-m-d', strtotime($from . ' +7 days'));

        $svc = new ClassScheduleService();
        $svc->generateFromTemplate($slot, $from, $to, true);
        $res2 = $svc->generateFromTemplate($slot, $from, $to, true);

        $this->assertEquals(0, $res2['created']);
        $this->assertGreaterThan(0, $res2['skipped']);
    }

    public function test_rolls_back_on_conflict_when_skipExisting_false(): void
    {
        $slot = [[
            'weekday' => 1, 'class_time' => '10:00:00', 'duration_min' => 75,
            'practice_ID' => $this->practiceID, 'room_ID' => $this->roomID, 'teacher_ID' => null,
            'title' => 'Yoga test rollback', 'capacity' => 10,
        ]];
        $from = date('Y-m-d', strtotime('next monday'));
        $to   = date('Y-m-d', strtotime($from . ' +14 days'));

        $svc = new ClassScheduleService();
        $svc->generateFromTemplate($slot, $from, $to, true);

        $countBefore = (int) $this->pdo->query("SELECT COUNT(*) FROM t_classes WHERE title = 'Yoga test rollback'")->fetchColumn();
        $this->expectException(\RuntimeException::class);
        try {
            $svc->generateFromTemplate($slot, $from, $to, false);
        } finally {
            $countAfter = (int) $this->pdo->query("SELECT COUNT(*) FROM t_classes WHERE title = 'Yoga test rollback'")->fetchColumn();
            $this->assertEquals($countBefore, $countAfter, 'Rollback doit empêcher tout INSERT');
        }
    }

    public function test_throws_when_from_after_to(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        (new ClassScheduleService())->generateFromTemplate(
            [['weekday'=>1,'class_time'=>'10:00','duration_min'=>60,'practice_ID'=>$this->practiceID,'room_ID'=>$this->roomID,'teacher_ID'=>null,'title'=>'x','capacity'=>10]],
            '2026-12-31', '2026-01-01', true
        );
    }
}
```

- [ ] **Step 9.2 : Lancer la suite**

```powershell
vendor/bin/phpunit --testsuite Feature --filter ClassScheduleService
```
Expected : 4 tests verts. Si Aurane est sur BDD aldanadb (pas aldanadb_test) attention aux fixtures résiduelles — adapter `setUp()` pour cleanup.

- [ ] **Step 9.3 : Commit**

```powershell
git add tests/Feature/ClassScheduleServiceTest.php
git commit -m "test(P6): ClassScheduleService — génération, dédoublonnage, rollback, validation dates"
```

---

## Task 10 : CRUD Évènements (`/admin/evenements`)

**Files:**
- Create: `src/Repositories/EventRepository.php`
- Create: `src/Controllers/Admin/EventsController.php`
- Create: `views/pages/admin/events/index.php`
- Create: `views/pages/admin/events/form.php`
- Modify: `config/routes.php`

Pattern : identique à Salles/Profs, mais avec workflow `draft / published / sold_out / completed / cancelled` et FK teacher.

- [ ] **Step 10.1 : `EventRepository`**

```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use App\Helpers\Database;
use PDO;

final class EventRepository
{
    private PDO $pdo;
    public function __construct() { $this->pdo = Database::getInstance()->getConnection(); }

    public function listAll(): array
    {
        return $this->pdo->query(
            'SELECT e.*, t.full_name AS teacher_name FROM t_events e
             LEFT JOIN t_teachers t ON e.teacher_ID = t.teacher_ID
             ORDER BY e.date_start DESC'
        )->fetchAll();
    }

    public function findByID(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM t_events WHERE event_ID = ?');
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }

    public function create(array $d): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_events (title, slug, subtitle, teacher_ID, date_start, date_end, location, description, price_cents, currency, stripe_price_id, capacity, spots_left, image_url, status)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $d['title'], $d['slug'], $d['subtitle'] ?: null, $d['teacher_ID'] ?: null,
            $d['date_start'], $d['date_end'],
            $d['location'] ?: null, $d['description'] ?: null,
            $d['price_cents'], $d['currency'] ?: 'EUR',
            $d['stripe_price_id'] ?: null,
            $d['capacity'], $d['capacity'], // spots_left = capacity init
            $d['image_url'] ?: null,
            $d['status'],
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, array $d): void
    {
        $current = $this->findByID($id);
        if (!$current) { throw new \RuntimeException('Évènement introuvable'); }
        $booked = (int) $current['capacity'] - (int) $current['spots_left'];
        $newSpotsLeft = max(0, (int) $d['capacity'] - $booked);

        $stmt = $this->pdo->prepare(
            'UPDATE t_events
             SET title = ?, slug = ?, subtitle = ?, teacher_ID = ?, date_start = ?, date_end = ?, location = ?, description = ?, price_cents = ?, currency = ?, stripe_price_id = ?, capacity = ?, spots_left = ?, image_url = ?, status = ?
             WHERE event_ID = ?'
        );
        $stmt->execute([
            $d['title'], $d['slug'], $d['subtitle'] ?: null, $d['teacher_ID'] ?: null,
            $d['date_start'], $d['date_end'],
            $d['location'] ?: null, $d['description'] ?: null,
            $d['price_cents'], $d['currency'] ?: 'EUR',
            $d['stripe_price_id'] ?: null,
            $d['capacity'], $newSpotsLeft,
            $d['image_url'] ?: null,
            $d['status'],
            $id,
        ]);
    }
}
```

- [ ] **Step 10.2 : `EventsController` + vues**

Controller similaire à `RoomsController` (form/save) avec champs adaptés. Vue index : tableau avec colonne statut (badge couleur par statut), tri par date_start DESC. Vue form : grand formulaire avec : title, slug, subtitle, teacher_ID select, date_start datetime-local, date_end datetime-local, location, description textarea, price_euros (converti en cents), capacity, image_url, stripe_price_id, status select.

Voir Task 4 (Rooms) pour le pattern complet de Controller + vues — adapter en remplaçant les champs.

- [ ] **Step 10.3 : Routes évènements**

```php
    'GET /admin/evenements'         => ['App\Controllers\Admin\EventsController', 'index',  ['middleware' => 'admin']],
    'GET /admin/evenements/nouveau' => ['App\Controllers\Admin\EventsController', 'form',   ['middleware' => 'admin']],
    'GET /admin/evenements/edit'    => ['App\Controllers\Admin\EventsController', 'form',   ['middleware' => 'admin']],
    'POST /admin/evenements/save'   => ['App\Controllers\Admin\EventsController', 'save',   ['middleware' => 'admin']],
```

- [ ] **Step 10.4 : Smoke + commit**

Test : créer "Week-end Bien-être Versailles", workflow draft → published → modifier capacité.

```powershell
git add src/Repositories/EventRepository.php src/Controllers/Admin/EventsController.php views/pages/admin/events/ config/routes.php
git commit -m "feat(P6): CRUD admin Évènements (workflow draft/published/sold_out)"
```

---

## Task 11 : Vue admin Réservations (`/admin/reservations`)

**Files:**
- Create: `src/Controllers/Admin/BookingsController.php`
- Create: `views/pages/admin/bookings/index.php`
- Modify: `config/routes.php`

**Scope** : listing read-only des bookings (filtres date + statut + user) + bouton "créditer 1 séance manuellement" sur un forfait.

- [ ] **Step 11.1 : `BookingsController`**

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Helpers\Database;

final class BookingsController extends BaseController
{
    public function index(): void
    {
        $pdo = Database::getInstance()->getConnection();
        $from   = $_GET['from']   ?? date('Y-m-d', strtotime('-7 days'));
        $to     = $_GET['to']     ?? date('Y-m-d', strtotime('+30 days'));
        $status = $_GET['status'] ?? '';

        $sql = 'SELECT b.*, u.email, u.full_name, c.class_date, c.class_time, c.title AS class_title
                FROM t_bookings b
                JOIN t_users u ON b.user_ID = u.user_ID
                JOIN t_classes c ON b.class_ID = c.class_ID
                WHERE c.class_date BETWEEN ? AND ?';
        $params = [$from, $to];
        if ($status !== '') { $sql .= ' AND b.status = ?'; $params[] = $status; }
        $sql .= ' ORDER BY c.class_date DESC, c.class_time DESC';
        $stmt = $pdo->prepare($sql);
        $stmt->execute($params);
        $bookings = $stmt->fetchAll();

        echo view('layouts.admin', [
            'title' => 'Réservations', 'currentPath' => '/admin/reservations',
            'content' => view('pages.admin.bookings.index', [
                'bookings' => $bookings, 'from' => $from, 'to' => $to, 'status' => $status,
            ]),
        ]);
    }

    public function creditSession(): void
    {
        $pdo = Database::getInstance()->getConnection();
        $userID = (int) ($_POST['user_ID'] ?? 0);
        $reason = trim((string) ($_POST['reason'] ?? ''));
        if ($userID === 0 || $reason === '') {
            flash('error', 'user_ID et motif requis.'); redirect('/admin/reservations');
        }
        // Crédite la session sur le forfait actif le plus ancien
        $stmt = $pdo->prepare(
            "SELECT subscription_ID FROM t_subscriptions
             WHERE user_ID = ? AND status = 'active' AND valid_until >= CURDATE() AND sessions_left IS NOT NULL
             ORDER BY valid_until ASC LIMIT 1"
        );
        $stmt->execute([$userID]);
        $subID = (int) ($stmt->fetchColumn() ?: 0);
        if ($subID === 0) {
            flash('error', "Aucun forfait actif avec compteur de séances pour l'user #{$userID}.");
            redirect('/admin/reservations');
        }
        $pdo->prepare('UPDATE t_subscriptions SET sessions_left = sessions_left + 1 WHERE subscription_ID = ?')->execute([$subID]);
        // Log audit
        error_log("[admin-audit] " . date('c') . " admin#{$_SESSION['user_ID']} crédité 1 séance user#{$userID} sub#{$subID} motif: {$reason}");
        flash('success', "1 séance créditée sur le forfait #{$subID}.");
        redirect('/admin/reservations');
    }
}
```

- [ ] **Step 11.2 : Vue listing**

```php
<!-- views/pages/admin/bookings/index.php -->
<?php /** @var array $bookings */ /** @var string $from */ /** @var string $to */ /** @var string $status */ ?>

<form method="GET" action="/admin/reservations" class="admin-filter">
    <label>Du <input type="date" name="from" value="<?= e($from) ?>"></label>
    <label>au <input type="date" name="to" value="<?= e($to) ?>"></label>
    <select name="status">
        <option value="">Tous statuts</option>
        <option value="confirmed"            <?= $status === 'confirmed' ? 'selected' : '' ?>>confirmed</option>
        <option value="cancelled_by_user"    <?= $status === 'cancelled_by_user' ? 'selected' : '' ?>>cancelled_by_user</option>
        <option value="cancelled_by_admin"   <?= $status === 'cancelled_by_admin' ? 'selected' : '' ?>>cancelled_by_admin</option>
    </select>
    <button type="submit" class="btn btn-ghost btn-sm">Filtrer</button>
</form>

<table class="admin-table">
    <thead><tr><th>Date cours</th><th>Cours</th><th>Membre</th><th>Statut</th><th>Réservé le</th><th>Action</th></tr></thead>
    <tbody>
    <?php if (empty($bookings)): ?>
        <tr><td colspan="6" class="text-muted">Aucune réservation.</td></tr>
    <?php else: foreach ($bookings as $b): ?>
        <tr>
            <td><?= e(date('d/m/Y', strtotime($b['class_date']))) ?> <?= e(substr($b['class_time'], 0, 5)) ?></td>
            <td><?= e($b['class_title']) ?></td>
            <td><?= e($b['full_name']) ?><br><span class="text-muted"><?= e($b['email']) ?></span></td>
            <td><span class="badge badge-<?= $b['status'] === 'confirmed' ? 'success' : 'muted' ?>"><?= e($b['status']) ?></span></td>
            <td><?= e(date('d/m H:i', strtotime($b['created_at']))) ?></td>
            <td>
                <form method="POST" action="/admin/reservations/credit-session" style="display:inline">
                    <?= csrf_field() ?>
                    <input type="hidden" name="user_ID" value="<?= (int) $b['user_ID'] ?>">
                    <input type="text" name="reason" placeholder="Motif" required style="width:140px">
                    <button type="submit" class="btn btn-secondary btn-sm" data-confirm="Créditer 1 séance manuelle ?">+1 séance</button>
                </form>
            </td>
        </tr>
    <?php endforeach; endif; ?>
    </tbody>
</table>
```

- [ ] **Step 11.3 : Routes**

```php
    'GET /admin/reservations'                 => ['App\Controllers\Admin\BookingsController', 'index',         ['middleware' => 'admin']],
    'POST /admin/reservations/credit-session' => ['App\Controllers\Admin\BookingsController', 'creditSession', ['middleware' => 'admin']],
```

- [ ] **Step 11.4 : Smoke + commit**

```powershell
git add src/Controllers/Admin/BookingsController.php views/pages/admin/bookings/ config/routes.php
git commit -m "feat(P6): admin Réservations (listing filtré + crédit séance manuel)"
```

---

## Task 12 : CMS Site Settings (`/admin/settings`)

**Files:**
- Create: `src/Controllers/Admin/SettingsController.php`
- Create: `views/pages/admin/settings/index.php`
- Modify: `config/routes.php`

**Scope** : édition des paires clé/valeur de `t_site_settings` (texte hero, adresse, téléphone, etc.). Un formulaire unique avec tous les settings en lignes éditables.

- [ ] **Step 12.1 : `SettingsController`**

```php
<?php
declare(strict_types=1);

namespace App\Controllers\Admin;

use App\Controllers\BaseController;
use App\Helpers\Database;

final class SettingsController extends BaseController
{
    public function index(): void
    {
        $pdo = Database::getInstance()->getConnection();
        $settings = $pdo->query('SELECT * FROM t_site_settings ORDER BY setting_key')->fetchAll();
        echo view('layouts.admin', [
            'title' => 'Site & contenu', 'currentPath' => '/admin/settings',
            'content' => view('pages.admin.settings.index', ['settings' => $settings]),
        ]);
    }

    public function save(): void
    {
        $pdo = Database::getInstance()->getConnection();
        $values = $_POST['setting'] ?? [];
        if (!is_array($values)) { flash('error', 'Payload invalide.'); redirect('/admin/settings'); }
        $count = 0;
        foreach ($values as $key => $val) {
            $key = preg_replace('/[^a-z0-9_]/', '', (string) $key);
            if ($key === '') { continue; }
            $stmt = $pdo->prepare('UPDATE t_site_settings SET setting_value = ? WHERE setting_key = ?');
            $stmt->execute([(string) $val, $key]);
            $count += $stmt->rowCount();
        }
        flash('success', "{$count} paramètre(s) mis à jour.");
        redirect('/admin/settings');
    }
}
```

- [ ] **Step 12.2 : Vue**

```php
<!-- views/pages/admin/settings/index.php -->
<?php /** @var array $settings */ ?>
<form method="POST" action="/admin/settings/save" class="admin-form">
    <?= csrf_field() ?>
    <p class="text-muted">Édite les textes du site (titre accueil, adresse, contact, signature…). Les champs HTML acceptent du balisage simple.</p>

    <?php foreach ($settings as $s): ?>
        <div class="form-group">
            <label for="s-<?= e($s['setting_key']) ?>">
                <strong><?= e($s['setting_key']) ?></strong>
                <span class="text-muted"> — <?= e($s['description'] ?? '') ?></span>
            </label>
            <?php if ($s['setting_type'] === 'html' || strlen((string) $s['setting_value']) > 100): ?>
                <textarea id="s-<?= e($s['setting_key']) ?>" name="setting[<?= e($s['setting_key']) ?>]" rows="4"><?= e($s['setting_value']) ?></textarea>
            <?php else: ?>
                <input id="s-<?= e($s['setting_key']) ?>" name="setting[<?= e($s['setting_key']) ?>]" type="text" value="<?= e($s['setting_value']) ?>">
            <?php endif; ?>
        </div>
    <?php endforeach; ?>

    <div class="form-actions">
        <button type="submit" class="btn btn-primary">Enregistrer</button>
    </div>
</form>
```

- [ ] **Step 12.3 : Routes**

```php
    'GET /admin/settings'       => ['App\Controllers\Admin\SettingsController', 'index', ['middleware' => 'admin']],
    'POST /admin/settings/save' => ['App\Controllers\Admin\SettingsController', 'save',  ['middleware' => 'admin']],
```

- [ ] **Step 12.4 : Smoke + commit**

Test : éditer `home_hero_title` "Le silence en mouvement" → "Nouvelle citation". Recharger page `/` → vérifier propagation. Restaurer.

```powershell
git add src/Controllers/Admin/SettingsController.php views/pages/admin/settings/ config/routes.php
git commit -m "feat(P6): CMS site settings (édition titres, adresse, signature)"
```

---

## Task 13 : Vérifs finales + recette utilisateur

- [ ] **Step 13.1 : Checklist sécurité admin**

- [ ] Toutes les routes `/admin/*` ont `'middleware' => 'admin'`
- [ ] Tous les POST ont `<?= csrf_field() ?>`
- [ ] Aucun SQL avec interpolation directe (uniquement prepared statements)
- [ ] `htmlspecialchars($var ?? '', ENT_QUOTES, 'UTF-8')` via `e()` dans toutes les vues
- [ ] Pas de role_ID hardcodé hors `t_users.role_ID IN (10,11,29)` (cf CLAUDE.md)

- [ ] **Step 13.2 : Recette Aurane — parcours utilisateur**

Scénario complet à dérouler en session :
1. Login Aurane → `/admin` (redirect ou bouton "Admin" dans header espace membre)
2. `/admin/recurrence` → saisir 4 cours hebdo type, plage 60 jours → succès ~30+ cours
3. `/admin/seances` → vérifier le listing affiche la nouvelle série
4. Modifier 1 cours individuel (changer titre, capacité)
5. Annuler 1 cours qui a une réservation test → vérifier email reçu + session recréditée
6. `/admin/tarifs` → désactiver Carnet 10, créer Carnet 5 à 100€
7. `/admin/profs/nouveau` → ajouter "Marie Dupont" guest
8. `/admin/salles` → ajouter "Salle annexe" capacité 6
9. `/admin/evenements/nouveau` → créer "Retraite Bourgogne 2026", status draft
10. `/admin/settings` → éditer `contact_phone` → recharger `/contact` vitrine, vérifier
11. Login en non-admin (créer un compte client) → `/admin` → 403 attendu

- [ ] **Step 13.3 : Ajouter lien "Admin" dans le menu espace membre (si Aurane)**

Modifier `views/partials/account/sidebar.php` (ou équivalent) pour afficher un lien `/admin` si `$_SESSION['user_role'] === 29`. (Optionnel mais ergonomique.)

- [ ] **Step 13.4 : Mémoire — créer fiche project**

Créer `C:\Users\rgalo\.claude\projects\c--Users-rgalo-Dropbox--ALDANA\memory\project_admin_p6.md` :

```markdown
---
name: project-admin-p6
description: Backoffice admin ALDANA — modules, conventions URL, scope hors-périmètre
metadata:
  type: project
---

P6 livré le YYYY-MM-DD. Backoffice admin accessible via /admin pour role_ID = 29.

**Modules CRUD livrés :**
- /admin/salles · /admin/profs · /admin/tarifs · /admin/seances · /admin/recurrence (template hebdo) · /admin/evenements · /admin/reservations · /admin/settings

**Conventions :**
- Soft-deactivate partout (UPDATE active=0), pas de DELETE
- Slug auto-généré via data-slugify (JS) au saisie du nom
- Photos en URL externe (pas d'upload — différé P7 médias)
- Stripe Price ID = texte libre rempli manuellement (auto-sync = P5)

**Hors scope (à venir) :**
- Upload photos (P7)
- Audit log structuré (P8 — pour l'instant error_log)
- Multi-lieux / multi-sites (non prévu)

Why: Aurane doit alimenter sans phpMyAdmin. How to apply: tout ajout de table métier nécessite un module admin associé (salles/profs/tarifs/etc.) dans le même style.
```

- [ ] **Step 13.5 : Commit final + tag**

```powershell
git add C:\Users\rgalo\.claude\projects\c--Users-rgalo-Dropbox--ALDANA\memory\project_admin_p6.md
git commit -m "docs(P6): mémoire — backoffice admin livré (9 modules, soft-deactivate, hors scope P7/P8)"
git tag -a v0.6.0-admin -m "P6 admin backoffice"
```

---

## Self-Review checklist

- [ ] **Spec coverage** : séances ✓, salles ✓, profs ✓, tarifs/carnets ✓ (1 seul module), évènements ✓. **Bonus** : récurrence batch ✓, annulation cascade ✓, bookings listing ✓, settings ✓.
- [ ] **Placeholders** : aucun "TBD"/"similar to". Le code Controllers/vues est répété explicitement même quand le pattern est identique (Rooms vs Teachers vs Plans).
- [ ] **Types & cohérence** : noms de méthodes consistants (`create`/`update`/`deactivate`/`findByID`). Nom des routes consistant (`save`/`form`/`deactivate`).
- [ ] **Pré-requis P4** : `BookingService::cancelByAdmin` étend une classe créée en P4. Si P6 démarre avant P4, Task 8 step 8.2 doit créer `BookingService` plutôt que l'étendre.
- [ ] **Cohérence avec CLAUDE.md** : `declare(strict_types=1)` partout ✓ · CSRF sur tout POST ✓ · `e($var ?? '')` ✓ · `IN (...)` role pas hardcodé ✓ · migrations idempotentes ✓ · Argon2id rappelé (mais hors scope P6).

---

**Durée estimée** : 4-6 jours dev humain · ~1h30 exécution inline subagent-driven.

**Prochain plan** : P5 Stripe (auto-sync `stripe_price_id` + checkout + webhooks) ou P7 Médias (upload photos avec resize Intervention/Image).
