# Sidebar figée, groupes de réservations et filtres — 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:** Rendre la sidebar navigable quand les subnets sont nombreux (zone scrollable + filtre), ajouter une notion de groupe aux réservations, et deux modes d'affichage (groupés/plat) avec filtre multi-champs dans la vue d'un subnet.

**Architecture :** Le groupe est une métadonnée applicative stockée dans `app_reservation_meta.group_name` (aucune table native Kea touchée). La vue `subnet.php` passe à un rendu côté JS (comme `leases.php`) pour un basculement mode/filtre instantané. La sidebar (`layout_header.php` + `app.css`) est réorganisée en zones figées/scrollable via flexbox.

**Tech Stack :** PHP 8.5 vanilla + PDO, Bootstrap 5 (servi localement), JavaScript vanilla. Pas de framework de test — vérification via `php -l` (lint), scripts PHP CLI contre la vraie base (`lib/init.php` charge `config/config.php`, DB joignable sur 192.168.1.2), et contrôle visuel dans le navigateur.

**Rappels projet (CLAUDE.md) :**
- Après chaque commit : `bash ~/sync.sh dhcpman` (commit **puis** sync).
- `git add` : nommer les fichiers explicitement, jamais `git add -A` (course avec `.claude/*.tmp.*`).
- Contrainte offline : aucun asset CDN, tout en local.
- Migration DB directe : `mysql -h 192.168.1.2 -u claude -pclaudedev123 kea < sql/xxx.sql`.

**Ordre des tâches :** 1 (migration) → 2 (modèle) → 3 (formulaires) → 4 (CSV) → 5 (sidebar) → 6 (vue subnet). Les tâches 3, 4, 5 sont indépendantes entre elles une fois la 2 faite ; la 6 dépend de la 2.

---

### Task 1 : Migration SQL — colonne `group_name`

**Files:**
- Create: `sql/add_reservation_group.sql`
- Modify: `sql/init_app_tables.sql` (définition de `app_reservation_meta` pour installation fraîche)

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

Create `sql/add_reservation_group.sql` :

```sql
-- Migration v0.12 → v0.13 : ajoute group_name dans app_reservation_meta
-- Métadonnée applicative de regroupement des réservations (aucun impact schéma Kea).
ALTER TABLE app_reservation_meta
    ADD COLUMN group_name VARCHAR(64) NULL AFTER description;
```

- [ ] **Step 2 : Mettre à jour le schéma d'installation fraîche**

Dans `sql/init_app_tables.sql`, repérer la définition de `app_reservation_meta` et ajouter la colonne juste après `description`. Chercher la ligne `description` dans le `CREATE TABLE app_reservation_meta` et insérer après :

```sql
    group_name VARCHAR(64) NULL,
```

(Respecter l'indentation et la virgule du fichier existant.)

- [ ] **Step 3 : Appliquer la migration sur la base**

Run:
```bash
mysql -h 192.168.1.2 -u claude -pclaudedev123 kea < sql/add_reservation_group.sql
```
Expected : aucune sortie (succès). En cas de « Duplicate column name 'group_name' », la colonne existe déjà — OK, continuer.

- [ ] **Step 4 : Vérifier la présence de la colonne**

Run:
```bash
php -r 'require "lib/init.php"; $c = Database::get()->query("SHOW COLUMNS FROM app_reservation_meta")->fetchAll(PDO::FETCH_COLUMN); echo in_array("group_name",$c) ? "OK group_name présent\n" : "MANQUE\n";'
```
Expected : `OK group_name présent`

- [ ] **Step 5 : Documenter la migration dans CLAUDE.md**

Dans `CLAUDE.md`, sous la section des migrations, ajouter un bloc au-dessus de « Migration sur installation existante (v0.11 → v0.12) » :

```markdown
### Migration sur installation existante (v0.12 → v0.13)

​```bash
# Ajoute group_name dans app_reservation_meta (regroupement des réservations)
mysql -u kea -p kea < sql/add_reservation_group.sql
​```

Ajoute une métadonnée applicative de groupe (texte libre) sur chaque réservation.
Aucune table native Kea n'est modifiée.
```

Ajouter aussi, dans la section décrivant `app_reservation_meta`, la ligne :
`- group_name VARCHAR(64) NULL — v0.13 : nom de groupe applicatif (regroupement d'affichage)`

- [ ] **Step 6 : Commit + sync**

```bash
git add sql/add_reservation_group.sql sql/init_app_tables.sql CLAUDE.md
git commit -m "feat: migration group_name dans app_reservation_meta (v0.13)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
bash ~/sync.sh dhcpman
```

---

### Task 2 : Modèle — `group_name` dans Reservation + `getGroups()`

**Files:**
- Modify: `lib/Reservation.php` (getBySubnet ~L20-58, getById ~L60-89, add ~L97-125, update ~L130-167 ; nouvelle méthode getGroups)

- [ ] **Step 1 : Ajouter `group_name` au SELECT de `getBySubnet()`**

Dans `getBySubnet()`, dans la liste des colonnes du SELECT, après `m.description,` ajouter :

```php
                    m.group_name,
```

- [ ] **Step 2 : Ajouter `group_name` au SELECT de `getById()`**

Dans `getById()`, après `m.description,` dans le SELECT, ajouter :

```php
                    m.group_name,
```

- [ ] **Step 3 : Écrire `group_name` dans `add()`**

Dans `add()`, remplacer le bloc d'insertion meta :

```php
        $db->prepare(
            'INSERT INTO app_reservation_meta
             (host_id, description, short_lease, created_by_user_id, created_at)
             VALUES (?, ?, ?, ?, NOW())'
        )->execute([$hostId, trim($data['description'] ?? ''), $shortLease, Auth::currentUserId()]);
```

par :

```php
        $group = trim($data['group_name'] ?? '');
        $db->prepare(
            'INSERT INTO app_reservation_meta
             (host_id, description, group_name, short_lease, created_by_user_id, created_at)
             VALUES (?, ?, ?, ?, ?, NOW())'
        )->execute([$hostId, trim($data['description'] ?? ''), ($group !== '' ? $group : null), $shortLease, Auth::currentUserId()]);
```

- [ ] **Step 4 : Écrire `group_name` dans `update()`**

Dans `update()`, remplacer le bloc upsert meta :

```php
        // Upsert meta
        $db->prepare(
            'INSERT INTO app_reservation_meta
             (host_id, description, short_lease, created_by_user_id, created_at, updated_by_user_id, updated_at)
             VALUES (?, ?, ?, ?, NOW(), ?, NOW())
             ON DUPLICATE KEY UPDATE
               description        = VALUES(description),
               short_lease        = VALUES(short_lease),
               updated_by_user_id = VALUES(updated_by_user_id),
               updated_at         = NOW()'
        )->execute([
            $hostId,
            trim($data['description'] ?? ''),
            $shortLease,
            Auth::currentUserId(),
            Auth::currentUserId(),
        ]);
```

par :

```php
        // Upsert meta
        $group = trim($data['group_name'] ?? '');
        $db->prepare(
            'INSERT INTO app_reservation_meta
             (host_id, description, group_name, short_lease, created_by_user_id, created_at, updated_by_user_id, updated_at)
             VALUES (?, ?, ?, ?, ?, NOW(), ?, NOW())
             ON DUPLICATE KEY UPDATE
               description        = VALUES(description),
               group_name         = VALUES(group_name),
               short_lease        = VALUES(short_lease),
               updated_by_user_id = VALUES(updated_by_user_id),
               updated_at         = NOW()'
        )->execute([
            $hostId,
            trim($data['description'] ?? ''),
            ($group !== '' ? $group : null),
            $shortLease,
            Auth::currentUserId(),
            Auth::currentUserId(),
        ]);
```

- [ ] **Step 5 : Ajouter la méthode `getGroups()`**

Dans `lib/Reservation.php`, juste après la fermeture de `getBySubnet()` (avant `getById()`), ajouter :

```php
    /**
     * Liste distincte des noms de groupe non vides utilisés sur un subnet.
     * Sert à l'autocomplétion du champ groupe. Trié alphabétiquement.
     */
    public static function getGroups(int $keaSubnetId): array {
        $stmt = Database::get()->prepare(
            'SELECT DISTINCT m.group_name
             FROM hosts h
             JOIN app_reservation_meta m ON m.host_id = h.host_id
             WHERE h.dhcp4_subnet_id = ?
               AND h.dhcp_identifier_type = ' . self::HW_ADDR_TYPE . '
               AND m.group_name IS NOT NULL AND m.group_name <> \'\'
             ORDER BY m.group_name'
        );
        $stmt->execute([$keaSubnetId]);
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }
```

- [ ] **Step 6 : Lint**

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

- [ ] **Step 7 : Test réel du cycle add/get/update/getGroups contre la base**

Créer un script temporaire `scratch_test_group.php` à la racine :

```php
<?php
require "lib/init.php";
// Choisir un subnet existant
$subnet = Subnet::getAll()[0] ?? null;
if (!$subnet) { echo "Aucun subnet — test impossible\n"; exit(1); }
$kea = (int)$subnet['kea_subnet_id'];

// add avec groupe
$hostId = Reservation::add($kea, [
    'mac' => '02:00:00:00:00:99', 'ip' => '', 'hostname' => 'test-grp',
    'description' => 'tmp', 'group_name' => 'ZZZ-TestGroupe', 'short_lease' => 1,
]);
echo "add host_id=$hostId\n";

// getById renvoie le groupe
$r = Reservation::getById($hostId);
echo "getById group_name=" . var_export($r['group_name'], true) . "\n";

// getBySubnet renvoie le groupe
$found = null;
foreach (Reservation::getBySubnet($kea) as $row) { if ((int)$row['host_id'] === $hostId) $found = $row; }
echo "getBySubnet group_name=" . var_export($found['group_name'] ?? 'ABSENT', true) . "\n";

// getGroups contient le groupe
$groups = Reservation::getGroups($kea);
echo "getGroups contient ZZZ-TestGroupe: " . (in_array('ZZZ-TestGroupe', $groups) ? "OUI" : "NON") . "\n";

// update vide le groupe → NULL
Reservation::update($hostId, ['mac' => '02:00:00:00:00:99', 'ip' => '', 'hostname' => 'test-grp', 'description' => 'tmp', 'group_name' => '', 'short_lease' => 1]);
$r2 = Reservation::getById($hostId);
echo "après update vide, group_name=" . var_export($r2['group_name'], true) . " (attendu NULL)\n";

// nettoyage
Reservation::delete($hostId);
echo "cleanup OK\n";
```

Run: `php scratch_test_group.php`
Expected (les valeurs clés) :
```
getById group_name='ZZZ-TestGroupe'
getBySubnet group_name='ZZZ-TestGroupe'
getGroups contient ZZZ-TestGroupe: OUI
après update vide, group_name=NULL (attendu NULL)
cleanup OK
```

- [ ] **Step 8 : Supprimer le script temporaire et commit**

```bash
rm scratch_test_group.php
git add lib/Reservation.php
git commit -m "feat: group_name dans Reservation (get/add/update) + getGroups()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
bash ~/sync.sh dhcpman
```

---

### Task 3 : Formulaires de réservation — champ Groupe + autocomplétion

**Files:**
- Modify: `public/reservation_add.php` (bloc `$data` ~L18-28 et ~L42-54, GET, champ HTML après description ~L163)
- Modify: `public/reservation_edit.php` (bloc `$data` ~L33 et ~L57, champ HTML après description ~L159)

- [ ] **Step 1 : `reservation_add.php` — pré-remplissage GET + POST du groupe**

Dans le bloc `$data` initial (pré-remplissage GET, ~L18), après la ligne `'description' => trim($_GET['description'] ?? ''),` ajouter :

```php
    'group_name'  => trim($_GET['group']       ?? ''),
```

Dans le bloc `$data` du POST (~L42), après `'description' => trim($_POST['description'] ?? ''),` ajouter :

```php
        'group_name'  => trim($_POST['group_name'] ?? ''),
```

- [ ] **Step 2 : `reservation_add.php` — charger la liste des groupes existants**

Juste avant `$page_title = 'Ajouter une réservation...` (~L87), ajouter :

```php
$existingGroups = Reservation::getGroups($subnet['kea_subnet_id']);
```

- [ ] **Step 3 : `reservation_add.php` — champ HTML Groupe + datalist**

Après le bloc `<div class="mb-3">` de la Description (le bloc se terminant après l'input `name="description"`, ~L163), insérer :

```php
            <div class="mb-3">
                <label class="form-label fw-semibold">Groupe</label>
                <input type="text" name="group_name" class="form-control"
                       list="group-list" maxlength="64"
                       value="<?= h($data['group_name'] ?? '') ?>"
                       placeholder="Optionnel — Imprimantes, Serveurs, Caméras…">
                <datalist id="group-list">
                    <?php foreach ($existingGroups as $g): ?>
                    <option value="<?= h($g) ?>"></option>
                    <?php endforeach; ?>
                </datalist>
                <div class="form-text">Regroupe la réservation dans la vue du subnet.</div>
            </div>
```

- [ ] **Step 4 : `reservation_edit.php` — POST + pré-remplissage du groupe**

Ouvrir `public/reservation_edit.php`. Dans le bloc `$data` initial (chargé depuis `$reservation`, ~L33 où figure `'description' => $reservation['description'] ?? '',`), ajouter après :

```php
        'group_name'  => $reservation['group_name'] ?? '',
```

Dans le bloc `$data` du POST (~L57 `'description' => trim($_POST['description'] ?? ''),`), ajouter après :

```php
        'group_name'  => trim($_POST['group_name'] ?? ''),
```

- [ ] **Step 5 : `reservation_edit.php` — charger les groupes existants**

Dans `public/reservation_edit.php`, la variable `$subnet` (avec `kea_subnet_id`) est définie L16 et `$page_title` L92. Insérer, juste avant `$page_title = 'Modifier...` (L92) :

```php
$existingGroups = Reservation::getGroups($subnet['kea_subnet_id']);
```

- [ ] **Step 6 : `reservation_edit.php` — champ HTML Groupe + datalist**

Après le bloc `<div class="mb-3">` de la Description (input `name="description"`, ~L159), insérer le même bloc qu'au Step 3 :

```php
            <div class="mb-3">
                <label class="form-label fw-semibold">Groupe</label>
                <input type="text" name="group_name" class="form-control"
                       list="group-list" maxlength="64"
                       value="<?= h($data['group_name'] ?? '') ?>"
                       placeholder="Optionnel — Imprimantes, Serveurs, Caméras…">
                <datalist id="group-list">
                    <?php foreach ($existingGroups as $g): ?>
                    <option value="<?= h($g) ?>"></option>
                    <?php endforeach; ?>
                </datalist>
                <div class="form-text">Regroupe la réservation dans la vue du subnet.</div>
            </div>
```

- [ ] **Step 7 : Lint**

Run:
```bash
php -l public/reservation_add.php && php -l public/reservation_edit.php
```
Expected : `No syntax errors detected` pour les deux.

- [ ] **Step 8 : Vérification navigateur**

Dans le navigateur (via le VirtualHost) :
1. Ouvrir un subnet → « Ajouter une réservation ». Le champ **Groupe** apparaît sous Description.
2. Créer une réservation avec un groupe (ex. « Imprimantes »). Enregistrer.
3. Rouvrir « Ajouter une réservation » : commencer à taper dans Groupe → « Imprimantes » est proposé (datalist).
4. Éditer la réservation créée : le champ Groupe est bien pré-rempli avec « Imprimantes ».

Cocher cette étape une fois les 4 points confirmés.

- [ ] **Step 9 : Commit + sync**

```bash
git add public/reservation_add.php public/reservation_edit.php
git commit -m "feat: champ Groupe (autocomplétion) dans les formulaires de réservation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
bash ~/sync.sh dhcpman
```

---

### Task 4 : Import / Export CSV — colonne `group`

**Files:**
- Modify: `public/subnet_export.php` (en-tête L23, ligne de données L39-48)
- Modify: `public/subnet_import.php` (parseCsv ~L60/L77, analyzeRows ~L92-260, étape 2 ~L348, aide ~L528, hidden ~L675)

- [ ] **Step 1 : Export — ajouter la colonne `group` à l'en-tête**

Dans `public/subnet_export.php`, remplacer :

```php
fputcsv($out, ['mac', 'ip', 'hostname', 'description', 'gateway', 'routes', 'short_lease', 'no_gateway']);
```

par :

```php
fputcsv($out, ['mac', 'ip', 'hostname', 'description', 'group', 'gateway', 'routes', 'short_lease', 'no_gateway']);
```

- [ ] **Step 2 : Export — ajouter la valeur du groupe à chaque ligne**

Dans la boucle, remplacer l'appel `fputcsv($out, [ ... ]);` par (insérer `$r['group_name']` après la description) :

```php
        fputcsv($out, [
            $r['mac'],
            $ipStr,
            $r['hostname'],
            $r['description'] ?? '',
            $r['group_name'] ?? '',
            $gateway,
            $routes,
            $r['short_lease'] ? '1' : '0',
            !empty($full['no_gateway']) ? '1' : '0',
        ]);
```

- [ ] **Step 3 : Import parseCsv — détecter et lire la colonne `group`**

Dans `public/subnet_import.php`, fonction `parseCsv()`. Après la ligne `$idxNoGateway = array_search('no_gateway',  $headers, true);` ajouter :

```php
    $idxGroup      = array_search('group',       $headers, true);
```

Dans la construction de chaque `$rows[]` (là où figure `'no_gateway' => $idxNoGateway ...`), ajouter une clé :

```php
            'group_name'  => $idxGroup       !== false ? trim($cols[$idxGroup]       ?? '') : '',
```

- [ ] **Step 4 : Import analyzeRows — propager le groupe dans chaque statut**

Dans `analyzeRows()`, repérer la lecture des champs en tête de boucle (près de `$noGateway = (int)($r['no_gateway'] ?? 0);`) et ajouter :

```php
        $group     = trim($r['group_name'] ?? '');
```

Puis, dans **chaque** tableau de résultat de ligne (les entrées contenant `'no_gateway'=>$noGateway`), ajouter la clé `'group_name'=>$group,`. Il y a plusieurs occurrences (une par statut : clean, skip, ip_conflict, mac_conflict, invalid, pool). Les traiter toutes de façon identique. Exemple de transformation :

```php
// avant
'gateway'=>$gateway,'routes_raw'=>$routesRaw,'short_lease'=>$shortLease,'no_gateway'=>$noGateway,
// après
'gateway'=>$gateway,'routes_raw'=>$routesRaw,'short_lease'=>$shortLease,'no_gateway'=>$noGateway,'group_name'=>$group,
```

- [ ] **Step 5 : Import étape 2 — passer le groupe à `Reservation::add/update`**

Dans le traitement de l'application (étape 2), repérer la construction du `$data` (~L348) :

```php
        $data = ['mac'=>$mac,'ip'=>$ip,'hostname'=>$host,'description'=>$desc,'gateway'=>$gateway,'routes'=>$routes,'short_lease'=>$shortLease,'no_gateway'=>$noGateway];
```

Juste au-dessus, lire le groupe depuis la ligne postée (à côté de `$noGateway = (int)($row['no_gateway'] ?? 0);`) :

```php
        $group = trim($row['group_name'] ?? '');
```

et l'ajouter au `$data` :

```php
        $data = ['mac'=>$mac,'ip'=>$ip,'hostname'=>$host,'description'=>$desc,'group_name'=>$group,'gateway'=>$gateway,'routes'=>$routes,'short_lease'=>$shortLease,'no_gateway'=>$noGateway];
```

- [ ] **Step 6 : Import étape 2 — transmettre le groupe dans le champ hidden de prévisualisation**

Repérer les `<input type="hidden" name="r[<?= $i ?>][...]">` (~L675). À côté de celui de `no_gateway`, ajouter :

```php
            <input type="hidden" name="r[<?= $i ?>][group_name]"    value="<?= h($row['group_name'] ?? '') ?>">
```

- [ ] **Step 7 : Import — mettre à jour le texte d'aide**

Dans le texte listant les colonnes (~L528), ajouter `<code>group</code>` à la liste des colonnes optionnelles. Remplacer la portion :

```php
<code>gateway</code>, <code>routes</code>, <code>short_lease</code>, <code>no_gateway</code>).
```

par :

```php
<code>group</code>, <code>gateway</code>, <code>routes</code>, <code>short_lease</code>, <code>no_gateway</code>).
```

- [ ] **Step 8 : Lint**

Run:
```bash
php -l public/subnet_export.php && php -l public/subnet_import.php
```
Expected : `No syntax errors detected` pour les deux.

- [ ] **Step 9 : Test aller-retour CSV réel**

Vérifier que l'export contient bien la colonne `group` et que le parse d'import la relit. Script temporaire `scratch_csv.php` :

```php
<?php
require "lib/init.php";
// Simuler parseCsv sur un CSV en mémoire n'est pas trivial (fonction privée dans le fichier page).
// À la place, on vérifie l'en-tête d'export.
$subnet = Subnet::getAll()[0] ?? null;
if (!$subnet) { echo "pas de subnet\n"; exit; }
ob_start();
$_GET['subnet_id'] = $subnet['id'];
$_GET['template']  = '1'; // en-têtes seuls
// subnet_export.php appelle exit ; on ne peut pas l'inclure ici sans tuer le process.
// Vérif directe de la ligne d'en-tête dans le source :
$src = file_get_contents('public/subnet_export.php');
echo strpos($src, "'group',") !== false ? "en-tête export contient group: OUI\n" : "NON\n";
$imp = file_get_contents('public/subnet_import.php');
echo strpos($imp, "array_search('group'") !== false ? "import détecte group: OUI\n" : "NON\n";
ob_end_clean();
```

Run: `php scratch_csv.php`
Expected :
```
en-tête export contient group: OUI
import détecte group: OUI
```

Puis vérification navigateur : exporter un subnet ayant une réservation avec groupe → ouvrir le CSV, colonne `group` présente et remplie. Réimporter ce CSV sur un subnet de test → la prévisualisation puis l'application conservent le groupe (vérifiable en éditant la réservation importée).

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

```bash
rm scratch_csv.php
git add public/subnet_export.php public/subnet_import.php
git commit -m "feat: colonne CSV group (import + export)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
bash ~/sync.sh dhcpman
```

---

### Task 5 : Sidebar figée avec liste scrollable + filtre

**Files:**
- Modify: `lib/layout_header.php` (structure sidebar L25-77)
- Modify: `public/assets/css/app.css` (règles `#sidebar` L16-99)

- [ ] **Step 1 : Restructurer le HTML de la sidebar**

Dans `lib/layout_header.php`, remplacer tout le bloc `<div id="sidebar"> ... </div>` (L25 à L77) par la structure à trois zones. La zone Navigation (haut) et la zone Administration + footer (bas) sont figées ; seule la liste des subnets scrolle, sous un filtre figé.

```php
<div id="sidebar">
    <a href="dashboard.php" class="sidebar-brand">
        <span class="brand-icon">&#127760;</span>
        <?= h(Settings::get('app_name', 'DHCPMan')) ?>
    </a>

    <div class="sidebar-nav">
        <div class="nav-section">Navigation</div>
        <?= navLink('dashboard.php',  'speedometer2',   'Tableau de bord', $current) ?>
        <?= navLink('subnets.php',    'diagram-3',      'Subnets',         $current) ?>
        <?= navLink('leases.php',     'clock-history',  'Leases',          $current) ?>
        <?= navLink('search.php',     'search',         'Recherche',       $current) ?>
        <?php if (in_array(Auth::currentUserRole(), ['admin', 'tech'])):
            $pendingCount = ReservationRequest::count();
        ?>
        <a href="requests.php" class="nav-link<?= $current === 'requests.php' ? ' active' : '' ?>">
            <span class="nav-icon"><i class="bi bi-envelope-plus"></i></span>Demandes<?php if ($pendingCount > 0): ?> <span class="badge bg-danger ms-1" style="font-size:.65rem"><?= $pendingCount ?></span><?php endif; ?>
        </a>
        <?php endif; ?>
    </div>

    <?php $subnets = Subnet::getAll(); ?>
    <div class="sidebar-subnets">
        <div class="nav-section">Subnets</div>
        <?php if ($subnets): ?>
        <div class="subnet-filter">
            <i class="bi bi-search"></i>
            <input type="text" id="subnet-filter-input" placeholder="Filtrer…" autocomplete="off">
            <button type="button" id="subnet-filter-clear" title="Effacer" hidden>&times;</button>
        </div>
        <div class="subnet-list" id="subnet-list">
            <?php foreach ($subnets as $s):
                $file   = 'subnet.php';
                $active = ($current === $file && ($_GET['id'] ?? '') == $s['id']) ? ' active' : '';
                $badge  = $s['is_active'] ? '' : ' <span class="badge bg-secondary ms-1" style="font-size:.6rem">off</span>';
            ?>
                <a href="subnet.php?id=<?= $s['id'] ?>" class="nav-link<?= $active ?>" data-subnet-name="<?= h(mb_strtolower($s['name'])) ?>">
                    <span class="nav-icon"><i class="bi bi-hdd-network"></i></span><span class="subnet-label"><?= h($s['name']) ?></span><?= $badge ?>
                </a>
            <?php endforeach; ?>
            <div class="subnet-empty text-muted small px-3 py-2" hidden>Aucun subnet.</div>
        </div>
        <?php endif; ?>
    </div>

    <div class="sidebar-admin">
        <div class="nav-section">Administration</div>
        <?php if (Auth::currentUserRole() === 'admin'): ?>
        <?= navLink('settings.php', 'gear', 'Paramètres', $current) ?>
        <?php endif; ?>
        <?= navLink('audit.php',    'clock-history', 'Journal', $current) ?>
    </div>

    <div class="sidebar-footer">
        <i class="bi bi-person-circle"></i>
        <?= h(Auth::currentUsername()) ?>
        <span class="badge bg-secondary ms-1" style="font-size:.65rem"><?= h(Auth::currentUserRole()) ?></span>
        <br>
        <a href="change_password.php"><i class="bi bi-key"></i> Mot de passe</a>
        &nbsp;·&nbsp;
        <a href="logout.php"><i class="bi bi-box-arrow-right"></i> Déconnexion</a>
    </div>
</div>
```

Note : le `<div class="mt-2">` qui enveloppait tout le menu est supprimé (les zones flex le remplacent). La variable `$subnets` était déjà chargée dans `subnets.php`/`subnet.php` ; ici elle est locale au header, sans conflit (même contenu).

- [ ] **Step 2 : Ajouter le script de filtre sidebar**

À la fin de `lib/layout_header.php`, juste avant le `<div id="main-content">` **non** — le script doit s'exécuter après le DOM de la sidebar. L'ajouter juste après la fermeture `</div>` du `#sidebar` (avant `<div id="main-content">`) :

```php
<script>
(function () {
    var input = document.getElementById('subnet-filter-input');
    if (!input) return;
    var list  = document.getElementById('subnet-list');
    var clear = document.getElementById('subnet-filter-clear');
    var empty = list ? list.querySelector('.subnet-empty') : null;
    var links = list ? list.querySelectorAll('a[data-subnet-name]') : [];

    function norm(s) {
        return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
    }
    function apply() {
        var q = norm(input.value.trim());
        var visible = 0;
        links.forEach(function (a) {
            var match = norm(a.getAttribute('data-subnet-name')).indexOf(q) !== -1;
            a.hidden = !match;
            if (match) visible++;
        });
        if (empty) empty.hidden = (visible !== 0);
        clear.hidden = (input.value === '');
    }
    input.addEventListener('input', apply);
    clear.addEventListener('click', function () { input.value = ''; apply(); input.focus(); });
}());
</script>
```

- [ ] **Step 3 : CSS — zones flex figées/scrollable**

Dans `public/assets/css/app.css`, remplacer la règle `#sidebar` existante (celle avec `overflow-y: auto;`) — retirer `overflow-y: auto;` du `#sidebar` global. La règle devient :

```css
#sidebar {
    position: fixed;
    top: 0; left: 0; bottom: 0;
    width: var(--sidebar-width);
    background: var(--sidebar-bg);
    color: var(--sidebar-text);
    z-index: 1030;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}
```

Puis, après la règle `#sidebar .sidebar-brand .brand-icon { ... }`, ajouter les zones :

```css
/* Zones sidebar : Navigation (figée) / Subnets (scroll) / Administration + footer (figées) */
#sidebar .sidebar-nav {
    flex: 0 0 auto;
    padding-top: .25rem;
}
#sidebar .sidebar-subnets {
    flex: 1 1 auto;
    min-height: 0;                 /* indispensable pour que .subnet-list scrolle */
    display: flex;
    flex-direction: column;
}
#sidebar .subnet-list {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
}
#sidebar .sidebar-admin {
    flex: 0 0 auto;
    border-top: 1px solid rgba(255,255,255,.08);
    padding-bottom: .25rem;
}

/* Champ de filtre des subnets */
#sidebar .subnet-filter {
    display: flex;
    align-items: center;
    gap: .4rem;
    margin: .1rem .6rem .35rem;
    padding: .3rem .55rem;
    background: rgba(255,255,255,.06);
    border-radius: 6px;
    color: var(--sidebar-text);
}
#sidebar .subnet-filter i { opacity: .6; font-size: .85rem; }
#sidebar .subnet-filter input {
    flex: 1 1 auto;
    background: transparent;
    border: none;
    outline: none;
    color: #fff;
    font-size: .85rem;
    min-width: 0;
}
#sidebar .subnet-filter input::placeholder { color: #7d90ad; }
#sidebar .subnet-filter button {
    background: none;
    border: none;
    color: #7d90ad;
    font-size: 1.1rem;
    line-height: 1;
    cursor: pointer;
    padding: 0 .2rem;
}
#sidebar .subnet-filter button:hover { color: #fff; }
```

Note : la règle existante `#sidebar .sidebar-footer { margin-top: auto; ... }` peut conserver `margin-top: auto` sans dommage (les zones flex la poussent déjà en bas) mais ce n'est plus nécessaire. La laisser telle quelle évite un changement inutile.

- [ ] **Step 4 : Lint PHP**

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

- [ ] **Step 5 : Vérification navigateur**

1. Charger n'importe quelle page : la sidebar affiche Navigation en haut, puis « Subnets » avec un champ de filtre, puis Administration + compte en bas.
2. Si beaucoup de subnets : seule la liste des subnets scrolle ; Navigation et Administration restent visibles.
3. Taper dans le filtre : la liste se réduit en direct ; le ✕ apparaît ; cliquer ✕ réaffiche tout.
4. Filtre sans correspondance : « Aucun subnet. » s'affiche.
5. Redimensionner sous 768px : la sidebar se masque comme avant (media query intacte).

Cocher une fois les 5 points confirmés.

- [ ] **Step 6 : Commit + sync**

```bash
git add lib/layout_header.php public/assets/css/app.css
git commit -m "feat: sidebar avec liste de subnets scrollable + filtre rapide

Navigation et Administration figées ; seule la liste des subnets scrolle,
sous un champ de filtre instantané (nom du subnet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
bash ~/sync.sh dhcpman
```

---

### Task 6 : Vue subnet — rendu JS, 2 modes d'affichage, filtre multi-champs

**Files:**
- Modify: `public/subnet.php` (rendu du tableau L106-181, remplacé par un conteneur + JS)
- Modify: `public/assets/css/app.css` (bandeaux et badges de groupe)

- [ ] **Step 1 : Préparer les données PHP pour l'injection JS**

Dans `public/subnet.php`, après `$reservations = Reservation::getBySubnet($subnet['kea_subnet_id']);` (~L13), construire un tableau prêt pour JSON :

```php
$rows = [];
foreach ($reservations as $r) {
    $ip = ($r['ip_address'] !== '' && $r['ip_address'] !== '0.0.0.0') ? $r['ip_address'] : '';
    $rows[] = [
        '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'],
        'modified'    => substr($r['updated_at'] ?? $r['created_at'] ?? '', 0, 16),
    ];
}
$canEdit = Auth::currentUserRole() !== 'viewer';
```

- [ ] **Step 2 : Remplacer le corps de la carte des réservations par un conteneur JS**

Remplacer le bloc `<div class="card-body p-0"> ... </div>` (L106-180, celui contenant `<table>...` et le `else` vide) par une barre de contrôles + un conteneur vide rempli par JS, en gardant l'état vide côté PHP quand il n'y a aucune réservation :

```php
    <div class="card-body p-0">
        <?php if ($rows): ?>
        <div class="reservation-toolbar d-flex flex-wrap gap-2 align-items-center px-3 py-2 border-bottom">
            <div class="btn-group btn-group-sm" role="group" aria-label="Mode d'affichage">
                <button type="button" class="btn btn-outline-secondary" id="mode-grouped">
                    <i class="bi bi-collection me-1"></i>Groupés
                </button>
                <button type="button" class="btn btn-outline-secondary" id="mode-flat">
                    <i class="bi bi-list-ul me-1"></i>Plat
                </button>
            </div>
            <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>
            <small class="text-muted ms-auto" id="res-count"></small>
        </div>
        <div id="reservation-container"></div>
        <?php else: ?>
        <div class="p-5 text-center text-muted">
            <i class="bi bi-hdd-network fs-1"></i>
            <p class="mt-3 mb-1">Aucune réservation sur ce subnet.</p>
            <?php if ($canEdit): ?>
            <a href="reservation_add.php?subnet_id=<?= $subnet['id'] ?>" class="btn btn-primary mt-2">
                <i class="bi bi-plus-lg"></i> Ajouter la première réservation
            </a>
            <?php endif; ?>
        </div>
        <?php endif; ?>
    </div>
```

- [ ] **Step 3 : Remplacer le script de fin de page par le moteur de rendu JS**

Remplacer le `<script>` existant en fin de `subnet.php` (celui qui initialise seulement les tooltips) par le script complet ci-dessous. Il gère les 2 modes, le filtre, le localStorage, l'échappement sûr (via `textContent`), et réinitialise les tooltips après rendu.

```php
<?php require_once APP_ROOT . '/lib/layout_footer.php'; ?>
<script>
(function () {
    var ROWS     = <?= json_encode($rows, JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP) ?>;
    var CAN_EDIT = <?= $canEdit ? 'true' : 'false' ?>;
    var SUBNET_ID = <?= (int)$subnet['id'] ?>;
    var container = document.getElementById('reservation-container');
    if (!container) return;

    var MODE_KEY = 'dhcpman.subnet.viewmode';
    var mode = localStorage.getItem(MODE_KEY) === 'flat' ? 'flat' : 'grouped';
    var filterInput = document.getElementById('res-filter');
    var countEl     = document.getElementById('res-count');
    var btnGrouped  = document.getElementById('mode-grouped');
    var btnFlat     = document.getElementById('mode-flat');

    function norm(s) {
        return String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
    }
    // Filtre : matche hostname, ip, mac (avec ou sans :), description, groupe. Pas la date.
    function matches(r, q) {
        if (q === '') return true;
        var macNoSep = r.mac_hex;                 // déjà sans séparateur, minuscule
        var hay = norm(r.hostname) + ' ' + norm(r.ip) + ' ' + norm(r.mac) + ' '
                + macNoSep + ' ' + norm(r.description) + ' ' + norm(r.group_name);
        return hay.indexOf(q) !== -1;
    }
    function byIp(a, b) { return a.ip_int - b.ip_int; }

    // ── Construction des cellules (sécurisé : textContent) ────────────────────
    function iconIndicators(r) {
        var span = document.createElement('span');
        function add(cls, color, title) {
            var i = document.createElement('i');
            i.className = 'bi ' + cls + ' ' + color + ' ms-1';
            i.style.fontSize = '.8em';
            i.setAttribute('data-bs-toggle', 'tooltip');
            i.setAttribute('title', title);
            span.appendChild(i);
        }
        if (r.has_options) add('bi-sliders', 'text-primary', 'Options DHCP spécifiques (gateway / routes)');
        if (r.has_ntp)     add('bi-clock', 'text-info', 'NTP activé (option 42)');
        if (r.short_lease) add('bi-hourglass-split', 'text-warning', 'Lease court (1h) — client class short-lease');
        return span;
    }
    function actionsCell(r) {
        var td = document.createElement('td');
        td.className = 'text-end';
        if (!CAN_EDIT) return td;
        var edit = document.createElement('a');
        edit.href = 'reservation_edit.php?id=' + r.host_id + '&subnet_id=' + SUBNET_ID;
        edit.className = 'btn btn-sm btn-outline-secondary me-1';
        edit.title = 'Modifier';
        edit.innerHTML = '<i class="bi bi-pencil"></i>';
        var del = document.createElement('a');
        del.href = 'reservation_delete.php?id=' + r.host_id + '&subnet_id=' + SUBNET_ID;
        del.className = 'btn btn-sm btn-outline-danger';
        del.title = 'Supprimer';
        del.innerHTML = '<i class="bi bi-trash"></i>';
        td.appendChild(edit);
        td.appendChild(del);
        return td;
    }
    function hostnameCell(r) {
        var td = document.createElement('td');
        td.className = 'fw-semibold';
        td.textContent = r.hostname;
        td.appendChild(iconIndicators(r));
        return td;
    }
    function ipCell(r) {
        var td = document.createElement('td');
        if (r.ip) {
            var s = document.createElement('span');
            s.className = 'ip-address';
            s.textContent = r.ip;
            td.appendChild(s);
        } else {
            var e = document.createElement('span');
            e.className = 'text-muted fst-italic';
            e.textContent = 'dynamique';
            td.appendChild(e);
        }
        return td;
    }
    function simpleCell(text, cls) {
        var td = document.createElement('td');
        if (cls) td.className = cls;
        var s = document.createElement('small');
        s.className = 'text-muted';
        s.textContent = text || '';
        td.appendChild(s);
        return td;
    }
    function macCell(r) {
        var td = document.createElement('td');
        var s = document.createElement('span');
        s.className = 'mac-address';
        s.textContent = r.mac;
        td.appendChild(s);
        return td;
    }
    function groupBadgeCell(r) {
        var td = document.createElement('td');
        if (r.group_name) {
            var b = document.createElement('span');
            b.className = 'group-badge';
            b.textContent = r.group_name;
            td.appendChild(b);
        } else {
            var m = document.createElement('span');
            m.className = 'text-muted';
            m.textContent = '—';
            td.appendChild(m);
        }
        return td;
    }

    function makeTable(withGroupCol) {
        var table = document.createElement('table');
        table.className = 'table table-hover mb-0';
        var thead = document.createElement('thead');
        thead.className = 'table-light';
        var heads = ['Hostname', 'Adresse IP', 'Adresse MAC'];
        if (withGroupCol) heads.push('Groupe');
        heads.push('Description', 'Modifié le', '');
        var tr = document.createElement('tr');
        heads.forEach(function (h, i) {
            var th = document.createElement('th');
            th.textContent = h;
            if (i === heads.length - 1) th.style.width = '100px';
            tr.appendChild(th);
        });
        thead.appendChild(tr);
        table.appendChild(thead);
        var tbody = document.createElement('tbody');
        table.appendChild(tbody);
        return { table: table, tbody: tbody };
    }
    function appendRow(tbody, r, withGroupCol) {
        var tr = document.createElement('tr');
        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);
    }

    function renderFlat(data) {
        container.innerHTML = '';
        var t = makeTable(true);
        data.slice().sort(byIp).forEach(function (r) { appendRow(t.tbody, r, true); });
        container.appendChild(t.table);
    }

    function renderGrouped(data) {
        container.innerHTML = '';
        // Regrouper
        var groups = {};
        var order = [];
        data.forEach(function (r) {
            var key = r.group_name && r.group_name.trim() !== '' ? r.group_name : ' '; // sentinelle = sans groupe
            if (!groups[key]) { groups[key] = []; order.push(key); }
            groups[key].push(r);
        });
        // Trier les noms de groupe alpha, « sans groupe » en dernier
        order.sort(function (a, b) {
            if (a === ' ') return 1;
            if (b === ' ') return -1;
            return a.localeCompare(b);
        });
        order.forEach(function (key) {
            var list = groups[key].slice().sort(byIp);
            var wrap = document.createElement('div');
            var header = document.createElement('div');
            header.className = 'group-header' + (key === ' ' ? ' group-header-none' : '');
            var caret = document.createElement('span'); caret.textContent = '▾'; caret.className = 'group-caret';
            var name = document.createElement('span');
            name.className = 'group-name';
            name.textContent = key === ' ' ? '(sans groupe)' : key;
            var count = document.createElement('span');
            count.className = 'group-count';
            count.textContent = list.length;
            header.appendChild(caret); header.appendChild(name); header.appendChild(count);
            var body = document.createElement('div');
            var t = makeTable(false);
            list.forEach(function (r) { appendRow(t.tbody, r, false); });
            body.appendChild(t.table);
            header.addEventListener('click', function () {
                var hidden = body.hasAttribute('hidden');
                if (hidden) { body.removeAttribute('hidden'); caret.textContent = '▾'; }
                else { body.setAttribute('hidden', ''); caret.textContent = '▸'; }
            });
            wrap.appendChild(header);
            wrap.appendChild(body);
            container.appendChild(wrap);
        });
    }

    function render() {
        var q = norm(filterInput.value.trim());
        var data = ROWS.filter(function (r) { return matches(r, q); });
        countEl.textContent = data.length + ' / ' + ROWS.length;
        if (mode === 'flat') renderFlat(data); else renderGrouped(data);
        btnGrouped.classList.toggle('active', mode === 'grouped');
        btnFlat.classList.toggle('active', mode === 'flat');
        // Ré-init tooltips
        container.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) {
            new bootstrap.Tooltip(el);
        });
    }

    btnGrouped.addEventListener('click', function () { mode = 'grouped'; localStorage.setItem(MODE_KEY, mode); render(); });
    btnFlat.addEventListener('click',    function () { mode = 'flat';    localStorage.setItem(MODE_KEY, mode); render(); });
    filterInput.addEventListener('input', render);

    render();
}());
</script>
```

Note : l'ancien `<script>` de fin (init tooltips seul) est entièrement remplacé par celui-ci — les tooltips sont désormais réinitialisés dans `render()`.

- [ ] **Step 4 : CSS — bandeaux et badges de groupe (clair + sombre)**

Dans `public/assets/css/app.css`, à la fin du fichier (avant ou après la media query responsive), ajouter :

```css
/* ── Groupes de réservations (vue subnet) ── */
.group-header {
    display: flex;
    align-items: center;
    gap: .5rem;
    background: #e2e8f0;
    color: #1e293b;
    border-top: 1px solid #cbd5e1;
    border-bottom: 1px solid #cbd5e1;
    padding: .5rem .9rem;
    font-weight: 600;
    cursor: pointer;
    user-select: none;
}
.group-header:hover { background: #dbe3ec; }
.group-header .group-caret { width: 1em; opacity: .7; }
.group-header.group-header-none { color: #64748b; font-style: italic; }
.group-header .group-count {
    margin-left: .3rem;
    background: #475569;
    color: #fff;
    font-size: .72rem;
    font-weight: 600;
    font-style: normal;
    padding: .05rem .5rem;
    border-radius: 999px;
}
.group-header.group-header-none .group-count { background: #94a3b8; }

.group-badge {
    display: inline-block;
    background: #e2e8f0;
    color: #1e293b;
    border: 1px solid #cbd5e1;
    font-size: .8rem;
    font-weight: 500;
    padding: .1rem .55rem;
    border-radius: 4px;
}

/* Thème sombre : inversion des tons */
@media (prefers-color-scheme: dark) {
    .group-header {
        background: #2d3e52;
        color: #e2e8f0;
        border-color: #3d5169;
    }
    .group-header:hover { background: #354a63; }
    .group-header.group-header-none { color: #94a3b8; }
    .group-badge {
        background: #2d3e52;
        color: #e2e8f0;
        border-color: #3d5169;
    }
}
```

- [ ] **Step 5 : Lint PHP**

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

- [ ] **Step 6 : Vérification navigateur — mode groupés**

Sur un subnet ayant plusieurs réservations réparties en groupes (au besoin, en créer 2-3 avec des groupes différents + une sans groupe) :
1. Ouvrir le subnet : par défaut mode **Groupés**. Un bandeau par groupe, compteur correct, tri par IP dans chaque groupe, « (sans groupe) » en dernier.
2. Cliquer un bandeau : le groupe se plie/déplie (caret ▾/▸).
3. Les icônes (options/NTP/short-lease) et leurs tooltips fonctionnent.
4. Les boutons Modifier/Supprimer pointent vers les bons `host_id` et `subnet_id`.

- [ ] **Step 7 : Vérification navigateur — mode plat, filtre, persistance**

5. Cliquer **Plat** : liste unique triée par IP + colonne Groupe (badge ; « — » si vide).
6. Recharger la page : le mode Plat est conservé (localStorage).
7. Taper dans le filtre « epson » (ou une valeur connue) : seules les lignes correspondantes restent, le compteur « N / M » se met à jour. Tester une portion de MAC **sans** `:` (ex. `bcdd`) → matche.
8. En mode Groupés avec filtre : un groupe entièrement filtré disparaît, les compteurs reflètent le visible.
9. Rôle viewer (si testable) : aucun bouton d'action affiché.

Cocher une fois les points 1-9 confirmés.

- [ ] **Step 8 : Commit + sync**

```bash
git add public/subnet.php public/assets/css/app.css
git commit -m "feat: vue subnet en rendu JS — modes groupés/plat + filtre multi-champs

Deux modes d'affichage (groupés par défaut, tri IP dans chaque groupe ;
plat avec colonne groupe), bascule mémorisée en localStorage, filtre
instantané sur hostname/IP/MAC/description/groupe (hors date).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
bash ~/sync.sh dhcpman
```

---

### Task 7 : Documentation finale (CLAUDE.md)

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

- [ ] **Step 1 : Documenter les nouveautés d'interface**

Dans `CLAUDE.md` :
- Section `subnet.php` : indiquer que la page est désormais rendue côté JS avec deux modes d'affichage (groupés/plat), tri par IP, filtre multi-champs, et mode mémorisé en localStorage.
- Section `subnets.php`/sidebar : mentionner la liste de subnets scrollable avec filtre rapide dans la sidebar.
- Section Import/Export CSV : ajouter la colonne `group` à la liste des colonnes (comme `no_gateway`).
- Section `app_reservation_meta` : confirmer `group_name` (déjà ajouté en Task 1, vérifier la cohérence).
- Ajouter `Reservation::getGroups(int $keaSubnetId): array` à la liste des méthodes clés de Reservation.

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

Créer un fichier mémoire `memory/project_reservation_groups.md` (métadonnée project) résumant : groupe stocké dans `app_reservation_meta.group_name` (jamais dans Kea), 2 modes d'affichage dans subnet.php (rendu JS), colonne CSV `group`, filtre multi-champs. Ajouter la ligne d'index dans `memory/MEMORY.md`. Lier `[[project_csv_no_gateway]]` (même pattern CSV) et `[[feedback_kea_cancelled]]` (contrainte : rien dans le schéma Kea).

- [ ] **Step 3 : Commit + sync**

```bash
git add CLAUDE.md
git commit -m "docs: sidebar scrollable, groupes de réservations, modes d'affichage

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
bash ~/sync.sh dhcpman
```

(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)

- [ ] Migration appliquée, `group_name` présent.
- [ ] Créer une réservation avec groupe via le formulaire → apparaît dans le bon groupe (mode groupés) et avec son badge (mode plat).
- [ ] Exporter le subnet en CSV → colonne `group` remplie ; réimporter → groupe conservé.
- [ ] Sidebar : Navigation + Administration figées, liste subnets scrollable, filtre fonctionnel.
- [ ] Filtre de la vue subnet : matche tous les champs voulus (dont MAC sans `:`), pas la date.
- [ ] Basculement de mode mémorisé entre visites.
- [ ] Aucun asset externe ajouté (contrainte offline respectée).
- [ ] `php -l` propre sur tous les fichiers modifiés.
