# Eolem Planning — Plan d'implémentation

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

**Goal:** Créer une interface web PHP pour saisir des formations et les créer comme événements dans Google Calendar, avec gestion de référentiels (formations, clients, lieux) stockés en JSON.

**Architecture:** Application PHP vanilla avec PSR-4 autoload. Le frontend utilise Tailwind CSS, HTMX et Tom Select via CDN. L'authentification se fait par formulaire HTML + session PHP. L'API Google Calendar est appelée via `google/apiclient` avec OAuth 2.0 et refresh token persisté côté serveur.

**Tech Stack:** PHP 8.1+, Composer, google/apiclient, Tailwind CSS (CDN), HTMX (CDN), Tom Select (CDN), PHPUnit (dev)

---

## File Structure

```
/
├── public/
│   ├── index.php                    # Login page
│   ├── form.php                     # Formulaire de création
│   ├── create.php                   # Traitement formulaire → Google Calendar
│   ├── callback.php                 # OAuth callback Google
│   ├── logout.php                   # Déconnexion
│   ├── admin/
│   │   ├── formations.php           # CRUD formations
│   │   ├── clients.php              # CRUD clients
│   │   └── lieux.php                # CRUD lieux
│   └── api/
│       └── referentials.php         # Endpoint JSON pour HTMX (admin CRUD)
├── src/
│   ├── AuthService.php              # Login/session/protection
│   ├── ReferentialService.php       # Lecture/écriture JSON
│   ├── FormValidator.php            # Validation formulaire création
│   └── GoogleCalendarService.php    # OAuth + création événements
├── templates/
│   ├── layout.php                   # Layout commun (head, nav, scripts)
│   ├── login.php                    # Template login
│   ├── form.php                     # Template formulaire création
│   ├── success.php                  # Template confirmation
│   └── admin/
│       ├── referential.php          # Template page admin
│       └── row.php                  # Fragment HTMX ligne tableau
├── data/                            # Référentiels JSON (hors git)
├── config/                          # Credentials (hors git)
├── tests/
│   ├── AuthServiceTest.php
│   ├── ReferentialServiceTest.php
│   ├── FormValidatorTest.php
│   └── GoogleCalendarServiceTest.php
├── composer.json
├── .env.example
├── .env                             # Hors git
└── .gitignore
```

---

### Task 1: Scaffolding du projet

**Files:**
- Modify: `composer.json`
- Create: `.gitignore`
- Create: `.env.example`
- Create: `phpunit.xml`

- [ ] **Step 1: Initialiser le dépôt git**

```bash
cd /Users/fgaurat/local_dev/eolem_planning
git init
```

- [ ] **Step 2: Mettre à jour composer.json avec les dépendances**

Remplacer le contenu de `composer.json` :

```json
{
    "name": "fgaurat/eolem_planning",
    "autoload": {
        "psr-4": {
            "Fgaurat\\EolemPlanning\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Fgaurat\\EolemPlanning\\Tests\\": "tests/"
        }
    },
    "authors": [
        {
            "name": "Frédéric GAURAT",
            "email": "fgaurat@gmail.com"
        }
    ],
    "require": {
        "php": ">=8.1",
        "google/apiclient": "^2.15",
        "vlucas/phpdotenv": "^5.6"
    },
    "require-dev": {
        "phpunit/phpunit": "^10.5"
    }
}
```

- [ ] **Step 3: Installer les dépendances**

```bash
composer install
```

- [ ] **Step 4: Créer .gitignore**

```
/vendor/
/data/
/config/google-credentials.json
/config/token.json
.env
```

- [ ] **Step 5: Créer .env.example**

```
APP_USERNAME=admin
# Généré avec: php -r "echo password_hash('votre_mot_de_passe', PASSWORD_DEFAULT);"
APP_PASSWORD_HASH=
GOOGLE_CALENDAR_ID=primary
```

- [ ] **Step 6: Créer phpunit.xml**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>
```

- [ ] **Step 7: Créer les répertoires vides**

```bash
mkdir -p public/admin public/api public/css src templates/admin tests config data
```

- [ ] **Step 8: Créer .env pour le développement local**

```
APP_USERNAME=admin
APP_PASSWORD_HASH=$2y$10$XXXXXX
GOOGLE_CALENDAR_ID=primary
```

Générer le hash :
```bash
php -r "echo password_hash('admin', PASSWORD_DEFAULT) . PHP_EOL;"
```

Copier le hash généré dans `.env` à la place de `$2y$10$XXXXXX`.

- [ ] **Step 9: Commit**

```bash
git add composer.json composer.lock .gitignore .env.example phpunit.xml
git commit -m "chore: scaffold project with composer deps and config"
```

---

### Task 2: AuthService — Tests

**Files:**
- Create: `tests/AuthServiceTest.php`

- [ ] **Step 1: Écrire les tests pour AuthService**

```php
<?php

namespace Fgaurat\EolemPlanning\Tests;

use Fgaurat\EolemPlanning\AuthService;
use PHPUnit\Framework\TestCase;

class AuthServiceTest extends TestCase
{
    private string $username;
    private string $passwordHash;
    private AuthService $auth;

    protected function setUp(): void
    {
        $this->username = 'admin';
        $this->passwordHash = password_hash('secret123', PASSWORD_DEFAULT);
        $this->auth = new AuthService($this->username, $this->passwordHash);
    }

    public function testLoginWithCorrectCredentials(): void
    {
        $this->assertTrue($this->auth->login('admin', 'secret123'));
    }

    public function testLoginWithWrongPassword(): void
    {
        $this->assertFalse($this->auth->login('admin', 'wrongpassword'));
    }

    public function testLoginWithWrongUsername(): void
    {
        $this->assertFalse($this->auth->login('wrong', 'secret123'));
    }

    public function testIsAuthenticatedReturnsFalseByDefault(): void
    {
        $this->assertFalse($this->auth->isAuthenticated());
    }
}
```

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

```bash
./vendor/bin/phpunit tests/AuthServiceTest.php
```

Attendu : FAIL — `Class Fgaurat\EolemPlanning\AuthService not found`

- [ ] **Step 3: Commit**

```bash
git add tests/AuthServiceTest.php
git commit -m "test: add AuthService unit tests (red)"
```

---

### Task 3: AuthService — Implémentation

**Files:**
- Create: `src/AuthService.php`

- [ ] **Step 1: Implémenter AuthService**

```php
<?php

namespace Fgaurat\EolemPlanning;

class AuthService
{
    public function __construct(
        private string $username,
        private string $passwordHash
    ) {}

    public function login(string $username, string $password): bool
    {
        if ($username !== $this->username) {
            return false;
        }

        if (!password_verify($password, $this->passwordHash)) {
            return false;
        }

        $_SESSION['authenticated'] = true;
        return true;
    }

    public function isAuthenticated(): bool
    {
        return isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true;
    }

    public function requireAuth(): void
    {
        if (!$this->isAuthenticated()) {
            header('Location: /index.php');
            exit;
        }
    }

    public function logout(): void
    {
        session_destroy();
    }
}
```

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

```bash
./vendor/bin/phpunit tests/AuthServiceTest.php
```

Attendu : OK (4 tests, 4 assertions)

- [ ] **Step 3: Commit**

```bash
git add src/AuthService.php
git commit -m "feat: implement AuthService with login/session management"
```

---

### Task 4: ReferentialService — Tests

**Files:**
- Create: `tests/ReferentialServiceTest.php`

- [ ] **Step 1: Écrire les tests pour ReferentialService**

```php
<?php

namespace Fgaurat\EolemPlanning\Tests;

use Fgaurat\EolemPlanning\ReferentialService;
use PHPUnit\Framework\TestCase;

class ReferentialServiceTest extends TestCase
{
    private string $dataDir;
    private ReferentialService $service;

    protected function setUp(): void
    {
        $this->dataDir = sys_get_temp_dir() . '/eolem_test_' . uniqid();
        mkdir($this->dataDir);
        $this->service = new ReferentialService($this->dataDir);
    }

    protected function tearDown(): void
    {
        $files = glob($this->dataDir . '/*.json');
        foreach ($files as $file) {
            unlink($file);
        }
        rmdir($this->dataDir);
    }

    public function testGetAllReturnsEmptyArrayWhenFileDoesNotExist(): void
    {
        $this->assertSame([], $this->service->getAll('clients'));
    }

    public function testAddCreatesEntryAndFileIfNeeded(): void
    {
        $this->service->add('clients', 'Acme Corp');
        $all = $this->service->getAll('clients');
        $this->assertCount(1, $all);
        $this->assertSame('Acme Corp', $all[0]);
    }

    public function testAddMultipleEntriesReturnsSortedAlphabetically(): void
    {
        $this->service->add('clients', 'Zebra Inc');
        $this->service->add('clients', 'Acme Corp');
        $this->service->add('clients', 'MegaSoft');
        $all = $this->service->getAll('clients');
        $this->assertSame(['Acme Corp', 'MegaSoft', 'Zebra Inc'], $all);
    }

    public function testAddDuplicateIsIgnored(): void
    {
        $this->service->add('clients', 'Acme Corp');
        $this->service->add('clients', 'Acme Corp');
        $this->assertCount(1, $this->service->getAll('clients'));
    }

    public function testUpdateRenamesEntry(): void
    {
        $this->service->add('clients', 'Acme Corp');
        $this->service->update('clients', 'Acme Corp', 'Acme Inc');
        $all = $this->service->getAll('clients');
        $this->assertSame(['Acme Inc'], $all);
    }

    public function testDeleteRemovesEntry(): void
    {
        $this->service->add('clients', 'Acme Corp');
        $this->service->add('clients', 'MegaSoft');
        $this->service->delete('clients', 'Acme Corp');
        $this->assertSame(['MegaSoft'], $this->service->getAll('clients'));
    }

    public function testWorksWithDifferentTypes(): void
    {
        $this->service->add('clients', 'Acme Corp');
        $this->service->add('formations', 'PHP Avancé');
        $this->service->add('lieux', 'Lyon');

        $this->assertSame(['Acme Corp'], $this->service->getAll('clients'));
        $this->assertSame(['PHP Avancé'], $this->service->getAll('formations'));
        $this->assertSame(['Lyon'], $this->service->getAll('lieux'));
    }
}
```

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

```bash
./vendor/bin/phpunit tests/ReferentialServiceTest.php
```

Attendu : FAIL — `Class Fgaurat\EolemPlanning\ReferentialService not found`

- [ ] **Step 3: Commit**

```bash
git add tests/ReferentialServiceTest.php
git commit -m "test: add ReferentialService unit tests (red)"
```

---

### Task 5: ReferentialService — Implémentation

**Files:**
- Create: `src/ReferentialService.php`

- [ ] **Step 1: Implémenter ReferentialService**

```php
<?php

namespace Fgaurat\EolemPlanning;

class ReferentialService
{
    public function __construct(private string $dataDir) {}

    public function getAll(string $type): array
    {
        $path = $this->filePath($type);
        if (!file_exists($path)) {
            return [];
        }

        $data = json_decode(file_get_contents($path), true);
        sort($data, SORT_STRING | SORT_FLAG_CASE);
        return $data;
    }

    public function add(string $type, string $value): void
    {
        $data = $this->getAll($type);
        $value = trim($value);
        if ($value === '' || in_array($value, $data, true)) {
            return;
        }

        $data[] = $value;
        $this->save($type, $data);
    }

    public function update(string $type, string $oldValue, string $newValue): void
    {
        $data = $this->getAll($type);
        $index = array_search($oldValue, $data, true);
        if ($index === false) {
            return;
        }

        $data[$index] = trim($newValue);
        $this->save($type, $data);
    }

    public function delete(string $type, string $value): void
    {
        $data = $this->getAll($type);
        $data = array_values(array_filter($data, fn($v) => $v !== $value));
        $this->save($type, $data);
    }

    private function save(string $type, array $data): void
    {
        sort($data, SORT_STRING | SORT_FLAG_CASE);
        file_put_contents(
            $this->filePath($type),
            json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
        );
    }

    private function filePath(string $type): string
    {
        return $this->dataDir . '/' . $type . '.json';
    }
}
```

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

```bash
./vendor/bin/phpunit tests/ReferentialServiceTest.php
```

Attendu : OK (7 tests, 9 assertions)

- [ ] **Step 3: Commit**

```bash
git add src/ReferentialService.php
git commit -m "feat: implement ReferentialService with JSON CRUD"
```

---

### Task 6: FormValidator — Tests

**Files:**
- Create: `tests/FormValidatorTest.php`

- [ ] **Step 1: Écrire les tests pour FormValidator**

```php
<?php

namespace Fgaurat\EolemPlanning\Tests;

use Fgaurat\EolemPlanning\FormValidator;
use PHPUnit\Framework\TestCase;

class FormValidatorTest extends TestCase
{
    private FormValidator $validator;

    protected function setUp(): void
    {
        $this->validator = new FormValidator();
    }

    private function validData(): array
    {
        return [
            'formation' => 'PHP Avancé',
            'client' => 'Acme Corp',
            'lieu' => 'Lyon',
            'statut' => 'option',
            'date_debut' => '2026-05-04',
            'date_fin' => '2026-05-08',
            'heure_debut' => '09:00',
            'heure_fin' => '17:00',
            'description' => '',
        ];
    }

    public function testValidDataReturnsNoErrors(): void
    {
        $this->assertSame([], $this->validator->validate($this->validData()));
    }

    public function testMissingFormationReturnsError(): void
    {
        $data = $this->validData();
        $data['formation'] = '';
        $errors = $this->validator->validate($data);
        $this->assertArrayHasKey('formation', $errors);
    }

    public function testMissingClientReturnsError(): void
    {
        $data = $this->validData();
        $data['client'] = '';
        $errors = $this->validator->validate($data);
        $this->assertArrayHasKey('client', $errors);
    }

    public function testMissingLieuReturnsError(): void
    {
        $data = $this->validData();
        $data['lieu'] = '';
        $errors = $this->validator->validate($data);
        $this->assertArrayHasKey('lieu', $errors);
    }

    public function testInvalidStatutReturnsError(): void
    {
        $data = $this->validData();
        $data['statut'] = 'invalide';
        $errors = $this->validator->validate($data);
        $this->assertArrayHasKey('statut', $errors);
    }

    public function testDateFinBeforeDateDebutReturnsError(): void
    {
        $data = $this->validData();
        $data['date_debut'] = '2026-05-08';
        $data['date_fin'] = '2026-05-04';
        $errors = $this->validator->validate($data);
        $this->assertArrayHasKey('date_fin', $errors);
    }

    public function testHeureFinBeforeHeureDebutReturnsError(): void
    {
        $data = $this->validData();
        $data['heure_debut'] = '17:00';
        $data['heure_fin'] = '09:00';
        $errors = $this->validator->validate($data);
        $this->assertArrayHasKey('heure_fin', $errors);
    }

    public function testAllThreeStatutsAreValid(): void
    {
        foreach (['option', 'confirmee', 'commandee'] as $statut) {
            $data = $this->validData();
            $data['statut'] = $statut;
            $this->assertSame([], $this->validator->validate($data), "Statut '$statut' devrait être valide");
        }
    }
}
```

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

```bash
./vendor/bin/phpunit tests/FormValidatorTest.php
```

Attendu : FAIL — `Class Fgaurat\EolemPlanning\FormValidator not found`

- [ ] **Step 3: Commit**

```bash
git add tests/FormValidatorTest.php
git commit -m "test: add FormValidator unit tests (red)"
```

---

### Task 7: FormValidator — Implémentation

**Files:**
- Create: `src/FormValidator.php`

- [ ] **Step 1: Implémenter FormValidator**

```php
<?php

namespace Fgaurat\EolemPlanning;

class FormValidator
{
    private const VALID_STATUTS = ['option', 'confirmee', 'commandee'];

    public function validate(array $data): array
    {
        $errors = [];

        if (empty(trim($data['formation'] ?? ''))) {
            $errors['formation'] = 'La formation est obligatoire.';
        }

        if (empty(trim($data['client'] ?? ''))) {
            $errors['client'] = 'Le client est obligatoire.';
        }

        if (empty(trim($data['lieu'] ?? ''))) {
            $errors['lieu'] = 'Le lieu est obligatoire.';
        }

        if (!in_array($data['statut'] ?? '', self::VALID_STATUTS, true)) {
            $errors['statut'] = 'Le statut est invalide.';
        }

        $dateDebut = $data['date_debut'] ?? '';
        $dateFin = $data['date_fin'] ?? '';
        if ($dateDebut === '' || $dateFin === '') {
            if ($dateDebut === '') $errors['date_debut'] = 'La date de début est obligatoire.';
            if ($dateFin === '') $errors['date_fin'] = 'La date de fin est obligatoire.';
        } elseif ($dateFin < $dateDebut) {
            $errors['date_fin'] = 'La date de fin doit être égale ou postérieure à la date de début.';
        }

        $heureDebut = $data['heure_debut'] ?? '';
        $heureFin = $data['heure_fin'] ?? '';
        if ($heureDebut === '' || $heureFin === '') {
            if ($heureDebut === '') $errors['heure_debut'] = "L'heure de début est obligatoire.";
            if ($heureFin === '') $errors['heure_fin'] = "L'heure de fin est obligatoire.";
        } elseif ($heureFin <= $heureDebut) {
            $errors['heure_fin'] = "L'heure de fin doit être postérieure à l'heure de début.";
        }

        return $errors;
    }
}
```

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

```bash
./vendor/bin/phpunit tests/FormValidatorTest.php
```

Attendu : OK (8 tests, 10 assertions)

- [ ] **Step 3: Commit**

```bash
git add src/FormValidator.php
git commit -m "feat: implement FormValidator with field and date validation"
```

---

### Task 8: GoogleCalendarService — Tests

**Files:**
- Create: `tests/GoogleCalendarServiceTest.php`

- [ ] **Step 1: Écrire les tests pour GoogleCalendarService**

Les tests vérifient la logique de construction des événements et le mapping des couleurs, sans appeler l'API Google.

```php
<?php

namespace Fgaurat\EolemPlanning\Tests;

use Fgaurat\EolemPlanning\GoogleCalendarService;
use PHPUnit\Framework\TestCase;

class GoogleCalendarServiceTest extends TestCase
{
    public function testGetColorIdForOption(): void
    {
        $this->assertSame(5, GoogleCalendarService::getColorId('option'));
    }

    public function testGetColorIdForConfirmee(): void
    {
        $this->assertSame(2, GoogleCalendarService::getColorId('confirmee'));
    }

    public function testGetColorIdForCommandee(): void
    {
        $this->assertSame(9, GoogleCalendarService::getColorId('commandee'));
    }

    public function testBuildEventDataSingleDay(): void
    {
        $events = GoogleCalendarService::buildEventsData(
            title: 'PHP Avancé - Acme Corp - Lyon',
            dateStart: '2026-05-04',
            dateEnd: '2026-05-04',
            timeStart: '09:00',
            timeEnd: '17:00',
            location: 'Lyon',
            description: 'Session intro',
            colorId: 5
        );

        $this->assertCount(1, $events);
        $this->assertSame('PHP Avancé - Acme Corp - Lyon', $events[0]['summary']);
        $this->assertSame('Lyon', $events[0]['location']);
        $this->assertSame('Session intro', $events[0]['description']);
        $this->assertSame(5, $events[0]['colorId']);
        $this->assertSame('2026-05-04T09:00:00', $events[0]['start']['dateTime']);
        $this->assertSame('2026-05-04T17:00:00', $events[0]['end']['dateTime']);
    }

    public function testBuildEventDataMultipleDays(): void
    {
        $events = GoogleCalendarService::buildEventsData(
            title: 'React - MegaSoft - Paris',
            dateStart: '2026-05-04',
            dateEnd: '2026-05-06',
            timeStart: '09:00',
            timeEnd: '17:00',
            location: 'Paris',
            description: '',
            colorId: 2
        );

        $this->assertCount(3, $events);
        $this->assertSame('2026-05-04T09:00:00', $events[0]['start']['dateTime']);
        $this->assertSame('2026-05-05T09:00:00', $events[1]['start']['dateTime']);
        $this->assertSame('2026-05-06T09:00:00', $events[2]['start']['dateTime']);
    }

    public function testBuildEventDataIncludesWeekends(): void
    {
        // 2026-05-08 = vendredi, 2026-05-11 = lundi → samedi et dimanche inclus
        $events = GoogleCalendarService::buildEventsData(
            title: 'Test - Client - Lieu',
            dateStart: '2026-05-08',
            dateEnd: '2026-05-11',
            timeStart: '09:00',
            timeEnd: '17:00',
            location: 'Lieu',
            description: '',
            colorId: 9
        );

        $this->assertCount(4, $events);
        $dates = array_map(fn($e) => substr($e['start']['dateTime'], 0, 10), $events);
        $this->assertSame(['2026-05-08', '2026-05-09', '2026-05-10', '2026-05-11'], $dates);
    }
}
```

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

```bash
./vendor/bin/phpunit tests/GoogleCalendarServiceTest.php
```

Attendu : FAIL — `Class Fgaurat\EolemPlanning\GoogleCalendarService not found`

- [ ] **Step 3: Commit**

```bash
git add tests/GoogleCalendarServiceTest.php
git commit -m "test: add GoogleCalendarService unit tests (red)"
```

---

### Task 9: GoogleCalendarService — Implémentation

**Files:**
- Create: `src/GoogleCalendarService.php`

- [ ] **Step 1: Implémenter GoogleCalendarService**

```php
<?php

namespace Fgaurat\EolemPlanning;

use Google\Client as GoogleClient;
use Google\Service\Calendar as GoogleCalendar;
use Google\Service\Calendar\Event as GoogleEvent;

class GoogleCalendarService
{
    // Google Calendar color IDs: 5=Banana(jaune), 2=Sage(vert), 9=Blueberry(bleu)
    private const COLOR_MAP = [
        'option' => 5,
        'confirmee' => 2,
        'commandee' => 9,
    ];

    private GoogleClient $client;
    private string $tokenPath;
    private string $calendarId;

    public function __construct(string $credentialsPath, string $tokenPath, string $calendarId)
    {
        $this->tokenPath = $tokenPath;
        $this->calendarId = $calendarId;

        $this->client = new GoogleClient();
        $this->client->setAuthConfig($credentialsPath);
        $this->client->addScope(GoogleCalendar::CALENDAR_EVENTS);
        $this->client->setAccessType('offline');
        $this->client->setPrompt('consent');

        if (file_exists($tokenPath)) {
            $token = json_decode(file_get_contents($tokenPath), true);
            $this->client->setAccessToken($token);

            if ($this->client->isAccessTokenExpired() && $this->client->getRefreshToken()) {
                $newToken = $this->client->fetchAccessTokenWithRefreshToken($this->client->getRefreshToken());
                file_put_contents($tokenPath, json_encode($newToken));
            }
        }
    }

    public function getAuthUrl(string $redirectUri): string
    {
        $this->client->setRedirectUri($redirectUri);
        return $this->client->createAuthUrl();
    }

    public function handleCallback(string $code, string $redirectUri): void
    {
        $this->client->setRedirectUri($redirectUri);
        $token = $this->client->fetchAccessTokenWithAuthCode($code);
        file_put_contents($this->tokenPath, json_encode($token));
        $this->client->setAccessToken($token);
    }

    public function isAuthenticated(): bool
    {
        return !$this->client->isAccessTokenExpired();
    }

    public function createEvents(string $title, string $dateStart, string $dateEnd, string $timeStart, string $timeEnd, string $location, string $description, string $statut): array
    {
        $colorId = self::getColorId($statut);
        $eventsData = self::buildEventsData($title, $dateStart, $dateEnd, $timeStart, $timeEnd, $location, $description, $colorId);

        $calendar = new GoogleCalendar($this->client);
        $createdEvents = [];

        foreach ($eventsData as $eventData) {
            $event = new GoogleEvent($eventData);
            $created = $calendar->events->insert($this->calendarId, $event);
            $createdEvents[] = $created;
        }

        return $createdEvents;
    }

    public static function getColorId(string $statut): int
    {
        return self::COLOR_MAP[$statut];
    }

    public static function buildEventsData(string $title, string $dateStart, string $dateEnd, string $timeStart, string $timeEnd, string $location, string $description, int $colorId): array
    {
        $events = [];
        $current = new \DateTimeImmutable($dateStart);
        $end = new \DateTimeImmutable($dateEnd);

        while ($current <= $end) {
            $dateStr = $current->format('Y-m-d');
            $events[] = [
                'summary' => $title,
                'location' => $location,
                'description' => $description,
                'colorId' => $colorId,
                'start' => [
                    'dateTime' => $dateStr . 'T' . $timeStart . ':00',
                    'timeZone' => 'Europe/Paris',
                ],
                'end' => [
                    'dateTime' => $dateStr . 'T' . $timeEnd . ':00',
                    'timeZone' => 'Europe/Paris',
                ],
            ];
            $current = $current->modify('+1 day');
        }

        return $events;
    }
}
```

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

```bash
./vendor/bin/phpunit tests/GoogleCalendarServiceTest.php
```

Attendu : OK (6 tests, 17 assertions)

- [ ] **Step 3: Lancer tous les tests pour vérifier la non-régression**

```bash
./vendor/bin/phpunit
```

Attendu : OK (18 tests)

- [ ] **Step 4: Commit**

```bash
git add src/GoogleCalendarService.php
git commit -m "feat: implement GoogleCalendarService with OAuth and event creation"
```

---

### Task 10: Bootstrap et Layout

**Files:**
- Create: `public/bootstrap.php`
- Create: `templates/layout.php`

- [ ] **Step 1: Créer le fichier bootstrap**

`public/bootstrap.php` — chargé par toutes les pages, initialise autoload, session, dotenv, et les services.

```php
<?php

session_start();

require_once __DIR__ . '/../vendor/autoload.php';

use Dotenv\Dotenv;
use Fgaurat\EolemPlanning\AuthService;
use Fgaurat\EolemPlanning\ReferentialService;
use Fgaurat\EolemPlanning\GoogleCalendarService;

$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();

$auth = new AuthService(
    $_ENV['APP_USERNAME'],
    $_ENV['APP_PASSWORD_HASH']
);

$referentials = new ReferentialService(__DIR__ . '/../data');

function getGoogleCalendarService(): GoogleCalendarService
{
    return new GoogleCalendarService(
        __DIR__ . '/../config/google-credentials.json',
        __DIR__ . '/../config/token.json',
        $_ENV['GOOGLE_CALENDAR_ID']
    );
}
```

- [ ] **Step 2: Créer le layout commun**

`templates/layout.php` — structure HTML avec Tailwind, HTMX, Tom Select via CDN, et navigation.

```php
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Eolem Planning<?= isset($pageTitle) ? ' — ' . htmlspecialchars($pageTitle) : '' ?></title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://unpkg.com/htmx.org@2.0.4"></script>
    <link href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.css" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
    <nav class="bg-white shadow mb-6">
        <div class="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
            <span class="font-bold text-lg text-gray-800">Eolem Planning</span>
            <div class="flex gap-4 text-sm">
                <a href="/form.php" class="text-blue-600 hover:underline">Nouvelle formation</a>
                <a href="/admin/formations.php" class="text-gray-600 hover:underline">Formations</a>
                <a href="/admin/clients.php" class="text-gray-600 hover:underline">Clients</a>
                <a href="/admin/lieux.php" class="text-gray-600 hover:underline">Lieux</a>
                <a href="/logout.php" class="text-red-600 hover:underline">Déconnexion</a>
            </div>
        </div>
    </nav>
    <main class="max-w-4xl mx-auto px-4">
        <?= $content ?>
    </main>
</body>
</html>
```

- [ ] **Step 3: Commit**

```bash
git add public/bootstrap.php templates/layout.php
git commit -m "feat: add bootstrap and shared layout with Tailwind/HTMX/TomSelect"
```

---

### Task 11: Page de login

**Files:**
- Create: `public/index.php`
- Create: `templates/login.php`
- Create: `public/logout.php`

- [ ] **Step 1: Créer le template de login**

`templates/login.php` :

```php
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Eolem Planning — Connexion</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center">
    <div class="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
        <h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">Eolem Planning</h1>
        <?php if (!empty($error)): ?>
            <div class="bg-red-100 text-red-700 p-3 rounded mb-4 text-sm"><?= htmlspecialchars($error) ?></div>
        <?php endif; ?>
        <form method="POST" action="/index.php">
            <div class="mb-4">
                <label for="username" class="block text-sm font-medium text-gray-700 mb-1">Identifiant</label>
                <input type="text" id="username" name="username" tabindex="1" required autofocus
                       class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
            </div>
            <div class="mb-6">
                <label for="password" class="block text-sm font-medium text-gray-700 mb-1">Mot de passe</label>
                <input type="password" id="password" name="password" tabindex="2" required
                       class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
            </div>
            <button type="submit" tabindex="3"
                    class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition">
                Connexion
            </button>
        </form>
    </div>
</body>
</html>
```

- [ ] **Step 2: Créer index.php (login handler)**

`public/index.php` :

```php
<?php

require_once __DIR__ . '/bootstrap.php';

if ($auth->isAuthenticated()) {
    header('Location: /form.php');
    exit;
}

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = $_POST['username'] ?? '';
    $password = $_POST['password'] ?? '';

    if ($auth->login($username, $password)) {
        header('Location: /form.php');
        exit;
    }

    $error = 'Identifiant ou mot de passe incorrect.';
}

require __DIR__ . '/../templates/login.php';
```

- [ ] **Step 3: Créer logout.php**

`public/logout.php` :

```php
<?php

require_once __DIR__ . '/bootstrap.php';

$auth->logout();
header('Location: /index.php');
exit;
```

- [ ] **Step 4: Tester manuellement**

```bash
cd /Users/fgaurat/local_dev/eolem_planning
php -S localhost:8080 -t public/
```

Ouvrir `http://localhost:8080` — vérifier :
- Le formulaire de login s'affiche
- Un mauvais mot de passe affiche l'erreur
- Le bon mot de passe redirige vers `/form.php` (erreur 404 attendue à ce stade)
- La déconnexion redirige vers le login

- [ ] **Step 5: Commit**

```bash
git add public/index.php public/logout.php templates/login.php
git commit -m "feat: add login page with HTML form and session auth"
```

---

### Task 12: Page de création de formation (formulaire)

**Files:**
- Create: `public/form.php`
- Create: `templates/form.php`

- [ ] **Step 1: Créer public/form.php (controller)**

```php
<?php

require_once __DIR__ . '/bootstrap.php';
$auth->requireAuth();

$formations = $referentials->getAll('formations');
$clients = $referentials->getAll('clients');
$lieux = $referentials->getAll('lieux');

$pageTitle = 'Nouvelle formation';
$errors = [];

ob_start();
require __DIR__ . '/../templates/form.php';
$content = ob_get_clean();

require __DIR__ . '/../templates/layout.php';
```

- [ ] **Step 2: Créer templates/form.php (template)**

```php
<h1 class="text-2xl font-bold text-gray-800 mb-6">Nouvelle formation</h1>

<?php if (!empty($errors)): ?>
    <div class="bg-red-100 text-red-700 p-4 rounded mb-4">
        <ul class="list-disc list-inside">
            <?php foreach ($errors as $err): ?>
                <li><?= htmlspecialchars($err) ?></li>
            <?php endforeach; ?>
        </ul>
    </div>
<?php endif; ?>

<form method="POST" action="/create.php" class="bg-white shadow rounded-lg p-6 space-y-4">
    <div>
        <label for="formation" class="block text-sm font-medium text-gray-700 mb-1">Formation</label>
        <select id="formation" name="formation" tabindex="1" required>
            <option value="">Sélectionner...</option>
            <?php foreach ($formations as $f): ?>
                <option value="<?= htmlspecialchars($f) ?>" <?= (($_POST['formation'] ?? '') === $f) ? 'selected' : '' ?>>
                    <?= htmlspecialchars($f) ?>
                </option>
            <?php endforeach; ?>
        </select>
    </div>

    <div>
        <label for="client" class="block text-sm font-medium text-gray-700 mb-1">Client</label>
        <select id="client" name="client" tabindex="2" required>
            <option value="">Sélectionner...</option>
            <?php foreach ($clients as $c): ?>
                <option value="<?= htmlspecialchars($c) ?>" <?= (($_POST['client'] ?? '') === $c) ? 'selected' : '' ?>>
                    <?= htmlspecialchars($c) ?>
                </option>
            <?php endforeach; ?>
        </select>
    </div>

    <div>
        <label for="lieu" class="block text-sm font-medium text-gray-700 mb-1">Lieu</label>
        <select id="lieu" name="lieu" tabindex="3" required>
            <option value="">Sélectionner...</option>
            <?php foreach ($lieux as $l): ?>
                <option value="<?= htmlspecialchars($l) ?>" <?= (($_POST['lieu'] ?? '') === $l) ? 'selected' : '' ?>>
                    <?= htmlspecialchars($l) ?>
                </option>
            <?php endforeach; ?>
        </select>
    </div>

    <div>
        <label for="statut" class="block text-sm font-medium text-gray-700 mb-1">Statut</label>
        <select id="statut" name="statut" tabindex="4" required
                class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
            <option value="option" <?= (($_POST['statut'] ?? '') === 'option') ? 'selected' : '' ?>>Option</option>
            <option value="confirmee" <?= (($_POST['statut'] ?? '') === 'confirmee') ? 'selected' : '' ?>>Confirmée</option>
            <option value="commandee" <?= (($_POST['statut'] ?? '') === 'commandee') ? 'selected' : '' ?>>Commandée</option>
        </select>
    </div>

    <div class="grid grid-cols-2 gap-4">
        <div>
            <label for="date_debut" class="block text-sm font-medium text-gray-700 mb-1">Date de début</label>
            <input type="date" id="date_debut" name="date_debut" tabindex="5" required
                   value="<?= htmlspecialchars($_POST['date_debut'] ?? '') ?>"
                   class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
        </div>
        <div>
            <label for="date_fin" class="block text-sm font-medium text-gray-700 mb-1">Date de fin</label>
            <input type="date" id="date_fin" name="date_fin" tabindex="6" required
                   value="<?= htmlspecialchars($_POST['date_fin'] ?? '') ?>"
                   class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
        </div>
    </div>

    <div class="grid grid-cols-2 gap-4">
        <div>
            <label for="heure_debut" class="block text-sm font-medium text-gray-700 mb-1">Heure de début</label>
            <input type="time" id="heure_debut" name="heure_debut" tabindex="7" required
                   value="<?= htmlspecialchars($_POST['heure_debut'] ?? '09:00') ?>"
                   class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
        </div>
        <div>
            <label for="heure_fin" class="block text-sm font-medium text-gray-700 mb-1">Heure de fin</label>
            <input type="time" id="heure_fin" name="heure_fin" tabindex="8" required
                   value="<?= htmlspecialchars($_POST['heure_fin'] ?? '17:00') ?>"
                   class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
        </div>
    </div>

    <div>
        <label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optionnel)</label>
        <textarea id="description" name="description" tabindex="9" rows="3"
                  class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
        ><?= htmlspecialchars($_POST['description'] ?? '') ?></textarea>
    </div>

    <button type="submit" tabindex="10"
            class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition font-medium">
        Créer dans Google Calendar
    </button>
</form>

<script>
document.addEventListener('DOMContentLoaded', function() {
    ['formation', 'client', 'lieu'].forEach(function(id) {
        new TomSelect('#' + id, {
            create: false,
            sortField: { field: 'text', direction: 'asc' }
        });
    });
});
</script>
```

- [ ] **Step 3: Tester manuellement**

```bash
php -S localhost:8080 -t public/
```

Se connecter puis vérifier :
- Le formulaire s'affiche avec la navigation
- Les 3 combobox fonctionnent avec autocomplétion au clavier
- Tab navigue dans l'ordre correct (1→10)
- Les horaires sont pré-remplis à 09:00 et 17:00

- [ ] **Step 4: Commit**

```bash
git add public/form.php templates/form.php
git commit -m "feat: add training creation form with Tom Select autocomplete"
```

---

### Task 13: Traitement du formulaire et création des événements

**Files:**
- Create: `public/create.php`
- Create: `templates/success.php`

- [ ] **Step 1: Créer create.php (handler)**

`public/create.php` :

```php
<?php

require_once __DIR__ . '/bootstrap.php';

use Fgaurat\EolemPlanning\FormValidator;

$auth->requireAuth();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: /form.php');
    exit;
}

$validator = new FormValidator();
$errors = $validator->validate($_POST);

if (!empty($errors)) {
    $formations = $referentials->getAll('formations');
    $clients = $referentials->getAll('clients');
    $lieux = $referentials->getAll('lieux');
    $pageTitle = 'Nouvelle formation';

    ob_start();
    require __DIR__ . '/../templates/form.php';
    $content = ob_get_clean();
    require __DIR__ . '/../templates/layout.php';
    exit;
}

$gcal = getGoogleCalendarService();

if (!$gcal->isAuthenticated()) {
    $_SESSION['pending_form'] = $_POST;
    $redirectUri = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . '/callback.php';
    header('Location: ' . $gcal->getAuthUrl($redirectUri));
    exit;
}

$title = $_POST['formation'] . ' - ' . $_POST['client'] . ' - ' . $_POST['lieu'];

$createdEvents = $gcal->createEvents(
    title: $title,
    dateStart: $_POST['date_debut'],
    dateEnd: $_POST['date_fin'],
    timeStart: $_POST['heure_debut'],
    timeEnd: $_POST['heure_fin'],
    location: $_POST['lieu'],
    description: $_POST['description'] ?? '',
    statut: $_POST['statut']
);

$pageTitle = 'Confirmation';
$eventCount = count($createdEvents);

ob_start();
require __DIR__ . '/../templates/success.php';
$content = ob_get_clean();
require __DIR__ . '/../templates/layout.php';
```

- [ ] **Step 2: Créer le template de confirmation**

`templates/success.php` :

```php
<div class="bg-green-50 border border-green-200 rounded-lg p-6">
    <h1 class="text-2xl font-bold text-green-800 mb-4">Formation créée</h1>

    <div class="mb-4 text-green-700">
        <p><strong><?= $eventCount ?></strong> événement<?= $eventCount > 1 ? 's' : '' ?> créé<?= $eventCount > 1 ? 's' : '' ?> dans Google Calendar.</p>
    </div>

    <div class="bg-white rounded p-4 mb-4">
        <dl class="space-y-2 text-sm">
            <div class="flex">
                <dt class="font-medium text-gray-600 w-32">Titre :</dt>
                <dd><?= htmlspecialchars($title) ?></dd>
            </div>
            <div class="flex">
                <dt class="font-medium text-gray-600 w-32">Statut :</dt>
                <dd><?= htmlspecialchars(ucfirst($_POST['statut'])) ?></dd>
            </div>
            <div class="flex">
                <dt class="font-medium text-gray-600 w-32">Dates :</dt>
                <dd><?= htmlspecialchars($_POST['date_debut']) ?> → <?= htmlspecialchars($_POST['date_fin']) ?></dd>
            </div>
            <div class="flex">
                <dt class="font-medium text-gray-600 w-32">Horaires :</dt>
                <dd><?= htmlspecialchars($_POST['heure_debut']) ?> - <?= htmlspecialchars($_POST['heure_fin']) ?></dd>
            </div>
            <div class="flex">
                <dt class="font-medium text-gray-600 w-32">Lieu :</dt>
                <dd><?= htmlspecialchars($_POST['lieu']) ?></dd>
            </div>
        </dl>
    </div>

    <a href="/form.php" class="inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">
        Créer une autre formation
    </a>
</div>
```

- [ ] **Step 3: Commit**

```bash
git add public/create.php templates/success.php
git commit -m "feat: add form handler with Google Calendar event creation"
```

---

### Task 14: OAuth callback

**Files:**
- Create: `public/callback.php`

- [ ] **Step 1: Créer callback.php**

```php
<?php

require_once __DIR__ . '/bootstrap.php';

$auth->requireAuth();

if (!isset($_GET['code'])) {
    header('Location: /form.php');
    exit;
}

$gcal = getGoogleCalendarService();
$redirectUri = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . '/callback.php';
$gcal->handleCallback($_GET['code'], $redirectUri);

if (isset($_SESSION['pending_form'])) {
    $_POST = $_SESSION['pending_form'];
    unset($_SESSION['pending_form']);
    require __DIR__ . '/create.php';
    exit;
}

header('Location: /form.php');
exit;
```

- [ ] **Step 2: Commit**

```bash
git add public/callback.php
git commit -m "feat: add OAuth callback with pending form replay"
```

---

### Task 15: API endpoint pour les référentiels (HTMX)

**Files:**
- Create: `public/api/referentials.php`
- Create: `templates/admin/row.php`

- [ ] **Step 1: Créer le template de ligne de tableau (fragment HTMX)**

`templates/admin/row.php` — fragment HTML renvoyé par l'API pour chaque entrée. Utilise des attributs `data-*` pour transmettre les valeurs au JavaScript de manière sûre, sans interpolation dans le HTML.

```php
<?php foreach ($items as $item):
    $escapedItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
    $rowId = 'row-' . md5($item);
?>
<tr class="border-b border-gray-100" id="<?= $rowId ?>">
    <td class="py-2 px-4"><?= $escapedItem ?></td>
    <td class="py-2 px-4 text-right space-x-2">
        <button class="edit-btn text-blue-600 hover:underline text-sm"
                data-value="<?= $escapedItem ?>"
                data-type="<?= htmlspecialchars($type, ENT_QUOTES, 'UTF-8') ?>">Modifier</button>
        <button class="delete-btn text-red-600 hover:underline text-sm"
                data-value="<?= $escapedItem ?>"
                data-type="<?= htmlspecialchars($type, ENT_QUOTES, 'UTF-8') ?>">Supprimer</button>
    </td>
</tr>
<?php endforeach; ?>
```

- [ ] **Step 2: Créer l'endpoint API**

`public/api/referentials.php` — endpoint utilisé par HTMX pour le CRUD admin. Renvoie des fragments HTML via le template `row.php`.

```php
<?php

require_once __DIR__ . '/../bootstrap.php';

$auth->requireAuth();

$type = $_GET['type'] ?? $_POST['type'] ?? '';
$allowedTypes = ['formations', 'clients', 'lieux'];

if (!in_array($type, $allowedTypes, true)) {
    http_response_code(400);
    exit('Type invalide');
}

$action = $_POST['action'] ?? 'list';

switch ($action) {
    case 'add':
        $value = trim($_POST['value'] ?? '');
        if ($value !== '') {
            $referentials->add($type, $value);
        }
        break;

    case 'update':
        $oldValue = $_POST['old_value'] ?? '';
        $newValue = trim($_POST['new_value'] ?? '');
        if ($oldValue !== '' && $newValue !== '') {
            $referentials->update($type, $oldValue, $newValue);
        }
        break;

    case 'delete':
        $value = $_POST['value'] ?? '';
        if ($value !== '') {
            $referentials->delete($type, $value);
        }
        break;
}

$items = $referentials->getAll($type);

require __DIR__ . '/../../templates/admin/row.php';
```

- [ ] **Step 3: Commit**

```bash
git add public/api/referentials.php templates/admin/row.php
git commit -m "feat: add referentials API endpoint for HTMX CRUD"
```

---

### Task 16: Pages d'administration des référentiels

**Files:**
- Create: `templates/admin/referential.php`
- Create: `public/admin/formations.php`
- Create: `public/admin/clients.php`
- Create: `public/admin/lieux.php`

- [ ] **Step 1: Créer le template admin partagé**

`templates/admin/referential.php` — utilise des méthodes DOM sûres au lieu de `innerHTML` pour l'édition inline. Les handlers d'événements sont attachés via `addEventListener` au lieu d'attributs `onclick`, et les valeurs transitent via `data-*` attributes.

```php
<h1 class="text-2xl font-bold text-gray-800 mb-6"><?= htmlspecialchars($adminTitle) ?></h1>

<form hx-post="/api/referentials.php" hx-target="#referential-table-body" hx-swap="innerHTML"
      hx-on::after-request="this.reset()" class="flex gap-2 mb-6">
    <input type="hidden" name="type" value="<?= htmlspecialchars($type) ?>">
    <input type="hidden" name="action" value="add">
    <input type="text" name="value" placeholder="Ajouter..." required
           class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
    <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">
        Ajouter
    </button>
</form>

<div class="bg-white shadow rounded-lg overflow-hidden">
    <table class="w-full">
        <thead class="bg-gray-50">
            <tr>
                <th class="text-left py-2 px-4 font-medium text-gray-600">Nom</th>
                <th class="text-right py-2 px-4 font-medium text-gray-600">Actions</th>
            </tr>
        </thead>
        <tbody id="referential-table-body"
               hx-get="/api/referentials.php?type=<?= htmlspecialchars($type) ?>"
               hx-trigger="load"
               hx-swap="innerHTML">
        </tbody>
    </table>
</div>

<script>
(function() {
    var currentType = <?= json_encode($type) ?>;
    var tbody = document.getElementById('referential-table-body');

    function reloadTable() {
        htmx.ajax('GET', '/api/referentials.php?type=' + encodeURIComponent(currentType), {
            target: '#referential-table-body',
            swap: 'innerHTML'
        });
    }

    function startEdit(button) {
        var oldValue = button.getAttribute('data-value');
        var row = button.closest('tr');
        var nameCell = row.querySelector('td:first-child');
        var actionsCell = row.querySelector('td:last-child');

        // Sauvegarder le contenu original
        var originalName = nameCell.textContent;
        var originalActions = actionsCell.cloneNode(true);

        // Créer le champ d'édition via DOM
        nameCell.textContent = '';
        var input = document.createElement('input');
        input.type = 'text';
        input.value = oldValue;
        input.className = 'border border-gray-300 rounded px-2 py-1 w-full focus:ring-2 focus:ring-blue-500';
        nameCell.appendChild(input);
        input.focus();

        // Créer les boutons OK/Annuler via DOM
        actionsCell.textContent = '';

        var okBtn = document.createElement('button');
        okBtn.textContent = 'OK';
        okBtn.className = 'text-green-600 hover:underline text-sm';

        var cancelBtn = document.createElement('button');
        cancelBtn.textContent = 'Annuler';
        cancelBtn.className = 'text-gray-600 hover:underline text-sm ml-2';

        actionsCell.appendChild(okBtn);
        actionsCell.appendChild(cancelBtn);

        function submitEdit() {
            var newValue = input.value.trim();
            if (newValue === '' || newValue === oldValue) {
                reloadTable();
                return;
            }
            htmx.ajax('POST', '/api/referentials.php', {
                target: '#referential-table-body',
                swap: 'innerHTML',
                values: {
                    type: currentType,
                    action: 'update',
                    old_value: oldValue,
                    new_value: newValue
                }
            });
        }

        okBtn.addEventListener('click', submitEdit);
        cancelBtn.addEventListener('click', reloadTable);
        input.addEventListener('keydown', function(e) {
            if (e.key === 'Enter') submitEdit();
            if (e.key === 'Escape') reloadTable();
        });
    }

    function deleteItem(button) {
        var value = button.getAttribute('data-value');
        if (!confirm('Supprimer \u00ab ' + value + ' \u00bb ?')) return;

        htmx.ajax('POST', '/api/referentials.php', {
            target: '#referential-table-body',
            swap: 'innerHTML',
            values: {
                type: currentType,
                action: 'delete',
                value: value
            }
        });
    }

    // Délégation d'événements sur le tbody pour gérer les boutons rendus par HTMX
    tbody.addEventListener('click', function(e) {
        var editBtn = e.target.closest('.edit-btn');
        if (editBtn) {
            startEdit(editBtn);
            return;
        }
        var deleteBtn = e.target.closest('.delete-btn');
        if (deleteBtn) {
            deleteItem(deleteBtn);
            return;
        }
    });
})();
</script>
```

- [ ] **Step 2: Créer les 3 pages admin (controllers)**

`public/admin/formations.php` :

```php
<?php

require_once __DIR__ . '/../bootstrap.php';
$auth->requireAuth();

$type = 'formations';
$adminTitle = 'Gérer les formations';
$pageTitle = 'Admin — Formations';

ob_start();
require __DIR__ . '/../../templates/admin/referential.php';
$content = ob_get_clean();
require __DIR__ . '/../../templates/layout.php';
```

`public/admin/clients.php` :

```php
<?php

require_once __DIR__ . '/../bootstrap.php';
$auth->requireAuth();

$type = 'clients';
$adminTitle = 'Gérer les clients';
$pageTitle = 'Admin — Clients';

ob_start();
require __DIR__ . '/../../templates/admin/referential.php';
$content = ob_get_clean();
require __DIR__ . '/../../templates/layout.php';
```

`public/admin/lieux.php` :

```php
<?php

require_once __DIR__ . '/../bootstrap.php';
$auth->requireAuth();

$type = 'lieux';
$adminTitle = 'Gérer les lieux';
$pageTitle = 'Admin — Lieux';

ob_start();
require __DIR__ . '/../../templates/admin/referential.php';
$content = ob_get_clean();
require __DIR__ . '/../../templates/layout.php';
```

- [ ] **Step 3: Tester manuellement**

```bash
php -S localhost:8080 -t public/
```

Se connecter puis naviguer vers `/admin/clients.php`. Vérifier :
- Le tableau est vide au départ
- "Ajouter" crée une entrée sans recharger la page
- "Modifier" passe en mode édition inline, Enter valide, Escape annule
- "Supprimer" demande confirmation et supprime
- Les entrées sont triées alphabétiquement
- Naviguer vers formations.php et lieux.php fonctionne pareil

- [ ] **Step 4: Commit**

```bash
git add templates/admin/referential.php public/admin/formations.php public/admin/clients.php public/admin/lieux.php
git commit -m "feat: add admin pages for formations/clients/lieux with HTMX CRUD"
```

---

### Task 17: Test d'intégration end-to-end et setup Google Cloud

**Files:** Aucun nouveau fichier

- [ ] **Step 1: Configurer Google Cloud**

Suivre ces étapes dans la Google Cloud Console :

1. Aller sur https://console.cloud.google.com
2. Sélectionner le projet existant
3. Menu hamburger → APIs & Services → Library
4. Chercher "Google Calendar API" → cliquer → "Enable"
5. Menu → APIs & Services → Credentials
6. "Create Credentials" → "OAuth client ID"
7. Application type : "Web application"
8. Name : "Eolem Planning"
9. Authorized redirect URIs : `http://localhost:8080/callback.php` (dev) et l'URL OVH de production
10. Cliquer "Create"
11. Télécharger le JSON → le sauvegarder dans `config/google-credentials.json`

- [ ] **Step 2: Créer le .env avec les vrais identifiants**

```bash
php -r "echo password_hash('VOTRE_MOT_DE_PASSE', PASSWORD_DEFAULT) . PHP_EOL;"
```

Mettre le hash dans `.env` :
```
APP_USERNAME=admin
APP_PASSWORD_HASH=<hash_généré>
GOOGLE_CALENDAR_ID=primary
```

- [ ] **Step 3: Test end-to-end**

```bash
php -S localhost:8080 -t public/
```

1. Se connecter sur `http://localhost:8080`
2. Aller dans Admin → ajouter une formation, un client, un lieu
3. Aller dans "Nouvelle formation"
4. Remplir le formulaire → "Créer dans Google Calendar"
5. Autoriser l'accès Google Calendar (première fois)
6. Vérifier la page de confirmation
7. Ouvrir Google Calendar → vérifier que les événements sont créés avec la bonne couleur

- [ ] **Step 4: Lancer tous les tests**

```bash
./vendor/bin/phpunit
```

Attendu : tous les tests passent

- [ ] **Step 5: Commit final**

```bash
git add -A
git commit -m "chore: finalize project setup"
```
