# Sélection multiple et mass update des réservations — 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:** Permettre de sélectionner plusieurs réservations d'un subnet et de modifier en une fois leurs options (groupe, lease court, gateway, no-gateway, routes, NTP), en ne touchant que les champs réellement modifiés et en gérant les disparités.

**Architecture :** Le popup de mass update est construit côté JS à partir des options détaillées de toutes les réservations, désormais injectées dans `subnet.php` (via un `getBySubnet()` enrichi par une requête groupée). L'application se fait par un POST vers un nouveau handler `reservation_bulk.php` qui appelle `Reservation::massUpdate()` (recompose l'état complet des options par hôte avant écriture), puis un seul `SyncService::sync()` et un `AuditLog`.

**Tech Stack :** PHP 8.5 vanilla + PDO, Bootstrap 5 (modale + composants, servis en local), JS vanilla. Pas de framework de test — vérification via `php -l`, scripts PHP CLI contre la vraie base (192.168.1.2), et contrôle navigateur.

**Rappels projet (CLAUDE.md) :**
- `git add` : nommer les fichiers explicitement, jamais `git add -A`.
- Message de commit finissant par `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- Contrainte offline : aucun asset CDN.
- Aucune table native Kea modifiée (groupe dans `app_reservation_meta`, options dans `dhcp4_options` scope_id=4 déjà existante).
- Sync : le contrôleur décide quand synchroniser (`bash ~/sync.sh dhcpman`). Les tâches font des commits LOCAUX ; ne pas synchroniser sauf instruction du contrôleur.

**Ordre des tâches :** 1 (getBySubnet enrichi) → 2 (massUpdate) → 3 (handler bulk) → 4 (injection front + reset filtre) → 5 (sélection multiple) → 6 (popup) → 7 (doc). Les tâches 1-3 sont backend et testables en CLI ; 4-6 sont front (vérif navigateur finale).

---

### Task 1 : `getBySubnet()` enrichi — options détaillées en une requête groupée

**Files:**
- Modify: `lib/Reservation.php` (`getBySubnet()` ~L20-59)

- [ ] **Step 1 : Remplacer les sous-requêtes EXISTS par un chargement groupé des options**

Dans `getBySubnet()`, retirer les deux blocs `EXISTS(...) AS has_options` et
`EXISTS(...) AS has_ntp` du SELECT. Le SELECT se termine alors après `u2.username AS updated_by,`.
Remplacer la méthode entière par :

```php
    public static function getBySubnet(int $keaSubnetId): array {
        $stmt = Database::get()->prepare(
            'SELECT h.host_id,
                    HEX(h.dhcp_identifier) AS mac_hex,
                    h.ipv4_address,
                    h.hostname,
                    m.description,
                    m.group_name,
                    m.short_lease,
                    m.created_at,
                    m.updated_at,
                    u1.username AS created_by,
                    u2.username AS updated_by
             FROM hosts h
             LEFT JOIN app_reservation_meta m ON m.host_id = h.host_id
             LEFT JOIN app_users u1 ON u1.id = m.created_by_user_id
             LEFT JOIN app_users u2 ON u2.id = m.updated_by_user_id
             WHERE h.dhcp4_subnet_id = ?
               AND h.dhcp_identifier_type = ' . self::HW_ADDR_TYPE . '
             ORDER BY h.ipv4_address'
        );
        $stmt->execute([$keaSubnetId]);
        $rows = $stmt->fetchAll();

        // Charger les options (scope_id=4) de toutes les réservations en une requête
        $hostIds = array_map(fn($r) => (int)$r['host_id'], $rows);
        $optionsByHost = self::loadOptionsForHosts($hostIds);

        foreach ($rows as &$row) {
            $row['mac']        = formatMacHex($row['mac_hex']);
            $row['ip_address'] = ((int)$row['ipv4_address'] === 0) ? '' : intToIp((int)$row['ipv4_address']);

            $opts = $optionsByHost[(int)$row['host_id']] ?? [];
            $row['no_gateway']  = isset($opts['_no_gateway']) ? 1 : 0;
            $row['gateway']     = $row['no_gateway'] ? '' : ($opts[self::OPT_ROUTERS] ?? '');
            $row['routes']      = isset($opts[self::OPT_ROUTES]) ? self::parseRoutes($opts[self::OPT_ROUTES]) : [];
            $row['ntp_enabled'] = isset($opts[self::OPT_NTP]) ? 1 : 0;
            // Conserver les booléens existants pour l'affichage des icônes
            $row['has_options'] = ($row['gateway'] !== '' || $row['no_gateway'] || !empty($row['routes'])) ? 1 : 0;
            $row['has_ntp']     = $row['ntp_enabled'];
        }
        return $rows;
    }
```

- [ ] **Step 2 : Ajouter la méthode privée `loadOptionsForHosts()`**

Juste après `loadHostOptions()` (méthode privée existante, ~L399), ajouter :

```php
    /**
     * Charge les options DHCP (scope_id=4) de plusieurs hôtes en une requête.
     * Retourne [host_id => [code => formatted_value, '_no_gateway' => true?]].
     */
    private static function loadOptionsForHosts(array $hostIds): array {
        $hostIds = array_values(array_unique(array_map('intval', $hostIds)));
        if (!$hostIds) return [];
        $in   = implode(',', $hostIds);
        $stmt = Database::get()->query(
            'SELECT host_id, code, formatted_value FROM dhcp4_options
             WHERE scope_id = ' . self::HOST_SCOPE . ' AND host_id IN (' . $in . ')
               AND formatted_value IS NOT NULL AND formatted_value != \'\''
        );
        $result = [];
        foreach ($stmt->fetchAll() as $row) {
            $hid  = (int)$row['host_id'];
            $code = (int)$row['code'];
            if (!isset($result[$hid])) $result[$hid] = [];
            if ($code === self::OPT_ROUTERS && $row['formatted_value'] === self::NO_GATEWAY_VALUE) {
                $result[$hid]['_no_gateway'] = true;
            } else {
                $result[$hid][$code] = $row['formatted_value'];
            }
        }
        return $result;
    }
```

Note : `$in` est construit uniquement à partir d'entiers (`intval`), donc pas d'injection SQL malgré l'interpolation.

- [ ] **Step 3 : Lint**

Run: `php -l lib/Reservation.php`
Expected : `No syntax errors detected in lib/Reservation.php`

- [ ] **Step 4 : Test réel — getBySubnet renvoie bien le détail des options**

Créer `scratch_getbysubnet.php` à la racine :

```php
<?php
require "lib/init.php";
$subnet = Subnet::getAll()[0] ?? null;
if (!$subnet) { echo "no subnet\n"; exit; }
$kea = (int)$subnet['kea_subnet_id'];
// créer une résa avec gateway + routes + ntp + short_lease + groupe
$hid = Reservation::add($kea, [
    'mac'=>'02:00:00:00:0B:01','ip'=>'','hostname'=>'masstest','description'=>'d','group_name'=>'GB',
    'short_lease'=>1,'gateway'=>'','routes'=>[['dest'=>'10.0.0.0/8','gw'=>'192.168.1.254']],'ntp_value'=>'','no_gateway'=>0,
]);
$found = null;
foreach (Reservation::getBySubnet($kea) as $r) { if ((int)$r['host_id'] === $hid) $found = $r; }
echo "gateway=" . var_export($found['gateway'], true) . "\n";
echo "no_gateway=" . var_export($found['no_gateway'], true) . "\n";
echo "routes=" . json_encode($found['routes']) . "\n";
echo "ntp_enabled=" . var_export($found['ntp_enabled'], true) . "\n";
echo "has_options=" . var_export($found['has_options'], true) . " (attendu 1)\n";
echo "group_name=" . var_export($found['group_name'], true) . "\n";
Reservation::delete($hid);
echo "cleanup OK\n";
```

Run: `php scratch_getbysubnet.php`
Expected (valeurs clés) :
```
no_gateway=0
routes=[{"dest":"10.0.0.0\/8","gw":"192.168.1.254"}]
ntp_enabled=0
has_options=1 (attendu 1)
group_name='GB'
cleanup OK
```

- [ ] **Step 5 : Supprimer le script et commit**

```bash
rm scratch_getbysubnet.php
git add lib/Reservation.php
git commit -m "feat: getBySubnet charge le détail des options en une requête groupée

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 2 : `Reservation::massUpdate()`

**Files:**
- Modify: `lib/Reservation.php` (nouvelle méthode publique après `update()` ~L167)

- [ ] **Step 1 : Ajouter la méthode `massUpdate()`**

Juste après la méthode `update()` (avant `delete()`), ajouter :

```php
    /**
     * Applique un sous-ensemble de champs à plusieurs réservations.
     * $changes ne contient QUE les champs réellement modifiés. Clés possibles :
     *   'group_name' (string), 'short_lease' (0/1), 'gateway' (string),
     *   'no_gateway' (0/1), 'routes' (array [{dest,gw}]), 'ntp' (0/1)
     * Un champ absent de $changes n'est pas touché.
     * Ne fait PAS de sync (le handler s'en charge une seule fois).
     * Retourne le nombre de réservations mises à jour.
     */
    public static function massUpdate(array $hostIds, array $changes): int {
        $hostIds = array_values(array_unique(array_map('intval', $hostIds)));
        if (!$hostIds || !$changes) return 0;

        $db = Database::get();
        $db->beginTransaction();
        try {
            $touchesOptions = array_intersect_key(
                $changes,
                array_flip(['gateway', 'no_gateway', 'routes', 'ntp'])
            );

            foreach ($hostIds as $hostId) {
                $current = self::getById($hostId);
                if (!$current) continue;

                // ── Meta : groupe ──
                if (array_key_exists('group_name', $changes)) {
                    $g = trim((string)$changes['group_name']);
                    self::upsertMetaField($hostId, 'group_name', $g !== '' ? $g : null);
                }

                // ── Short lease : hosts.dhcp4_client_classes + meta ──
                if (array_key_exists('short_lease', $changes)) {
                    $sl = !empty($changes['short_lease']) ? 1 : 0;
                    $db->prepare('UPDATE hosts SET dhcp4_client_classes = ? WHERE host_id = ?')
                       ->execute([$sl ? 'short-lease' : null, $hostId]);
                    self::upsertMetaField($hostId, 'short_lease', $sl);
                }

                // ── Options DHCP : recomposer l'état complet puis réécrire ──
                if ($touchesOptions) {
                    $gateway    = $current['gateway'] ?? '';
                    $noGateway  = !empty($current['no_gateway']);
                    $routes     = $current['routes'] ?? [];
                    $ntpEnabled = !empty($current['ntp_enabled']);

                    if (array_key_exists('no_gateway', $changes)) $noGateway = !empty($changes['no_gateway']);
                    if (array_key_exists('gateway', $changes))    $gateway   = trim((string)$changes['gateway']);
                    if (array_key_exists('routes', $changes))     $routes    = $changes['routes'];
                    if (array_key_exists('ntp', $changes))        $ntpEnabled = !empty($changes['ntp']);

                    // Exclusivité RFC 3442
                    $hasRoutes = false;
                    foreach ($routes as $r) {
                        if (trim($r['dest'] ?? '') !== '' && trim($r['gw'] ?? '') !== '') { $hasRoutes = true; break; }
                    }
                    if ($hasRoutes) { $gateway = ''; $noGateway = false; }
                    elseif ($noGateway) { $gateway = ''; $routes = []; }

                    $ntpValue = $ntpEnabled ? self::subnetNtp($hostId) : '';
                    self::saveHostOptions($hostId, $gateway, $routes, $ntpValue, $noGateway);
                }

                // Traçage updated_by/updated_at (même si seules les options changent)
                self::touchMeta($hostId);
            }
            $db->commit();
        } catch (\Throwable $e) {
            $db->rollBack();
            throw $e;
        }
        return count($hostIds);
    }
```

- [ ] **Step 2 : Ajouter les helpers privés `upsertMetaField()`, `touchMeta()`, `subnetNtp()`**

Juste après `massUpdate()`, ajouter :

```php
    /** Upsert d'un seul champ de app_reservation_meta (crée la ligne si absente). */
    private static function upsertMetaField(int $hostId, string $field, $value): void {
        // Liste blanche du nom de colonne (jamais de valeur utilisateur ici)
        $allowed = ['group_name', 'short_lease'];
        if (!in_array($field, $allowed, true)) return;
        Database::get()->prepare(
            "INSERT INTO app_reservation_meta (host_id, $field, created_at)
             VALUES (?, ?, NOW())
             ON DUPLICATE KEY UPDATE $field = VALUES($field)"
        )->execute([$hostId, $value]);
    }

    /** Met à jour updated_by_user_id / updated_at (crée la ligne meta si absente). */
    private static function touchMeta(int $hostId): void {
        Database::get()->prepare(
            'INSERT INTO app_reservation_meta (host_id, created_at, updated_by_user_id, updated_at)
             VALUES (?, NOW(), ?, NOW())
             ON DUPLICATE KEY UPDATE updated_by_user_id = VALUES(updated_by_user_id), updated_at = NOW()'
        )->execute([$hostId, Auth::currentUserId()]);
    }

    /** Serveur NTP du subnet d'un hôte (pour l'option 42 lorsqu'on active le NTP). */
    private static function subnetNtp(int $hostId): string {
        $stmt = Database::get()->prepare('SELECT dhcp4_subnet_id FROM hosts WHERE host_id = ?');
        $stmt->execute([$hostId]);
        $keaId = (int)$stmt->fetchColumn();
        $subnet = Subnet::getByKeaSubnetId($keaId);
        return $subnet['ntp_servers'] ?? '';
    }
```

Note : `subnetNtp()` reflète le comportement du formulaire individuel (le NTP hôte utilise
le serveur NTP du subnet). Si le subnet n'a pas de `ntp_servers`, activer NTP n'écrit rien
d'exploitable — cohérent avec `reservation_add.php` qui n'affiche la case NTP que si le
subnet a un serveur NTP.

- [ ] **Step 3 : Lint**

Run: `php -l lib/Reservation.php`
Expected : `No syntax errors detected in lib/Reservation.php`

- [ ] **Step 4 : Test réel — massUpdate ne touche que les champs fournis**

Créer `scratch_massupdate.php` :

```php
<?php
require "lib/init.php";
$subnet = Subnet::getAll()[0] ?? null;
if (!$subnet) { echo "no subnet\n"; exit; }
$kea = (int)$subnet['kea_subnet_id'];

// Deux résa : A avec gateway .254, B avec route 10.0.0.0/8
$a = Reservation::add($kea, ['mac'=>'02:00:00:00:0C:01','ip'=>'','hostname'=>'mua','description'=>'','group_name'=>'X','short_lease'=>0,'gateway'=>'192.168.1.254','routes'=>[],'ntp_value'=>'','no_gateway'=>0]);
$b = Reservation::add($kea, ['mac'=>'02:00:00:00:0C:02','ip'=>'','hostname'=>'mub','description'=>'','group_name'=>'Y','short_lease'=>0,'gateway'=>'','routes'=>[['dest'=>'10.0.0.0/8','gw'=>'192.168.1.1']],'ntp_value'=>'','no_gateway'=>0]);

// Mass update : uniquement le groupe → 'COMMUN'. On ne touche PAS les options.
$n = Reservation::massUpdate([$a,$b], ['group_name'=>'COMMUN']);
echo "massUpdate count=$n (attendu 2)\n";
$ra = Reservation::getById($a); $rb = Reservation::getById($b);
echo "A group=" . var_export($ra['group_name'],true) . " gateway=" . var_export($ra['gateway'],true) . " (gateway préservée .254)\n";
echo "B group=" . var_export($rb['group_name'],true) . " routes=" . json_encode($rb['routes']) . " (route préservée)\n";

// Mass update : forcer short_lease=1 sur les deux
Reservation::massUpdate([$a,$b], ['short_lease'=>1]);
$ra = Reservation::getById($a); $rb = Reservation::getById($b);
echo "A short_lease=" . $ra['short_lease'] . " B short_lease=" . $rb['short_lease'] . " (attendu 1 et 1)\n";
echo "A gateway toujours=" . var_export($ra['gateway'],true) . " (préservée)\n";

// Mass update : imposer no_gateway=1 → doit vider gateway ET routes (exclusivité)
Reservation::massUpdate([$a,$b], ['no_gateway'=>1]);
$ra = Reservation::getById($a); $rb = Reservation::getById($b);
echo "A no_gateway=" . $ra['no_gateway'] . " gateway=" . var_export($ra['gateway'],true) . " routes=" . json_encode($ra['routes']) . "\n";
echo "B no_gateway=" . $rb['no_gateway'] . " routes=" . json_encode($rb['routes']) . " (routes vidées)\n";

Reservation::delete($a); Reservation::delete($b);
echo "cleanup OK\n";
```

Run: `php scratch_massupdate.php`
Expected (valeurs clés) :
```
massUpdate count=2 (attendu 2)
A group='COMMUN' gateway='192.168.1.254' (gateway préservée .254)
B group='COMMUN' routes=[{"dest":"10.0.0.0\/8","gw":"192.168.1.1"}] (route préservée)
A short_lease=1 B short_lease=1 (attendu 1 et 1)
A gateway toujours='192.168.1.254' (préservée)
A no_gateway=1 gateway='' routes=[]
B no_gateway=1 routes=[] (routes vidées)
cleanup OK
```

Si un résultat diffère (surtout la préservation des options non modifiées), corriger avant de continuer.

- [ ] **Step 5 : Supprimer le script et commit**

```bash
rm scratch_massupdate.php
git add lib/Reservation.php
git commit -m "feat: Reservation::massUpdate (n'écrit que les champs modifiés, recompose les options)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 3 : Handler `reservation_bulk.php`

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

- [ ] **Step 1 : Créer le handler POST**

Create `public/reservation_bulk.php` :

```php
<?php

require_once __DIR__ . '/../lib/init.php';
Auth::requireLogin();
Auth::requireRole('admin', 'tech');

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

$subnetId = (int)($_POST['subnet_id'] ?? 0);
$subnet   = Subnet::getById($subnetId);
if (!$subnet) {
    header('Location: dashboard.php');
    exit;
}
$backUrl = 'subnet.php?id=' . $subnetId;

$hostIds = array_values(array_unique(array_map('intval', $_POST['host_ids'] ?? [])));
if (!$hostIds) {
    $_SESSION['flash'] = ['type' => 'warning', 'message' => 'Aucune réservation sélectionnée.'];
    header('Location: ' . $backUrl);
    exit;
}

// Sécurité : tous les host_ids doivent appartenir à ce subnet
$in   = implode(',', $hostIds);
$stmt = Database::get()->prepare(
    "SELECT host_id FROM hosts WHERE dhcp4_subnet_id = ? AND host_id IN ($in)"
);
$stmt->execute([$subnet['kea_subnet_id']]);
$valid = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
if (count($valid) !== count($hostIds)) {
    $_SESSION['flash'] = ['type' => 'danger', 'message' => 'Sélection invalide (réservation hors subnet).'];
    header('Location: ' . $backUrl);
    exit;
}

// Champs modifiés : $_POST['changed'][<champ>] = '1'
$changed = $_POST['changed'] ?? [];
$changes = [];

if (!empty($changed['group_name'])) {
    $changes['group_name'] = trim((string)($_POST['group_name'] ?? ''));
}
if (!empty($changed['short_lease'])) {
    $changes['short_lease'] = !empty($_POST['short_lease']) ? 1 : 0;
}
if (!empty($changed['ntp'])) {
    $changes['ntp'] = !empty($_POST['ntp']) ? 1 : 0;
}
if (!empty($changed['no_gateway'])) {
    $changes['no_gateway'] = !empty($_POST['no_gateway']) ? 1 : 0;
}
if (!empty($changed['gateway'])) {
    $changes['gateway'] = trim((string)($_POST['gateway'] ?? ''));
}
if (!empty($changed['routes'])) {
    $routesDest = $_POST['routes_dest'] ?? [];
    $routesGw   = $_POST['routes_gw']   ?? [];
    $routes = [];
    for ($i = 0; $i < max(count($routesDest), count($routesGw)); $i++) {
        $dest = trim($routesDest[$i] ?? '');
        $gw   = trim($routesGw[$i]   ?? '');
        if ($dest !== '' || $gw !== '') $routes[] = ['dest' => $dest, 'gw' => $gw];
    }
    $changes['routes'] = $routes;
}

if (!$changes) {
    $_SESSION['flash'] = ['type' => 'warning', 'message' => 'Aucune modification à appliquer.'];
    header('Location: ' . $backUrl);
    exit;
}

// Validation des valeurs fournies (réutilise les mêmes règles que le formulaire)
$errors = [];
if (isset($changes['gateway']) && $changes['gateway'] !== '' && !isValidIp($changes['gateway'])) {
    $errors[] = 'Gateway spécifique : format IPv4 invalide.';
}
if (isset($changes['routes'])) {
    foreach ($changes['routes'] as $i => $r) {
        $dest = trim($r['dest'] ?? ''); $gw = trim($r['gw'] ?? '');
        if ($dest === '' && $gw === '') continue;
        if (!preg_match('/^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/', $dest)) {
            $errors[] = 'Route ' . ($i + 1) . ' : destination CIDR invalide.';
        }
        if (!isValidIp($gw)) {
            $errors[] = 'Route ' . ($i + 1) . ' : gateway invalide.';
        }
    }
}
if ($errors) {
    $_SESSION['flash'] = ['type' => 'danger', 'message' => implode(' ', $errors)];
    header('Location: ' . $backUrl);
    exit;
}

$count = Reservation::massUpdate($hostIds, $changes);
$sync  = SyncService::sync();

AuditLog::log(
    'reservation.bulk_update',
    'host',
    null,
    sprintf('%d réservation(s) — champs: %s (subnet %s)', $count, implode(', ', array_keys($changes)), $subnet['name'])
);

if ($sync['success']) {
    $_SESSION['flash'] = ['type' => 'success', 'message' => "$count réservation(s) mise(s) à jour et Kea rechargé."];
} else {
    $_SESSION['flash'] = ['type' => 'warning', 'message' => "$count réservation(s) mise(s) à jour mais le reload Kea a échoué : " . $sync['message']];
}
header('Location: ' . $backUrl);
exit;
```

- [ ] **Step 2 : Lint**

Run: `php -l public/reservation_bulk.php`
Expected : `No syntax errors detected in public/reservation_bulk.php`

- [ ] **Step 3 : Vérifier la présence des dépendances utilisées**

Run:
```bash
grep -q "function isValidIp" lib/helpers.php && echo "isValidIp OK"
grep -q "public static function log" lib/AuditLog.php && echo "AuditLog::log OK"
grep -q "public static function getByKeaSubnetId" lib/Subnet.php && echo "getByKeaSubnetId OK"
```
Expected : les trois lignes `OK`.

- [ ] **Step 4 : Commit**

```bash
git add public/reservation_bulk.php
git commit -m "feat: handler POST reservation_bulk (mass update + sync + audit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 4 : Front — injection des options détaillées + reset du filtre réservations

**Files:**
- Modify: `public/subnet.php` (construction `$rows` ~L17-31 ; champ filtre ~toolbar)

- [ ] **Step 1 : Injecter les options détaillées dans `$rows`**

Dans `public/subnet.php`, dans la boucle qui construit `$rows` (~L17), ajouter les clés
d'options après `'short_lease'` :

```php
        'host_id'     => (int)$r['host_id'],
        'hostname'    => $r['hostname'],
        'ip'          => $ip,
        'ip_int'      => $ip === '' ? 0 : (int)ipToInt($ip),
        'mac'         => $r['mac'],
        'mac_hex'     => strtolower($r['mac_hex']),
        'description' => $r['description'] ?? '',
        'group_name'  => $r['group_name'] ?? '',
        'has_options' => (int)$r['has_options'],
        'has_ntp'     => (int)$r['has_ntp'],
        'short_lease' => (int)$r['short_lease'],
        'gateway'     => $r['gateway'] ?? '',
        'no_gateway'  => (int)($r['no_gateway'] ?? 0),
        'routes'      => $r['routes'] ?? [],
        'ntp_enabled' => (int)($r['ntp_enabled'] ?? 0),
        'modified'    => substr($r['updated_at'] ?? $r['created_at'] ?? '', 0, 16),
```

(Remplacer le tableau `$rows[] = [ ... ];` existant par cette version augmentée en
conservant l'ordre des clés déjà présentes.)

- [ ] **Step 2 : Ajouter le bouton ✕ de reset au champ de filtre**

Dans la toolbar des réservations, le champ de filtre est actuellement :

```php
            <div class="reservation-filter flex-grow-1 position-relative" style="max-width:340px">
                <input type="text" id="res-filter" class="form-control form-control-sm"
                       placeholder="Filtrer (hostname, IP, MAC, description, groupe)…" autocomplete="off">
            </div>
```

Le remplacer par (ajout du bouton ✕) :

```php
            <div class="reservation-filter flex-grow-1 position-relative" style="max-width:340px">
                <input type="text" id="res-filter" class="form-control form-control-sm"
                       placeholder="Filtrer (hostname, IP, MAC, description, groupe)…" autocomplete="off">
                <button type="button" id="res-filter-clear" class="res-filter-clear" title="Effacer" hidden>&times;</button>
            </div>
```

- [ ] **Step 3 : Câbler le reset dans le JS (dans la fonction render existante)**

Dans le `<script>` de `subnet.php`, repérer la ligne
`filterInput.addEventListener('input', render);` et ajouter juste après :

```php
    var filterClear = document.getElementById('res-filter-clear');
    if (filterClear) {
        filterInput.addEventListener('input', function () { filterClear.hidden = (filterInput.value === ''); });
        filterClear.addEventListener('click', function () {
            filterInput.value = '';
            filterClear.hidden = true;
            render();
            filterInput.focus();
        });
    }
```

- [ ] **Step 4 : CSS du bouton reset**

Dans `public/assets/css/app.css`, à la fin du fichier, ajouter :

```css
/* Bouton reset du filtre des réservations */
.reservation-filter { position: relative; }
.reservation-filter .res-filter-clear {
    position: absolute;
    right: .4rem;
    top: 50%;
    transform: translateY(-50%);
    background: none;
    border: none;
    color: #94a3b8;
    font-size: 1.15rem;
    line-height: 1;
    cursor: pointer;
    padding: 0 .2rem;
}
.reservation-filter .res-filter-clear:hover { color: #475569; }
```

- [ ] **Step 5 : Lint**

Run: `php -l public/subnet.php`
Expected : `No syntax errors detected in public/subnet.php`

- [ ] **Step 6 : Commit**

```bash
git add public/subnet.php public/assets/css/app.css
git commit -m "feat: options détaillées injectées dans subnet.php + reset du filtre réservations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 5 : Front — sélection multiple (cases + barre d'action)

**Files:**
- Modify: `public/subnet.php` (toolbar, moteur de rendu JS, barre d'action)
- Modify: `public/assets/css/app.css` (barre d'action)

- [ ] **Step 1 : Ajouter la barre d'action (masquée par défaut) sous le conteneur**

Dans `public/subnet.php`, juste après `<div id="reservation-container"></div>`, ajouter
(uniquement pour admin|tech) :

```php
        <?php if ($canEdit): ?>
        <div id="selection-bar" class="selection-bar" hidden>
            <span id="selection-count">0 sélectionnée(s)</span>
            <button type="button" class="btn btn-sm btn-primary" id="btn-mass-edit">
                <i class="bi bi-pencil-square me-1"></i>Modifier les options
            </button>
            <button type="button" class="btn btn-sm btn-outline-secondary" id="btn-clear-selection">
                Tout désélectionner
            </button>
        </div>
        <?php endif; ?>
```

- [ ] **Step 2 : Ajouter l'état de sélection et les cases dans le JS**

Dans le `<script>` de `subnet.php`, après la ligne `var CAN_EDIT = ...;`, ajouter :

```php
    var selected = new Set();
    var selectionBar   = document.getElementById('selection-bar');
    var selectionCount = document.getElementById('selection-count');

    function updateSelectionBar() {
        if (!selectionBar) return;
        var n = selected.size;
        selectionBar.hidden = (n === 0);
        if (selectionCount) selectionCount.textContent = n + ' sélectionnée(s)';
    }
```

- [ ] **Step 3 : Modifier `makeTable` et `appendRow` pour la colonne case (mode canEdit)**

Dans le JS, dans `makeTable(withGroupCol)`, ajouter une colonne d'en-tête case à cocher
en tête si `CAN_EDIT`. Remplacer la construction de `heads`/`tr` par :

```php
        var heads = [];
        if (CAN_EDIT) heads.push('__check__');
        heads.push('Hostname', 'Adresse IP', 'Adresse MAC');
        if (withGroupCol) heads.push('Groupe');
        heads.push('Description', 'Modifié le', '');
        var tr = document.createElement('tr');
        heads.forEach(function (htext, i) {
            var th = document.createElement('th');
            if (htext === '__check__') {
                th.style.width = '34px';
                var all = document.createElement('input');
                all.type = 'checkbox';
                all.className = 'form-check-input select-all';
                th.appendChild(all);
            } else {
                th.textContent = htext;
                if (i === heads.length - 1) th.style.width = '100px';
            }
            tr.appendChild(th);
        });
```

Et dans `appendRow(tbody, r, withGroupCol)`, ajouter la cellule case en tête si `CAN_EDIT`.
Remplacer le début de la fonction par :

```php
    function appendRow(tbody, r, withGroupCol) {
        var tr = document.createElement('tr');
        if (CAN_EDIT) {
            var tdc = document.createElement('td');
            var cb = document.createElement('input');
            cb.type = 'checkbox';
            cb.className = 'form-check-input row-check';
            cb.setAttribute('data-host-id', r.host_id);
            cb.checked = selected.has(r.host_id);
            cb.addEventListener('change', function () {
                if (cb.checked) selected.add(r.host_id); else selected.delete(r.host_id);
                updateSelectionBar();
                syncSelectAll();
            });
            tdc.appendChild(cb);
            tr.appendChild(tdc);
        }
        tr.appendChild(hostnameCell(r));
        tr.appendChild(ipCell(r));
        tr.appendChild(macCell(r));
        if (withGroupCol) tr.appendChild(groupBadgeCell(r));
        tr.appendChild(simpleCell(r.description));
        tr.appendChild(simpleCell(r.modified));
        tr.appendChild(actionsCell(r));
        tbody.appendChild(tr);
    }
```

- [ ] **Step 4 : Câbler les cases « tout » après chaque rendu**

Dans le JS, ajouter cette fonction (avant `render`) :

```php
    function syncSelectAll() {
        document.querySelectorAll('.select-all').forEach(function (all) {
            var scope = all.closest('table');
            var boxes = scope ? scope.querySelectorAll('.row-check') : [];
            var total = boxes.length, checked = 0;
            boxes.forEach(function (b) { if (b.checked) checked++; });
            all.checked = (total > 0 && checked === total);
            all.indeterminate = (checked > 0 && checked < total);
        });
    }
    function wireSelectAll() {
        document.querySelectorAll('.select-all').forEach(function (all) {
            all.addEventListener('change', function () {
                var scope = all.closest('table');
                var boxes = scope ? scope.querySelectorAll('.row-check') : [];
                boxes.forEach(function (b) {
                    b.checked = all.checked;
                    var id = parseInt(b.getAttribute('data-host-id'), 10);
                    if (all.checked) selected.add(id); else selected.delete(id);
                });
                updateSelectionBar();
            });
        });
    }
```

Puis, à la fin de `render()`, juste après la ré-init des tooltips, ajouter :

```php
        wireSelectAll();
        syncSelectAll();
        updateSelectionBar();
```

- [ ] **Step 5 : Câbler les boutons de la barre**

Dans le JS, après la ligne `filterInput.addEventListener('input', render);` (ou après le bloc
reset ajouté en Task 4), ajouter :

```php
    var btnClearSel = document.getElementById('btn-clear-selection');
    if (btnClearSel) {
        btnClearSel.addEventListener('click', function () {
            selected.clear();
            render();
        });
    }
```

(Le bouton « Modifier les options » est câblé en Task 6.)

- [ ] **Step 6 : CSS de la barre d'action**

Dans `public/assets/css/app.css`, à la fin, ajouter :

```css
/* Barre d'action de sélection multiple (bas de liste) */
.selection-bar {
    display: flex;
    align-items: center;
    gap: .6rem;
    padding: .6rem .9rem;
    border-top: 1px solid #dee2e6;
    background: #f8f9ff;
    position: sticky;
    bottom: 0;
}
.selection-bar #selection-count { font-weight: 600; margin-right: auto; }
@media (prefers-color-scheme: dark) {
    .selection-bar { background: #2d3e52; border-color: #3d5169; }
}
```

- [ ] **Step 7 : Lint + vérification navigateur**

Run: `php -l public/subnet.php`
Expected : `No syntax errors detected`.

Vérification navigateur (à cocher par le contrôleur/utilisateur) :
1. Une colonne case à cocher apparaît en tête de chaque ligne (admin/tech).
2. Cocher des lignes → la barre d'action apparaît en bas avec le bon compteur.
3. La case « tout » (en-tête tableau/plat ; en-tête de groupe/groupés) coche/décoche le
   groupe et passe en indéterminé si sélection partielle.
4. Basculer mode groupés/plat ou filtrer : la sélection est conservée (les cases reflètent
   le Set).
5. « Tout désélectionner » vide la sélection, la barre disparaît.
6. Rôle viewer : aucune case ni barre.

- [ ] **Step 8 : Commit**

```bash
git add public/subnet.php public/assets/css/app.css
git commit -m "feat: sélection multiple des réservations (cases + barre d'action)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 6 : Front — popup de mass update

**Files:**
- Modify: `public/subnet.php` (markup modale + logique JS du popup)

- [ ] **Step 1 : Ajouter le markup de la modale**

Dans `public/subnet.php`, avant `<?php require_once APP_ROOT . '/lib/layout_footer.php'; ?>`,
ajouter le squelette de la modale (uniquement admin|tech). Le corps est rempli par JS :

```php
<?php if ($canEdit): ?>
<div class="modal fade" id="massEditModal" tabindex="-1" aria-hidden="true">
  <div class="modal-dialog modal-lg modal-dialog-centered">
    <div class="modal-content">
      <form method="post" action="reservation_bulk.php" id="mass-edit-form">
        <input type="hidden" name="subnet_id" value="<?= (int)$subnet['id'] ?>">
        <div id="mass-edit-hostids"></div>
        <div class="modal-header">
          <h5 class="modal-title"><i class="bi bi-pencil-square me-2"></i>Modifier <span id="mass-edit-count">0</span> réservation(s)</h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
        </div>
        <div class="modal-body" id="mass-edit-body"></div>
        <div class="modal-footer">
          <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
          <button type="submit" class="btn btn-primary" id="mass-edit-apply" disabled>Appliquer</button>
        </div>
      </form>
    </div>
  </div>
</div>
<?php endif; ?>
```

- [ ] **Step 2 : Construire le corps du popup en JS**

Dans le `<script>` de `subnet.php`, ajouter la logique du popup. Placer ce bloc après la
définition de `render()` et le câblage des boutons (mais dans la même IIFE). Il définit
`buildMassEditBody()` et câble le bouton « Modifier les options ».

```php
    // ── Popup de mass update ──────────────────────────────────────────────────
    var btnMassEdit = document.getElementById('btn-mass-edit');
    var massModalEl = document.getElementById('massEditModal');
    var massModal   = massModalEl ? new bootstrap.Modal(massModalEl) : null;

    function selectedRows() {
        return ROWS.filter(function (r) { return selected.has(r.host_id); });
    }

    // Retourne 'on' | 'off' | 'mixed' pour un champ booléen sur la sélection
    function boolConsensus(rows, key) {
        var anyOn = false, anyOff = false;
        rows.forEach(function (r) { if (r[key]) anyOn = true; else anyOff = true; });
        if (anyOn && anyOff) return 'mixed';
        return anyOn ? 'on' : 'off';
    }
    // Valeur commune d'un champ texte, ou null si divergent
    function textConsensus(rows, key) {
        var first = rows.length ? (rows[0][key] || '') : '';
        for (var i = 1; i < rows.length; i++) { if ((rows[i][key] || '') !== first) return null; }
        return first;
    }
    // Routes communes (JSON identique) ou null si divergent
    function routesConsensus(rows) {
        var first = rows.length ? JSON.stringify(rows[0].routes || []) : '[]';
        for (var i = 1; i < rows.length; i++) { if (JSON.stringify(rows[i].routes || []) !== first) return null; }
        return JSON.parse(first);
    }

    function buildMassEditBody() {
        var rows = selectedRows();
        var body = document.getElementById('mass-edit-body');
        body.innerHTML = '';
        document.getElementById('mass-edit-count').textContent = rows.length;

        // hidden host_ids
        var hidden = document.getElementById('mass-edit-hostids');
        hidden.innerHTML = '';
        rows.forEach(function (r) {
            var i = document.createElement('input');
            i.type = 'hidden'; i.name = 'host_ids[]'; i.value = r.host_id;
            hidden.appendChild(i);
        });

        // état initial calculé
        var init = {
            group_name: textConsensus(rows, 'group_name'),   // string | null(mixed)
            short_lease: boolConsensus(rows, 'short_lease'),  // on|off|mixed
            gateway: textConsensus(rows, 'gateway'),
            no_gateway: boolConsensus(rows, 'no_gateway'),
            ntp: boolConsensus(rows, 'ntp_enabled'),
            routes: routesConsensus(rows)                     // array | null(mixed)
        };

        body.appendChild(buildTextField('group_name', 'Groupe', init.group_name));
        body.appendChild(buildTriField('short_lease', 'Lease court (1h)', init.short_lease));
        body.appendChild(buildTextField('gateway', 'Gateway spécifique', init.gateway));
        body.appendChild(buildTriField('no_gateway', 'Ne pas envoyer de gateway', init.no_gateway));
        body.appendChild(buildRoutesField('routes', 'Routes statiques', init.routes));
        body.appendChild(buildTriField('ntp', 'NTP', init.ntp));

        refreshApplyState();
        applyExclusivity();
    }
```

- [ ] **Step 3 : Ajouter les constructeurs de champs et la logique modifié/reset**

Dans la même IIFE, ajouter ces fonctions (elles produisent des lignes de champ avec un
`changed[<field>]` caché et un bouton reset qui n'apparaît que si modifié) :

```php
    function markChanged(row, field, isChanged) {
        var flag = row.querySelector('input[name="changed[' + field + ']"]');
        var reset = row.querySelector('.field-reset');
        flag.value = isChanged ? '1' : '';
        if (reset) reset.hidden = !isChanged;
        refreshApplyState();
    }

    function fieldRow(field, label) {
        var row = document.createElement('div');
        row.className = 'mass-field d-flex align-items-center gap-2 py-2 border-bottom';
        row.setAttribute('data-field', field);
        var lab = document.createElement('label');
        lab.className = 'fw-semibold';
        lab.style.width = '190px';
        lab.textContent = label;
        row.appendChild(lab);
        var flag = document.createElement('input');
        flag.type = 'hidden'; flag.name = 'changed[' + field + ']'; flag.value = '';
        row.appendChild(flag);
        return row;
    }
    function addReset(row, onReset) {
        var b = document.createElement('button');
        b.type = 'button'; b.className = 'field-reset btn btn-sm btn-link p-0 ms-1';
        b.title = 'Annuler ce changement'; b.hidden = true;
        b.innerHTML = '<i class="bi bi-arrow-counterclockwise"></i>';
        b.addEventListener('click', function () { onReset(); });
        row.appendChild(b);
        return b;
    }

    function buildTextField(field, label, initial) {
        var row = fieldRow(field, label);
        var mixed = (initial === null);
        var input = document.createElement('input');
        input.type = 'text'; input.name = field; input.className = 'form-control form-control-sm';
        input.style.maxWidth = '240px';
        input.value = mixed ? '' : initial;
        if (mixed) input.placeholder = 'valeurs mêlées';
        row.appendChild(input);
        addReset(row, function () { input.value = mixed ? '' : initial; markChanged(row, field, false); });
        input.addEventListener('input', function () {
            var changed = mixed ? (input.value !== '') : (input.value !== initial);
            markChanged(row, field, changed);
            if (field === 'gateway') applyExclusivity();
        });
        return row;
    }

    // Case 3 états : initial 'on'|'off'|'mixed'. Clic = on->off->on. Reset -> initial.
    function buildTriField(field, label, initial) {
        var row = fieldRow(field, label);
        var box = document.createElement('input');
        box.type = 'checkbox'; box.className = 'form-check-input mass-tri';
        box.name = field; box.value = '1';
        var hint = document.createElement('span');
        hint.className = 'text-muted small';
        function setVisual(state) {
            // state: 'on'|'off'|'mixed'
            if (state === 'mixed') { box.indeterminate = true; box.checked = false; hint.textContent = 'ne pas changer (valeurs mêlées)'; }
            else if (state === 'on') { box.indeterminate = false; box.checked = true; hint.textContent = ''; }
            else { box.indeterminate = false; box.checked = false; hint.textContent = ''; }
        }
        setVisual(initial);
        row.appendChild(box);
        row.appendChild(hint);
        addReset(row, function () { setVisual(initial); markChanged(row, field, false); });
        box.addEventListener('click', function () {
            // Clic : on->off->on (jamais retour à mixed via clic)
            box.indeterminate = false;
            // 'click' bascule déjà box.checked ; on s'assure d'un état binaire
            var nowOn = box.checked;
            hint.textContent = '';
            var changed = (initial === 'mixed') ? true : (initial === 'on' ? !nowOn : nowOn);
            // Si initial 'on' et nowOn true => inchangé ; si initial 'off' et nowOn false => inchangé
            markChanged(row, field, changed);
            if (field === 'no_gateway') applyExclusivity();
        });
        return row;
    }

    function buildRoutesField(field, label, initial) {
        var row = fieldRow(field, label);
        var mixed = (initial === null);
        var wrap = document.createElement('div');
        wrap.className = 'flex-grow-1';
        var editor = document.createElement('div'); editor.className = 'routes-editor';
        var status = document.createElement('div'); status.className = 'text-muted small mb-1';
        var addBtn = document.createElement('button');
        addBtn.type = 'button'; addBtn.className = 'btn btn-sm btn-outline-secondary';
        addBtn.innerHTML = '<i class="bi bi-plus-lg"></i> Ajouter une route';

        function markIfChanged() {
            var cur = [];
            editor.querySelectorAll('.route-row').forEach(function (rr) {
                cur.push({ dest: rr.querySelector('input[name="routes_dest[]"]').value.trim(),
                           gw: rr.querySelector('input[name="routes_gw[]"]').value.trim() });
            });
            var changed;
            if (mixed) changed = true; // dès qu'on définit pour tous, c'est un changement
            else changed = JSON.stringify(cur) !== JSON.stringify(initial || []);
            markChanged(row, field, changed);
            applyExclusivity();
        }
        function addRouteRow(dest, gw) {
            var rr = document.createElement('div');
            rr.className = 'route-row d-flex gap-2 mb-1 align-items-center';
            rr.innerHTML =
                '<input type="text" name="routes_dest[]" class="form-control form-control-sm font-monospace" style="max-width:150px" placeholder="10.0.0.0/8">'
              + '<span class="text-muted">→</span>'
              + '<input type="text" name="routes_gw[]" class="form-control form-control-sm font-monospace" style="max-width:140px" placeholder="192.168.1.254">'
              + '<button type="button" class="btn btn-sm btn-outline-danger route-del"><i class="bi bi-x-lg"></i></button>';
            editor.appendChild(rr);
            rr.querySelector('input[name="routes_dest[]"]').value = dest || '';
            rr.querySelector('input[name="routes_gw[]"]').value = gw || '';
            rr.querySelector('.route-del').addEventListener('click', function () { rr.remove(); markIfChanged(); });
            rr.querySelectorAll('input').forEach(function (i) { i.addEventListener('input', markIfChanged); });
        }

        if (mixed) {
            status.textContent = 'routes divergentes';
            editor.hidden = true;
            var setBtn = document.createElement('button');
            setBtn.type = 'button'; setBtn.className = 'btn btn-sm btn-outline-primary';
            setBtn.textContent = 'Définir pour tous…';
            setBtn.addEventListener('click', function () {
                editor.hidden = false; setBtn.hidden = true; status.textContent = '';
                markIfChanged();
            });
            wrap.appendChild(status); wrap.appendChild(setBtn);
        } else {
            (initial || []).forEach(function (r) { addRouteRow(r.dest, r.gw); });
        }
        wrap.appendChild(editor);
        wrap.appendChild(addBtn);
        addBtn.addEventListener('click', function () { editor.hidden = false; addRouteRow('', ''); });
        row.appendChild(wrap);
        addReset(row, function () {
            editor.querySelectorAll('.route-row').forEach(function (n) { n.remove(); });
            if (!mixed) { (initial || []).forEach(function (r) { addRouteRow(r.dest, r.gw); }); }
            else { editor.hidden = true; status.textContent = 'routes divergentes'; }
            markChanged(row, field, false);
            applyExclusivity();
        });
        return row;
    }
```

- [ ] **Step 4 : Exclusivité RFC 3442 + état du bouton Appliquer**

Ajouter dans la même IIFE :

```php
    function fieldChanged(field) {
        var flag = document.querySelector('#mass-edit-body input[name="changed[' + field + ']"]');
        return flag && flag.value === '1';
    }
    function hasRoutesDefined() {
        var rows = document.querySelectorAll('#mass-edit-body .route-row');
        for (var i = 0; i < rows.length; i++) {
            var d = rows[i].querySelector('input[name="routes_dest[]"]').value.trim();
            var g = rows[i].querySelector('input[name="routes_gw[]"]').value.trim();
            if (d !== '' && g !== '') return true;
        }
        return false;
    }
    function setDisabled(field, disabled) {
        var frow = document.querySelector('#mass-edit-body .mass-field[data-field="' + field + '"]');
        if (!frow) return;
        frow.querySelectorAll('input:not([type=hidden]), button').forEach(function (el) {
            if (!el.classList.contains('field-reset')) el.disabled = disabled;
        });
        frow.style.opacity = disabled ? '.5' : '';
    }
    function applyExclusivity() {
        var routesActive   = fieldChanged('routes') && hasRoutesDefined();
        var noGwBox = document.querySelector('#mass-edit-body .mass-field[data-field="no_gateway"] .mass-tri');
        var noGwActive = fieldChanged('no_gateway') && noGwBox && noGwBox.checked && !noGwBox.indeterminate;

        // Si routes imposées → gateway et no_gateway désactivés
        setDisabled('gateway', routesActive || noGwActive);
        setDisabled('no_gateway', routesActive);
        setDisabled('routes', noGwActive);
    }
    function refreshApplyState() {
        var apply = document.getElementById('mass-edit-apply');
        var any = ['group_name','short_lease','gateway','no_gateway','routes','ntp'].some(fieldChanged);
        apply.disabled = !any;
    }
```

Puis câbler l'ouverture du popup — remplacer/compléter le câblage de `btn-mass-edit` :

```php
    if (btnMassEdit && massModal) {
        btnMassEdit.addEventListener('click', function () {
            if (selected.size === 0) return;
            buildMassEditBody();
            massModal.show();
        });
    }
```

- [ ] **Step 5 : Lint**

Run: `php -l public/subnet.php`
Expected : `No syntax errors detected in public/subnet.php`.

- [ ] **Step 6 : Vérification navigateur (contrôleur/utilisateur)**

Sur un subnet avec plusieurs réservations aux options variées :
1. Sélectionner 2-3 réservations → « Modifier les options » ouvre le popup.
2. Champs communs affichés (groupe, gateway, cases cochées si homogène) ; champs divergents
   en « valeurs mêlées » / cases indéterminées / « routes divergentes ».
3. Modifier un champ → son ↺ reset apparaît ; le reset ramène à l'état initial et le fait
   disparaître. « Appliquer » ne s'active que si au moins un champ est modifié.
4. Case 3 états : clic bascule ON/OFF ; le reset seul revient à indéterminé (si initial mêlé).
5. Exclusivité : définir des routes désactive gateway/no-gateway ; cocher no-gateway désactive
   gateway/routes.
6. « Appliquer » → POST, page rechargée, flash de confirmation. Vérifier en base/édition que
   SEULS les champs modifiés ont changé (un champ non touché reste tel quel sur chaque résa).
7. Rôle viewer : pas de popup accessible.

- [ ] **Step 7 : Commit**

```bash
git add public/subnet.php
git commit -m "feat: popup de mass update (états initiaux, reset par champ, exclusivité RFC 3442)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 7 : Documentation (CLAUDE.md + mémoire)

**Files:**
- Modify: `CLAUDE.md`
- Memory: `memory/project_mass_update.md` + `memory/MEMORY.md`

- [ ] **Step 1 : Documenter dans CLAUDE.md**

- Ajouter `reservation_bulk.php` à la liste des pages (`public/`) :
  `reservation_bulk.php             — [admin|tech] Mass update de réservations sélectionnées (POST uniquement)`
- Dans la description de `subnet.php`, mentionner la sélection multiple + popup de mass update.
- Dans les méthodes clés de Reservation, ajouter :
  `Reservation::massUpdate(array $hostIds, array $changes): int  // n'écrit que les champs de $changes`
  et noter que `getBySubnet()` renvoie désormais `gateway`, `no_gateway`, `routes`, `ntp_enabled`.
- Ajouter une courte section « Mass update des réservations (v0.14) » décrivant : sélection
  par cases, barre d'action en bas, popup JS instantané, états initiaux reflétant la sélection,
  reset par champ, cases 3 états, exclusivité RFC 3442, application via `reservation_bulk.php`
  (un seul sync + audit `reservation.bulk_update`).

- [ ] **Step 2 : Mémoire de session**

Créer `memory/project_mass_update.md` (type project) résumant la fonctionnalité, avec liens
`[[project_reservation_groups]]` et `[[feedback_kea_cancelled]]`. Ajouter la ligne d'index
dans `memory/MEMORY.md`.

- [ ] **Step 3 : Commit**

```bash
git add CLAUDE.md
git commit -m "docs: mass update des réservations (v0.14)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

(Le dossier `memory/` est ignoré par git mais copié par le sync — pas de `git add` dessus.)

---

## Vérification de bout en bout (après toutes les tâches)

- [ ] `getBySubnet()` renvoie le détail des options (test CLI Task 1 vert).
- [ ] `massUpdate()` ne modifie que les champs fournis, recompose les options, applique
      l'exclusivité (test CLI Task 2 vert).
- [ ] Sélection multiple : cases, barre d'action, case « tout », persistance mode/filtre.
- [ ] Popup : états initiaux corrects (commun / mêlé), reset par champ, cases 3 états,
      routes divergentes qui s'alignent, exclusivité RFC 3442.
- [ ] Application réelle : seuls les champs modifiés changent ; un champ non touché reste
      intact sur chaque réservation ; un seul reload Kea ; entrée d'audit présente.
- [ ] Reset du filtre des réservations (✕) fonctionne.
- [ ] Rôle viewer : aucune case, barre ni popup.
- [ ] `php -l` propre sur tous les fichiers modifiés ; aucun asset externe ajouté.
