# Design — Sélection multiple et mass update des réservations

**Date :** 2026-07-11
**Périmètre :** vue `subnet.php`. Sélection de plusieurs réservations et modification groupée de leurs options, avec gestion des disparités. Plus un petit ajout : reset du filtre de la liste des réservations.

## Contexte et problème

Depuis l'ajout de la notion de groupe (v0.13), le groupe se gère réservation par
réservation. L'utilisateur veut pouvoir sélectionner plusieurs réservations d'un subnet
et modifier en une fois leurs **options** — sans page de gestion dédiée. La modification
groupée doit gérer les **disparités** : ne modifier que les champs réellement touchés,
montrer un indice visuel du changement, permettre d'annuler un changement (reset), et
signaler quand les valeurs diffèrent entre réservations.

## Décisions retenues

- **Périmètre sélection** : un seul subnet à la fois (la vue `subnet.php` est déjà
  mono-subnet). NTP/gateway/routes gardent un sens cohérent.
- **Champs mass-updatables** : groupe, lease court, gateway spécifique, no-gateway,
  routes statiques, NTP. **Pas** MAC/IP/hostname (identité unique).
- **Activation sélection** : cases à cocher visibles en permanence (version A). Barre
  d'action en **bas** de la liste. **Un seul** bouton d'action (le groupe est un champ
  parmi d'autres dans le popup).
- **Popup** : modale Bootstrap construite **côté JS** (instantané), à partir des données
  déjà injectées dans la page.
- **États initiaux (logique b)** : reflètent la réalité de la sélection (valeur commune
  affichée ; case cochée/décochée si homogène ; indéterminée si mêlée).
- **Reset par champ** : apparaît uniquement si l'état courant diffère de l'état initial ;
  le ramène à l'état initial et disparaît. Pas de badge « modifié » séparé.
- **Cycle des cases 3 états** : clic = ON → OFF → ON… ; seul le **reset** revient à
  indéterminé (« ne pas changer »).
- **Exclusivité RFC 3442** : même comportement qu'au formulaire individuel.
- **Backend** : page POST dédiée `reservation_bulk.php` + `Reservation::massUpdate()`,
  un seul `SyncService::sync()`, un `AuditLog` groupé.

## Contrainte rappelée

Aucune table native Kea modifiée. Le groupe reste dans `app_reservation_meta.group_name`.
Les options (gateway/routes/no-gateway/ntp) restent dans `dhcp4_options` scope_id=4
(table native Kea déjà utilisée, on n'ajoute ni colonne ni table). Contrainte offline :
aucun asset CDN.

---

## 1. Reset du filtre de la liste des réservations (`subnet.php`)

Le champ de filtre des réservations (ajouté en v0.13) reçoit un bouton **✕** qui vide le
filtre et relance le rendu, sur le modèle exact du filtre de la sidebar (`layout_header.php`,
`#subnet-filter-clear`). Le bouton n'est visible que lorsque le champ n'est pas vide.
Ajout mineur, groupé dans le même lot que la mass update.

---

## 2. Modèle — `lib/Reservation.php`

### 2.1 Enrichir `getBySubnet()`

Aujourd'hui `getBySubnet()` retourne `has_options` et `has_ntp` (booléens via `EXISTS`),
mais pas les valeurs. Pour construire le popup entièrement côté JS, il faut le détail des
options de **toutes** les réservations du subnet.

Remplacer les sous-requêtes `EXISTS` par un chargement des valeurs en **une requête
groupée** sur `dhcp4_options` (scope_id=4) pour tous les host_id du subnet. Puis, en PHP,
enrichir chaque ligne avec :

- `gateway` (string) — option 3, vide si absente ou si no-gateway
- `no_gateway` (0/1) — option 3 == `0.0.0.0`
- `routes` (array `[{dest, gw}]`) — option 121 parsée
- `ntp_enabled` (0/1) — présence option 42

`has_options` et `has_ntp` restent calculables à partir de ces valeurs (pour l'affichage
des icônes existant) : `has_options = (gateway !== '' || no_gateway || routes non vides)`,
`has_ntp = ntp_enabled`. Conserver ces clés pour ne pas casser l'affichage actuel.

Implémentation de la requête groupée :

```sql
SELECT host_id, code, formatted_value
FROM dhcp4_options
WHERE scope_id = 4 AND host_id IN (<liste des host_id du subnet>)
  AND formatted_value IS NOT NULL AND formatted_value != ''
```

Indexer le résultat par `host_id` puis par `code`, en réutilisant la logique de parsing
existante (`parseRoutes`, détection `_no_gateway` via valeur `0.0.0.0`). Éviter le
N+1 : ne pas appeler `getById()` en boucle.

### 2.2 Nouvelle méthode `massUpdate()`

```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)
 * Ne touche pas aux champs absents de $changes.
 * 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
```

Logique par host_id :
- Si `group_name` ∈ $changes : upsert `app_reservation_meta.group_name` (vide → NULL).
- Si `short_lease` ∈ $changes : mettre à jour `hosts.dhcp4_client_classes`
  (`'short-lease'` ou NULL) **et** `app_reservation_meta.short_lease`, comme le fait
  `update()`.
- Options DHCP (`gateway`, `no_gateway`, `routes`, `ntp`) : comme elles vivent toutes dans
  `dhcp4_options` scope_id=4 et que `saveHostOptions()` **remplace** l'ensemble des options
  de l'hôte, il faut recomposer l'état complet des options pour chaque hôte avant d'écrire :
  partir de l'état actuel de l'hôte (chargé via la même logique que `getBySubnet`/`getById`),
  écraser uniquement les clés présentes dans `$changes`, puis appeler `saveHostOptions()`
  avec l'état recomposé. Cela garantit qu'un champ option non modifié est préservé.
- Renseigner `updated_by_user_id`/`updated_at` sur le meta.

Le tout dans une transaction (cohérence si erreur au milieu).

---

## 3. Handler — `public/reservation_bulk.php`

Page **POST-only** (redirige sur GET), rôles **admin|tech** (`Auth::requireRole`).

Entrées POST :
- `subnet_id` (app id)
- `host_ids[]` — liste des réservations sélectionnées
- Pour chaque champ mass-updatable : un marqueur « modifié » (ex. `changed[group_name]=1`)
  et la valeur associée. **Seuls les champs marqués modifiés sont pris en compte.**

Traitement :
1. Charger `$subnet` ; 404/redirect si absent.
2. **Sécurité** : vérifier que tous les `host_ids` appartiennent bien à
   `$subnet['kea_subnet_id']` (requête de contrôle). Rejeter sinon (flash danger).
3. Construire `$changes` à partir des seuls champs marqués modifiés. Appliquer
   l'exclusivité RFC 3442 côté serveur aussi (défense en profondeur) : si `routes`
   présentes et non vides → vider gateway/no-gateway ; si `no_gateway=1` → vider
   gateway/routes.
4. Valider les valeurs fournies (gateway IPv4 si non vide, routes CIDR+gw valides) en
   réutilisant les validations existantes ; en cas d'erreur, flash danger + redirection.
5. `Reservation::massUpdate($hostIds, $changes)`.
6. **Un seul** `SyncService::sync()`.
7. `AuditLog::log('reservation.bulk_update', 'host', null, "N réservations — champs: ...")`.
8. Redirection `subnet.php?id=X` + flash success (ou warning si sync KO), mentionnant le
   nombre de réservations et les champs modifiés.

---

## 4. Sélection multiple — front (`subnet.php`)

Le rendu JS existant (v0.13) est étendu :

- **Colonne case à cocher** en tête de chaque ligne (permanente). `data-host-id` sur la case.
- **Case « tout »** : dans le `<thead>` du tableau (mode plat) et dans chaque en-tête de
  groupe (mode groupés) — coche/décoche toutes les lignes visibles concernées.
- **État de sélection** : un `Set` de `host_id` en JS, indépendant du mode d'affichage et
  du filtre. Une ligne filtrée (masquée) reste sélectionnée. Au re-render (changement de
  mode/filtre), les cases reflètent le `Set`.
- **Barre d'action** en **bas** de la liste (sous le conteneur), masquée si sélection vide.
  Contenu : « **N sélectionnée(s)** » · bouton « **Modifier les options** » · bouton
  « **Tout désélectionner** ». La barre n'apparaît que pour les rôles admin|tech
  (les viewers n'ont pas les cases ni la barre).
- Le bouton « Modifier les options » ouvre le popup (section 5).

---

## 5. Popup de mass update — front (JS instantané)

Modale Bootstrap (composant déjà disponible localement), construite en JS à partir des
`$rows` enrichis (section 2.1) filtrés sur les `host_id` sélectionnés. Aucun aller-retour
serveur à l'ouverture.

### Calcul des états initiaux (logique b)

Pour la sélection courante :
- **Groupe, Gateway** (texte) : si toutes les réservations ont la même valeur → affichée ;
  sinon champ vide + placeholder « valeurs mêlées ». État initial = valeur commune ou
  « mêlé ».
- **Lease court, No-gateway, NTP** (case 3 états) : toutes ON → cochée ; toutes OFF →
  décochée ; sinon → indéterminée.
- **Routes** : si toutes ont exactement le même jeu de routes → affiché (éditable) ;
  sinon → mention « **routes divergentes** » + bouton « Définir pour tous… ».

### Interactions

- **Cases 3 états** : clic = ON → OFF → ON… . Le **reset** (↺) revient à l'état initial
  (qui peut être indéterminé). Le reset n'est visible que si l'état courant ≠ état initial.
- **Champs texte** : le reset revient à la valeur initiale affichée à l'ouverture ; visible
  seulement si modifié.
- **Routes** : « Définir pour tous… » ouvre l'éditeur de routes (même widget add/remove que
  le formulaire individuel). Une fois défini, le champ routes est marqué modifié
  (reset visible) ; à l'application, ce jeu **remplace** les routes de toute la sélection.
- **Exclusivité RFC 3442** : imposer des routes (non vides) vide et désactive gateway +
  no-gateway ; cocher no-gateway vide gateway + routes. Réplique le comportement du
  formulaire individuel.

### Détermination des champs à soumettre

Un champ est « modifié » si son reset est visible (état courant ≠ état initial). À
« Appliquer aux N », le JS construit le POST avec **uniquement** les champs modifiés
(`changed[<champ>]=1` + valeur) et les `host_ids`. Soumission classique (form POST) vers
`reservation_bulk.php`. Le popup se ferme, la page se recharge avec le flash.

### Boutons

- « **Annuler** » : ferme sans rien faire.
- « **Appliquer aux N** » : soumet. Désactivé si aucun champ n'est modifié.

---

## Fichiers touchés

| Fichier | Nature |
|---------|--------|
| `lib/Reservation.php` | `getBySubnet()` enrichi (requête groupée options) ; `massUpdate()` |
| `public/subnet.php` | injection des options détaillées dans `$rows` ; cases de sélection ; barre d'action ; popup JS ; reset du filtre résa |
| `public/reservation_bulk.php` | **nouveau** — handler POST mass update |
| `public/assets/css/app.css` | styles barre d'action, case 3 états, reset, popup |
| `CLAUDE.md` | doc mass update + `reservation_bulk.php` + `massUpdate()` |

## Hors périmètre (YAGNI)

- Pas d'écran de gestion des groupes (création/renommage en masse).
- Pas de mass update sur MAC/IP/hostname.
- Pas de sélection multi-subnet.
- Pas d'undo après application (l'AuditLog trace ; pas de rollback).
- Pas de persistance de la sélection entre visites.

## Risques et points d'attention

- **`saveHostOptions()` remplace tout** : `massUpdate()` doit recomposer l'état complet
  des options par hôte avant d'écrire, sinon un champ option non modifié serait effacé.
  C'est le point le plus délicat de l'implémentation.
- **Sécurité** : vérifier l'appartenance des `host_ids` au subnet côté serveur (ne pas
  faire confiance au POST).
- **Échappement** : les valeurs injectées dans `$rows` (routes, gateway, groupe) doivent
  passer par `json_encode` avec les flags `JSON_HEX_*` déjà utilisés, et être rendues via
  `textContent`/valeurs de champ, jamais `innerHTML`.
- **Performance** : la requête groupée d'options remplace N sous-requêtes EXISTS — gain net
  même avec beaucoup de réservations.
- **Cohérence sync** : un seul `SyncService::sync()` après l'ensemble des écritures.
