# Skill "module Vente" — recette à répliquer sur les autres modules

> Référence produite le 21/07/2026 sur le périmètre **Vente** (Clients, Cartes, Encaissements,
> Grand Livre). Objectif : servir de check-list/copier-coller quand on refait le même travail sur
> un autre module (Achat, Coffre, Compte, Stocks...). À lire avec `DESIGN.md` (journal Phase 4) et
> `CLAUDE.md` (règles générales du projet).

## 1. Composants du design system utilisés (public/css/design/components.css)

| Besoin | Classe / mécanisme |
|---|---|
| Panneau unique filtre+tableau (remplace card-dans-card) | `.ssm-panel` / `.ssm-panel-header` / `.ssm-panel-body` |
| Onglets horizontaux hors card-header (ex. Clients/Groupes) | `.ssm-tabs` / `.ssm-tabs-link` |
| Rangée de filtres + action à droite garantie | `.ssm-panel-toolbar` (flex) + `.ssm-panel-actions` (`margin-left:auto`) |
| Bouton plein écran (attaché au coin du card, pas d'espace réservé) | `.ssm-panel-fullscreen-btn` (position absolute) + JS `ssm_panel_fullscreen(selector)` |
| Boutons Imprimer/Exporter génériques au niveau du tableau | bloc statique `.ssm-table-toolbar` + JS `ssm_table_toolbar(panelSelector, {titre, filtres})` |
| Totaux/KPI en carte (ex. Situation client) | `.ssm-kpi-row` / `.ssm-kpi` / `.ssm-kpi-label` / `.ssm-kpi-value` / `.ssm-kpi-sub` |
| Totaux "juste des chiffres" sans carte (ex. soldes Grand Livre) | `.ssm-stat-row` / `.ssm-stat` / `.ssm-stat-label` / `.ssm-stat-value` (`.ssm-stat-positive/negative`, `.is-loading`) / `.ssm-stat-sub` / `.ssm-stat-skeleton` |
| Case à cocher "option" stylée (ex. Bons/Facturation) | `.ssm-options-row` / `.ssm-option-toggle` |
| Sous-menu de module avec thumb glissant | `.ssm-module-nav` (piste) + `.ssm-module-nav-thumb` (JS positionne, ne pas styliser en CSS statique) |
| Navigation AJAX inter-pages (pas de rechargement complet) | attribut `data-ssm-nav="1"` sur les `<a>` + conteneur `#ssm_module_content` |

## 2. Principe transversal : navigation AJAX inter-pages

**Zéro changement de contrôleur nécessaire.** `Controller::view()` détecte l'en-tête `X-Ssm-Nav`
(posé par `ssm_nav_ajax()`, ajax.js) et renvoie directement le contenu de la vue au lieu du layout
complet. Pour équiper un module :

1. Dans le sous-menu du module (équivalent de `menu_client.php`), ajouter `data-ssm-nav="1"` sur
   chaque `<a>`, plus `role="tablist"`/`role="tab"`/`aria-selected` et le span
   `<span class="ssm-module-nav-thumb" aria-hidden="true"></span>` en premier enfant de `<nav>`.
2. Dans **chaque vue** du module, envelopper tout ce qui suit l'include du sous-menu dans
   `<div id="ssm_module_content">...</div>` (ouvrir juste après l'include, fermer à la toute fin
   du fichier, après le `</script>`). Sans ce wrapper, la navigation retombe automatiquement sur
   un remplacement complet de `#body_app` (pas de régression, juste moins fin).
3. Rien d'autre : `ssm_nav_ajax()` gère seul l'historique (pushState/popstate), le thumb glissant
   + sa direction, l'animation d'entrée/sortie du contenu, et la réinitialisation des select2
   (`ssm_init_select2()`) du contenu injecté.

**Piège vécu et corrigé (2)** : naviguer (clic sur un onglet du sous-menu, `data-ssm-nav`) alors
qu'une modale Bootstrap est ouverte (ex. "Ajouter" sur Clients) détruit le DOM de cette modale
(elle vit dans `#ssm_module_content`, remplacé par la navigation) **sans que Bootstrap ait pu
nettoyer son `.modal-backdrop`** (ajouté comme frère de `<body>`, donc en dehors de la zone
remplacée) ni les classes qu'il pose sur `<body>` (`modal-open`, `padding-right`). Résultat : un
fond invisible reste bloqué pour toujours et intercepte tous les clics sur toute l'application, y
compris la top bar — symptôme signalé : "je change vers Carte, plus aucune modale ne fonctionne,
ni la top bar, ni rien". Fix (systémique, dans `ssm_nav_ajax()`, profite à tous les modules) :
avant tout remplacement de contenu, fermer les modales ouvertes (`$('.modal.show').modal('hide')`)
**et** nettoyer sans condition tout backdrop/classe body restants (`$('.modal-backdrop').remove()`
+ `$('body').removeClass('modal-open')`) — ne jamais compter sur le cycle de vie normal de
Bootstrap quand le DOM de la modale peut disparaître de force.

**Piège vécu et corrigé (3), plus sournois** : même en fermant proprement la modale précédente, un
retour utilisateur a persisté ("carte → retour vers client → ajouter → modale affichée mais sans
focus, rien ne fonctionne dedans"). Cause trouvée **en pilotant un vrai Chromium headless**
(Playwright, voir méthode ci-dessous) : `#ssm_module_content.ssm-content-in-droite`/`-gauche`/`-in`
(l'animation d'entrée de la nav AJAX, `components.css`) utilise `animation-fill-mode: both` pour
éviter un flash — mais `both` laisse l'état final de la keyframe (`transform: translateX(0)`, pas
`none`) **posé en permanence** sur `#ssm_module_content` tant que la classe reste sur l'élément, ce
qui n'était jamais retiré après la fin de l'animation. Or **n'importe quel `transform` (même
`translateX(0)`, sans mouvement visible) crée un nouveau conteneur/contexte d'empilement pour tout
descendant `position:fixed`** — exactement le même piège que celui déjà documenté plus haut pour le
plein écran (§3), mais cette fois c'est une **modale Bootstrap** qui se retrouvait piégée : rendue
plus petite que l'écran, et son `.modal-backdrop` (resté enfant de `<body>`, donc hors du piège)
passait par-dessus et interceptait tous les clics - la modale semblait "morte" sans aucune erreur
JS visible. Fix : retirer la classe d'animation (`ssm_module_nav_transition()`, ajax.js) une fois
la durée de l'animation écoulée (`setTimeout`, même délai que le nettoyage de la couche de sortie)
- aucune différence visuelle (l'état "animé fini" et l'état "pas de classe" sont identiques), mais
ça referme le conteneur d'empilement. **Leçon générale** : toute classe qui pose un `transform`
(animation ou transition, `fill-mode: both` ou `forwards` compris) sur un ancêtre de modale/popup
doit être retirée une fois l'animation terminée, jamais laissée en place indéfiniment.

**Méthode utilisée pour trouver ce bug** : impossible à voir par simple lecture de code ou curl (il
faut un vrai rendu + de vrais clics). Un Chromium headless piloté par Playwright (installé à la
volée dans le scratchpad de session : `npm init -y && npm install playwright && npx playwright
install chromium`, pas de dépendance ajoutée au projet) a permis de rejouer le scénario exact
(login → Clients → Cartes (nav AJAX) → Clients (nav AJAX) → clic Ajouter → clic dans un champ) et
d'inspecter en direct le DOM (`page.evaluate(() => ...)` : z-index/position calculés,
`document.elementFromPoint()` pour voir QUEL élément reçoit vraiment le clic, `getComputedStyle`)
- c'est `elementFromPoint()` au centre du champ qui a révélé que c'était le `.modal-backdrop` et
pas le champ qui recevait le clic, pointant directement vers un problème d'empilement plutôt qu'un
handler manquant.

**Piège vécu et corrigé** : ne jamais passer `cache:false` à `$.ajax()` pour ces requêtes — jQuery
ajoute alors `?_=<timestamp>` à l'URL, et le routeur (`public/index.php`) construit `$paths` via
`explode('/', $_SERVER['REQUEST_URI'])` **sans jamais retirer la query string** → le nom du
contrôleur se retrouve pollué et `namespace_resolve()` échoue (404 => `public/404.php` injecté à
la place du contenu). Symptôme observé : "Retour à l'Accueil" + menu disparu.

## 3. Piège CSS le plus coûteux de la session : `overflow:hidden` casse `position:sticky`

`.ssm-panel` avait `overflow:hidden` (pour clipper les coins carrés du header/body contre le cadre
arrondi). N'importe quel ancêtre à `overflow != visible` devient le scroll-port de référence pour
`position:sticky`, et comme `.ssm-panel` ne défile jamais lui-même, ça annulait tout header de
tableau sticky à l'intérieur. **Fix retenu** : ne jamais mettre `overflow:hidden` sur un
conteneur qui pourrait accueillir un descendant sticky — clipper les coins via un `border-radius`
explicite sur le header (coins hauts) et le body (coins bas) à la place.

## 4. Autre piège CSS répété deux fois : la spécificité `:has()`

Une règle `:has(> selecteur)` (attribut + classe + pseudo-classe) est plus spécifique qu'une classe
seule ou même deux classes combinées, et gagne **même avec `!important` sur l'autre règle** si
elle n'a pas elle-même `!important`. Rencontré sur :
- Le plein écran (`.ssm-panel--fullscreen` vs `:has(> .ssm-panel-fullscreen-btn){position:relative}`)
  → fix retenu au final : poser les styles critiques (position/inset/z-index/background) en
  **inline avec `element.style.setProperty(prop, val, 'important')` depuis le JS**, qui bat
  n'importe quelle règle de feuille de style externe, présente ou future. C'est la solution la
  plus robuste pour tout ce qui doit *garantir* d'être au-dessus de tout — ne pas hésiter à
  l'utiliser directement plutôt que de chasser un conflit de spécificité CSS.
- Les filtres/totaux en version mobile (`body[data-design] .input-group.input-group-sm` plus
  spécifique que la media query) → fix retenu : `!important` sur la media query.

## 5. Impression / export générique (sans backend dédié par module)

`ssm_table_toolbar(panelSelector, {titre, filtres: {"Libellé": "#selecteur"}})` (ajax.js) :
- Injecte une modale commune (une seule pour toute l'appli) au premier appel.
- La modale propose : titre modifiable, afficher les filtres (case), afficher la pagination
  actuelle "Page X sur Y" (case), afficher les infos de bas de tableau (case), et une liste de
  colonnes à cocher (générée depuis les vraies `<th>` de la table, tout coché sauf "Action").
- Imprime/exporte **exactement ce qui est affiché à l'écran** (respecte le sélecteur de lignes
  10/20/50/100/Tout) — pas de re-pagination côté impression.
- Les boutons doivent être placés en HTML **statique** juste au-dessus de la `<table>` visée
  (`.ssm-table-toolbar` avec 2 boutons `.ssm-table-print`/`.ssm-table-export`) — **ne jamais**
  essayer d'injecter dynamiquement dans `.dataTables_filter` (tenté puis abandonné : trop fragile,
  boutons disparus en prod).
- Si le module a un mécanisme d'impression **métier** déjà existant (ex. Grand Livre avec
  récapitulatif de solde) : le garder tel quel, à côté des filtres comme une action normale
  (type "Ajouter"), et ajouter le mécanisme générique **en plus**, au niveau du tableau — ce sont
  deux besoins différents, ne pas les fusionner.

## 6. DataTables : configuration commune (déjà dans `init_datatable()`, ajax.js)

- Longueur de page 10/20/50/100/Tout déjà active par défaut (`lengthChange:true`).
- Traductions françaises **en dur** dans `language` (pas `oLanguage` — clé legacy qui attend
  l'ancien format hongrois et ignore silencieusement les sous-clés modernes) — ne plus dépendre
  d'un fetch vers `cdn.datatables.net` (échouait silencieusement en local, retombait sur
  l'anglais).
- En-tête de tableau sticky déjà actif (`thead th{position:sticky;top:0}` + fond opaque
  obligatoire). Topbar déjà `position:sticky;top:0;z-index:20` pour rester au-dessus.

## 7. Perf : vérifier les modèles avant de répliquer un module lourd en données

Le module Encaissements cachait un vrai problème de fond (pas lié au design) : une requête
`UNION ALL` à 8 branches, jointes sur des colonnes sans index, exécutée jusqu'à 3 fois par appel
(deux `fetchAll()` uniquement pour compter + une fois sans LIMIT pour sommer en PHP). Avant de
refaire le design d'un module aux requêtes complexes, vérifier rapidement :
- Y a-t-il un `count(...->fetchAll())` qui devrait être un `SELECT COUNT(*) FROM (...)` ?
- Une somme/total calculée en bouclant du PHP qui devrait être un `SELECT SUM(...)` ?
- Les colonnes de jointure (`source`, `id_source`, `zone`, `id_client`...) ont-elles un index ?
Voir `database/migrations/007_index_encaissements_performance.sql` comme modèle de migration
d'index, et le commit `ab0b6f1` pour le patron de correction PHP.

## 7bis. Totaux qui se rafraîchissent au changement de filtre : ne jamais recharger tout le bloc

Piège vécu sur les soldes du Grand Livre : `load_portion()` remplace toute la zone cible par un
loader générique le temps de la requête → le bloc entier "disparaît" à chaque changement de filtre
("moche" signalé par l'utilisateur). Pour un simple total/compteur qui se met à jour souvent :
1. Rendre la structure HTML (libellés, icônes) **une seule fois**, jamais recréée.
2. Le contrôleur renvoie du **JSON** (`header('Content-Type: application/json'); echo json_encode(...);`),
   pas un fragment de vue.
3. Le JS fait un `$.post(..., callback, 'json')`, ajoute `.is-loading` sur les seuls éléments
   `.ssm-stat-value` avant l'appel, remplace juste leur texte au retour (`ssm_stat_set()`,
   ajax.js) et retire `.is-loading`. Voir `Livre.php::solde()` / `GrandLivreController::solde()`.
4. Avant le tout premier chargement (aucune donnée encore reçue) : un squelette animé
   (`.ssm-stat-skeleton`) plutôt qu'un 0 ou un vide.

## 8. Check-list pour un nouveau module

1. Lire ce fichier + `DESIGN.md` (journal) + regarder `app/views/vente/Clients.php` et
   `app/views/vente/portion/menu_client.php` comme référence vivante.
2. Sous-menu du module : copier `menu_client.php` (thumb, `data-ssm-nav`, rôles ARIA).
3. Chaque page du module : `.ssm-panel` (ou `.card` si déjà en place et sans besoin de sticky),
   `.ssm-panel-toolbar`/`.ssm-panel-actions`, bouton plein écran, `.ssm-table-toolbar` +
   `ssm_table_toolbar(...)`, wrapper `#ssm_module_content`.
4. Icônes sur les `<th>` (sémantiques par colonne : calendrier, personne, argent...).
5. Modales : `data-mode="ajout"|"modification"` + `.ssm-modal-icon` (header) + `.ssm-field-icon`
   sur les labels + boutons `.ssm-btn`/`.ssm-btn-primary` (voir `carte.php`/`reglage_carte.php`,
   `Nouveau_client.php`) — **checklist à part entière, à ne PAS considérer optionnelle/à part** :
   - **Boutons `.ssm-btn` partout, y compris les déclencheurs de niveau page** ("Ajouter" du panel,
     boutons toggle de dropdown) : sans cette classe précise, le bouton est invisible au scanner de
     raccourcis Ctrl+`<lettre>` (§31, `ssm_raccourci_candidats()` ne regarde QUE `.ssm-btn`) — un
     bouton stylé en `.ssm-btn` "visuellement" mais resté en `btn btn-sm bg-primary` ne recevra
     jamais Ctrl+A/Ctrl+<sa lettre>, même si tout le reste (icônes, data-mode) est fait. Vérifier
     avec Ctrl+<lettre> en conditions réelles, pas juste visuellement.
   - **Notification + fermeture automatique** (§10) sur CHAQUE flux ajout/modification en modale
     qui ne fait pas un rechargement de page complet : `ssm_notify()` (jamais `toastr` sur un flux
     repris) + `ssm_modal_auto_close('#afficher_div', 5000)` pour un ajout (le formulaire revient
     vierge), fermeture directe pour une modification. Utiliser le callback de `load_portion(...,
     function(){...})`, jamais un `setTimeout` arbitraire après un appel fire-and-forget.
   - Cas où l'auto-fermeture ne s'applique PAS (documenter pourquoi, ne pas l'ajouter quand même) :
     un flux qui fait un `location.reload()`/redirection complète de page après l'ajout (l'auto-
     fermeture n'a pas de sens juste avant que toute la page change) — remplacer `toastr` par
     `ssm_notify` quand même, c'est gratuit et cohérent visuellement.
6. Si le module a des requêtes lourdes/anciennes : passage rapide perf (section 7) avant de se
   concentrer sur le visuel.
7. Tester : `php -l`, vérif accolades CSS, login + curl sur chaque page (normal ET avec
   `X-Ssm-Nav: 1`), Ctrl+`<lettre>` sur chaque bouton d'action principal de chaque page (pas
   seulement visuel - vérifier que le clic/la modale se déclenche vraiment), puis commit avec
   message détaillé (quoi + pourquoi + comment testé).

## 9. Réglages d'affichage par tableau + thème par module (21/07/2026, pilote Vente/Clients)

Demande explicite : laisser l'utilisateur personnaliser l'affichage (alignement, couleurs) mais
**jamais de couleur libre** — uniquement des presets fermés, validés par le design system, pour
garantir un bon rendu quel que soit le choix.

**Par tableau** (bouton engrenage, à gauche du bouton plein écran) :
- HTML : envelopper les deux boutons dans `.ssm-panel-toolbelt` (remplace le
  `.ssm-panel-fullscreen-btn` seul en position absolute) :
  ```html
  <div class="ssm-panel-toolbelt">
    <button type="button" class="ssm-btn ssm-panel-settings-btn" title="Réglage d'affichage"><i class="fa fa-sliders" aria-hidden="true"></i></button>
    <button type="button" class="ssm-btn ssm-panel-fullscreen-btn" title="Plein écran">...</button>
  </div>
  ```
- JS : un seul appel dans le script de la vue, comme `ssm_panel_fullscreen()`/`ssm_table_toolbar()` :
  `ssm_panel_settings('#mon_panel_id', 'cle_stable_unique')` (ex. `'vente_clients'`). Le panel
  **doit avoir un id** (sert de scope aux règles CSS de couleur par colonne). Cet appel branche le
  bouton ET applique automatiquement le réglage déjà enregistré au chargement (pas besoin d'ouvrir
  la modale pour que ça s'applique).
- Le composant est 100% générique (`ssm_panel_settings`, `ssm_ensure_reglage_modal`, `ssm_reglage_appliquer`
  dans `ajax.js` + `App\controllers\parametre\ReglageAffichageController` + `AffichageTable` model) —
  aucune modification serveur nécessaire par module, juste ces deux ajouts HTML/JS par panel.
- Presets fermés (voir `AffichageTable::PRESETS_*`/`COULEURS_COLONNE`/`ALIGNEMENTS_COLONNE`) :
  entête défaut/accent/sombre/succès/attention et bordure de card défaut/accent/succès/attention/
  alerte (portée sur tout le tableau) ; **alignement ET couleur de texte sont réglables par
  colonne** (retour utilisateur du 21/07/2026 : "un alignement pour chaque colonne", pas un seul
  pour toute la table) — la colonne est identifiée par le **texte de son `<th>`** (comme
  `ssm_export_colonnes_html()`), pas son index : robuste si des colonnes sont réordonnées plus tard.
- Persistance : `parametre_affichage_table` (migrations 010 + 011), une ligne par (utilisateur,
  cle_table), colonne JSON `colonnes` = `{"Nom de colonne": {"couleur":"accent","alignement":"droite"}}`.
- **Piège vécu et corrigé** : un premier essai appliquait l'alignement au niveau du panel entier
  (classe `.ssm-panel.ssm-align-droite`, sans `!important`) — silencieusement sans effet, car la
  quasi-totalité des tableaux posent déjà `className: 'text-center'`/`'text-right'` via les
  `columnDefs` de DataTables, et les classes Bootstrap `.text-center`/`.text-right` sont elles-mêmes
  en `!important`. Sans `!important` d'un côté ET une spécificité au moins égale de l'autre, le
  réglage perdait systématiquement face à la classe posée par la vue elle-même ("j'ai choisi
  droite mais rien n'est fait", signalé par l'utilisateur). Fix retenu : les règles générées pour
  l'alignement ET la couleur par colonne sont désormais scopées par l'**id du panel** (`#mon_panel
  table.dataTable tbody td:nth-child(N)`, spécificité largement suffisante) et systématiquement en
  `!important` — voir `ssm_reglage_appliquer()` (ajax.js). À reproduire pour tout nouveau réglage
  de ce type : ne jamais compter sur la seule spécificité face à du Bootstrap `!important` déjà en
  place sur les cellules de tableau.

**Par module** (bouton "Thème" à l'extrême droite du sous-menu, ex. `menu_client.php`) :
- Envelopper le `<nav class="ssm-module-nav">` existant dans `.ssm-module-nav-bar`, puis appeler
  `echo ssm_module_theme_menu_html('nom_du_module');` (vendor/function.php) juste après — génère
  le dropdown des 4 directions + un "Revenir au thème global" si une surcharge existe déjà.
  `'nom_du_module'` doit être une clé de `namespace_resolve()` (ex. `'vente'`, `'coffre'`...).
- Persistance : `parametre_style_module` (migration 010), scope (utilisateur, module) — n'affecte
  QUE les pages de ce module ; les autres gardent le thème global (`parametre_style_user`). Voir
  `vendor/function.php::style()` : override résolu via `namespace_resolve($GLOBALS['paths'][1])`
  et mis en cache session dans `$_SESSION['style_module'][$module]` (jamais dans
  `$_SESSION['style_mode']`/`style_nom`, qui restent le cache du thème global — piège à éviter si
  on retouche cette fonction : ne jamais faire fuiter la surcharge d'un module dans le cache global).

**Piège évité d'entrée** : `menu_client.php` est inclus aussi bien en navigation normale qu'en
navigation AJAX inter-pages (`X-Ssm-Nav`, qui ne passe PAS par `Layout::donnees()` donc pas de
variable `$layout` disponible à cet endroit) — `ssm_module_theme_menu_html()` s'appuie uniquement
sur `design_directions()` (fonction globale) et `$GLOBALS['style']` (toujours peuplé, `style()` est
appelé sans condition dans `public/index.php` avant le routage), jamais sur `$layout`.

**Sous-module Client (Vente) entièrement équipé** (21/07/2026) : `client_data`/`groupe_data`
(Clients.php), `matricules` (Cartes.php), `encaissement_data` (Encaissements.php), `livre_data`
(Livre.php) ont tous le bouton réglage (`vente_clients`/`vente_groupes`/`vente_cartes`/
`vente_encaissements`/`vente_livre` comme `cle_table`). Prochain sous-module à équiper : **Gestion
des bons** (menu séparé, voir `namespace_resolve()` → `bons`) — même recette, 2 lignes HTML +
1 ligne JS par panel (§9 ci-dessus) + un `ssm_module_theme_menu_html('bons')` dans son propre menu.

## 10. Notification + fermeture automatique après ajout/modification en modale (21/07/2026)

Retour utilisateur sur le flux "Ajouter/Modifier un client" (`vente/modal/Nouveau_client.php` via
`ClientsController::modal()`) : après un **ajout**, le contrôleur recharge la modale avec un
formulaire **vierge** (pratique pour enchaîner plusieurs ajouts) ; après une **modification**, ce
même formulaire vierge n'a pas de sens - il faut fermer directement.

- **Notification** : `ssm_notify(message, type, duree)` (ajax.js) remplace `toastr.success(...)`
  sur ce flux - carte alignée sur les tokens du thème (pas l'habillage générique de toastr),
  empilée en haut à droite. **Ne remplace pas toastr partout** (encore utilisé ailleurs, migration
  au fil de l'eau comme le reste de la Phase 4) - juste sur les flux repris avec cette recette.
- **Fermeture automatique (ajout uniquement)** : `ssm_modal_auto_close(modalSelector, duree)`
  injecte une barre rouge qui se rétrécit en haut de `.modal-content` (profite du `overflow:hidden`
  déjà en place) ; à la fin du délai (5000ms par défaut), ferme la modale (`.modal('hide')`) - SAUF
  si l'utilisateur a interagi entretemps (clic/saisie/changement dans `.modal-body`/`.modal-footer`),
  auquel cas la fermeture est annulée silencieusement.
- **Piège vécu et corrigé** : le formulaire rechargé repeuple son select2 des groupes via
  `.trigger('change')` **programmatique** (`remplir_groupe()`, script de `Nouveau_client.php`) -
  ce `change` synthétique remontait dans le délégué d'écoute et annulait la fermeture automatique
  à peine démarrée, avant même que l'utilisateur touche à quoi que ce soit. Fix : ignorer tout
  événement sans `e.originalEvent` (un événement réellement émis par le navigateur en a toujours
  un ; un `.trigger()` JS n'en a jamais) - ne réagir qu'aux vraies interactions utilisateur.
- **Usage dans le handler de succès** (voir `Clients.php`, `#ajouter_client`) :
  ```js
  var etait_ajout = (param['id'] == 0); // lu AVANT le rechargement (le formulaire revient vierge apres)
  load_portion('/Clients/modal', 'modal_div', param, function() {
    ssm_notify(msg, 'success');
    if (etait_ajout) ssm_modal_auto_close('#afficher_div', 5000);
    else $('#afficher_div').modal('hide');
    Clients.draw();
  });
  ```
- **Reste à faire** : seul `Clients.php` (`#ajouter_client`) a été repris ; répliquer sur les
  autres modales ajout/modification du même module (Groupes, Cartes...) puis des autres modules,
  au fur et à mesure - même recette, quelques lignes par handler de succès.
- **Détail de ce qui a été ajouté/modifié** (retour utilisateur du 21/07/2026) : `ssm_notify()`
  accepte un 4e paramètre `details` (`[{label, valeur}]`) affiché sous le message principal - les
  entrées vides sont ignorées automatiquement. Construit côté appelant à partir des **champs
  pertinents** du formulaire (pas les id techniques ni les options booléennes) juste avant l'appel
  à `load_portion` (les valeurs du formulaire sont encore là, avant qu'il ne revienne vierge) :
  ```js
  var details = [
    { label: 'Nom', valeur: param['nom'] },
    { label: 'Groupe', valeur: $('#id_groupe option:selected').text().trim() },
    { label: 'Plafond', valeur: param['plafond'] ? Nombre(parseFloat(param['plafond'])) + ' MAD' : '' },
  ];
  ```
  **Piège vécu et corrigé** : `Nombre()` (ajax.js) appelle `number.toFixed(...)` directement sans
  convertir son argument - lui passer `param['plafond']` (une **chaîne**, issue de `.val()`) fait
  planter `Number.prototype.toFixed` (indisponible sur `String`) **avant même l'appel à
  `load_portion`**, empêchant silencieusement tout l'ajout (formulaire jamais envoyé, bouton
  simplement resté caché). Toujours passer un vrai nombre à `Nombre()` : `Nombre(parseFloat(valeur))`.
  Trouvé uniquement grâce au test navigateur (Playwright) - rien dans la réponse serveur ou la
  console ne l'aurait signalé sans capturer les `pageerror`.

**Méthode de test utilisée** : Chromium headless piloté par Playwright (voir §9 pour l'installation
à la volée) - a permis de vérifier en conditions réelles les 3 scénarios (ajout puis silence →
fermeture automatique après 5s ; ajout puis interaction → fermeture annulée ; modification →
fermeture directe) avant de livrer, y compris le piège du `change` synthétique qu'une simple
relecture de code n'aurait pas révélé.

## 11. Dispositions de navigation alternatives : palette / rail / dock (21/07/2026)

Retour utilisateur : implémenter les 3 propositions de `left et top .md` (racine du projet, prompts
4a/4b/4c) comme de vraies dispositions **choisies par l'utilisateur** (pas des maquettes), chacune
**adaptée à nos 4 directions visuelles** (jamais les couleurs en dur des prompts), en réutilisant
les vraies données de l'appli plutôt que les arbres de menu/notifications fictifs des prompts.

**Architecture** :
- `parametre_layout_user` (migration 012) : préférence par utilisateur parmi
  `classique` (défaut, comportement historique inchangé) / `palette` / `rail` / `dock`. Cache
  session `$_SESSION['layout_mode']`, résolu dans `Layout::donnees()` — même schéma que
  `parametre_style_module` (§9).
- `Layout::menu_arbre()` : regroupe le menu **déjà filtré par droits** (`Layout::menu_config()`,
  la vraie source) en arbre section→pages, injecté une fois en JSON (`window.SSM_MENU_ARBRE`,
  `top_bar.php`) — jamais de section/page inventée, contrairement aux prompts qui décrivaient un
  menu fictif. Les entrées sans sous-menu (Accueil, Compte Journalier, Stock...) sont regroupées
  sous deux sections synthétiques "Général"/"Exploitation" (présentation uniquement).
- `/Home/activite_json` : réutilise `JournalActivite::derniers()` (déjà construit pour le bandeau
  classique, §9/§10) en JSON brut, pour les timelines de rail/dock.
- `/Home/notifications_groupees` : réutilise `Statistiques::notification_{vente,achat,coffre,banque}()`
  (déjà utilisées par les dropdowns de la disposition classique) regroupées par domaine **réel**
  de l'appli — pas les catégories fictives "Messages/Système" des prompts (aucun système de
  messagerie n'existe dans SSTM).
- `public/css/design/layouts.css` + `public/js/ssm_layouts.js` : chargés **uniquement** si
  `layout_mode != 'classique'` (voir `header.php`/`top_bar.php`) — zéro impact sur les comptes
  restés en disposition classique (le défaut, pour tous les comptes existants).
- `app/views/layout/nav/_{palette,rail,dock}.php` : partials inclus depuis `top_bar.php`
  (`include('nav/_' . $mode . '.php')`), markup minimal, toute la logique dans `ssm_layouts.js`.
- `left_bar.php` : le menu classique (`<aside class="ssm-sidebar">`) ne se rend que si
  `layout_mode == 'classique'` — les 3 autres dispositions n'ont pas de colonne latérale
  persistante (leurs mécanismes sont en `position:fixed`, indépendants du flux).

**Les 3 dispositions** :
- **Palette** (`Ctrl/Cmd+K` ou clic sur la pastille de recherche) : modale de recherche floutée,
  filtre en direct sur label page+section, navigation clavier (`↑`/`↓`/`Entrée`), tag de section
  par ligne. Réutilise **tel quel** le bandeau défilant du journal (`ssm-journal-ticker`, §9) comme
  "activity ticker" — c'est exactement le même composant, juste rechargé dans un autre conteneur.
- **Rail caché** : liseré de 6 points au bord gauche (un par section, actif = accent), tiroir
  accordéon au survol (overlay, ne pousse pas le contenu, fermeture différée de 260ms pour pouvoir
  traverser vers le tiroir sans qu'il se referme). Activité = puce épinglée (pastille verte
  pulsante + dernière action) qui ouvre un tiroir plein-largeur avec filtres
  (Tout/Créations/Modifs/Suppr.) + timeline verticale.
- **Dock magnétique** : FAB flottant bas-gauche (rotation 45° à l'ouverture), dock centré avec
  magnification style macOS au survol (échelle 1.45/1.2/1.08/1 selon la distance à l'icône
  survolée), flyout de pages au-dessus de l'icône survolée. Activité = puce "équaliseur" (4 barres
  animées) + même tiroir chronologique que le rail (mutualisé, seule la puce déclencheuse diffère).
- **Notifications groupées** (palette/rail/dock) : un seul composant partagé
  (`ssm_notif_grouped_init()`), le **déclencheur** change de forme par mode (cloche+badge pour
  palette, pastilles colorées empilées pour rail, pastille "Statut" avec total pour dock) mais le
  panneau détaillé est identique - simplification assumée pour rester dans un périmètre
  raisonnable plutôt que reproduire un donut conic-gradient et un cluster de compteurs
  entièrement différents pour un gain visuel marginal.
- **Comptes ouverts** : commun aux 4 dispositions (`nav/_comptes_ouverts.php`), voir plus bas.

**Réglage** : nouvelle section "Disposition de navigation" dans le panneau Réglages (footer.php,
4 cartes) — sauvegarde via `/Home/layout_mode` + `location.reload()`, même schéma que le thème de
couleur.

**Méthode de test** : les 3 dispositions + la palette (ouverture Ctrl+K, filtre clavier, Entrée qui
navigue), le rail (survol, dépli de section, tiroir d'activité) et le dock (ouverture FAB,
magnétisme au survol) ont été vérifiés avec Playwright (captures d'écran + `pageerror` vides) avant
livraison - voir captures envoyées en conversation. Disposition classique re-testée après coup
(modale Ajouter + comptes ouverts) pour confirmer l'absence de régression.

## 12. Comptes ouverts séparés du panneau Réglages (21/07/2026)

Retour utilisateur : le panneau "Réglages" (icône `#ssm_toggle_settings`) mélangeait les comptes
journaliers/PS/alimentations/transferts ouverts avec le thème de couleur - à séparer, avec un
style différent.

- Nouvelle icône dédiée dans la top bar (`#ssm_toggle_comptes`, porte ouverte, accent vert/succès)
  + dropdown propre (`nav/_comptes_ouverts.php`, réutilisé par les 4 dispositions), au lieu d'un
  panneau glissant mélangé au thème.
- `app/views/layout/portion/notifications.php` : le fragment `/Home/notifications` cible
  désormais `#comptes_ouverts_body`/`#badge_comptes_ouverts` (au lieu de `#sidebar_comptes` dans
  le panneau Réglages) ; markup reconstruit en classes `ssm-comptes-*` (plus de `.card`/classes
  bootstrap `text-success`), avec état vide explicite ("Aucun compte ouvert actuellement") plutôt
  qu'un panneau blanc quand rien n'est ouvert.
- `footer.php` : le panneau Réglages ne contient plus que "Direction visuelle" + "Disposition de
  navigation" + Déconnexion.

## 13. Corrections retour utilisateur sur palette/rail/dock (21/07/2026)

Quatre bugs concrets remontés après livraison de §11/§12, tous corrigés et revérifiés en
navigateur réel (Playwright) :

1. **Icônes de notification invisibles en thème clair** : `fa fa-bar-chart text-white` (et les 3
   autres) dans `notifications.php` dataient d'avant la refonte Phase 4 (quand le bouton avait un
   fond sombre) - sur `.ssm-icon-btn` (fond transparent), une icône blanche est invisible en clair.
   Fix : classes `ssm-notif-icon--{accent,warn,ok,bad}` (une couleur adéquate et distincte par
   domaine, mêmes tokens que `notifications_groupees()`), plus de `text-white`.
2. **Dock : le survol affiche le volet de pages, mais il se referme avant qu'on puisse cliquer un
   lien** : le volet (`.ssm-dock-flyout`) est positionné 10px au-dessus de l'icône - un `:hover`
   CSS pur perd l'état pendant la traversée de cet espace vide. Fix : affichage piloté en JS
   (classe `.ssm-dock-flyout--visible`) avec un délai de fermeture de 300ms, même principe déjà
   utilisé pour le rail (sliver + tiroir).
3. **Ordre du menu différent entre classique et les 3 autres dispositions** (un regroupement
   "Général"/"Exploitation" avait été inventé) : `Layout::menu_arbre()` retourne désormais les
   entrées **dans le même ordre et avec les mêmes libellés exacts** que `menu_config()` - chaque
   noeud a un `type` (`'section'` a des pages ; `'lien'` est une entrée simple, ex. Accueil/Stock,
   affichée sans sous-liste dans les 3 dispositions - lien direct en rail/dock, ligne sans étiquette
   de section en palette).
4. **"Texte noir, texte à couleur de thème, incohérent"** : cause racine unique des 3 signalements
   visuels (rail : tous les liens simples en accent au lieu de leur couleur normale ; comptes
   ouverts : les noms de zone en accent au lieu de `--ink`) - `components.css` a une règle globale
   `body[data-design] a { color: var(--accent); }` dont la spécificité (attribut+élément) bat
   n'importe quelle classe seule non préfixée. Toutes les classes de `layouts.css` (et
   `.ssm-comptes-item` dans `shell.css`) qui posent une couleur sur un `<a>` ont été re-préfixées
   `body[data-design] .ma-classe` pour regagner la course de spécificité - **règle à appliquer
   systématiquement pour toute nouvelle classe posant une couleur sur un lien**, sous peine de la
   voir silencieusement écrasée par cette règle générique.
   - **Mode sombre** : testé à fond (bascule clair/sombre, direction Majorelle ET Aurore, disposition
     classique ET dock) - fonctionne correctement dans tous les cas essayés. Le ressenti "plus
     fonctionnel" vient très probablement du bug #4 ci-dessus (rendu visuellement incohérent) plutôt
     que d'un vrai dysfonctionnement du mode sombre lui-même. À confirmer après un Ctrl+F5 côté
     utilisateur (rappel : `ssm_layouts.js`/`layouts.css` ne se rechargent pas tant que la page
     n'est pas rechargée en dur).

## 14. Thème global qui "revenait" à l'ancien (21/07/2026)

Signalé : changer le thème global (direction/clair-sombre) dans Réglages semblait ne pas
fonctionner - il revenait systématiquement à un thème existant.

**Deux causes cumulées, toutes deux corrigées** :
1. **Cause principale** : une ligne de test oubliée dans `parametre_style_module` (créée en
   construisant la surcharge de thème par module, §9) donnait au module Vente une direction figée
   (Majorelle) - sur toute page Vente, cette surcharge gagne TOUJOURS sur le thème global
   (comportement voulu pour la fonctionnalité elle-même), donc changer le thème global depuis une
   page Vente semblait "ne rien faire" alors que la sauvegarde avait bien eu lieu. Supprimée -
   aucune ligne ne doit rester dans cette table pour un compte tant qu'il n'a pas explicitement
   choisi un thème par module via le bouton dédié du sous-menu.
2. **Vraie course critique corrigée en plus** (`footer.php`) : les handlers de clic
   (`.mode_application`, `.mode_clair_sombre_btn`) appelaient `location.reload()` **juste après**
   avoir lancé `load_portion('/Home/style', ...)` (asynchrone), sans attendre sa fin - la page
   pouvait donc se recharger avant que le serveur ait fini d'enregistrer, retombant sur l'ancien
   thème lu en base. Fix : le `reload()` se fait maintenant dans le **callback** de `load_portion`
   (4e paramètre), une fois la sauvegarde réellement confirmée.

**Leçon** : après toute session de test d'une fonctionnalité qui écrit en base (surcharges de
thème/disposition par module, réglages d'affichage...), vérifier qu'aucune ligne de test ne reste
dans les tables concernées avant de rendre la main - une surcharge oubliée peut donner l'impression
qu'une AUTRE fonctionnalité (ici le thème global, sans rapport direct) est cassée.

**Suite (même session, persistant après nettoyage)** : l'utilisateur a retesté et a de nouveau
observé "je change le thème, rien ne se passe" - vérifié en se connectant soi-même (Playwright) :
**reproductible**, pas une histoire de cache navigateur cette fois. Cause : l'utilisateur avait
entre-temps utilisé le bouton "Thème" du sous-menu Vente (§9, surcharge par module) - une nouvelle
ligne `parametre_style_module` existait. Tant qu'on navigue dans le module Vente, cette surcharge
gagne TOUJOURS sur le thème global (comportement voulu de la fonctionnalité), donc changer le
thème global depuis le panneau Réglages semblait "ne rien faire" tant qu'on reste sur une page
Vente (il s'appliquait bien ailleurs, ex. sur l'Accueil - vérifié). **Fix de fond** (pas qu'un
nettoyage cette fois) : `HomeController::style()` (changement de thème GLOBAL) supprime désormais
**toutes** les surcharges par module de l'utilisateur (`parametre_style_module`) et invalide leur
cache session - un choix de thème dans le panneau Réglages est un geste explicite et global, il
doit gagner partout, y compris sur un module qui avait une surcharge oubliée. Revérifié avec
Playwright : changement de thème répété (aurore→pupitre→majorelle) directement depuis une page
Vente, chaque choix s'applique immédiatement et reste appliqué.

## 15. Confirmation avant d'écraser les thèmes de module + icônes de sous-menu (21/07/2026)

Suite à §14, retour utilisateur : au lieu d'écraser silencieusement les surcharges de module au
changement de thème global, **demander confirmation** ("voulez-vous appliquer ce thème aussi aux
modules qui en ont un spécifique, ou non ?"). Plus, au passage : même piège de couleur qu'en §13
mais dans le **menu classique** cette fois (`.ssm-nav-link`/`.ssm-nav-sublink` non préfixés,
perdaient face à `body[data-design] a`), et ajout d'icônes aux sous-menus.

- **Confirmation** : `Layout::donnees()` calcule `modules_surcharges` (labels lisibles des modules
  ayant une ligne dans `parametre_style_module` pour l'utilisateur courant,
  `StyleModule::modules_surcharges()`). Si la liste est vide, le changement de thème global se
  fait sans rien demander (comportement immédiat, comme avant) - la modale
  `#ssm_confirm_theme_modal` ne s'affiche QUE s'il y a réellement quelque chose à arbitrer.
  Le choix de l'utilisateur ("Appliquer partout" / "Garder leurs thèmes spécifiques") est transmis
  à `/Home/style` via `reset_modules` (`'1'`/`'0'`), lu par `HomeController::style()`.
- **Icônes de sous-menu** : chaque `enfant` de `Layout::menu_config()` a maintenant sa propre clé
  `icone` (ex. Graphique→`fa-chart-line`, Caisse→`fa-cash-register`...) - propagée par
  `Layout::menu_arbre()` dans `pages[].icone`, affichée dans `left_bar.php` (classique) ET dans
  `ssm_layouts.js` (rail/dock/palette utilisent l'icône de la PAGE plutôt que celle de la section
  quand elle existe) - cohérence totale entre les 4 dispositions.
- **Piège identique à §13, trouvé cette fois dans le menu classique** : `.ssm-nav-link`
  (entrées simples, `<a>`) et `.ssm-nav-sublink` (sous-entrées, `<a>`) n'étaient pas préfixés
  `body[data-design]`, donc perdaient face à la règle générique `body[data-design] a { color:
  var(--accent); } ` - les entêtes de groupe (`<button>`, insensibles à cette règle) restaient
  correctement en `--ink`, créant le mélange "noir/couleur du thème" signalé. Ce piège existait
  **depuis le tout premier jour de la refonte Phase 4** (20/07/2026), pas quelque chose
  d'introduit récemment - juste jamais remarqué avant d'avoir un mélange bouton/lien côte à côte
  d'assez près pour être visuellement choquant.

**Méthode de test** : Playwright, scénario complet - sans surcharge (pas de modale) ; avec
surcharge Vente, changement de thème global → modale apparaît, liste "Ventes" ; "Garder" → thème
global change mais Vente garde sa surcharge ; refait avec "Appliquer partout" → surcharge
effacée, thème uniforme partout. Préférence réelle de l'utilisateur restaurée après tests.

## 16. Top bar unique + réglages en FAB + refonte des notifications (21/07/2026)

Retour utilisateur : la top bar doit être **la même (classique) pour toutes les dispositions** -
palette/rail/dock ne changent QUE le mécanisme de menu, jamais la top bar elle-même. Plus une
série de polish : marge autour du bandeau d'activité, nom de la station affiché, défilement plus
soigné, contenu des dropdowns de notification refait, bouton Réglages détaché en FAB sticky bas-
droite, icônes de notification agrandies et unifiées en une seule couleur.

- **Top bar unique** : `top_bar.php` n'a plus qu'une seule branche de rendu (l'ancienne branche
  "palette/rail/dock" avec activité/notifications groupées a été supprimée). Seul le bouton
  hamburger change de comportement selon `layout_mode` : classique → replie le menu (inchangé) ;
  palette → ouvre la palette (`ssm_palette_ouvrir()`) ; rail → bascule le tiroir
  (`#ssm_rail_drawer`) ; dock → déclenche le FAB du dock (`#ssm_dock_fab`).
- **Marque + nom de station dans la top bar** : `Station::nom()` (nouvelle méthode, table
  `parametre_station.nom`, "SSTM" par défaut si non configurée) injecté dans
  `Layout::donnees()['station']`. Tronqué proprement (`text-overflow:ellipsis`, `max-width`) si
  trop long, masqué sous 960px pour ne jamais écraser le fil d'ariane à côté.
- **Bandeau d'activité** : marge de 10px avec ses voisins + fondu des bords
  (`mask-image: linear-gradient(...)`) pour que les entrées n'apparaissent/disparaissent plus de
  façon abrupte pendant le défilement.
- **Contenu des dropdowns de notification refait** (Ventes/Achats/Coffre/Banque) : remplace les
  `<ul>`/`<li>`/`dropdown-divider`/classes bootstrap `text-dark`/`text-danger`... par des rangées
  `.ssm-notif-row` propres (icône en pastille + libellé + compteur), même famille visuelle que
  "Comptes ouverts" (§12). Helper PHP `ssm_notif_ligne()` dans `notifications.php` pour éviter la
  répétition.
- **Icônes de notification** : une seule couleur partout (`var(--accent)`, plus 4 couleurs
  différentes comme en §13 - retour utilisateur explicite "une seule couleur qui marche avec le
  thème"), légèrement agrandies (16px).
- **Bouton Réglages détaché en FAB** : `.ssm-settings-fab`, `position:fixed` bas-droite,
  indépendant de la top bar - coexiste avec le FAB du dock (bas-gauche) sans chevauchement.
- **Nettoyage** : les fonctions JS `ssm_activity_*`/`ssm_notif_grouped_init` et les endpoints
  `HomeController::activite_json()`/`notifications_groupees()` (devenus inutiles, la top bar ne
  se différencie plus par mode) ont été supprimés - pas de code mort laissé derrière.

**Testé** : les 4 dispositions (classique/palette/rail/dock) affichent la même top bar exacte
(bandeau, recherche, notifications, comptes ouverts), hamburger vérifié dans chaque mode, FAB
Réglages + FAB Dock coexistent sans conflit, contenu des dropdowns vérifié visuellement. Aucune
erreur JS. Préférences réelles de l'utilisateur (Dock, Aurore/sombre) intactes après tests.

## 17. Top bar agrandie, fil d'ariane en pastilles, bandeau des prix carburant (21/07/2026)

Retour utilisateur (suite à §16), demandes de polish + une nouvelle fonctionnalité :

- **Notifications élargies** : `.ssm-menu-lg` passe de 320px à 420-460px, `.ssm-notif-row`/icônes/
  compteurs agrandis - chaque dropdown n'affiche qu'un seul domaine à la fois, autant utiliser
  l'espace ("le contenu soit pro et lisible").
- **Top bar agrandie** : hauteur 56px→68px, `.ssm-icon-btn` 34px→38px, marque 26px→32px, fil
  d'ariane en 12.5px au lieu de 13px mais en gras+majuscules (voir ci-dessous).
- **Fil d'ariane en "piste de pastilles"** (retour utilisateur "faire un style pour la partie
  Vente/Client...") : remplace le texte brut + `/` par une piste (fond `--surface-2`) avec une
  pastille pleine (accent) pour l'étape courante et des pastilles discrètes pour les parents -
  même langage visuel que le thumb du sous-menu de module.
- **Logo + nom de station : plus de doublon** - vivait à la fois dans la top bar (§16) ET dans le
  menu classique (`left_bar.php`, historique). Retiré du menu classique (retour utilisateur
  explicite), ne vit plus que dans la top bar, elle-même agrandie pour bien le présenter
  (`max-width` 200px→240px, marque 26px→32px).
- **Bandeau du journal décomposé en DEUX** (retour utilisateur "un qui affiche les actions, et un
  qui affiche les derniers prix de vente des carburants") :
  - `#journal_ticker_topbar` (inchangé, actions) empilé au-dessus de `#prix_carburants_topbar`
    (nouveau), dans un conteneur commun `.ssm-ticker-stack` qui porte la marge avec les voisins.
  - Chaque bandeau réduit de 32px à 24px de haut pour tenir empilés dans la top bar agrandie.
  - **Prix carburant** : nouvelle action `HomeController::prix_carburants()` +
    `app/views/layout/portion/prix_carburants.php`, réutilise **telle quelle**
    `ProduitPrix::produit()` (déjà utilisée ailleurs dans l'appli pour les mêmes prix courants -
    prix du compte carburant ouvert, ou prix catalogue à défaut) - aucune nouvelle logique
    métier, juste un nouvel affichage. Rafraîchi toutes les 3 minutes (les prix changent rarement,
    contrairement au journal d'activité rafraîchi toutes les 90s).

**Testé** : top bar plus haute, éléments agrandis, fil d'ariane en pastilles, les deux bandeaux
empilés et fonctionnels (prix carburant réels affichés : Adblue, Gasoil Excellium, Super Sans
Plomb 95...), dropdown de notification élargi et bien plus lisible, logo/nom de station affichés
une seule fois (top bar uniquement). Aucune erreur JS.

## 18. Fix élargissement notifications (texte sur deux lignes malgré §17)

Retour utilisateur avec capture à l'appui : le texte des dropdowns de notification retombait
quand même sur 2 lignes malgré l'élargissement de `.ssm-menu-lg` en §17.

**Cause** : `.ssm-menu-lg` (une seule classe, `min-width:420px`, dans `shell.css`) et `.ssm-menu`
(une seule classe aussi, `min-width:180px`, dans `components.css`) ont la **même spécificité**.
`header.php` charge `components.css` **après** `shell.css` - à spécificité égale, la règle qui
apparaît en dernier dans la cascade gagne, donc `.ssm-menu` (180px) écrasait silencieusement les
420px de `.ssm-menu-lg`, quel que soit l'ordre dans lequel les classes sont écrites dans le HTML.

**Fix** : `.ssm-menu.ssm-menu-lg` (deux classes chaînées sur le même sélecteur) au lieu de
`.ssm-menu-lg` seule - spécificité supérieure à `.ssm-menu`, gagne désormais quel que soit l'ordre
des fichiers CSS. **Leçon générale** : quand une classe doit forcer une valeur sur un élément qui
porte aussi une classe "de base" plus générique définie dans un AUTRE fichier chargé après, ne
jamais compter sur une simple classe seule - toujours chaîner avec la classe de base
(`.base.variante`) pour garantir la victoire par spécificité, indépendamment de l'ordre de
chargement des fichiers.

Revérifié avec Playwright : dropdown mesuré à 420px, les 3 lignes du domaine Ventes ("États à
traiter", "Impayées à traiter", "Règlements à traiter") tiennent chacune sur une seule ligne
(hauteur mesurée 16px, pas ~32px qu'aurait donné un retour à la ligne).

## 19. Fix menu classique : lien actif survolé = texte blanc sur fond blanc

Retour utilisateur : dans le menu classique, survoler un lien déjà **actif** faisait apparaître un
fond clair avec un texte resté blanc - illisible.

**Cause** : `.ssm-nav-link:hover` (une classe + un pseudo-classe) a une spécificité supérieure à
`.ssm-nav-link--active` (une seule classe). Sans `!important` sur le `background` de la variante
active (seul `color` en avait), survoler un lien actif faisait gagner le `background:
var(--surface-2)` du survol (fond clair) alors que le texte restait `var(--accent-ink)` (blanc,
pensé pour contraster sur l'accent plein, pas sur un fond clair) - texte blanc sur fond clair,
quasi invisible.

**Fix** : `background` passé en `!important` sur `.ssm-nav-link--active` (même traitement que
`color`, déjà en `!important`) - l'état actif ne doit jamais être effacé par un simple survol.
Même correctif appliqué à `.ssm-nav-sublink--active` par cohérence (moins critique : le texte
foncé y restait lisible sur fond clair, mais l'état actif disparaissait visuellement au survol).

Revérifié avec Playwright : couleur de fond et de texte du lien actif identiques avant/pendant le
survol (plus de changement du tout, comme attendu pour un état "actif").

## 20. Retrait des thèmes historiques (Sombre/Lightblue)

Retour utilisateur : supprimer "Sombre (historique)" et "Lightblue (historique)" du panneau
Réglages, ne garder que les 4 directions actuelles.

- `footer.php` : les deux boutons retirés du `.ssm-swatch-grid`, ne reste que la boucle sur
  `$layout['directions']` (Majorelle/Pupitre/Irisé/Aurore).
- **Migration 013** : réassigne à `majorelle` toute préférence (`parametre_style_user`,
  `parametre_style_module`) qui pointait encore vers `dark`/`blue` - `style()`
  (`vendor/function.php`) retombait déjà automatiquement sur Majorelle pour ces comptes (fallback
  déjà en place, aucun risque d'affichage cassé), cette migration remet juste les données en
  cohérence avec ce qui s'affiche réellement plutôt que de laisser une préférence pointer
  silencieusement vers un thème qui n'existe plus dans l'interface. Les lignes `dark`/`blue` de
  `parametre_style` elles-mêmes restent en place (catalogue historique, inoffensif).

Testé : le panneau Réglages n'affiche plus que 4 cartes (`majorelle`/`pupitre`/`irise`/`aurore`).

## 21. Fix chevauchement des FAB flottants + scrollbar du panneau Réglages (22/07/2026)

Retour utilisateur : le FAB Réglages (bas-droite) et le FAB du dock (bas-gauche, disposition dock)
chevauchaient le contenu de fin de page ; et le panneau Réglages n'avait pas de scrollbar sur un
écran de petite hauteur (Déconnexion inatteignable).

- **Chevauchement** : `.ssm-container` n'avait que 40px de padding-bottom, insuffisant pour
  dégager les FAB (48px de haut, `bottom:20px`). Porté à `18px 20px 96px`.
- **Scrollbar absente** : `.ssm-settings-body` avait bien `overflow-y:auto` mais aucun
  `flex:1 1 auto; min-height:0` — sans ça, un enfant flex ne se contraint jamais en dessous de la
  hauteur de son contenu, donc l'overflow ne se déclenchait jamais (piège déjà vu ailleurs dans ce
  projet avec `position:fixed`/`transform`, même famille de bug "un enfant flex/contenu déborde
  silencieusement sans le layout qui le permette explicitement").

Revérifié avec Playwright (viewport réduit à 620px de haut) : `scrollHeight` (593px) > `clientHeight`
(553px) et le panneau scrolle réellement (`scrollTop` passe à 40 après `scrollTop = scrollHeight`).

## 22. Style + alignement du sous-menu de module (22/07/2026)

Retour utilisateur : la barre d'onglets Client/Carte/Encaissement/Grand Livre n'avait qu'un seul
style (fond plein glissant, §composants.css "V4"). Demande : proposer 2 styles alternatifs + un
choix d'alignement (gauche/centre/droite), réglables à la fois globalement (panneau Réglages) et
par module (dropdown "Thème" du sous-menu) — même architecture que la direction visuelle/mode déjà
en place (§ci-dessus, `parametre_style_user`/`parametre_style_module`).

- **Migration 014** : ajoute `nav_style` (varchar, défaut `plein`) et `nav_align` (varchar, défaut
  `gauche`) sur `parametre_style_user` ET `parametre_style_module`.
- **3 styles** (`ssm_nav_style_options()`, `vendor/function.php`) :
  - `plein` (défaut, inchangé) : thumb plein glissant (`.ssm-module-nav--plein` = style de base).
  - `ligne` : reprend l'esprit de l'ancienne "V3" (trait fin glissant sous l'onglet actif, icône +
    texte colorés, piste transparente) — **réutilise le même `.ssm-module-nav-thumb` positionné en
    JS** (`ssm_module_nav_positionner_thumb()`, ajax.js), seule sa géométrie CSS change (hauteur
    3px, collé en bas) : aucun changement JS nécessaire pour cette variante.
  - `contour` (3e proposition) : pas de thumb du tout (`display:none`), chaque onglet est une chip
    avec bordure neutre au repos, bordure + texte colorés à l'état actif — le plus sobre des trois,
    zéro surface pleine.
- **Alignement** : nouveau wrapper `.ssm-module-nav-align[data-align="gauche|centre|droite"]`
  (`flex:1 1 auto` + `justify-content`), autour de `<nav class="ssm-module-nav">` dans
  `menu_client.php` ; le bouton "Thème" reste hors de ce wrapper donc toujours poussé à l'extrême
  droite de `.ssm-module-nav-bar` (`justify-content:space-between`), quel que soit l'alignement
  choisi pour les onglets.
- **Résolution** (`vendor/function.php::style()`) : mêmes deux colonnes lues dans la MÊME requête
  groupée que `style`/`mode_theme` (globale et par module), avec surcharge module qui gagne si
  non-vide — même mécanisme que la direction visuelle.
- **Piège rencontré et corrigé** : `HomeController::nav_style()` (endpoint global) invalidait au
  début SEULEMENT `$_SESSION['style_nav_style']`/`style_nav_align`, mais `style()` ne relit la BDD
  que dans la branche `else` gardée par `isset($_SESSION['style_nom'])` — comme `style_nom` restait
  en cache, cette branche entière était sautée et les nouvelles valeurs de nav jamais relues malgré
  l'UPDATE SQL réussi (constaté : le SELECT montrait la bonne valeur, la page affichée gardait
  l'ancienne classe CSS). Fix : invalider aussi `style_nom`/`style_mode` dans `nav_style()` pour
  forcer la relecture groupée. `nav_style_module()` n'a pas ce problème : il invalide déjà tout
  `$_SESSION['style_module']`, qui est lui aussi lu par une requête groupée unique par module.
- **Endpoints** : `/Home/nav_style` (global, met à jour uniquement les 2 colonnes sur la ligne
  `parametre_style_user` existante, ou crée la ligne avec la direction visuelle globale actuelle
  si l'utilisateur n'en a encore aucune) ; `/Home/nav_style_module` (même principe, scope à un
  module — si ce module n'a pas encore de surcharge, la ligne créée reprend la direction visuelle
  GLOBALE actuelle, donc aucun changement de couleur, juste le style/alignement qui devient propre
  à ce module).
- UI : nouvelle section "Style du sous-menu de module" dans le panneau Réglages global
  (`footer.php`, avec mini-aperçus de chaque style) + section équivalente compacte dans le dropdown
  "Thème" par module (`ssm_module_theme_menu_html()`).

Testé via curl (login + appel direct des endpoints + lecture du HTML rendu, plus fiable que
Playwright ici car un vrai override de module "vente" existait déjà sur le compte de test et
masquait les changements globaux le temps de comprendre que c'était le comportement attendu, pas un
bug) : sauvegarde global → reflété quand aucune surcharge de module n'existe ; sauvegarde par module
→ reflété immédiatement et gagne sur le global ; reset du module → retombe bien sur le global.
Préférences réelles de l'utilisateur (thème `aurore` sur le module vente, style `plein`/`gauche`
partout) restaurées à l'identique après les tests.

## 23. Panneau Réglages : enregistrement groupé + fix "contour" + style de texte par colonne (22/07/2026)

Trois retours utilisateur traités ensemble.

**1. Un seul bouton "Enregistrer" pour le panneau Réglages global**, au lieu d'un
enregistrement+reload à chaque clic - "je veux jusqu'à terminer mes choix, un bouton enregistrer,
sticky, pour ne pas chercher où cliquer".
- Chaque bouton (direction, mode, disposition, style de sous-menu, alignement) ne fait plus
  qu'une sélection visuelle locale (classe `--active`) + mémorise la valeur dans un objet JS
  `ssmReglages` - plus aucun appel serveur au clic.
- Nouveau bouton `#ssm_settings_save` (`.ssm-settings-footer`), placé HORS de
  `.ssm-settings-body` (qui défile) donc toujours visible sans défiler - même principe que le fix
  scrollbar du §21, mais ici le but est l'inverse (garder un élément visible en permanence plutôt
  que scrollable). Désactivé tant qu'aucun réglage n'a été touché.
- Au clic sur "Enregistrer" : compare `ssmReglages` à un instantané `ssmReglagesInitial` pris au
  chargement, n'envoie QUE ce qui a changé (`/Home/style` si direction/mode a changé - avec la
  même modale de confirmation qu'avant si des modules ont une surcharge active §14/§15 -,
  `/Home/layout_mode` si la disposition a changé, `/Home/nav_style` si style/alignement de
  sous-menu a changé), attend que TOUTES les sauvegardes soient terminées (`$.when.apply`) puis
  ne recharge la page qu'UNE seule fois.

**2. Fix "contour" : pas de bordure visible + police/taille différente des 2 autres styles.**
- Cause bordure absente : déjà corrigée au tour précédent (spécificité `body[data-design]`) mais
  le style posait la bordure directement sur `.ssm-module-nav-item` (avec un padding réduit à
  8px/14px rien que pour lui) - ce qui donnait à "contour" une boîte différente de "plein"/"ligne"
  (9px/16px), d'où le ressenti "police/taille de texte pas pareil".
- Cause "pas le même mouvement" : contour masquait purement et simplement le
  `.ssm-module-nav-thumb` (`display:none`) et posait une bordure statique par onglet - donc aucune
  transition, contrairement à "plein"/"ligne" qui font glisser ce même thumb en JS
  (`ssm_module_nav_positionner_thumb()`, ajax.js) à chaque changement d'onglet.
- **Fix unique qui règle les deux problèmes** : "contour" réutilise maintenant EXACTEMENT le même
  `.ssm-module-nav-thumb` que les deux autres styles (transform+width animés en JS, aucun
  changement JS nécessaire) - seule sa géométrie CSS change (fond transparent, bordure colorée au
  lieu d'un pavé plein). Les onglets eux-mêmes (`.ssm-module-nav-item`) n'ont plus aucune bordure
  ni padding spécifique à "contour" : ils gardent la boîte strictement identique aux 2 autres
  styles (police, taille, padding), seule la piste/le thumb en arrière-plan change d'apparence.
- Revérifié avec Playwright : `transform`/`width` du thumb changent bien (glissement animé,
  0.6s cubic-bezier, identique aux autres styles) entre deux clics d'onglet en mode contour ;
  `font-size`/`font-weight`/`padding` de l'onglet actif strictement identiques entre "contour" et
  "plein" (11.5px / 700 / 9px 16px dans les deux cas).

**3. Style de texte (gras/normal/italique) par colonne**, en plus de la couleur et de
l'alignement déjà existants (réglage d'affichage d'un tableau, bouton "reglage"/gear - voir
migration 010/011, `app/models/parametre/AffichageTable.php`).
- Aucune migration nécessaire : `colonnes` est déjà un blob JSON par colonne
  (`{"Nom colonne": {"couleur":"...", "alignement":"...", "style": "..."}}`) - juste une nouvelle
  sous-clé `style`, même liste fermée que le reste (`AffichageTable::STYLES_COLONNE` =
  `defaut/gras/normal/italique` - "normal" force explicitement le poids normal, utile pour
  alléger une colonne qu'une vue mettait en gras par défaut).
- `ajax.js` : nouveau groupe de boutons `.ssm-reglage-style-btn` (icônes gras/police/italique) à
  côté du groupe alignement existant dans chaque ligne de colonne du modal réglage ; génère une
  règle CSS `font-weight`/`font-style` scopée `#idPanel table.dataTable tbody td:nth-child(n)`
  (même mécanisme `!important` + ancrage sur l'id du panel que couleur/alignement, pour gagner
  face aux classes Bootstrap/DataTables déjà posées).
- Testé : 20 boutons de style rendus sur un tableau à 5 colonnes (4 choix × 5 colonnes), clic sur
  "Gras" appliqué immédiatement (`font-weight:700` confirmé en style calculé) et persistant après
  rechargement.

Préférences réelles de l'utilisateur (thème Aurore sur le module Vente, style Plein/Gauche)
restaurées après tests ; donnée de test (réglage d'affichage sur `vente_cartes`) supprimée.

## 24. Police et taille de texte de toute l'application, global + par module (22/07/2026)

Retour utilisateur : "je vois pas la police et la taille dans le reglage theme global et module"
- portée confirmée par question de clarification : "tout le texte de l'application" (pas
seulement le sous-menu de module).

- **Migration 015** : ajoute `police` (défaut `theme`) et `taille_texte` (défaut `normal`) sur
  `parametre_style_user` ET `parametre_style_module` - même mécanisme global/par-module que le
  style de sous-menu (§22).
- **Police** (`ssm_police_options()`, `vendor/function.php`) : `theme` (défaut, garde la police
  Google Fonts propre à chaque direction visuelle - Hanken Grotesk/Space Grotesk/Sora/Instrument
  Sans), `systeme` (police native de l'OS), `classique` (serif générique) - ces deux dernières
  **sans aucun chargement réseau supplémentaire**, contrairement à `theme`. Chaque bouton du
  panneau affiche son libellé DANS la police qu'il représente (aperçu réel, pas juste un nom).
- **Taille de texte** (`petit`/`normal`/`grand`) : implémentée via `zoom` CSS (pas
  `transform:scale`) sur `<body>` ENTIER (pas juste `.ssm-shell`) - `zoom` recalcule le viewport
  effectif pour les éléments `position:fixed` (contrairement à `transform`, qui casserait leur
  positionnement), donc les modales Bootstrap (rendues hors `.ssm-shell`, voir §modal-brouillé)
  suivent aussi le zoom.
- **Résolution** (`vendor/function.php::style()`) : mêmes deux colonnes lues dans la même requête
  groupée que `style`/`nav_style`/`nav_align`, avec surcharge module qui gagne si non-vide.
  Attributs `data-police`/`data-taille-texte` posés sur `<body>` (`header.php`), consommés par
  `public/css/design/tokens.css` (règles ajoutées APRÈS toutes les règles par direction - même
  spécificité `body[attr]`, donc le dernier gagne).
- **Endpoints réutilisés** (pas de nouveaux) : `/Home/nav_style` (global) et
  `/Home/nav_style_module` (par module) acceptent maintenant aussi `police`/`taille_texte` en plus
  de `nav_style`/`nav_align` - ils construisaient déjà un tableau de colonnes à mettre à jour de
  façon générique, extension directe sans rien casser.
- **UI** : nouvelle section "Police du texte" + "Taille du texte" dans le panneau Réglages global
  (`footer.php`, intégrée au flux "Enregistrer" groupé du §23 - `ssmReglages.police`/`tailleTexte`
  ajoutés à l'objet suivi, envoyés dans le même appel `/Home/nav_style` que nav_style/nav_align
  s'il y a le moindre changement parmi les 4) + section équivalente dans le dropdown "Thème" par
  module (`ssm_module_theme_menu_html()`).

Testé avec Playwright : `font-family` calculée passe bien de la police du thème (`"Instrument
Sans"...`) à `Georgia, Cambria, "Times New Roman", serif` après choix "Classique" + Enregistrer ;
`zoom` de `<body>` passe à `1.15` après choix "Grand" ; boutons de police/taille bien présents et
fonctionnels dans le dropdown par module. Préférences réelles de l'utilisateur (thème Aurore,
police/taille par défaut) restaurées après tests.

## 25. Totaux "Page"/"Filtré" d'une DataTable à montants — pattern de référence (22/07/2026)

Retour utilisateur sur la page Encaissements (module Vente/Client) : affichage des totaux pas
satisfaisant + optimiser le calcul, "on a après plusieurs tables qui ont ce principe" (~30 autres
vues du même principe dans l'appli). Encaissements sert de MODÈLE pour généraliser plus tard.

**Avant** : jusqu'à 4 exécutions complètes de la requête (un `UNION ALL` à 8 branches) par tirage
de la DataTable - COUNT non filtré, COUNT filtré, page de résultats, PLUS un appel AJAX séparé
(`/EncaissementClient/total_total`) qui relançait la requête une 4e fois rien que pour la somme
filtrée, avec son propre aller-retour réseau et un décalage d'affichage (le total apparaissait
après coup, dans un fragment renvoyé sous forme de `<script>` inline qui allait modifier un
élément situé ailleurs dans le DOM - affichage "bricolé").

**Après** :
- `Client::encaissement()` (`app/models/client/Client.php`) : COUNT filtré et SUM(montant) filtré
  fusionnés en **une seule requête** (`SELECT COUNT(*), SUM(montant) FROM (...) WHERE ...`, au lieu
  de deux) ; le résultat (`montant_filtre`) est renvoyé **directement dans le même JSON** que la
  page de résultats (clé ajoutée à côté de `aaData`/`iTotalRecords`) - plus aucun appel séparé.
  Suppression complète de l'ancien mode "total seul" de la méthode et du controller
  `EncaissementClientController::total_total()` (devenu inutile).
- Affichage repris sur le pattern `.ssm-stat-row` déjà en place pour le Grand Livre (§ solde) -
  structure HTML rendue une seule fois, jamais remplacée. Nouvelle variante
  `.ssm-stat-row--fin` (séparateur + centré) pour un total placé APRÈS un tableau plutôt que dans
  l'en-tête d'un panel.
- Nouveaux helpers génériques dans `ajax.js`, réutilisables tels quels par toute autre table à
  montants :
  - `ssm_stat_set_montant(selector, valeur)` : même formatage que `ssm_stat_set()` mais SANS le
    code couleur positif/négatif (qui a du sens pour un solde comptable signé, pas pour une simple
    somme de montants toujours positive - `ssm_stat_set()` reste réservé aux soldes signés type
    Grand Livre).
  - `ssm_table_totaux(dataTable, colonneIndex, selecteurTotalPage, selecteurTotalFiltre)` :
    branche les deux totaux en une seule fois - "Total Page" = somme de la colonne sur les lignes
    déjà rendues (`draw.dt`, aucune requête supplémentaire, donnée déjà en mémoire côté client) ;
    "Total Filtré" = lu directement dans le JSON déjà reçu par la DataTable (`xhr.dt`, clé
    `montant_filtre`) - suppose que le modèle/contrôleur de la table cible renvoie cette même clé.
- Vue (`Encaissements.php`) : un seul appel `ssm_table_totaux(dataTable, 5, '#encaissement_total_page', '#encaissement_total_filtre')`
  remplace tout le bloc JS précédent (fonction `load_total_total()`, gestion manuelle du
  `.each()`, div `#total_total_row`).

**Pour généraliser à une autre table du même principe** : (1) dans le modèle, fusionner les deux
`COUNT`/`SUM` filtrés en une requête et ajouter `montant_filtre` (ou le nom qui convient) à la
réponse JSON ; (2) dans la vue, remplacer le `<tfoot>` par un bloc `.ssm-stat-row.ssm-stat-row--fin`
(2 `.ssm-stat`) après le tableau ; (3) un seul appel à `ssm_table_totaux()` après `init_datatable()`.

Revérifié avec Playwright : une seule requête `/EncaissementClient/data` par tirage (plus de
`total_total`), les deux totaux affichent des valeurs cohérentes, persistent correctement après un
changement de taille de page.

**Règle permanente ajoutée à `CLAUDE.md`** : toute DataTable à montants (n'importe quel module)
doit avoir ce total Page/Filtré par défaut, même sans demande explicite - voir §26 pour
l'extension à une table à PLUSIEURS colonnes de montant (Grand Livre : Débit/Crédit).

## 26. Même pattern étendu à une table à PLUSIEURS colonnes de montant — Grand Livre (22/07/2026)

Suite du §25 : même principe appliqué au Grand Livre (`Client::livre()`, `Livre.php`), qui a DEUX
colonnes de montant (Débit index 5, Crédit index 6) au lieu d'une seule - Encaissements ne
suffisait pas comme modèle unique, il fallait une variante multi-colonnes.

- `Client::livre()` : ajoute, uniquement dans la branche `empty($param)` (le tirage DataTable -
  ne touche PAS le mode export/impression, appelé avec `$param` rempli) une requête
  `SELECT SUM(debit), SUM(credit) FROM ($sql) as t` sur le `$sql` DÉJÀ filtré (le `$where` est
  déjà inclus dedans à ce stade du code) - `debit_filtre`/`credit_filtre` ajoutés au JSON. Les
  colonnes `debit`/`credit` du UNION contiennent `''` (chaîne vide, pas `NULL`) pour les lignes
  qui ne concernent pas ce sens - `SUM()` MySQL convertit silencieusement `''` en `0` dans un
  contexte numérique, aucun `CASE` nécessaire.
- Nouveau helper `ssm_table_totaux_multi(dataTable, colonnes)` (`ajax.js`), sœur de
  `ssm_table_totaux()` : `colonnes` est un tableau de `{index, cleFiltre, selecteurPage,
  selecteurFiltre}`, un seul branchement `xhr.dt`/`draw.dt` traite toutes les colonnes. À utiliser
  dès qu'une table a 2+ colonnes de montant ; `ssm_table_totaux()` (colonne unique) reste pour les
  cas comme Encaissements.
- Vue : nouveau bloc `.ssm-stat-row.ssm-stat-row--fin` avec 4 `.ssm-stat` (Débit Page/Filtré,
  séparateur, Crédit Page/Filtré), placé après le tableau - **distinct** du bandeau `#solde` déjà
  présent en haut du panel (solde comptable signé `SUM(debit-credit)`, filtré différemment,
  alimenté par l'endpoint séparé `/GrandLivre/solde` qui ne suit pas la recherche DataTables) :
  les deux bandeaux coexistent, ils répondent à des questions différentes ("quel est le solde ?"
  vs "combien de débit/crédit dans ce que j'affiche/filtre ?").

Revérifié avec Playwright : une seule requête `/GrandLivre/datatable` par tirage (`/GrandLivre/solde`
reste un appel séparé, légitime - stat différente), les 4 totaux affichent des valeurs cohérentes
(Débit Page = le seul montant débit visible sur la page courante, Crédit Page = somme des lignes
crédit visibles, Filtré = totaux sur l'ensemble des 73 lignes filtrées), aucune requête
supplémentaire après changement de taille de page.

## 27. Disposition "Rail caché" remplacée par "Roue" (menu radial) (22/07/2026)

Retour utilisateur avec un cahier des charges précis (fourni tel quel : principe + géométrie) pour
remplacer entièrement l'ancienne disposition "Rail caché" (sliver + tiroir accordéon) par un menu
radial à 2 niveaux. Le **slug interne reste `rail`** (`parametre_layout_user.layout`, aucune
migration nécessaire) - seul le libellé affiché ("Roue") et le mécanisme visuel changent.

**Principe** (repris du cahier des charges, adapté à nos tokens/données réelles) :
- Au repos : seul un hub circulaire (`#ssm_wheel_fab`, 56px, accent) est visible, position fixe
  bas-gauche (même emplacement que le FAB du dock).
- Clic sur le hub → plateau centré au milieu de l'écran (backdrop semi-transparent) : hub (78px)
  + anneau des SECTIONS autour (tuiles icône+libellé, `.ssm-wheel-item`), positions calculées en
  trigonométrie (`angle = -90° + i*(360/n)`, départ en haut, sens horaire).
- Clic sur une section (type `section`, a des pages) → l'anneau est remplacé par les PAGES de
  cette section (pastilles libellé seul, `.ssm-wheel-pill`) ; le hub se transforme en bouton
  "Retour" (icône + libellé, fond neutre sombre).
- Clic sur une section de type `lien` (Accueil, Stock... - pas de sous-pages) → navigation directe,
  comme les autres dispositions.
- Clic sur une page → navigation + fermeture. Clic sur le hub en niveau SECTIONS → ferme tout ;
  en niveau PAGES → revient aux sections (pas de fermeture directe). Clic en dehors (backdrop) ou
  Échap → ferme entièrement, quel que soit le niveau.

**Adaptations à nos principes/architecture** (le cahier des charges d'origine ne les précisait pas) :
- **Données réelles** : `window.SSM_MENU_ARBRE` (déjà utilisé par palette/dock), donc mêmes
  sections/pages/icônes/ordre que le menu classique - aucune catégorie inventée.
- **Rayon dynamique** (pas fixe à 112px comme l'exemple "8 sections") : `ssm_wheel_position()`
  (`ssm_layouts.js`) calcule un rayon proportionnel au nombre réel d'éléments
  (`max(112, n * tailleApprox / (2π))`) - le menu réel va jusqu'à ~13 sections pour un profil
  complet, pas seulement les 8 de l'exemple ; sans cette adaptation, les tuiles se seraient
  chevauchées pour un compte avec beaucoup d'accès.
- **Centrage sans JS de mesure** : `.ssm-wheel-center` est un point 0×0 centré par le `flex` du
  parent `.ssm-wheel-backdrop` (`align-items:center;justify-content:center`) - fonctionne quelle
  que soit la taille d'écran sans avoir à lire `window.innerWidth/innerHeight` en JS.
- **Tokens de couleur/police/rayon/ombre** (`--accent`, `--ink`, `--surface`, `--radius-card`,
  `--shadow`, `--font-ui`) au lieu des couleurs en dur du cahier des charges - s'adapte
  automatiquement aux 4 directions visuelles + clair/sombre, comme toutes les autres dispositions.
- **Easing réutilisé** : `var(--ssm-nav-duree)`/`var(--ssm-nav-easing)` (déjà définis pour le
  thumb du sous-menu de module, composants.css) plutôt qu'une nouvelle courbe "spring" ad hoc -
  cohérence visuelle avec le reste de l'appli.
- **`body[data-design]` sur `.ssm-wheel-item--active`/`.ssm-wheel-pill--active`** : même piège de
  spécificité déjà rencontré plusieurs fois cette session (`body[data-design] a { color:
  var(--accent); }` gagne sur une simple classe) - anticipé directement cette fois.

Fichiers : `app/views/layout/nav/_rail.php` (markup réécrit), `public/js/ssm_layouts.js`
(`ssm_rail_*` remplacés par `ssm_wheel_*`), `public/css/design/layouts.css` (`.ssm-rail-*`
remplacées par `.ssm-wheel-*`), `app/views/layout/top_bar.php` (le hamburger appelle
`ssm_wheel_toggle()` pour le mode `rail`), `app/views/layout/footer.php` (libellé "Roue" dans le
sélecteur de disposition).

Testé avec Playwright : 13 sections rendues et positionnées (profil admin complet), entrée en
fondu/échelle, clic sur une section avec pages → 2 pages affichées + hub "Retour", clic hub depuis
pages → retour sections (reste ouvert), clic hub depuis sections → fermeture complète, Échap
ferme depuis n'importe quel niveau, FAB masqué/réaffiché correctement. Préférence réelle de
l'utilisateur (disposition Palette) restaurée après test.

## 28. Fix thumb du sous-menu de module décalé de 6px vers la droite (22/07/2026)

Retour utilisateur : dans les 3 styles (Plein/Ligne/Contour), le thumb/trait/contour dépasse
l'onglet - "pour Clients c'est bien, pour le deuxième un petit peu, pour Grand Livre beaucoup".

**Cause** : `.ssm-module-nav-thumb` (base CSS, `components.css`) avait `left: 6px` codé en dur EN
PLUS du `transform: translateX(...)` calculé en JS
(`ssm_module_nav_positionner_thumb()`, `ajax.js`) à partir de `$actif.position().left` - qui
renvoie DÉJÀ la position complète de l'onglet actif relative à `.ssm-module-nav` (padding inclus,
confirmé empiriquement : `item.left - nav.left` = exactement la valeur du `translateX` posé par le
JS). Le `left: 6px` du CSS s'additionnait à ce calcul déjà complet → le thumb finissait
systématiquement décalé de 6px vers la droite par rapport à l'onglet qu'il doit recouvrir. Peu
visible sur "Plein" (le remplissage a de la marge interne, 9px/16px de padding sur l'onglet
absorbe une bonne partie du décalage) ; net sur "Ligne"/"Contour" (trait fin / bordure précise,
censés coller exactement aux bords de l'onglet) - d'où "un peu" sur un onglet court (Cartes) et
"beaucoup" sur le plus long/le plus à droite (Grand Livre), le décalage constant de 6px devenant
proportionnellement plus visible visuellement à mesure que le trait s'éloigne du bord droit réel
de l'onglet.

Bug en réalité pré-existant à la refonte "Ligne"/"Contour" (present depuis la V4 "Plein" d'origine,
20/07/2026) - jamais remarqué car masqué par le padding généreux de "Plein". Les nouveaux styles
plus précis l'ont rendu visible, d'où le ressenti "vous avez ajouté quelque chose qui casse le
principe" alors que la cause réelle était plus ancienne.

**Fix** : `left: 6px` → `left: 0` sur `.ssm-module-nav-thumb` (base commune aux 3 styles) - `top`/
`bottom` (6px chacun) restent inchangés, jamais touchés par le JS (qui ne calcule que
`translateX`/`width`), ils servaient déjà correctement leur rôle d'inset vertical.

Revérifié avec Playwright sur les 4 onglets (Clients/Cartes/Encaissements/Grand Livre) × les 3
styles : `getBoundingClientRect()` du thumb correspond maintenant EXACTEMENT à celui de l'onglet
actif (gauche, droite, largeur identiques à 0.001px près) dans tous les cas, y compris Grand Livre
qui montrait le plus gros écart avant le fix. Préférence réelle de l'utilisateur (Plein/Gauche)
inchangée par les tests (valeur déjà correcte, restaurée par précaution).

## 29. Fix thumb du sous-menu — 2e cause, une vraie course de chargement (pas du cache) (22/07/2026)

Suite du §28 : l'utilisateur a signalé une capture d'écran montrant le MÊME symptôme
(icône/texte qui dépasse le pastille) **après** le fix du 6px, **malgré un Ctrl+F5 complet**.

**Diagnostic** : vérifié d'abord que le fix du §28 était bien servi (le CSS chargé via l'URL
exacte anti-cache contenait bien `left: 0`) et qu'une session Playwright fraîche donnait un
alignement pixel-parfait - donc pas de régression ni de problème serveur. Un `Ctrl+F5` qui ne
change rien élimine par définition le cache comme cause : ça pointe vers une **course
(race condition)**, pas une version périmée. Reproduit en bloquant volontairement la requête
Google Fonts (`page.route(...).abort()`, Playwright) : le décalage réapparaît immédiatement,
identique à la capture de l'utilisateur.

**Cause réelle** : `ssm_module_nav_positionner_thumb()` s'exécute une seule fois, sur
`$(document).ready()` - synchrone, avant que toutes les ressources externes n'aient fini de
charger. Si la police Google Fonts de la direction active (chargée en arrière-plan, voir
`font_import`) n'est pas encore prête à cet instant, le texte des onglets est encore rendu avec la
police de repli (largeur différente) - le thumb est alors dimensionné/positionné sur cette
largeur **provisoire**, jamais recalculé une fois la vraie police chargée et le texte reflow. Un
test avec `document.fonts.ready` seul a réduit l'écart sans l'éliminer totalement : la police
d'icônes Font Awesome (glyphes des badges) semble aussi contribuer à un reflow tardif, séparément.
Rien ne garantit qu'il n'y ait pas d'autres ressources en cause - deviner chaque cas un par un
n'est pas fiable.

**Fix robuste** (au lieu de traquer chaque ressource) : un `ResizeObserver` posé sur chaque
`.ssm-module-nav` (`$(function(){...})`, `ajax.js`) réagit à **tout** changement de sa taille de
contenu, quelle qu'en soit la cause (police, icônes, zoom, ou une future ressource non identifiée
aujourd'hui), et repositionne le thumb en conséquence - remplace/complète le calcul unique
initial. Support natif dans tous les navigateurs modernes ; sans support, retombe simplement sur
l'ancien comportement (pas de casse).

Revérifié avec Playwright : requête Google Fonts bloquée volontairement (webfont jamais chargée,
repli permanent) → AVANT ce fix, écart mesuré (thumb décalé de ~9-16px, largeur fausse de
~3.5px) ; APRÈS ce fix, correspondance exacte (thumb = onglet actif au pixel près) malgré la
police de repli. Condition la plus proche possible de ce qu'un vrai utilisateur peut rencontrer
sur une connexion lente - contrairement aux tests précédents (§25-28) qui tournaient tous sur une
connexion locale rapide où la course ne se manifestait pas.

**Retenue pour la suite** : "Ctrl+F5 sans effet" est un signal fiable qu'il faut arrêter de
chercher côté cache et regarder une course de chargement (police, image, script async) plutôt
qu'une version périmée - les deux se ressemblent ("ça ne s'affiche pas comme attendu") mais se
diagnostiquent et se corrigent différemment.

## 30. Palette (Ctrl+K) : affichage groupé par section façon `<optgroup>` (22/07/2026)

Retour utilisateur : "je veux un affichage par niveau comme optgroup dans select, pour qu'il soit
bien présenté et facile à atteindre un lien". La palette affichait jusque-là une liste PLATE avec
une pastille de section répétée sur chaque ligne (`.ssm-palette-row-tag`) - moins lisible qu'un
regroupement visuel classique.

- `ssm_palette_lignes()` → `ssm_palette_groupes()` (`ssm_layouts.js`) : un noeud `section` devient
  un groupe `{titre, lignes}` ; un noeud `lien` (Accueil, Stock...) reste une ligne isolée SANS
  titre - exactement comme un `<select>` peut mélanger des `<option>` simples et des `<optgroup>`
  au même niveau.
- `ssm_palette_rendre()` : construit maintenant un entête `.ssm-palette-section-titre` avant les
  lignes de chaque groupe qui en a un - classe **déjà stylée mais jamais utilisée** jusque-là
  (trouvée dans `layouts.css`, restée d'un brouillon antérieur) - reprise telle quelle plutôt que
  d'inventer une nouvelle classe.
- Entête de groupe rendu `position: sticky; top: 0` : reste visible en haut de la liste pendant
  qu'on parcourt les pages de ce groupe, comme un vrai `<optgroup>` figé au défilement. Séparateur
  (bordure + marge) avant chaque groupe sauf le tout premier élément de la liste.
- `.ssm-palette-row-tag` (pastille de section par ligne, redondante avec l'entête) retirée du CSS
  et du JS.
- Navigation clavier (flèches haut/bas, Entrée) et filtre de recherche **inchangés** : ils ciblent
  déjà uniquement `.ssm-palette-row` (jamais les entêtes de groupe), donc fonctionnent sans
  modification sur la structure groupée - un entête sans ligne correspondante (toutes ses pages
  filtrées) disparaît naturellement puisqu'il n'est simplement pas généré si le groupe est vide.

Testé avec Playwright : structure DOM vérifiée (entêtes + lignes dans le bon ordre, entrées
isolées sans entête bien positionnées), ligne active par défaut sur la première entrée,
navigation flèches bas fonctionnelle à travers les groupes, filtre "client" réduit bien à
"Ventes" + "Clients" (l'entête suit sa seule ligne restante).

## 31. Raccourcis clavier globaux Ctrl+<lettre> (22/07/2026)

Retour utilisateur : "si je suis dans une page et il y a par exemple Ajouter, Ctrl+A clique sur ce
bouton ; si plusieurs commencent par la même lettre, un modal pour choisir ; si le bouton a un
dropdown, choisir quel `<a>` dedans ; Ctrl+M ouvre le menu (roue/palette/ancrage/classique)".

**Portée** : global, dans `ajax.js` (chargé sur TOUTE page, contrairement à `ssm_layouts.js` qui
ne charge que pour les dispositions non-classiques) - un seul système, même comportement partout,
pas propre au module Vente.

- `ssm_raccourci_candidats()` : scanne tous les `.ssm-btn` visibles et non désactivés de la page,
  en excluant les entrées déjà à l'intérieur d'un menu déroulant (`.dropdown-item` - ce sont des
  sous-choix, pas des candidats de premier niveau). Libellé = texte visible du bouton (les icônes
  FontAwesome sont des pseudo-éléments CSS, jamais inclus dans `.text()`) ou, à défaut (bouton
  icône seule), son `title`/`aria-label`. Première lettre normalisée (accents retirés via
  `normalize('NFD')`, casse ignorée).
- **1 seul candidat** → clic direct. **Plusieurs candidats** → modal de choix
  (`ssm_raccourci_ouvrir_modal()`), sélection à la souris OU en tapant `1`/`2`/`3`... pendant que
  le modal est ouvert (évite de reprendre la souris pour un système pensé clavier).
- **Bouton `[data-toggle="dropdown"]`** → au lieu d'un simple clic (qui ne ferait qu'ouvrir le
  menu), propose directement le choix de ses entrées (même modal). **Piège rencontré** : un menu
  déroulant Bootstrap est caché (`display:none`) tant qu'il n'est pas ouvert - un premier essai
  filtrait ses entrées par `:visible`, ce qui les excluait TOUTES puisque le menu entier est
  invisible avant le clic ; retiré ce filtre pour cette lecture programmatique (on ne s'appuie pas
  sur l'affichage réel à l'écran, juste sur le contenu du menu).
- **Garde-fou obligatoire** : aucune interception si le focus est actuellement dans un champ de
  saisie (`input`/`textarea`/`select`/`contenteditable`) - sinon un Ctrl+A pendant l'édition d'un
  champ (usage très fréquent dans l'appli) volerait le vrai "sélectionner tout" du navigateur.
  Testé explicitement : focus dans un champ → Ctrl+A ne déclenche rien, le comportement natif du
  navigateur reste intact.
- **Ctrl+M** : universel, relaie un simple clic sur le bouton hamburger existant
  (`#ssm_toggle_menu`), qui sait déjà quoi faire selon la disposition active (voir `top_bar.php`)
  - aucune logique de disposition dupliquée. **Collision acceptée en connaissance de cause** : si
  la page a un bouton "Modifier", Ctrl+M ouvre quand même le menu (jamais "Modifier") - c'est le
  choix explicite de l'utilisateur (Ctrl+M toujours réservé au menu), documenté ici pour ne pas le
  "corriger" par erreur plus tard en pensant à un bug.
- **Ctrl+K** reste réservé à la palette (déjà géré par `ssm_layouts.js`, actif uniquement en
  disposition Palette) - non concerné par ce système.
- **Proposition ajoutée (bonus)** : **Ctrl+/** (ou **Ctrl+?**) ouvre un récapitulatif des
  raccourcis disponibles sur la page courante (`ssm_raccourci_aide()`) - un raccourci Ctrl+<lettre>
  n'a aucun indice visuel sur le bouton lui-même, ce menu d'aide comble ce manque de
  découvrabilité sans avoir à ajouter un badge sur chaque bouton de l'appli (trop invasif pour
  cette passe).

Fichiers : `public/js/ajax.js` (`ssm_raccourci_*`, ~140 lignes), `public/css/design/components.css`
(`.ssm-raccourci-liste`/`.ssm-raccourci-choix`).

Testé avec Playwright : Ctrl+A sur `/Clients` ouvre bien la modale "Ajouter un Nouveau Client" ;
focus dans un champ de saisie → Ctrl+A neutralisé ; Ctrl+M ouvre la palette (disposition active) ;
modal multi-choix rendu et cliquable ; **bug trouvé et corrigé en cours de route** (filtre
`:visible` sur les entrées d'un dropdown fermé) puis reverifié : Ctrl+Z sur un dropdown de test
propose bien ses 2 entrées, sélection au clavier ("2") déclenche la bonne ; Ctrl+/ liste
correctement tous les raccourcis de la page courante.

## 32. Trois compléments rapides : Ctrl+flèches multi-table, Échap, clic dehors (22/07/2026)

Suite du §31, trois retours utilisateur traités ensemble.

**1. Ctrl+flèches gauche/droite = page précédente/suivante d'une DataTable, table "en focus"
suivie si plusieurs tables coexistent sur la même page.** Nouvelle variable globale
`SSM_TABLE_FOCUS` (`ajax.js`) mise à jour au survol/clic de n'importe quel
`.dataTables_wrapper` ; Ctrl+flèche agit sur cette table si elle existe encore dans le DOM
(sinon retombe sur la première table de la page - cas le plus courant, une seule table).
`e.preventDefault()` uniquement si une vraie DataTable est trouvée (sinon la flèche garde son
comportement natif, ex. défilement de page).

**2. Échap ferme la modale Bootstrap actuellement ouverte**, même celles avec
`data-keyboard="false"` (formulaires Ajouter/Modifier - ce réglage empêchait volontairement une
fermeture accidentelle en cours de saisie, mais l'utilisateur veut Échap actif partout). Géré
directement dans le handler `keydown` global de `ajax.js` (`$('.modal.show').last().modal('hide')`),
indépendant du réglage `data-keyboard` du markup - **override délibéré**, à garder en tête si
un comportement de fermeture-au-clavier semble "ne plus être bloqué" ailleurs, ce n'est pas un
oubli.

**3. Panneau Réglages global (`#sidebar_reglage`) : fermeture au clic en dehors**, en plus du
bouton X existant - `mousedown` délégué sur `document` (`footer.php`), exclut les clics sur le
panneau lui-même ET sur le bouton qui l'ouvre (`#ssm_toggle_settings`) pour ne pas se refermer
instantanément au moment même de l'ouverture.

Testé avec Playwright : panneau Réglages se ferme bien au clic ailleurs ; Ctrl+A puis Échap ouvre
et referme la modale "Ajouter" sur `/Clients` ; Ctrl+ArrowRight puis Ctrl+ArrowLeft sur la table
Clients avance puis revient exactement à la page de départ.

## 33. Ctrl+Maj+flèches : onglet précédent/suivant du sous-menu de module (22/07/2026)

Retour utilisateur : un raccourci pour passer d'un onglet à l'autre du sous-menu de module
(Clients/Cartes/Encaissements/Grand Livre) sans avoir à d'abord y mettre le focus (la navigation
flèches simples gauche/droite existait déjà, mais seulement une fois l'onglet lui-même focus -
voir plus haut dans `ajax.js`, "Navigation clavier gauche/droite entre les onglets").

Trois choix proposés, l'utilisateur a choisi **Ctrl+Maj+flèches** (les deux autres : Alt+flèches -
risque de collision avec Précédent/Suivant de l'historique navigateur, ignoré par
`preventDefault()` dans la plupart des navigateurs ; Ctrl+, / Ctrl+. - zéro collision mais moins
intuitif).

- Branché dans le MÊME handler `keydown` global que les autres raccourcis (`ajax.js`) - vérifie
  `e.shiftKey` AVANT le bloc Ctrl+flèches simples (pagination de table, §32) pour que les deux
  combinaisons cohabitent sans se marcher dessus.
- Cherche le premier `.ssm-module-nav` de la page, trouve l'onglet actif, calcule le suivant/
  précédent (avec bouclage circulaire : après le dernier onglet, revient au premier), et simule un
  clic dessus (`$items.eq(suivant).trigger('click')`) - réutilise tel quel le mécanisme de
  navigation AJAX déjà en place pour les onglets (`ssm_module_nav_activer()` etc.), aucune
  logique de navigation dupliquée.
- Ne fait rien si la page n'a pas de sous-menu de module (`.ssm-module-nav` absent) - sans danger
  sur les pages hors Vente/Client.

Testé avec Playwright sur `/Clients` : Ctrl+Maj+→ deux fois → `/CarteClient` puis
`/EncaissementClient` ; Ctrl+Maj+← → retour à `/CarteClient`. Non-régression vérifiée sur les
raccourcis du §32 (Ctrl+flèches simples = pagination table, Échap, clic dehors).

## 34. Modal "Raccourcis clavier" — référence complète, depuis le panneau Réglages (22/07/2026)

Retour utilisateur : "un bouton d'informations qui affiche une modal contenant tous les
raccourcis... à chaque fois qu'on en ajoute un on le met là, pour informer le client".

- Nouveau bouton "Raccourcis clavier" dans le panneau Réglages global (`footer.php`, juste avant
  Déconnexion) → ouvre une modal dédiée listant TOUS les raccourcis globaux, chacun avec un badge
  (la combinaison de touches) + une explication complète en une phrase.
- **Source unique à maintenir** : `SSM_RACCOURCIS_INFO` (tableau JS, `ajax.js`, juste avant
  `ssm_ensure_raccourcis_info_modal()`) - **règle permanente : tout nouveau raccourci clavier
  global ajouté par la suite doit être ajouté à ce tableau**, sinon il reste invisible pour
  l'utilisateur (aucun autre indice visuel n'existe sur les boutons eux-mêmes). Distincte de
  `ssm_raccourci_aide()` (Ctrl+/, §31) qui ne montre QUE ce qui est cliquable sur la page
  courante - celle-ci décrit le système dans son ensemble, indépendamment de la page.
- **Bug de collision trouvé et corrigé en marge de cet ajout** : `ssm_raccourci_candidats()`
  scannait déjà TOUT `.ssm-btn` `:visible` de la page - or le panneau Réglages est TOUJOURS
  présent dans le DOM, seulement déplacé hors écran (`right:-340px`) tant qu'il n'est pas ouvert,
  ce que `:visible` (jQuery) ne détecte pas comme "invisible" (seuls `display`/`visibility`
  comptent, pas la position). Le nouveau bouton "Raccourcis clavier" (lettre R) serait donc
  entré en collision avec "Réglage d'affichage" (lettre R aussi) sur CHAQUE page ayant un tableau,
  même panneau fermé. Fix : `ssm_raccourci_candidats()` exclut désormais tout ce qui est à
  l'intérieur de `#sidebar_reglage` - ce panneau n'est pas une action "de la page courante".

Testé avec Playwright : bouton présent et fonctionnel dans le panneau Réglages, 7 raccourcis
listés avec badge + description complète ; Ctrl+R sur une page avec un bouton "Réglage
d'affichage" déclenche bien CE bouton directement (plus de collision/modal ambiguë).

## 35. Modal de choix : sélection par défaut + flèches, focus auto sur formulaire d'ajout, Select2 dans une modale, Ctrl+↓/↑ entre champs (22/07/2026)

Quatre retours utilisateur traités ensemble, avec plusieurs bugs non-triviaux trouvés et corrigés
en cours de route.

**1. Modal de choix (Ctrl+<lettre> multiple, ou entrées d'un dropdown)** : premier choix
sélectionné par défaut à l'ouverture, flèches haut/bas pour déplacer la sélection (bouclage
circulaire), Entrée valide la sélection courante - en plus des touches 1/2/3... déjà existantes,
aucune des trois méthodes ne remplace les autres.

**2. Focus automatique sur le premier champ d'un formulaire d'AJOUT** (pas Modification - détecté
via le titre de la modale, `/ajout|nouvel|nouvelle/i`, sans toucher à aucune vue existante). Si le
premier champ est un `<select>` Select2, ouvre son menu et focus sa zone de recherche si elle en a
une.

**3. Bug non-trivial trouvé et corrigé : `.modal('hide')` puis exécuter une action qui ouvre une
AUTRE modale, dans la même frame, laissait Bootstrap 4 dans un état incohérent** - la première
modale restait "show" indéfiniment (jamais de `hidden.bs.modal`), gardait le focus piégé, et la
seconde modale s'empilait derrière au lieu de s'afficher proprement. Reproduit de façon fiable en
choisissant une action depuis le modal de choix. Fix : `ssm_raccourci_choisir()` attend
`hidden.bs.modal` avant d'exécuter l'action choisie, **avec un filet de sécurité** (timeout +
nettoyage manuel des classes/backdrop) si l'événement ne se déclenche pas dans les 350ms - constaté
en test que ce délai suffit largement quand aucune 2e modale ne s'enchaîne, mais que l'événement
devient peu fiable quand une 2e modale s'ouvre immédiatement après.

**4. Bug non-trivial trouvé et corrigé : Select2 dans une modale Bootstrap → focus impossible sur
sa zone de recherche.** Select2 ajoute par défaut son menu déroulant comme enfant direct de
`<body>`, hors du périmètre DOM de la modale - or Bootstrap **impose** que le focus reste à
l'intérieur de la modale ouverte (accessibilité) et le reprend de force dès qu'il en sort. Résultat
avant fix : le menu s'affichait, mais la frappe clavier ne filtrait rien (focus réellement piégé
ailleurs). Fix à la source, **une seule fois pour toute l'application** (`ajax.js`, tout en haut) :
`$.fn.select2` est enveloppé pour injecter automatiquement `dropdownParent: $(this).closest('.modal')`
quand l'élément est dans une modale et qu'aucun `dropdownParent` n'est déjà précisé - le menu vit
alors dans le DOM de la modale, dans le périmètre du focus-trap. Ignore les appels "méthode"
(`.select2('open')`, `.select2('destroy')`...), seul un objet d'options (ou aucun argument)
déclenche l'injection.

**5. Ctrl+↓ / Ctrl+↑ : champ suivant/précédent d'un formulaire.** Retour utilisateur : "Tab passe
parfois par des boutons, et fait des choses bizarres sur un select". Fonctionnalité VOLONTAIREMENT
sans le garde-fou `ssm_raccourci_champ_actif()` (ce raccourci doit justement fonctionner PENDANT
qu'un champ a le focus). Cible le formulaire de la modale ouverte s'il y en a une, sinon toute la
page. Un select2 actuellement ouvert est refermé avant de sauter ailleurs (jamais deux menus
empilés).
- **Bug trouvé et corrigé** : la position du champ "actif" doit être calculée AVANT de fermer un
  éventuel Select2 ouvert (`isOpen()` devient faux dès `.select2('close')` - la calculer après
  faisait toujours échouer la détection, "Ctrl+Haut" retombait sur le mauvais champ).
- **Bug trouvé et corrigé : Select2 empêche ce raccourci d'atteindre `$(document)`, même en phase
  de capture.** Sa zone de recherche intercepte les flèches pour naviguer parmi ses propres
  résultats et stoppe leur propagation d'une façon qu'aucun listener global (bouillonnement OU
  capture) ne contourne. Fix : en plus du listener global (converti en **phase de capture** via
  `document.addEventListener(..., true)` plutôt que `$(document).on(...)`, plus robuste par
  principe face à du contenu qui stoppe la propagation), un handler délégué dédié posé
  spécifiquement sur `.select2-search__field`.
- **Limite connue, non résolue** : un `<select>` Select2 configuré SANS zone de recherche (trop peu
  d'options, ex. un select de "groupe" à quelques valeurs) ne présente aucun `.select2-search__field`
  du tout - dans ce cas précis, Ctrl+↓/↑ depuis ce champ ne fonctionne pas de façon fiable
  (le focus semble retomber sur le `<select>` natif lui-même, hors de portée des handlers
  posés). Rare en pratique (la plupart des select2 de l'appli ont assez d'options pour afficher une
  recherche) - documenté ici plutôt que masqué, à reprendre si signalé comme gênant.

Ajouté à `SSM_RACCOURCIS_INFO` (§34, modal "Raccourcis clavier") : "Ctrl + ↓ / Ctrl + ↑".

Testé avec Playwright, y compris en reproduisant délibérément chaque bug AVANT correction pour
confirmer la cause exacte (requêtes bloquées pour simuler une police non chargée au tour précédent,
ici modale forcée à rester ouverte pour observer `hidden.bs.modal` ne jamais se déclencher, focus
suivi image par image pour localiser exactement où Bootstrap le reprenait).

## 36. Reprise de session — que dire à Claude

> "Lis SKILL_MODULE_VENTE.md et DESIGN.md, on continue le module vente" (pour peaufiner) —
> ou — "Lis SKILL_MODULE_VENTE.md, on applique la même recette au module [Achat/Coffre/...]"

Claude relira ce fichier + `CLAUDE.md` + `DESIGN.md` en tout début de session (rappel déjà présent
en haut de `CLAUDE.md` : consulter `PLAN.md` avant tout gros chantier) et disposera de tout le
contexte nécessaire sans qu'il faille ré-expliquer les mêmes points.

### Prochain chantier : 2e sous-module de Vente — "Gestion des Bons"

Le sous-module Client (Clients/Cartes/Encaissements/Grand Livre, `menu_client.php`) est terminé
(design + totaux + thème + tout ce qui précède dans ce fichier). Le sous-menu "Gestion des Bons"
(2e entrée du menu Ventes, `Layout::menu_config()`) reste encore en rendu pré-Phase 4 - à reprendre
avec exactement la même recette (composants `ssm-*`, navigation AJAX, plein écran, réglages
d'affichage, totaux Page/Filtré sur toute colonne de montant - règle permanente `CLAUDE.md`).

Pages concernées (routes → vue → contrôleur, tous dans `app/controllers/bons/`) :
- **Bons** : `/ClientBon` → `app/views/bons/ClientBon.php` (`ClientBonController`) - sous-menu
  propre `app/views/bons/portion/menu_BonClient.php` (équivalent de `menu_client.php`) + en-tête
  `portion/header_BonClient.php`.
- **Etats** : `/ClientEtat` → `app/views/bons/Etats.php` (liste) **et**
  `app/views/bons/ClientEtat.php` (**page détail "état d'un client" - pas encore vue/auditée**,
  `ClientEtatController.php` lignes 96 et 122).
- **Avoirs/Avances** : `/ClientAvoir` → `app/views/bons/ClientAvoir.php` (`ClientAvoirController`).
- **Archive** : `/ClientArchive` → `app/views/bons/Archive.php` (`ClientArchiveController`).
- **Statistique/Mensualité** : `/MensualiteCredit` → `app/views/bons/Mensualite.php`
  (`MensualiteCreditController`).
- **Passage** : `/BonPassage` → `app/views/bons/BonPassage.php` (`BonPassageController`).

À faire en arrivant sur ce chantier : lire ces 6 vues (surtout `ClientEtat.php`, jamais encore
ouverte cette session) avant de commencer, pour repérer d'éventuelles tables à montants
(règle Page/Filtré à appliquer directement dès la 1ère passe plutôt qu'en rattrapage) et le
concept métier "état d'un client" (probablement lié à `client_etat`/`CoffreRemplacementPiece`,
voir la section "Concept PieceRemplacement" de `CLAUDE.md`).

## 37. Sous-module Bons équipé (22-23/07/2026) + 4 pièges génériques trouvés à ne pas répéter

Sous-module "Gestion des Bons" équipé avec la recette du §8/§9 (`menu_BonClient.php` refait sur le
modèle `menu_client.php`, les 6 pages en `.ssm-panel` + toolbelt + `.ssm-table-toolbar` +
`#ssm_module_content`, totaux Page/Filtré fusionnés en une seule requête SQL partout où une colonne
montant existe). Au passage, **4 pièges génériques** (pas spécifiques à Bons) trouvés en testant
avec Playwright — **à vérifier systématiquement sur tout futur module**, pas seulement à corriger
une fois ici :

**a) Thumb du sous-menu qui déborde sous "Taille de texte : Grand/Petit" (zoom CSS)** — retour
utilisateur : "le menu, en contour/ligne/plein, dépasse le texte". Cause : `zoom` (tokens.css,
`body[data-taille-texte]`, §24) rend les mesures jQuery `.position()`/`.outerWidth()` **déjà à
l'échelle visuelle** (post-zoom) — réappliquées comme `transform`/`width` CSS sur un élément du
même sous-arbre zoomé, elles sont re-multipliées par le zoom au rendu (constaté avec Playwright :
zoom 1.15 → décalage/largeur ×1.15 en trop, ex. thumb à x=899px au lieu de x=785px). Fix
(`ssm_module_nav_positionner_thumb()`, `ajax.js`) : remplacer `$actif.position().left`/
`.outerWidth()` par `actifEl.offsetLeft`/`.offsetWidth` (natifs DOM, repère LOCAL non ambigu,
identiques aux valeurs jQuery quand zoom=1 → aucune régression). **Concerne tout `.ssm-module-nav`
de tout module** (composant déjà partagé) — corrigé une fois pour toutes, rien à refaire par
module, mais à garder en tête si un autre composant JS del'appli fait le même genre de calcul
(mesurer avec jQuery `.position()/.outerWidth()` puis réappliquer en CSS brut) : toujours préférer
`offsetLeft`/`offsetWidth`/`offsetTop`/`offsetHeight` pour ce genre d'aller-retour mesure→CSS.

**b) Select2 dans une modale : menu qui ne s'affiche pas sous le select** — même famille de piège
que (a) mais côté librairie tierce (Select2), qu'on ne peut pas corriger à la source : Select2
positionne son menu via jQuery `.offset()` (déjà "zoomé"), réappliqué en `top`/`left` CSS dans le
même sous-arbre zoomé → décalage ×zoom (ex. menu à x=508px au lieu de x=442px, soit 442×1.15).
Fix global (`ajax.js`, écouteur délégué `select2:open`) : relit la position réelle
(`getBoundingClientRect()`, jamais ambiguë) et la réapplique divisée par le zoom courant. Sans
effet si zoom=1 (compte "Normal", la grande majorité des comptes) — **seulement visible sur un
compte avec "Taille de texte" ≠ Normal dans les Réglages**, penser à tester avec ce réglage actif
sur tout nouveau module avant de conclure "aucun problème".
  - **Piège vécu et corrigé dans le fix lui-même (flash visible)** : une 1ère version corrigeait la
    position dans un `setTimeout(fn, 0)` — le navigateur peint d'abord la position erronée de
    Select2 AVANT que le timeout ne se déclenche (un timeout, même à 0ms, est mis en file APRÈS le
    rendu de la frame en cours) → flash visible ("les options se lancent à droite puis reviennent
    sous le select", retour utilisateur). Une 2e version corrigeait de façon 100% synchrone (même
    tick que l'événement `select2:open`) : plus de flash, MAIS Select2 recalcule/réapplique
    lui-même sa position juste APRÈS avoir émis cet événement (logique interne) — la correction,
    trop précoce, se faisait écraser et le menu redevenait mal placé en PERMANENCE cette fois (pas
    juste un flash). Fix retenu : cacher (`visibility:hidden`) immédiatement + corriger dans un
    **`requestAnimationFrame`** (s'exécute juste avant la prochaine peinture — jamais pendant, donc
    aucun flash — mais après tout le travail interne de Select2, contrairement au tick synchrone)
    + révéler seulement une fois repositionné. **Leçon générale** : pour corriger une position
    calculée par une librairie tierce après son propre événement, ni `setTimeout` (peut peindre
    l'état erroné avant) ni le tick synchrone (peut être écrasé par la suite du travail de la
    librairie) ne suffisent seuls — `requestAnimationFrame` combine les deux garanties.

**c) `iTotalRecords` confondu avec `iTotalDisplayRecords` dans le modèle** — retour utilisateur :
"quand tu fais dans datatable le nombre de page à afficher, il faut corriger aussi la méthode dans
le modèle". Deux méthodes du modèle Bons renvoyaient `iTotalRecords` (total NON filtré, doit rester
constant qu'il y ait une recherche ou non) égal à la valeur FILTRÉE (`ClientBon::data_etat()` :
`"iTotalRecords" => $filtre` au lieu de `$total` déjà calculé juste au-dessus ; `ClientEtat::etat_produit()` :
`$filtre=$total = count(...)` calculé sur la MÊME requête déjà filtrée par la recherche, jamais de
vrai total non filtré). Conséquence concrète : l'info "Affichage de X à Y sur Z entrées"/le total de
pages de la DataTable se trompe dès qu'une recherche est active. Fix : toujours calculer `$total`
sur la requête AVANT application de `$where`/`$where_recherche` (recherche), `$filtre` sur la
requête APRÈS — **vérifier ce point sur toute méthode `data()`/équivalent copiée d'un autre modèle**,
c'est une confusion facile à introduire/propager en dupliquant un modèle existant qui a déjà ce bug
(trouvé aussi dans `Client::livre()`, module Vente, pas corrigé cette session — hors périmètre
Bons, à reprendre si on retouche le Grand Livre).

**d) Fond coloré (`bg-danger`/`bg-success`/`bg-warning`/`bg-info`) sur les `<thead>`/`<tr>` de
DataTable : à ne plus jamais poser** — retour utilisateur explicite : "la background d'entête dans
une table datatable il faut la supprimer, maintenant on travaille avec nos nouveaux styles". Ces
classes Bootstrap/AdminLTE étaient recopiées telles quelles depuis les vues pré-Phase 4 (un fond
différent par module : rouge pour Bons, vert pour Etats...). **Règle permanente à partir de
maintenant** : `<thead>` sans classe de couleur du tout sur toute nouvelle vue Phase 4 — le design
system impose déjà un fond neutre opaque + sticky via `table.dataTable thead th` (components.css,
`background: var(--surface) !important`), les icônes par colonne (déjà de rigueur, §8 point 4)
suffisent à distinguer visuellement les tableaux, pas besoin d'un bandeau de couleur en plus.
Appliqué sur les 6 pages Bons (`Archive.php`, `BonPassage.php`, `ClientAvoir.php`, `ClientBon.php`,
`ClientEtat.php` ×3 tableaux, `Etats.php`) — **pas encore rétro-appliqué au module Vente** (ses
`<thead>` gardent encore `bg-success`/`class` historiques, ex. `Encaissements.php`), à corriger si
on retouche une de ces vues.

**Méthode de test utilisée pour (a)/(b)** : un vrai Chromium headless (Playwright, réinstallation à
la volée dans le scratchpad, Chromium déjà présent depuis une session précédente sous
`%LOCALAPPDATA%\ms-playwright`) a permis de mesurer les rects réels (`getBoundingClientRect`) du
thumb/du menu Select2 vs leur cible attendue, de confirmer le facteur exact (×1.15 = zoom "Grand")
et de vérifier après coup que le rect final correspond au pixel près — impossible à diagnostiquer
par simple lecture de code (l'écart ne se voit qu'au rendu, sous un réglage de zoom particulier).

## 38. Recette modale (icônes/notify/auto-close/Ctrl+lettre) oubliée sur tout le sous-module Bons — corrigée

Retour utilisateur (23/07/2026) : le §8 point 5 (recette modale complète, déjà écrite depuis le
module Vente) n'avait été appliqué à AUCUNE modale du sous-module Bons — seul l'habillage de page
(panel/plein écran/totaux/nav, §37) avait été fait. Repéré et corrigé :
- `modal/ajout_BonClient.php` (Ajouter/Modifier un Bon) et `modal/ajout_Avoir.php` (Ajouter/Modifier
  un Avoir) : `data-mode`, `.ssm-modal-icon`, `.ssm-field-icon` sur les labels principaux, boutons
  `.ssm-btn`/`.ssm-btn-icon`/`.ssm-btn-primary` (y compris les petits boutons annexes - annuler/
  confirmer matricule, ajouter/supprimer une ligne produit).
- Boutons "Ajouter" de niveau page (`ClientBon.php`, `Etats.php`, `ClientAvoir.php`) convertis de
  `btn btn-sm bg-primary` vers `.ssm-btn.ssm-btn-primary` — **cause directe et concrète du "Ctrl+A
  ne déclenche pas le bouton Ajouter" signalé** : le scanner de raccourcis (§31,
  `ssm_raccourci_candidats()`) ne considère QUE les éléments `.ssm-btn`, un bouton stylé à l'ancienne
  (même visuellement identique) est invisible pour Ctrl+`<lettre>`, quoi qu'on fasse par ailleurs.
- `ssm_notify()`/`ssm_modal_auto_close()` branchés sur les handlers de succès ajout/modification
  (`ClientBon::data()`/Archive - garde le `location.reload()` existant, notify seule car l'auto-
  fermeture n'aurait pas de sens juste avant que la page change entièrement ; `ClientAvoir` Avance
  et Avoir - migré vers le callback de `load_portion(..., function(){...})`, plus de
  `setTimeout` arbitraire après un appel fire-and-forget).
- Etats.php ("Nouveau Etat Client") : icône/`data-mode`/field-icon/`.ssm-btn` appliqués, mais PAS de
  notify/auto-close - ce formulaire soumet un vrai `<form>` HTML (`action="/ClientEtat/nouveau"`,
  navigation complète vers la page de l'état créé), pas un flux AJAX - la page change entièrement,
  l'auto-fermeture d'une modale qui va disparaître de toute façon n'a pas de sens ici non plus.
- **Non fait, portée restante identifiée** : les modales d'ACTION de `ClientEtat.php` (clôturer,
  attacher avance/avoir, facturer, détacher, déclarer perdu...) - ce ne sont pas des formulaires
  "ajout d'un enregistrement" au même sens que Client/Bon/Avoir, plutôt des actions sur un état déjà
  existant ; à revoir avec la même grille de lecture (icône/mode/field-icon systématiques, notify
  généralisé) la prochaine fois qu'on retouche cette page - non traité cette session par manque de
  temps, pas par oubli cette fois.

**Règle définitive, valable pour TOUT module futur (pas seulement Bons)** : la recette modale du
§8 point 5 n'est PAS une étape "en plus" optionnelle après l'habillage de page - elle fait partie du
MÊME passage, page par page, modale par modale. Avant de considérer une page "terminée", vérifier
explicitem la check-list : icône de modale, `data-mode`, field-icons, boutons `.ssm-btn`, ET
tester Ctrl+`<lettre>` en conditions réelles sur son bouton déclencheur (pas juste visuellement).

## 39. `ClientEtat.php` : header AJAX-ifié (numbers/boutons JSON, plus de rechargement HTML complet)

Retour utilisateur (23/07/2026) : le bloc `#header` de `ClientEtat.php` (infos client, boutons
Clôturer/Annuler/Supprimer/Ajuster/imprimer, chiffres, 3ᵉ DataTable "Informations Remplacements")
était **entièrement re-rendu côté serveur** (`load_portion('/ClientEtat/header', ...)`) à CHAQUE
action (attacher un paiement, détacher un bon, clôturer, ajuster les dates...) - demande explicite :
juste redessiner les DataTables, mettre à jour les chiffres en AJAX (JSON), gérer l'affichage des
boutons conditionnels en JS - plus jamais de re-render HTML complet après le tout premier chargement.

**Architecture retenue** (`ClientEtatController.php`) :
- `header_donnees($id_etat)` (privée) : extrait TEL QUEL l'ancien calcul de `header()` (`$etat`,
  `$matricules`, `$facture`, `$entete_impression`) - aucune règle métier dupliquée.
- `header_affichage($etat)` (privée) : traduit `$etat` en valeurs d'affichage PRÊTES À L'EMPLOI
  (chiffres déjà formatés `Nombre()`/`formater_date()`, booléens de visibilité déjà tranchés :
  `cloture_visible`, `drop`, `supprimer_visible`, `annulation_visible`, `ajuster_visible`,
  `condition.*`...) - **utilisée à la fois par le rendu HTML initial ET par le JSON**, jamais deux
  calculs séparés qui pourraient diverger.
- `header()` : rendu HTML complet, réservé désormais au TOUT PREMIER chargement de la page (le
  seul moment où `#header` est vide).
- `header_json()` (nouvelle) : renvoie `header_affichage()` en JSON, aucun HTML - c'est CE endpoint
  qu'on appelle après toute action.
- `annuler_cloture()` (nouvelle, extraite de l'ancien `header()` qui faisait mutation+rendu en un
  seul appel) : fait uniquement la mutation "annuler la clôture", plus aucun rendu.

**Côté vue** :
- `portion/Header.php` : tous les blocs conditionnels (boutons Clôturer/Supprimer/Annuler/Ajuster/
  Nouveau bon, items du menu "Ajouter un remplacement") sont désormais **toujours rendus dans le
  DOM**, juste avec une classe `.d-none` calculée depuis `$aff` au lieu d'un `if/else` PHP qui
  omettrait carrément le HTML - condition nécessaire pour pouvoir les afficher/masquer en JS SANS
  réinjecter de HTML par la suite. Les chiffres (`#header_montant`, `#header_nombre`,
  `#header_date_debut/fin`, `#header_remplacement_nbr/montant`, `#header_texte_resultat`,
  `#header_difference`, `#header_text_etat`, `#header_icon_etat`, `#header_client_nom`) ont des
  `id` stables pour être mis à jour en `.text()`/`.val()`.
- `ClientEtat.php` : nouvelle fonction JS `rafraichir_header()` (exposée aussi en
  `window.rafraichir_header` pour du débogage/reuse futur) - appelle `/ClientEtat/header_json` et
  applique les valeurs/toggles reçus. Remplace TOUS les anciens appels `load_header()` sauf le tout
  premier (`load_header(0)` au `$(document).ready()`, qui reste un rendu HTML complet car `#header`
  est vide à ce moment). Les notifications `toastr` de ces mêmes handlers migrées vers `ssm_notify()`
  au passage (cohérent avec §10/§38).
- La 3ᵉ DataTable (`#paiements_data`, définie dans Header.php) est exposée en
  `window.ssm_dataTable_paiements` (elle vivait avant dans une variable locale `dataTable_paiements`
  invisible depuis le script de `ClientEtat.php` - **bug pré-existant découvert au passage** : un
  appel `dataTable_paiements.draw()` dans `ClientEtat.php` levait une `ReferenceError` silencieuse
  à chaque clic sur "Modifier" un paiement, jamais remarqué car noyé dans le rechargement complet
  du header qui suivait juste après). Tout handler qui ajoute/modifie/supprime un paiement rappelle
  désormais `window.ssm_dataTable_paiements.draw()` explicitement en plus de `rafraichir_header()`.
- **Bug pré-existant corrigé au passage** : la DataTable `#data_bon` (tableau de sélection des bons
  dans la modale "Nouveau bon") utilisait encore l'ancienne clé `oLanguage.sUrl` vers
  `cdn.datatables.net` (bloquée par CORS) - erreur JS (`Cannot set properties of undefined (setting
  'nTf')`) à chaque chargement de la page, pas seulement sur `Home.php` comme noté précédemment
  dans `PLAN.md` (21/07/2026) - corrigé avec la même traduction française en dur que partout
  ailleurs (`init_datatable()`).

**Méthode de test** : `header_json()` vérifié par curl (JSON cohérent avec le rendu HTML initial,
mêmes valeurs/flags des deux côtés) ; `rafraichir_header()` vérifié avec Playwright en l'appelant
directement sans mutation (doit reproduire un état strictement identique - confirmé) ; un test de
mutation réelle (détacher un bon d'un état ouvert réel, `#1341`) a confirmé le bon fonctionnement
bout en bout des chiffres ET du re-toggle des boutons - **la mutation de test a été restaurée
manuellement en base** (`UPDATE client_bon SET id_etat=1341 WHERE id=15395`) immédiatement après
vérification, aucune donnée réelle laissée altérée.

**Reste à faire (demande explicite de l'utilisateur, prochaine étape)** : séparer le dispatcher
`ClientEtatController::datatable()` (branches conditionnelles sur `$_POST['table']`/`id_etat`/
`produit` pour aiguiller vers `Etat->data('bon')`, `ClientBon->data_etat()`, `Etat->etat_produit()`,
`Remplacement->remplacement_etat()`) en une méthode dédiée par DataTable - pas fait cette session,
prévue explicitement pour une prochaine tâche.

## 40. Sous-module Facturation (Vente) traité + fix global du rechargement de page en entrant sur un détail

Retour utilisateur (24/07/2026) : la Facturation (`ImpayeesClient`/`FacturesClient`/
`ReglementsClient`+`ReglementClient`/`AvoirClient`) n'avait pas reçu le traitement Phase 4 fait sur
Bons (§36) - pas de toolbelt réglage/plein écran, pas de barre imprimer/exporter, totaux encore en
`<tfoot>` texte brut au lieu du composant `.ssm-stat-row`. Plus grave : cliquer sur un règlement (ou
un état, ou n'importe quelle ligne menant à un détail dans TOUT le projet) rechargeait la page
entière - jamais corrigé avant car la cause n'était pas dans une vue mais dans un helper PHP partagé.

**Cause racine du rechargement de page (corrigée une fois pour toutes)** : `dt_btn_lien()`
(`vendor/function.php`, génère le bouton "aller au détail" utilisé par bons/vente/achat/stocks/
coffre - `ClientEtatController`, `ReglementsClientController`, `ReglementsFournisseurController`,
`BonCommandesController`, `TransfertsController`, `AlimentationsController`,
`PieceEnvoiController`, `PieceRemplacementController`) rendait un `<button onclick="window.location.
href='...'">` - une vraie navigation plein navigateur, jamais interceptée par le système de
navigation AJAX (`data-ssm-nav`, voir §2/§9 - fonctionne uniquement sur des `<a href>`). Fix :
`dt_btn_lien()` rend maintenant `<a href="..." data-ssm-nav="1" class="ssm-btn ssm-btn-icon">` -
un seul changement dans un seul fichier corrige le problème PARTOUT où la fonction est utilisée
(y compris les modules pas encore repris comme Achat/Stocks/Coffre), sans toucher aucun contrôleur.
Sûr par construction : `ssm_nav_ajax()` a déjà un repli automatique sur navigation classique si la
réponse n'est pas un fragment de vue (session expirée...).

**Reste de la migration Facturation** (mêmes patterns que Bons, rien de nouveau) :
- `Impayees.php`/`Factures.php`/`Reglements.php`/`Avoirs.php` : panel `.ssm-panel-toolbelt` +
  `.ssm-table-toolbar` + totaux `.ssm-stat-row` (remplace les anciens `<tfoot>` bruts, mêmes valeurs
  déjà calculées côté modèle - `ClientImpayes::data()`/`ClientFacture::data()`/
  `ClientReglement::data()`/`ClientAvance::data()` avaient déjà les totaux fusionnés en SQL, seul
  l'affichage changeait).
- `Reglement.php` (détail d'un règlement) : **cas nouveau, à retenir** - 2 DataTables simultanément
  visibles côte à côte (factures/paiements, pas commutées par un sélecteur comme Bon/Produit sur
  `ClientEtat.php`) - `ssm_table_toolbar()`/`ssm_panel_settings()` résolvent la table via
  `$panel.find('table').filter(':visible').first()`, qui prendrait TOUJOURS la même (la 1ère du
  DOM) si les deux tables partagent un seul panel. **Solution retenue** : 2 sous-panels distincts
  (un par table, chacun avec son propre id + son propre bouton réglage, sans bouton plein écran
  individuel), le plein écran restant sur le panel englobant (toute la page détail). À reproduire
  telle quelle pour toute future page à plusieurs tables côte à côte (pas commutées).
  - Ajout des totaux Page/Filtré aux 2 tables (`ClientReglement::factures_reglement()`/
    `paiement_reglement()`, n'avaient encore aucun total filtré, contrairement aux tables de liste).
  - `portion/Header_reglement.php` restylé en `.ssm-panel-header`/`.ssm-situation-detail`/
    `.ssm-stat-row` (même look que `bons/portion/Header.php`) - tous les `id` ciblés par le JS de
    `Reglement.php` conservés à l'identique, comportement de rechargement du fragment `#header`
    inchangé (pas repris en JSON allégé façon §39 - non demandé cette fois, `Header_reglement.php`
    est un fragment nettement plus petit que celui de `ClientEtat.php`).
- Ancien `vente/portion/Header.php` (sous-menu à boutons `window.location.href`) : devenu du code
  mort côté Facturation, remplacé par `menu_facturation.php` (thumb AJAX, même famille que
  `menu_client.php`/`menu_BonClient.php`) sur `Reglements.php`/`Impayees.php`/`Factures.php`/
  `Avoirs.php`/`Reglement.php`. Pas supprimé (peut encore être atteint via une action contrôleur
  `header()` residuelle) - à nettoyer une prochaine fois si confirmé inutilisé partout.

**Testé** : `php -l` sur tous les fichiers touchés, pages rendues via curl (200 partout), fragment
`/ReglementClient/header` vérifié (nouveau balisage `.ssm-panel-header` présent), JSON de
`/ReglementsClient/datatable` vérifié : le bouton d'action contient bien
`<a href="/ReglementClient/index/…" data-ssm-nav="1" …>` au lieu de l'ancien `<button onclick=...>`.

**Reste à faire** : généraliser le même traitement (toolbelt/toolbar/totaux) aux ~28 autres tables
identifiées en fin de `PLAN.md` (Achat/Coffre/Stocks/Banque...), un module à la fois - la Facturation
(Vente) est désormais entièrement traitée, le fix `dt_btn_lien()` bénéficie déjà automatiquement à
Achat/Stocks/Coffre sans action supplémentaire pour ce point précis (navigation seulement - le reste
du traitement panel/toolbar/totaux de ces modules reste à faire).

## 41. Renommage métier Règlement→Lettrage / Avance→Règlement, cards Factures ouvertes, Facture en AJAX inline

Retour utilisateur (24/07/2026) : 3 demandes, plan approuvé avant exécution (voir `EnterPlanMode`/
`ExitPlanMode` de la session - tâche jugée trop volumineuse/ambiguë pour foncer sans confirmer la
compréhension d'abord, conformément à la consigne explicite de l'utilisateur).

**1. Renommage** : ce qui s'appelait "Règlement" (`client_reglement`) est en réalité un **lettrage**
(rapprochement factures↔paiements) → renommé "Lettrage" partout (titres, breadcrumbs, modales -
`Reglement.php`, `Header_reglement.php`, `reglement_avance.php`, `reglement_factures.php`,
`cloture_reglement.php`, `Nouveau_reglement.php`, `Reporter_reglement.php`). Ce qui s'appelait
"Avance" (`client_avance`, page `Avoirs.php`/`AvoirClient`) est en réalité un **règlement** (un
paiement du client) → renommé "Règlement". Les 2 onglets du sous-menu (`menu_facturation.php`)
échangent leurs LIBELLÉS (pas leurs `href`/clés `$type` - aucun contrôleur à toucher) : `/AvoirClient`
→ "Règlements", `/ReglementsClient` → "Lettrage". `ClientReglement::info_paiement()` (cas `'avance'`)
et `paiement_reglement_optimise()` : retrait du préfixe "Avance : " (affiche directement le mode).

**2. Nouvelle vue unifiée "Règlements"** (`ClientAvance::data_reglement()`, nouvelle méthode - `data()`
existante gardée intacte, plus utilisée par personne d'autre après vérification par grep) : UNION de
2 sources sans jamais montrer le même règlement deux fois :
- `client_avance` avec `source != 'Reglement'` (règlements créés directement - une avance générée par
  `ClientReglement::cloture()` est un SOUS-PRODUIT du lettrage, pas un nouveau règlement distinct).
- `client_paiement` avec `source != 'avance'` (paiements de lettrage directs - un paiement de type
  `'avance'`, créé au moment d'ATTACHER un règlement existant à un lettrage, ferait doublonner le
  MÊME argent : une fois comme règlement lettré, une fois comme "paiement").
Statut (Non lettré/Partiel/Lettré) et double montant (Règlement/Lettré) : **clarifié avec
l'utilisateur en cours de route** - l'attachement d'un règlement à un lettrage reste TOUJOURS total
(bouton "Attacher", un clic, jamais de saisie de montant partiel) ; le "partiel" est réservé au badge
de statut (3 classes prêtes) mais n'est atteint par AUCUN code aujourd'hui avec le schéma actuel
(`client_avance.id_paiement` = simple FK 0/rempli, jamais fractionnable) - documenté tel quel, pas
caché. **2 bugs trouvés et corrigés pendant la construction de la requête** (jamais remarqués avant
faute de test end-to-end) :
- `$this->datatable()` fait `ORDER BY <nom envoyé par le JS>` - la colonne SQL doit s'appeler EXACTEMENT
  pareil (`date_document`, pas `date`) sous peine d'erreur SQL fatale au tri - même piège que
  `paiement_reglement_optimise()` la veille, reproduit puis corrigé ici aussi.
- 27 paiements "opération bancaire" avaient `date_operation` NULL (donnée réelle, `date_flux` renseigné
  à la place) - `fi.date_document BETWEEN du AND a` les excluait silencieusement du total "Lettré" -
  fix : `COALESCE(op.date_operation, op.date_flux)`. Détecté en comparant le total obtenu (2174) au
  total réel de `client_paiement` (2199) sur la base réelle - jamais laissé passer sans explication.
**Vérifié** : script de comparaison manuel (comptages croisés SQL) sur toute la base réelle (2201
lignes, 0 divergence après les 2 fixes), PUIS scénario complet créé de A à Z (nouveau client de test,
facture + article, nouveau lettrage, attachement facture, paiement espèce de 80 pour une facture de
52,50, clôture avec excédent → nouvelle avance `source='Reglement'`) confirmant que cette avance
n'apparaît JAMAIS dans la liste "Règlements" (Non Lettré = 0 ligne) alors que le paiement de 80 y
apparaît une seule fois, 100% lettré, avec un lien fonctionnel vers son lettrage - toutes les données
de test supprimées immédiatement après vérification.

**3. Cards factures ouvertes + Facture.php en navigation AJAX inline** :
- `Factures.php` : quand le filtre est sur "Ouverte", affiche une grille de cards
  (`.ssm-facture-card`, nouveau composant CSS - encadré net, pas le poids d'un `.ssm-kpi`) au lieu du
  tableau - alimentée par `ClientFacture::ouvertes()`/`FacturesClientController::ouvertes()` (JSON
  simple, pas un format DataTables). **Piège trouvé** : une facture OUVERTE n'a PAS encore
  `montant_ttc`/`dont_tva` figés (écrits seulement à la clôture, `FactureClientController::cloture()`)
  - lire directement ces colonnes aurait affiché des montants NULL/faux sur toutes les cards ; fix :
  total calculé EN DIRECT depuis `client_facture_description`, même principe que
  `FactureClientController::header()`. Si 0 facture ouverte au premier chargement, le select bascule
  automatiquement sur "Fermée" (vérifié : la base réelle n'a actuellement AUCUNE facture ouverte,
  scénario donc réellement rencontré en production, pas hypothétique).
- `Facture.php` (l'éditeur d'une facture, ex-`window.open` dans une fenêtre séparée) : retrait de
  `$super_admin=true` (réservait le layout complet au seul mode fenêtre, devenu obsolète),
  ajout de `menu_facturation.php` + `#ssm_module_content` + bouton "Retour vers Factures"
  (`data-ssm-nav`), même pattern que `Reglement.php`. `FacturesClientController::datatable()` : le
  bouton "Infos" utilise désormais `dt_btn_lien()` (pas `dt_btn()`) - bénéficie du fix nav AJAX déjà
  posé cette session. `Nouvelle_facture.php` (création) : le `window.open()` vers la nouvelle facture
  remplacé par `ssm_nav_ajax(...)` direct (la modale n'est pas un lien `<a data-ssm-nav>`, appel JS
  direct nécessaire). `FactureClientController::suppression()` : `echo '<script>window.close()</script>'`
  (n'avait de sens que pour une fenêtre séparée) remplacé par un vrai retour vers `/FacturesClient`.
  Le bricolage "nouvel onglet + bouton Actualiser l'état" (ajouté la veille pour compenser
  `window.open`) entièrement retiré, devenu inutile.
**Vérifié** : `curl` avec/sans `X-Ssm-Nav` sur `/FactureClient/index/{id}` confirmant fragment (392
lignes, pas de `<html>`) vs page complète (1129 lignes, sidebar/topbar présents) - la facture de test
créée pour le point 2 a aussi servi ici (cards, montants calculés en direct, cloture, suppression).

**Complément du même jour (retour utilisateur après livraison)** : `Reglement.php` (Factures/
Paiements côte à côte) réorganisé une 2e fois :
- Chaque colonne est maintenant un vrai bloc cadré au lieu d'un simple `<div class="col-6">` sans
  cadre - nécessaire pour que l'égalité de hauteur (déjà vraie en interne : `.row` Bootstrap 4 est
  `display:flex`/`align-items:stretch` par défaut, les 2 `.col-6` s'étirent déjà à la hauteur du
  plus grand) devienne VISIBLE (sans cadre, rien ne matérialise la boîte - constaté par capture
  d'écran utilisateur après la 1ère passe : aucune bordure visible du tout). **Piège trouvé** : un
  1er essai avec `.card.card-outline.card-info/success` (même classes que les panels de liste)
  rendait plat, sans cadre visible - cause : `body[data-design] .card .card { ... background:
  transparent; padding-left:0; padding-right:0 }` (`components.css` ~L222) aplatit délibérément
  toute carte **imbriquée dans une autre carte** (évite l'effet "boîte dans une boîte") - or ces 2
  colonnes vivent DANS le `.card` englobant `#reglement_detail_panel`. Fix retenu : `.ssm-situation-
  detail h-100` (déjà utilisé par paires similaires - `bons/portion/Header.php`, "Informations Bons"/
  "Informations Remplacements") - un vrai encadré (fond `--surface-2`, bordure, radius, padding) qui
  n'est PAS une `.card`, donc jamais aplati par cette règle. `.ssm-parallele-col` (`padding-right`/
  `padding-left` supplémentaires en CSS) pour un écart net entre les 2 colonnes, au-delà de la
  gouttière Bootstrap standard.
  **Règle à retenir pour toute future paire de sous-panels dans un panel existant** : ne jamais
  emboîter `.card` dans `.card` (aplati par design), utiliser `.ssm-situation-detail` (ou un
  composant non-`.card` équivalent) pour un encadré visible.
- Réglage/Imprimer/Exporter et le bouton "Ajouter" étaient dispersés à 3 endroits différents (titre
  + toolbelt flottant + barre séparée) dans une colonne étroite - clarifié en 2 lignes par carte :
  ligne 1 = titre + bouton "Ajouter" **icône ET texte** (`ssm-btn-primary`, pas juste une icône -
  demande explicite), ligne 2 = `.ssm-table-toolbar` unique regroupant réglage + imprimer + exporter
  (le bouton réglage n'a PAS besoin d'être dans `.ssm-panel-toolbelt` pour fonctionner -
  `ssm_panel_settings()` cherche `.ssm-panel-settings-btn` par délégation d'événements n'importe où
  dans le panel, la classe toolbelt n'est qu'un helper de positionnement CSS absolu, pas une
  exigence fonctionnelle - utile à savoir pour toute future colonne étroite du même genre).
- `ClientReglement::paiement_reglement()` ne pouvait PAS trier par date/numéro/montant (seul "mode"
  triable côté JS avant retouche) - testé, une tentative de tri par une autre colonne provoque une
  vraie **erreur SQL fatale** (`Unknown column 'date' in order clause` - la requête de base ne
  contient que `mode`/`montant`/`id`, `date`/`numero` sont résolus ligne par ligne EN PHP après
  coup via `info_paiement()`, hors de la requête que `$this->datatable()` trie). Nouvelle méthode
  **`paiement_reglement_optimise()`** : UNE requête `UNION ALL` des 4 méthodes directes (espece/
  piece/operation/carte, même jointure `source='Reglement' AND zone='client'` que
  `info_paiement()`) + avance (jointure sur `client_avance`, résolution DIRECTE en SQL du cas
  courant - mêmes 3 jointures que `ClientAvance::info_avance()` - avec repli PHP sur
  `info_paiement()`/`info_avance()` **inchangées** pour le seul cas non reproduit en SQL : une
  avance chaînée sur une avance antérieure, `av.source='Reglement'`, résolution récursive trop
  fragile à dupliquer). Colonnes de la requête nommées exactement comme les colonnes de la
  DataTable (`date`/`mode`/`numero`/`montant`) pour que `$this->datatable()` (ORDER BY sur le nom
  de colonne) fonctionne enfin sur les 4. `paiement_reglement()`/`info_paiement()`/
  `paiement_modif()` **non touchées**, toujours utilisées telles quelles ailleurs (`cloture()`,
  et comme filet de sécurité ci-dessus) - seul `ReglementClientController::datatable()` appelle
  désormais la nouvelle méthode. **Vérifié exhaustivement** : script de comparaison ligne à ligne
  entre l'ancienne et la nouvelle méthode sur les **2009 règlements réels ayant des paiements**
  (tous types confondus, y compris les 13 avances existantes) - 0 différence.

## 42. Module Achat, sous-module Facturation Fournisseur traité en entier (24/07/2026)

Premier module hors Vente traité avec cette recette (précédé d'une lecture exhaustive préparatoire
du module Achat entier, journalisée dans `PLAN.md`). Périmètre : Impayées/Factures/Règlements/
Avoirs-Avances + le détail d'un Règlement (`ReglementFournisseur`) - l'équivalent quasi-exact,
côté fournisseur, du sous-module Facturation Vente déjà fait (§40-41). Contrairement à la Vente,
**aucun renommage métier requis** ici : le module Achat n'a jamais eu l'inversion Avance/Règlement
que la Vente a eue (Avance = paiement direct, Règlement = rapprochement, déjà cohérent).

- **Totaux Page/Filtré fusionnés en SQL** (règle CLAUDE.md, jusque-là violée sur les 6 tables à
  montant du sous-module) : `FournisseurImpayes::data()`, `FournisseurFacture::data()` (HT+TTC),
  `FournisseurReglement::data()` (à payer/payé/diff - requête déjà groupée par règlement, le total
  filtré s'obtient en enveloppant la requête groupée dans un `SELECT COUNT(*),SUM(...) FROM (...)
  as ssm_filtre`), `FournisseurAvance::data()`, et les 2 sous-tables du détail d'un règlement
  (`factures_reglement()`/`paiement_reglement()` - n'avaient **aucun** total avant, même pas
  l'ancien pattern JS+AJAX séparé). Les 4 méthodes `total_total()` correspondantes (une par
  contrôleur : Impayees/Factures/Reglements/Avoir) **supprimées** (plus jamais appelées) plutôt que
  laissées mortes - choix différent de celui fait sur `AvoirClientController` (Vente) qui les avait
  gardées ; les deux choix sont défendables, pas de règle stricte du projet là-dessus.
- **Nouveau `achat/portion/menu_achat.php`** (sous-nav AJAX, thème `facturation_achat` distinct du
  thème top-niveau `achat` déjà utilisé par `StyleModule::LABELS` pour toute la section Achats du
  menu principal - collision de nommage évitée en choisissant une clé différente), remplace
  `achat/portion/Header.php` (boutons `window.location.href`). Les clés `$type` (impayees/factures/
  reglements/avoir_avance) restent identiques, aucun contrôleur à toucher pour la navigation.
  L'ancienne action `header()` de chaque contrôleur (rendait `achat.portion.Header` en AJAX) n'est
  plus appelée par aucune vue - laissée en place (code mort), même choix que côté Vente.
- Les 4 vues liste + `Reglement.php`/`Header_reglement.php` copiés-adaptés depuis leur pendant Vente
  déjà fait (mêmes classes `.ssm-panel-toolbelt`/`.ssm-table-toolbar`/`.ssm-stat-row`/
  `.ssm-situation-detail` à 2 colonnes pour Factures/Paiements). Dropdown paiement du détail
  Règlement : espece/piece/operation/**avance/avoir** (pas de "carte" côté fournisseur,
  contrairement à la Vente - jamais existé ici).
- Modales redessinées (icône/`data-mode`/field-icon/`ssm-btn`) : `reglement_factures.php`,
  `reglement_avance.php`, `reglement_avoir.php` (bonus : leur `oLanguage.sUrl` pointant vers un CDN
  externe, même piège CORS déjà documenté ailleurs pour `Home.php`/`#data_bon`, remplacé par les
  traductions français en dur), `cloture_reglement.php`, `Nouvelle_facture.php`, `Nouveau_avoir.php`,
  `Nouvelle_avance.php`, `Reporter_reglement.php` (**code mort**, comme son équivalent Vente -
  aucun contrôleur ne le charge, restylé quand même par cohérence). `Nouveau_reglement.php` :
  **volontairement laissé en soumission plein-page classique** (`<form>` + redirection PHP vers le
  nouveau règlement) - seul le visuel a été refait ; le convertir en AJAX aurait demandé de
  faire naviguer le JS vers le détail après un `load_portion()` sans réponse structurée
  (contrôleur actuel), jugé hors scope pour ce chantier (risque > bénéfice pour une action rare).
  Les modales de paiement partagées (`paiement/espece.php`/`piece.php`/`operation.php`, utilisées
  aussi bien par la Vente que l'Achat) étaient **déjà** entièrement ssm-* depuis l'audit modales de
  Facturation Vente (24/07/2026 plus tôt) - rien à refaire ici, gèrent déjà `isset($param
  ['fournisseurs'])` en plus de `isset($param['clients'])`.
- **Bug pré-existant repéré (non corrigé)** : `FournisseurReglement::paiement_reglement()` a
  exactement le même défaut que l'ancien `ClientReglement::paiement_reglement()` (§40 complément) -
  la requête SQL de base n'a pas de colonne `date` (résolue en PHP après coup via
  `info_paiement()`), un tri par cette colonne provoquerait une erreur SQL fatale. **Non
  exploitable en pratique** : la colonne "Date" est déjà `orderable:false` côté JS (comme
  l'ancien code Vente avant sa refonte) - documenté ici pour mémoire, pas corrigé (aurait demandé
  la même requête `UNION ALL` que `paiement_reglement_optimise()`, hors scope de ce chantier
  visuel/totaux).
- **Vérifié** : `php -l` sur les 24 fichiers touchés, chaque page rendue par curl (200, pas de
  Fatal/Warning), chaque endpoint `datatable` avec les nouvelles clés de total dans le JSON
  (`montant_filtre`/`ht_filtre`+`ttc_filtre`/`apayer_filtre`+`payer_filtre`+`diff_filtre`), le
  détail d'un règlement réel (`ReglementFournisseur/index/2`) et ses 2 sous-tables, les modales de
  création/attachement/clôture. Pas de test de mutation réelle en base cette fois (aucune logique
  métier modifiée, uniquement SQL de lecture/totaux et habillage visuel - risque de régression
  fonctionnelle jugé nul).

## 43. Achat Facturation Fournisseur : renommage Règlement→Lettrage, split Avoirs/Avances → Règlement + Avoirs/Déductions (24/07/2026)

Retour utilisateur juste après §42 : "vous avez pas fait la même chose qu'on a fait dans la
facturation vente" - demande explicite d'appliquer le même renommage métier que §41 (Vente) au
module Achat, avec une différence propre à l'Achat que le §41 n'avait pas à gérer : l'ancien onglet
unique `/AvoirFournisseur` ("Avoirs/Avances") mélangeait DEUX concepts distincts
(`fournisseur_avance` = paiement direct réutilisable, `fournisseur_avoir` = avoir/déduction reçu du
fournisseur) - décision prise via 2 questions posées à l'utilisateur (routage, portée) avant
d'exécuter (Plan Mode, chantier jugé assez risqué financièrement pour le justifier) :

- `/ReglementsFournisseur`/`/ReglementFournisseur` (rapprochement factures↔paiements) relabellés
  **"Lettrage"** dans le menu - URLs et logique métier strictement inchangées.
- **Nouvelle route `/AvancesFournisseur`** (nouveau contrôleur `AvancesFournisseurController`) =
  onglet **"Règlement"** : vue unifiée `FournisseurAvance::data_reglement()`, copie fidèle de
  `ClientAvance::data_reglement()` (Vente, §41) adaptée au schéma fournisseur (`fournisseur_avance`/
  `fournisseur_paiement`/`fournisseur_reglement` ont les mêmes colonnes que leur pendant client) :
  - Branche avance directe : `WHERE source != 'Reglement'` (exclut les avances auto-générées par
    une clôture - sous-produit du lettrage, déjà "visible" via le paiement d'origine).
  - Branches paiement : **3 seulement** (espece/piece/operation) - **pas de "carte"** côté
    fournisseur, contrairement à la Vente (ce mode de paiement n'existe pas ici).
  - **Exclusion supplémentaire propre à l'Achat, absente côté Vente** : `fournisseur_paiement.source
    IN ('avance','avoir')` exclu des branches paiement (`avance` pour ne pas compter deux fois la
    même avance déjà montrée par la branche directe ; `avoir`, cas inédit, parce que cet argent
    appartient désormais à un tout autre onglet - voir plus bas).
  - Boutons d'action : reprend le pattern déjà utilisé par `AvoirClientController::datatable()`
    (Vente) - une ligne `type_ligne='paiement'` obtient un `dt_btn_lien()` vers
    `/ReglementFournisseur/index/{id_lettrage}` au lieu d'un bouton "Infos" inerte.
- **`/AvoirFournisseur` (URL et contrôleur conservés, mais fortement simplifiés)** = onglet
  **"Avoirs / Déductions"** pur : nouvelle méthode `FournisseurAvoir::data()` (le fichier modèle
  était vide jusque-là, seul `$table` déclaré), extraite de l'ancienne branche "avoir" de
  `FournisseurAvance::data()` (supprimée après vérification par grep qu'aucun autre appelant
  n'existait). **Détail métier facile à rater, préservé depuis l'ancien code** : un Avoir peut être
  "utilisé" de 3 façons différentes selon son `type` - `Avoir` (transaction=1) s'attache via
  `id_paiement` (pivot `fournisseur_paiement`, depuis un Règlement) ; `Déduction` (transaction=-1)
  s'attache directement via `id_reglement` (même mécanisme que factures/restes,
  `ReglementFournisseurController::factures()`) ; **et** un Avoir peut aussi être consommé via
  `id_remplacement` (attaché à un `PieceRemplacement`, concept documenté dans `CLAUDE.md`, sans
  lien avec un Règlement) - la condition "libre/modifiable" doit vérifier les 3 colonnes à la fois,
  reprise telle quelle (`(id_reglement+id_paiement+id_remplacement)=0`, même astuce de somme que
  l'ancien code combiné, qui fonctionne car chaque colonne ne peut être non-nulle que pour UN seul
  mécanisme d'attachement selon le `type`).
- Vue `achat/Reglement.php` (détail d'un Lettrage) : le libellé du dropdown "Ajouter un paiement"
  pour attacher un règlement existant renommé "Avance"→**"Attacher"** (même mot que la Vente pour ce
  cas précis, évite la redondance avec le nouveau nom "Règlement" de l'onglet) ; le libellé "Avoir"
  reste inchangé (concept distinct, toujours attachable depuis un Lettrage même si son onglet de
  liste a changé).
- **Bug trouvé et corrigé en cours de vérification** : `FournisseurAvoir::data()` avait d'abord des
  alias SQL (`date_avoir`/`type`) ne correspondant pas aux clés attendues par le JS DataTable
  (`date_document`/`type_document`) - `$this->datatable()` (tri `ORDER BY <nom_colonne_JS>`)
  provoquait une erreur SQL fatale dès le premier chargement. Même famille de piège que celui déjà
  documenté ailleurs cette session (alias `date`/`date_document`, §40-41) - detecté immédiatement
  par le test curl de vérification, corrigé avant tout usage réel.
- **Vérifié par comparaison SQL directe sur la base réelle** (pas de données de test créées,
  vérification par lecture seule) : total `fournisseur_avoir` = 81 lignes / 583 418,26 Dhs = Non
  Utilisé (43 200,00, curl `/AvoirFournisseur/datatable?id_reglement=0`) + Utilisé (540 218,26,
  `id_reglement=1`) - somme exacte, aucune ligne perdue ni comptée deux fois. Total paiements
  espèce/pièce/opération attachés à un lettrage (545 lignes / 100 335 170,85 Dhs en SQL brut) =
  intégralement retrouvé dans le filtre "Lettré" de `/AvancesFournisseur/datatable` (Non Lettré et
  Partiel à 0 - cohérent avec l'état réel de cette base, où aucune avance directe non générée par
  clôture n'existe encore ; les 2 seules `fournisseur_avance` existantes sont `source='Reglement'`,
  donc exclues de la branche directe, et déjà attachées via `fournisseur_paiement.source='avance'`).
  `php -l` sur les 8 fichiers touchés/créés, chaque page/fragment AJAX rendu par curl (200, pas de
  Fatal), les modales de création (Nouvelle_avance mode select + espece, Nouveau_avoir) et le
  libellé "Attacher" sur le détail d'un lettrage réel.

## 44. Achat : "Compte Fournisseur" (renommage) + nouvel onglet Grand Livre Fournisseur (24/07/2026)

Retour utilisateur juste après §43 : "tu complète maintenant l'achat, et pour fournisseur tu
renomme par compte fournisseur, et tu ajoute un onglet grand livre comme dans vente le même
principe". Périmètre très large au global (Fournisseur + Alimentation de stock + Bons de Commande +
Comparaison + Dépense) - **séquencé avec l'utilisateur** (2 questions posées, Plan Mode) : Compte
Fournisseur + Grand Livre traités maintenant, le reste (Alimentation/BC/Comparaison/Dépense, déjà
audité en détail lors de la lecture préparatoire du 24/07) devient le prochain chantier.

- **Renommage** : section menu principal "Fournisseurs" → **"Compte Fournisseur"** (`Layout.php`
  L52), même modèle exact que "Compte Client" côté vente (`label`/`lien` inchangés à part le texte,
  `actifs` étendu avec la nouvelle route). Nouveau sous-menu `achat/portion/menu_fournisseur.php`
  (copie de `vente/portion/menu_client.php`) : seulement 2 onglets, **Fournisseurs** + **Grand
  Livre** (la page liste Fournisseurs n'avait jusque-là AUCUN sous-menu, page isolée).
- **Grand Livre Fournisseur** : nouveau `Fournisseur::livre($param=[])`/`solde($param,$quant)`
  (`app/models/fournisseur/Fournisseur.php`), copie fidèle de `Client::livre()`/`solde()`
  (`app/models/client/Client.php:362-772`) - UNION SQL de tous les mouvements comptables du compte
  fournisseur : **Crédit** = `fournisseur_facture` (TOUTES les lignes - contrairement à
  `client_facture`, `fournisseur_facture` n'a pas de notion `id_cloture`/brouillon, donc pas de
  branche miroir "payée cash" comme côté client). **Débit** = espèce/pièce/opération (mêmes 3
  sources que `FournisseurAvance::data_reglement()`, §43 - pas de "carte" côté fournisseur) **+ une
  branche "Avoir" propre à l'achat, absente côté vente** (décision utilisateur confirmée par
  question posée : un avoir attaché à un règlement réduit réellement la dette envers le
  fournisseur, comme un paiement - contrairement aux avoirs client qui vivent ailleurs et n'ont pas
  d'équivalent dans le Grand Livre Vente). Cette branche avoir est datée par
  `COALESCE(fournisseur_paiement.date_decaissement, fournisseur_avoir.date_avoir)` - le
  décaissement n'est renseigné qu'à la clôture du règlement (`ReglementFournisseurController::
  cloture()`, déjà en place). **Piège de signe repéré et évité en comparant au code source avant
  d'écrire la requête** : les tables de flux (`compte_ps_caisse_flux`/`coffre_piece`/
  `compte_bancaire_flux`) utilisent `debit`/`credit` du point de vue de la CAISSE, pas du client/
  fournisseur - un paiement CLIENT s'y enregistre en `credit` (`ReglementClientController::
  espece()` : `'credit'=>montant,'debit'=>0`), un paiement FOURNISSEUR s'y enregistre en `debit`
  (`ReglementFournisseurController::espece()` : `'debit'=>montant,'credit'=>0`, sens inverse) -
  `Client::livre()` lit `flux.credit`, `Fournisseur::livre()` doit lire `flux.debit` : copier le SQL
  vente tel quel sans vérifier ce point aurait produit un Grand Livre avec Débit/Crédit
  systématiquement inversés, sans qu'aucune erreur ne le signale (juste des montants totalement
  faux). Pas de bloc "encours" dans le bandeau solde (concept vente-spécifique : bons non facturés
  d'un état ouvert, aucun équivalent identifié côté achat).
- Nouveau `app/controllers/achat/GrandLivreFournisseurController.php` (copie de
  `vente/GrandLivreController.php`), nouvelle route `GrandLivreFournisseur` (2 déclarations
  `$namespaces['achat']`, `vendor/function.php`). `achat/Fournisseurs.php`/`modal/
  Nouveau_fournisseur.php` redessinés en ssm-* (pas de colonne montant → pas de totaux Page/Filtré
  à ajouter). Nouveau `achat/Livre.php` (copie de `vente/Livre.php`), sans les boutons
  impression/export dédiés (`impression('livre_client',...)`/`/Excel/livre_client` sont des routes
  spécifiques vente, aucun équivalent côté achat - gardés uniquement les boutons génériques
  imprimer/exporter du `.ssm-table-toolbar`, qui fonctionnent sans backend dédié).
- **Bonus fix trouvé en relisant `Layout.php`** : la route `AvancesFournisseur` (créée en §43) avait
  été oubliée des `actifs` du bloc "Facturation" achat - le menu latéral ne restait pas
  surligné/actif sur ce nouvel onglet. Ajoutée au passage.
- **Vérifié par comparaison SQL directe sur la base réelle** (aucune donnée de test créée) : crédit
  total du Grand Livre (100 557 466,37 Dhs, plage large 2000-2030) = somme exacte de 677 factures
  valides sur 678 - la 678e (`fournisseur_facture.id=360`) a `date_facture='0024-08-22'` (année 24
  au lieu de 2024, coquille de saisie réelle dans la base), correctement exclue par le filtre de
  date (`BETWEEN '2000-01-01' AND '2030-01-01'`) - pas un bug du code, une anomalie de donnée à
  signaler/corriger côté saisie si besoin. Débit total (102 281 636,48 Dhs) = somme exacte
  espèce+pièce+opération (9 593,50 + 100 325 577,35 + 1 425 703,45, ces 3 chiffres obtenus
  directement des tables de flux avec `zone='fournisseur'`, PAS de `fournisseur_paiement` - une
  comparaison initiale contre `fournisseur_paiement` seul avait semblé montrer un écart, résolu en
  réalisant que les avances directes non attachées à un règlement n'ont jamais de ligne
  `fournisseur_paiement`, seulement une ligne de flux - la bonne référence est le flux lui-même) +
  520 762,18 Dhs d'avoirs attachés. `php -l` sur les 10 fichiers touchés/créés, `/Fournisseurs` et
  `/GrandLivreFournisseur` (page + fragment + datatable + endpoint `solde`) testés par curl.

## 45. Achat : Alimentation de stock (Alimentations/Bons de Commande/Comparaison) + Dépense (24/07/2026)

Dernier volet du module Achat (avec Fournisseur/Grand Livre §44 et Facturation §42-43, tout le
module Achat est désormais en Phase 4). Demande explicite et un peu différente des chantiers
précédents : **priorité au fond, liberté totale sur la forme** - "fait le nécessaire pour que ce
soit sans loading html juste les json" (= tuer le pattern `total_total()`) et "tu peux faire ce que
tu veux, tu dois pas respecter ce que j'ai fait, juste la logique reste la même" (réorganisation
visuelle/boutons libre, aucune contrainte de fidélité au design existant).

- **Totaux fusionnés en SQL** sur les 5 méthodes concernées : `StocksAlimentation::
  alimentation_between()` (liste Alimentations), `BonCommande::data()` (liste BC), `BonCommande::
  comparaison()` (**7 colonnes chiffrées en une seule requête SUM** - nbr_bc/qte+montant
  demandée/reçue/écart -, la fusion la plus dense de toute la Phase 4 jusqu'ici), `BonCommande::
  data_description()` (sous-tableau produits d'un BC, n'avait **aucun** total avant, même pas
  l'ancien pattern JS+AJAX séparé), `Depense::data()`. Les 4 `total_total()` correspondants
  (Alimentations/BonCommandes/ComparaisonAlimentation/Depense) supprimés - plus aucun total ne
  transite par un fragment HTML/`<script>` séparé dans ce module, uniquement le JSON de la
  DataTable elle-même.
- **Bug réel trouvé et corrigé au passage** (indépendant de la demande, repéré en touchant la ligne
  juste à côté pour y ajouter la fusion des totaux) : `BonCommande::data_description()` faisait
  `$sql . $this->datatable();` - concaténation SANS affectation (`.` au lieu de `.=`), le résultat
  était immédiatement jeté. Conséquence : le tri et la pagination DataTable du tableau "produits
  d'un bon de commande" n'ont **jamais** été appliqués (toujours affiché dans l'ordre naturel de la
  table, sans LIMIT) depuis l'écriture de cette méthode - corrigé en `.=`. Même famille de piège
  (opérateur de concaténation oublié) déjà rencontrée sous une autre forme cette session, toujours
  aussi facile à rater visuellement dans du code dense.
- Nouveau `stocks/portion/menu_alimentation.php` (sous-nav `ssm-module-nav` + navigation AJAX,
  remplace les 3 boutons `window.location.href` qui rechargeaient toute la page à chaque
  changement d'onglet Alimentations/Bons de Commande/Comparaison) - même composant que
  `achat/portion/menu_achat.php`/`menu_fournisseur.php`, thème propre (`alimentation`).
  `achat/Depense.php` n'a PAS reçu ce sous-menu (page unique, pas d'onglets frères dans le menu
  principal - `Layout.php` la liste comme sa propre section "Dépenses", contrairement à
  Alimentation qui regroupe 3 pages sous "Alimentations Stock").
  **Piège évité** : `Depense.php` définissait une fonction JS locale `ajax_json()` (utilisée pour
  peupler dynamiquement le select Catégorie) - ce n'est **pas** une fonction globale de `ajax.js`
  (vérifié par grep : chaque vue qui l'utilise la redéfinit localement, ~30 fichiers dans tout le
  projet) - oubliée lors d'une première passe de nettoyage du fichier, remise en place avant de
  livrer (sinon le select Catégorie serait resté vide silencieusement, aucune erreur JS visible
  sans ouvrir la console).
- Les 5 vues (Alimentations/BonCommandes/Comparaison listes + Alimentation/BonCommande détails)
  passées en `ssm-*` : panel + toolbelt (réglage/plein écran) + `.ssm-table-toolbar` + bandeaux
  `.ssm-stat-row`. Boutons "Ajouter" déplacés en fin de barre de filtres (`margin-left:auto`) sur
  les listes, pour bien les séparer visuellement des filtres eux-mêmes (demande explicite de mieux
  ordonner l'affichage/les boutons). Détails `Alimentation.php`/`BonCommande.php` : toute la
  logique JS (bascule alimentations/descriptions, header AJAX-ifié, clôture/annulation, ajout
  produit) conservée à l'identique, uniquement l'habillage visuel a changé + lien "Retour"
  `data-ssm-nav`. `Depense.php` bug pré-existant déjà documenté (`PLAN.md` §295, filtre fournisseur
  transmis mais jamais affiché, bouton d'action mort `id=""`) **non touché**, hors périmètre de
  cette demande (visuel + totaux uniquement).
- Bug déjà documenté dans l'audit du 24/07 (`montant_ttc_hors` calculé puis jeté dans
  `StocksAlimentation::alimentation_between()`, TVA silencieusement perdue sur le montant d'une
  alimentation sans facture ayant des lignes de description hors-stock) **non corrigé** - hors
  périmètre, cas d'usage marginal, laissé tel quel comme prévu dans l'audit préparatoire.
- **Vérifié par comparaison SQL directe sur la base réelle** (aucune donnée de test créée) :
  `montant_demande_filtre` de la Comparaison (75 695 608,928278 Dhs, 624 lignes) = somme exacte de
  `stocks_bc_description` (SUM(qte*prix) brut) ; total du détail d'un bon de commande réel existant
  (id 382, 2 lignes : Super Sans Plomb 95 + Gasoil Advanced) = 369 302,40 Dhs, somme exacte des 2
  montants unitaires. `php -l` sur les 13 fichiers touchés/créés, les 5 pages + leurs fragments AJAX
  + tous les endpoints datatable/modal/ajax testés par curl (200, pas de Fatal).

## 46. Fix global focus auto-avance Select2 + Dépense directe + réorganisation boutons Alimentation (24/07/2026)

Trois retours utilisateur groupés après §45, dont un bug transversal touchant potentiellement
toute l'appli.

### Bug focus auto-avance (le plus important, portée globale)

Constaté dans la modale "Ajouter Article de Stock" (`stocks/modal/mouvement_alimentation.php`,
champs Produit→Stock→Prix→Quantité) : choisir une option dans le select2 **Stock** renvoyait le
focus en ARRIÈRE sur **Produit** (premier champ du formulaire) au lieu d'avancer sur Prix. Demande
explicite de l'utilisateur : "cette erreur est dans tous les modals... tu fais un principe dans une
fonction JavaScript pour que corriger ça devienne facile" - un seul point de vérité existait déjà
(`ssm_champ_est_actif()`, appelée par `ssm_champ_naviguer()`, elle-même utilisée à la fois par le
raccourci manuel Ctrl+↓/↑ ET par l'avance automatique sur sélection ajoutée en §40), donc le fix a
été fait UNIQUEMENT là, sans toucher aucune modale individuellement - bénéficie automatiquement à
toute l'appli, vente comprise.

**Root cause** : au moment précis où Select2 émet l'évènement `select2:select`, son menu déroulant
vient déjà de se refermer (`instance.isOpen()` renvoie déjà `false`) et le focus réel du navigateur
est reparti sur le conteneur visible du widget (`.select2-container`/`.select2-selection`), PAS sur
le `<select>` caché lui-même que jQuery manipule. `ssm_champ_est_actif()` ne testait que
`document.activeElement === $champ.get(0)` (faux, le `<select>` caché n'a jamais le focus DOM) et
`inst.isOpen()` (faux à cet instant précis) - **aucun** champ du formulaire ne passait donc pour
"actif", `ssm_champ_naviguer()` tombait dans son cas `iActuel===-1` et repartait par défaut sur le
champ d'index 0.

**Fix** (`ssm_champ_est_actif()`, `ajax.js`) : ajout d'un 3e test - si l'élément actif du document
est le conteneur `.select2-container` du champ (ou un de ses descendants, via `$.contains()`), le
champ est considéré actif. Un seul changement, dans la fonction déjà factorisée - aucune modale à
modifier individuellement.

### Dépense : bouton "Ajouter Dépense" manquant

Signalé après §45 : la page Dépense n'avait toujours qu'un CRUD de catégories, aucun moyen de créer
une ligne `achat_depense` directement. Nouvelle action `DepenseController::depense()` + modale
`achat/modal/Nouvelle_depense.php` (Catégorie/Fournisseur/Date/Montant/Description) : insère avec
`type_depense='sans_facture'`, `source='Direct'`, `id_compte=0` - distinct des dépenses
`avec_facture` créées en miroir par `FacturesFournisseurController::modal()` (jamais touchées ici,
`Depense::data()` expose désormais un flag `modifiable` = `type_depense=='sans_facture'`, repris
par `DepenseController::datatable()` pour n'afficher Modifier/Supprimer que sur les dépenses créées
directement - modifier/supprimer une dépense "avec facture" depuis cet écran désynchroniserait
`fournisseur_facture`).

**Piège de test rencontré (curl, pas un bug applicatif)** : premier test avec `id_fournisseur`
laissé vide dans la chaîne `clé=.=valeur/./clé2=.=valeur2` a fait échouer le parsing de TOUTES les
clés suivantes (`date_depense`/`montant`/`description` remontaient "undefined" côté PHP) - propre
au format artisanal de cette chaîne quand une valeur est vide en toute fin de segment, pas un bug
du contrôleur ni du JS réel (qui, lui, sérialise via `tableau()`/POST normal, sans ce problème).
Confirmé en refaisant le test avec une valeur non vide : insertion, modification, récupération et
suppression toutes vérifiées correctes sur la base réelle (ligne de test supprimée après).

### Alimentation (détail) : boutons "Ajouter Article de Stock"/"Ajouter Hors Stock" réorganisés

`stocks/portion/Header_alimentation.php` : les deux boutons "+" (auparavant mélangés aux affichages
en lecture seule "Articles de Stock : X"/"Articles hors Stock : Y") extraits sur une ligne dédiée,
"Ajouter Article de Stock" à l'extrémité gauche, "Ajouter Hors Stock" à l'extrémité droite (visible
seulement si `type=='facture'`, comme avant) - mêmes ids (`#modal`/`#description`) et mêmes routes
JS, aucun changement de logique.

**Vérifié** : `node -c` sur `ajax.js`, `php -l` sur les 7 fichiers touchés/créés, cycle CRUD complet
de la nouvelle Dépense directe testé par curl sur la base réelle, fragment header Alimentation
(id réel 545) confirmé avec les 2 boutons présents et bien libellés.

## 47. Alimentation (détail) : tableau Stock/Hors Stock unifié + retouches header + 2 bugs corrigés (24/07/2026)

3 retours utilisateur après §46, tous sur `Header_alimentation.php` et le tableau détail d'une
alimentation, plus 2 bugs signalés séparément (Dépense, ajout Alimentation).

### Tableau unifié Stock + Hors Stock

Demande explicite : ne plus faire basculer entre 2 vues ("on affiche les deux à la fois"), colonne
Stock à `---` pour une ligne hors stock.

- Nouvelle **`StocksAlimentation::articles($id_alimentation)`** : `UNION ALL` entre `stocks_mouvement`
  (tagué `type_ligne='stock'`, `stock`=nom de l'entrepôt, `tva='---'`) et
  `fournisseur_facture_description` (tagué `type_ligne='description'`, `stock='---'`, `tva` réelle) -
  volontairement une méthode À PART plutôt que de toucher `StocksMouvement::mouvements_source()`
  (méthode partagée, aussi utilisée par les Transferts).
- `AlimentationsController::datatable()` : l'ancien `if($_POST['affichage']==0){...}else{...}`
  (deux branches, deux modèles différents) remplacé par un seul appel à `articles()` + une boucle
  commune qui construit les boutons d'action - le discriminant `type_ligne` pilote le `title` passé
  à `dt_btn()`/`dt_btn_dropdown_confirm()` (`'description'` ou `'alimentation'`), **exactement le
  même mécanisme de routage JS qu'avant** (le JS lit déjà `$(this).attr('title')` pour choisir entre
  `/Alimentations/description` et `/Alimentations/modal` - aucun changement JS necessaire pour ça).
- `stocks/Alimentation.php` : thead fusionné en une seule ligne (Stock/Produit/**TVA** (nouvelle
  colonne)/Prix Unitaire/Quantité/Action), `columns`/`columnDefs` mis à jour, suppression complète
  du champ caché `#affichage` et des 2 handlers de bascule `#alimentations`/`#descriptions`.

**Bug pré-existant trouvé et corrigé au passage** : `FournisseurFactureDescription::description_facture($id)`
(l'ancienne méthode "hors stock seul", maintenant dead code après l'unification mais corrigée par
prudence) avait ses alias SQL décalés d'un cran : `produit as stock, prix as entre, qte as prix, tva
as produit`. Conséquence réelle sur l'ancien tableau "Articles hors Stock" : Prix Unitaire et
Quantité s'affichaient **inversés** - ne fonctionnait que par coïncidence positionnelle côté JS
(les colonnes étaient triées dans le même ordre erroné). Confirmé par grep qu'aucun autre appelant
réel n'existe (un homonyme sans rapport vit sur `ClientFacture`).

### Header_alimentation.php : 3 retouches

1. **Clôturer redevenu un bouton direct** (pas de dropdown-confirm) - "ne pas faire dropdown car on
   a une modal après" : Clôturer ouvre déjà `#afficher_div` (`data-toggle="modal"`), un
   dropdown-confirm par-dessus était une confirmation redondante. Annuler et Supprimer gardent le
   dropdown-confirm (pas de modale ensuite, l'action est immédiate).
2. **Icône Modifier corrigée** : `fa fa-pencil-square-o` (FontAwesome 4, invisible avec le
   FontAwesome 5/6 chargé par l'appli) → `fas fa-edit`, cohérent avec le reste des boutons Modifier
   de l'appli.
3. **Date déplacée sur la ligne du titre** : `Fournisseur - Référence / DateTime` (au lieu d'une
   ligne de titre + un stat "Date" séparé) ; les 2 boutons "Ajouter Article de Stock"/"Ajouter Hors
   Stock" déplacés en bas de header sur une ligne dédiée (gauche/droite) ; la ligne de bascule
   `#alimentations`/`#descriptions` supprimée (plus nécessaire, tableau unifié ci-dessus).

### 2 bugs signalés séparément par l'utilisateur, corrigés au passage

- **`DepenseController::depense()`** : cliquer sur "Ajouter Dépense" pour simplement OUVRIR le
  formulaire vide envoie `param={id:0}` - le contrôleur testait `if($this->param['id']==0)` en
  PREMIER et insérait immédiatement, avant même que l'utilisateur ait rempli le formulaire
  (`Warning: Undefined array key "id_categorie"/"description"/...` puis `Fatal error` sur la
  contrainte NOT NULL de `id_categorie`). Corrigé pour suivre le pattern standard à 4 cas du projet
  (voir `PieceRemplacementController` dans `CLAUDE.md`) : `isset($this->param['montant'])` teste
  d'ABORD si c'est une vraie soumission (montant présent) avant de regarder `id==0` vs `id!=0` pour
  choisir insert/update ; sans `montant`, `id!=0` seul déclenche le `findone()` d'ouverture, `id==0`
  seul (ouverture du formulaire d'ajout) ne fait plus rien.
- **`stocks/modal/alimentation.php`** : tag mal fermé (`<h3>` au lieu de `</h3>`) après le lien
  "Accéder" affiché suite à un ajout réussi d'alimentation - ce flux ("afficher l'alimentation
  ajoutée sans modal, cliquer sur Accéder recharge la page") existait déjà (le lien n'a pas
  `data-ssm-nav`, donc un clic dessus fait une vraie navigation navigateur, pas un chargement AJAX) ;
  seul le tag était cassé.

**Vérifié sur la base réelle** : `articles()` testé sur l'alimentation facture réelle id 545 (2
lignes stock, `tva:"---"`) + une ligne hors-stock de test insérée dans `fournisseur_facture_description`
liée à sa facture (`stock:"---"`, `tva` réelle affichée) - supprimée après. `Header_alimentation.php`
vérifié sur id 545 (Clôturer direct visible) et sur l'alimentation non-facture clôturée id 1 (Annuler
en dropdown visible), titre/date fusionnés dans les 2 cas. `DepenseController::depense()` : ouverture
du formulaire vide (id=0 seul) confirmée sans insertion (compte `achat_depense` inchangé), puis
soumission complète confirmée avec un vrai insert (id 184, supprimé après). **Piège d'outillage
rencontré pendant ce test** (pas un bug applicatif) : Git Bash/MSYS convertit automatiquement les
séquences façon chemin Unix (`/./`) dans les arguments passés à `curl.exe` en chemin Windows
(`C:/Program Files/Git/...`), corrompant silencieusement la chaîne `clé=.=valeur/./clé2=.=valeur2`
envoyée en paramètre - invisible sans `--trace-ascii`. Contournement : passer par un petit script PHP
CLI utilisant l'extension curl (`curl_setopt(CURLOPT_POSTFIELDS, [...])`), qui envoie la chaîne telle
quelle sans passer par l'expansion d'arguments du shell. `php -l` sur les 6 fichiers touchés.

## 48. Alimentation : suppression du clic "Accéder"/message de succès (24/07/2026)

Retour immédiat après §47 : le message "ajoutée avec succès" + lien "Accéder" (et son équivalent
pour Modifier) ne convenait pas - l'utilisateur veut que la modale se ferme direct dès
"Enregistrer" et que la page utile s'affiche tout de suite, sans étape intermédiaire ni rechargement
dur du navigateur (le lien "Accéder" faisait une vraie navigation `<a href>`, donc un F5 complet).

### Marqueur caché commun

`stocks/modal/alimentation.php` : le bloc `elseif($param['modification']) echo '...'; else echo
'... <a href=...>Accéder</a>'` remplacé par un simple marqueur invisible, unique pour les 2 cas :

```php
<div id="alimentation_resultat" data-modifie="<?= $param['modification'] ? '1' : '0'; ?>" data-id="<?= $ajouter; ?>" style="display:none;"></div>
```

`AlimentationsController::modal()` : `$ajouter` ne servait auparavant qu'à la branche ajout (id==0).
Il est maintenant renseigné aussi dans la branche modification (`$ajouter = $id_alimentation;`) pour
que la vue dispose toujours de l'id, quel que soit le cas.

### Côté JS : 2 comportements différents selon la page

- **`stocks/Alimentations.php`** (liste - cas ajout) : le callback de `load_portion()` détecte
  `#alimentation_resultat`, notifie, puis appelle **`ssm_nav_ajax('/Alimentations/index/'+id)`** -
  navigation AJAX (pas de rechargement dur), qui se charge elle-même de fermer/nettoyer toute modale
  ouverte (`$('.modal.show').modal('hide')` + retrait du backdrop, déjà géré en interne par
  `ssm_nav_ajax`) - pas besoin de fermer la modale manuellement avant l'appel.
- **`stocks/Alimentation.php`** (détail - cas modification) : le callback détecte le même marqueur,
  notifie, ferme la modale (`$('#afficher_div').modal('hide')`) puis appelle **`load_header()`**
  (fonction déjà existante sur cette page) pour rafraîchir le header sur place, sans quitter la page.

**Piège évité** : `load_portion(chemin, element, param, callback)` appelle `callback(status)` APRÈS
avoir injecté le HTML reçu dans le DOM (à l'intérieur du `.load()` de jQuery) - `$('#alimentation_resultat')`
est donc bien trouvable au moment où le callback s'exécute, sans `setTimeout`.

**Vérifié sur la base réelle** : ajout d'une alimentation de test (id 548) confirmé via
`data-modifie="0"` + id correct dans la réponse ; modification du même enregistrement confirmée via
`data-modifie="1"` et les valeurs (`livreur`/`note`/`date_alimentation`) bien mises à jour en base -
ligne de test supprimée après. `php -l` sur les 4 fichiers touchés (`AlimentationsController.php`,
`stocks/modal/alimentation.php`, `stocks/Alimentations.php`, `stocks/Alimentation.php`).

## 49. Alimentation : select Stock vide après choix du Produit + auto-fermeture "Ajouter Article de Stock" (24/07/2026)

Deux retours dans la modale `stocks/modal/mouvement_alimentation.php` ("Ajouter Article de Stock").

### Select Stock vide juste après avoir choisi le Produit

Cascade classique : choisir un Produit vide `#id_stock` et le repeuple en AJAX
(`/Alimentations/ajax`, `stocks:true`) selon le type de produit. Problème : le système partagé
d'auto-avance du focus (`ssm_champ_naviguer`/`ssm_champ_focuser`, déjà documenté plus haut dans ce
fichier) avance sur `#id_stock` et **ouvre son menu Select2 immédiatement** dès que l'événement
`select2:select` du Produit se déclenche - AVANT que la requête AJAX de peuplement soit revenue.
L'utilisateur voit donc un menu ouvert mais vide, doit le refermer et recliquer pour voir les stocks
une fois chargés.

Nouvelle fonction partagée **`ssm_select2_rouvrir_si_ouvert($champ)`** (`ajax.js`, juste après
`ssm_champ_focuser`) : si le select2 ciblé est actuellement ouvert, ferme puis rouvre pour forcer
Select2 à re-rendre son panneau avec les options fraîchement ajoutées (Select2 n'observe pas les
`<option>` ajoutées par script pendant qu'il est déjà ouvert - `append().trigger('change')` seul ne
suffit pas). Appelée en fin de `remplir_stock()` (dans `stocks/modal/mouvement_alimentation.php`),
une fois toutes les options insérées.

**Portée volontairement limitée à `stocks/modal/mouvement_alimentation.php`** pour cette passe (la
demande visait explicitement cette modale) - les copies identiques `service/modal/
mouvement_alimentation.php` et `restaurant/modal/mouvement_alimentation.php` ont le même risque
théorique mais n'ont pas été touchées, à traiter si signalé pour ces modules.

### Modale qui ne se referme jamais toute seule après "Ajouter"

Après un ajout réussi, `AlimentationsController::modal()` (branche mouvement, `$mouvement` reste
`[]`) et `description()` (`$DescriptionFacture`, même principe) renvoient toujours un formulaire
vierge (pratique pour enchaîner plusieurs ajouts) - mais rien ne la refermait automatiquement si
l'utilisateur n'y touchait plus, contrairement au principe déjà établi ailleurs dans l'appli
(`ssm_modal_auto_close`, voir `vente/Clients.php`, `vente/modal/Nouvelle_facture.php`,
`bons/ClientBon.php`...). Le handler `#ajouter_mouvement` de `stocks/Alimentation.php` est
maintenant aligné sur ce principe :
- `ssm_notify()` déplacé DANS le callback de `load_portion()` (après le rechargement, pas avant) -
  message conditionné `etait_ajout = (param['id']==0)` (bonus : le message disait toujours "est
  ajouté" même en modification, jamais corrigé jusque-là).
- **Ajout** (`id==0`) : `ssm_modal_auto_close('#afficher_div', 5000)` - barre rouge qui se rétrécit,
  annulée dès que l'utilisateur retouche la modale (signe qu'il enchaîne un autre ajout).
- **Modification** (`id!=0`) : fermeture directe (`$('#afficher_div').modal('hide')`), rien à
  ré-afficher.
- Même traitement pour les 2 branches (stock ET hors stock, `$(this).attr('title')=='description'`).

**Vérifié sur la base réelle** : insertion d'un mouvement de test sur l'alimentation facture réelle
id 545 (produit Gasoil Advanced, stock Citerne 1&2&4, id 13774) confirmée en base, réponse re-render
bien un formulaire vierge (le cas exact où l'auto-fermeture s'applique) - ligne supprimée après.
`php -l` sur les 2 fichiers PHP touchés, `node -c` sur `ajax.js`.

## 50. Renommage "Alimentation de stock" → "Gestion des Bons (Dépotage)" + Dépense en 4e onglet (25/07/2026)

Demande explicite : renommer tout le sous-module Achat "Alimentation de stock" en "Gestion des Bons
(Dépotage)", remplacer le mot "Alimentation" par "Bon de Livraison" partout côté UI, intégrer
Dépense comme 4e onglet du sous-menu (retiré du menu principal), avec une séparation visuelle entre
les 3 premiers onglets et Dépense, dans l'ordre : Bon de Commande - Bon de Livraison - Comparaison -
Dépense.

### Règle appliquée : renommage de LIBELLÉ uniquement, jamais d'identifiant technique

Comme pour tous les renommages métier de cette session (§41 Règlement→Lettrage, §43 Achat,
§44 Compte Fournisseur) : **URLs (`/Alimentations`, `/Alimentation`), noms de classes
(`AlimentationsController`, `StocksAlimentation`), et valeurs `source` en base
(`stocks_mouvement.source='Alimentation'`, `'AlimentationBL'`) restent INCHANGÉS** - seuls les
libellés VUS par l'utilisateur (titres de page, boutons, messages de notification, breadcrumbs,
libellés d'onglets) sont renommés. Changer les URLs/valeurs de `source` casserait des filtres SQL
existants ailleurs dans l'appli (ex. `Mouvements.php` filtre par `source='Alimentation'`) sans
aucun bénéfice utilisateur.

Fichiers touchés pour le renommage textuel : `AlimentationsController.php` (breadcrumb
"ALIMENTATIONS"→"BONS DE LIVRAISON", ×2), `Alimentations.php` (label filtre, bouton "Ajouter",
message succès, titre toolbar + libellé filtre), `Alimentation.php` (lien retour, messages succès
annulation/modification, titre toolbar "Bon de Livraison N°X"), `modal/alimentation.php` (titres
ajout/modification - "Ajouter un Nouveau Bon de Livraison :" reste bien capté par le regex
`/ajout|nouvel|nouvelle|nouveau/` de `ssm_focus_ajout_si_besoin()`, à vérifier à chaque renommage de
titre de modale "ajout"), `modal/cloture.php` (titre, libellé montant, message succès clôture),
`Comparaison.php` (titre toolbar "Comparaison Bon de Livraison"), `Mouvements.php` (libellé de
l'option de filtre "Type Mouvement", `value="Alimentation"` INCHANGÉE côté code).

**Non touché volontairement** : `stocks/BonCommande.php` a un handler `#annulation` avec le message
"L'Alimentation a été annulée avec succès" - vérifié par grep qu'aucun bouton `#annulation` n'existe
dans `header_bc.php` (contrairement à `Header_alimentation.php`), donc ce handler est du code mort
copié-collé, jamais atteignable - laissé tel quel (renommer une chaîne inaccessible n'a aucun effet
utilisateur et risquerait de masquer ce vrai problème si jamais quelqu'un le réactive un jour).
`service/Alimentation.php` et `restaurant/Alimentation.php` : modules complètement différents
(fonctionnalité "alimentation" propre à chacun, sans rapport avec l'achat/stock fournisseur) -
hors périmètre de cette demande.

### Dépense intégrée comme 4e onglet

`app/views/achat/Depense.php` (namespace `achat`, dossier différent de `stocks/`) inclut désormais
`stocks/portion/menu_alimentation.php` avec `$type='depense'` - fonctionne malgré le dossier
différent car `Controller::view()`/`include()` résolvent par rapport au **cwd** (`public/`), pas au
dossier du fichier appelant (voir `Controller::view_modal()` : `'../app/views/' . str_replace(...)`)
- exactement le même mécanisme que les 3 autres vues du sous-module. `DepenseController` reste dans
le namespace `achat` (aucun déplacement de fichier nécessaire, l'intégration est purement une
question de menu/navigation, pas de routage).

`Layout.php` : entrée `'Alimentations Stock'` → `'Gestion des Bons (Dépotage)'`, entrée séparée
`'Dépenses'` supprimée, `'Depense'` ajouté aux `actifs` de l'entrée renommée (menu latéral reste
surligné/actif quand on est sur `/Depense`).

### Séparateur visuel entre groupes d'onglets

Nouvelle classe `.ssm-module-nav-sep` (`components.css`) : simple trait vertical 1px, **ne porte
PAS** la classe `.ssm-module-nav-item` - `ssm_module_nav_positionner_thumb()` et
`ssm_module_nav_direction_par_url()` (`ajax.js`) ciblent explicitement cette classe pour le
positionnement du thumb glissant et la navigation clavier, donc le séparateur est automatiquement
ignoré par ces deux mécanismes sans aucune adaptation JS nécessaire. Inséré entre l'onglet
"Comparaison" et l'onglet "Dépense" dans `menu_alimentation.php`.

**Vérifié en profondeur par curl sur le serveur réel** (login réel, pas de données de test à créer
ni nettoyer - renommage textuel pur) :
- Les 5 pages (`/Alimentations`, `/BonCommandes`, `/ComparaisonAlimentation`, `/Depense`,
  `/Alimentations/index/545`) répondent HTTP 200, aucun `Fatal error`/`Warning` dans le corps.
- Les 4 onglets (Bon de Commande | Bon de Livraison | Comparaison | Dépense) + le séparateur
  apparaissent dans le bon ordre sur les 5 pages, avec le bon onglet actif à chaque fois.
- Navigation AJAX (header `X-Ssm-Nav: 1`) fonctionne sur les 4 pages du sous-menu (fragment renvoyé,
  jamais de page HTML complète).
- Les 4 endpoints `datatable` (Alimentations/BonCommandes/ComparaisonAlimentation/Depense)
  répondent tous un JSON valide avec les bons params.
- Titres de modales (ajout sans `id` envoyé, modification avec `id`, demande de clôture) affichent
  bien "Bon de Livraison" - testé les 2 branches (avec `id` = modification, sans `id` = ajout,
  piège rencontré : envoyer `id=0` seul déclenche la branche "modification produit" du contrôleur,
  pas la branche "ajout" - reproduit l'erreur puis corrigé le test pour matcher exactement ce que
  le JS réel envoie).
- Menu latéral : une seule entrée "Gestion des Bons (Dépotage)", aucun résidu de l'ancien libellé
  "Alimentations Stock" ni de l'ancienne entrée séparée "Dépenses".
- `php -l` sur les 10 fichiers PHP touchés/modifiés.

## 51. Module Trésorerie (Caisse + Banque) traité en Phase 4 (25/07/2026)

Demande explicite ("à toi l'honneur" pour Caisse, "tu fais un bon plan et tu l'exécutes" pour
Banque). Ni Caisse ni Banque n'avaient reçu la moindre passe Phase 4 avant ce chantier (audit
"recherche/HTML sorti du modèle" antérieur, §propre - voir `PLAN.md` ligne ~100 - c'est un chantier
différent, ne touchait ni le visuel ni les totaux fusionnés).

### Bug signalé en premier : DataTable Caisse qui plantait

`CaissePsFlux::libelle()` (`case 'Reglement:client'`) indexait `ClientPaiement::findone($id_source)`
sans vérifier son existence - une ligne orpheline dans `compte_ps_caisse_flux` (paiement supprimé
sans passer par `delete_flux()`, donc jamais nettoyée) faisait `findone(null)` sur le règlement,
qui construit `SELECT * from client_reglement where id=` (valeur vide) → erreur SQL fatale, non
rattrapée, plantant **tout le datatable** dès que la période filtrée incluait la ligne orpheline.
Garde-fous ajoutés (retour `"(paiement/règlement supprimé)"` au lieu de planter) - même schéma
répliqué préventivement partout où il existait (`case 'Reglement:fournisseur'` dans le même
fichier, et `CompteBancaireFlux::libelle()` côté Banque, cas `Reglement`/zone `client` et cas
`Impaye` - ce dernier a effectivement révélé un vrai orphelin en base pendant les tests). 2 lignes
orphelines réelles supprimées via `CaissePsFlux::delete_flux()` (jamais de SQL brut sur une table
de flux financier - il faut recalculer le solde en cache de la zone en même temps).

**Principe retenu pour tout `findone()` sur une clé étrangère venant d'un flux financier historique** :
ne jamais indexer le résultat sans un `if (!$ligne) return '(... supprimé)';` avant - ce type de
ligne peut légitimement pointer vers un enregistrement supprimé des années plus tard, et un flux de
caisse/banque ne doit JAMAIS empêcher l'affichage du reste de la liste à cause d'une seule ligne
cassée.

### Caisse : redesign complet

`Caisse.php` en ssm-* complet (toolbelt réglage+plein écran, toolbar filtres+dropdown Ajouter,
table-toolbar imprimer/exporter génériques - remplace le formulaire cache dédié `/Excel/caisse`),
`header_caisse.php` en `.ssm-stat-row` (solde coloré rouge si négatif via `ssm-stat-negative` - la
logique existait déjà dans l'ancien code mais n'était jamais appliquée au DOM, dead code corrigé
au passage), les 3 modales (`operation_caisse.php`, `transfert_caisse.php`,
`coffre/modal/nouvelle_operation.php`) avec icône/`data-mode`/field-icons/`ssm-btn`.

Total Page/Filtré fusionné dans `CaissePsFlux::caisse_between()` (`debit_filtre`/`credit_filtre`
dans le même JSON, `ssm_table_totaux_multi()`) - remplace le round-trip séparé `/Caisse/total_total`
(contrôleur + vue `totaux_caisse.php` supprimés). Au passage, l'ancien `count($query->fetchAll())`
(rapatriait toutes les colonnes de toutes les lignes juste pour compter leur nombre) remplacé par un
vrai `COUNT(*)` SQL - optimisation demandée explicitement, gain réel en plus de la fusion. Code mort
supprimé : `CaisseController::export()`/`telechargement()` (données factices "Dupont/Martin/Durand"
codées en dur, sans rapport avec le vrai export `/Excel/caisse`, qui reste actif).

**Bug PHP8 trouvé en testant** : titres "Ajouter"/"Modifier" inversés dans `operation_caisse.php`
(`if ($type==0)`) et `transfert_caisse.php` (`if ($id_destination==0)`) - `$type`/`$id_destination`
valent `""` par défaut pour un ajout, et `"" == 0` était `true` en PHP7 mais est **`false` en PHP8**
(changement de règle de comparaison lâche entre chaîne non-numérique et entier) - le serveur tourne
en PHP 8.0.30, donc ces 2 titres affichaient "Modifier" même en ajout, et cassaient au passage
l'auto-focus du 1er champ (`ssm_focus_ajout_si_besoin()` cherche "ajout/nouveau" dans le titre).
Corrigé en comparant `$id==0` partout, comme le reste de l'appli. **Réflexe à généraliser** : toute
comparaison `$variable == 0` où `$variable` peut valoir `""` par défaut est suspecte sur ce projet
(PHP 8.0.30) - préférer `$id==0` (int déjà fiable) quand disponible.

### Banque : redesign visuel SANS toucher à la navigation (décision assumée)

`CompteBancaire.php`/`OperationEnAttente.php`/`PieceImpayee.php` passés en ssm-* (mêmes composants
que Caisse), `portion/menu.php` (sélecteur de compte bancaire + solde + les 3 boutons de
navigation + bouton Ajouter partagé) restylé en `ssm-panel-toolbar`/`ssm-stat-row`/`ssm-btn`.

**Décision explicite de NE PAS convertir la navigation** (actuellement un `<form method="POST">`
caché soumis en JS vers l'une des 3 URLs, rechargement de page complet) **en AJAX `ssm-module-nav`**
comme le reste de l'appli redessinée. Raison : ces 3 pages sont fortement couplées - le sélecteur de
compte bancaire et le solde doivent survivre au changement d'onglet (actuellement via le POST
complet qui repasse `id_compte_bancaire`/`date_prevu`), il y a un sous-tableau "pièces de remise"
partagé, et le bouton `#nouveau` des 3 pages route vers la MÊME action (`/OperationEnAttente/modal`)
quelle que soit la page d'où on l'ouvre. Convertir proprement aurait exigé de sortir le sélecteur de
compte/le solde/le bouton Ajouter de la zone remplacée par la nav AJAX (les rendre persistants entre
les 3 onglets, avec un binding JS namespacé `.off('change.x').on('change.x', ...)` pour éviter
d'empiler des handlers à chaque navigation) - faisable (analysé en détail, voir le paragraphe "reste
à faire" dans `PLAN.md`) mais risqué à valider sans navigateur sur un module qui manipule de
l'argent réel. Le mécanisme actuel **fonctionne correctement**, ce n'est pas un bug - juste une
architecture plus ancienne que le reste de l'appli. Laissé tel quel, documenté comme chantier
séparé.

Total Page/Filtré fusionné dans les 3 méthodes concernées :
- `CompteBancaireFlux::data()` : `debit_filtre`/`credit_filtre` (colonnes séparées, `<span>` couleur
  retiré du HTML - couleur appliquée en CSS via `columnDefs` `className` côté vue, même convention
  que `Client::livre()` - nécessaire pour rester compatible avec `ssm_table_totaux_multi()` qui lit
  la valeur texte brute de la cellule).
- `CompteBancaireFlux::en_attente()` : une seule colonne "montant" mélange débit/crédit (distingués
  par une classe CSS de couleur selon le signe) - PAS réductible à `ssm_table_totaux_multi()` (qui
  suppose une colonne dédiée par montant) : le "Total Page" reste calculé en JS (comme avant,
  distinction par couleur dans la chaîne HTML), seul le "Total Filtré" est fusionné en SQL
  (`SUM(CASE WHEN credit=0 THEN debit+credit ELSE 0 END)`/l'inverse, même logique que la boucle PHP
  d'origine) et lu via `ssm_stat_set_montant()` sur l'événement `xhr.dt`.
- `EnvoiRemisePiece::data_impaye()` : la branche `id_remise==0` groupe par pièce (`GROUP BY p.id`) -
  le COUNT/SUM fusionné doit envelopper la requête groupée dans une sous-requête
  (`SELECT COUNT(*), SUM(montant) FROM (... GROUP BY p.id) as t`), pas l'appliquer avant le GROUP
  BY. Remplace au passage un `load_total_total()` appelé côté JS mais **jamais défini** dans
  `PieceImpayee.php` - le "Total Filtré" y était déjà silencieusement cassé avant ce chantier.

Les 2 anciens `total_total()` (contrôleurs `CompteBancaireController`/`OperationEnAttenteController`,
retournaient du `<script>` inline - un pattern encore plus ancien que le JSON séparé de Caisse) sont
supprimés.

**3 bugs supplémentaires trouvés en testant, même famille "orphelin non gardé"** :
- `EnvoiRemisePiece::data_impaye()` : `$piece['id_piece']` (alias `p.id`) peut être `NULL` si la
  `coffre_piece` jointe a été supprimée - interpolé tel quel dans une sous-requête
  (`find_attribut('max(id)...', "... where id_piece=$id_piece ...")`) ça donnait
  `id_piece= and statut=-1` → erreur SQL fatale. Reproduit avec un vrai orphelin trouvé en base
  (branche `id_remise=1`, montant 0, toutes les colonnes `null`) - corrigé avec un
  `and $piece['id_piece']` avant d'exécuter la sous-requête.
- `CompteBancaireFlux::libelle()` cas `Impaye` : même famille, `$Piece->findone($ligne['id_piece'])`
  pouvait renvoyer `false` (pièce supprimée) - `$piece['source']` sur `false` émettait un warning.
  Corrigé avec un garde-fou `if (!$piece) return '(pièce supprimée)';`.
- `CaisseController::operation_banque()`, `CompteBancaireController::annuler()` et
  `OperationEnAttenteController::modal()` (les 3 branches "suppression") : `find_attribut(...)[0]['id']`
  sans vérifier que le tableau n'était pas vide - cas NORMAL quand l'opération banque supprimée
  n'a pas de ligne caisse liée (la majorité des opérations banque pures) - émettait un warning
  "Undefined array key 0" à chaque suppression. Les branches "modification" de ces mêmes
  contrôleurs avaient déjà le bon réflexe (`isset($flux_caisse[0]['id'])`) - seules les branches
  "suppression" avaient été copiées-collées sans le garde-fou ; corrigé aux 3 endroits avec le même
  motif que les branches modification.

**Vérifié en profondeur par curl sur le serveur réel** : les 4 pages (Caisse, CompteBancaire,
OperationEnAttente, PieceImpayee) répondent 200 sans Fatal/Warning ; les 4 datatables (avec les 2
variantes réelles `id_remise=0`/`id_remise=1` pour PieceImpayee, la seconde ayant révélé l'orphelin
réel) renvoient un JSON valide avec les totaux fusionnés ; `/CompteBancaire/solde` fonctionne ;
cycle complet ajout→vérification en base (ligne + flux miroir le cas échéant)→suppression→
vérification du nettoyage testé pour : une opération de caisse (id 51), un transfert entre caisses
(id 1371), une opération banque directe (id 8626 puis 8627, pour re-tester après le fix du warning
"Undefined array key 0") - toutes les données de test supprimées après vérification. `php -l` sur
les ~16 fichiers PHP touchés/créés/modifiés.

## 52. Module Coffre, sous-groupe Chèque/LCN traité en Phase 4 (25/07/2026)

Demande explicite ("n'importe quel principe déjà fait doit être appliqué... on fait tout cumulé,
vas-y"), avec UN exemple précis donné par l'utilisateur - le bouton "Remplacer" (`Piece.php`, liste
"non traité") qui rechargeait toute la page - mais consigne explicite de traiter tout le sous-groupe,
pas seulement l'exemple.

**Bug corrigé exactement comme demandé** : `.remplacer`/`.envoyer` (`Piece.php`) utilisaient un vrai
`<form>`+`.submit()` vers `PieceController::nouveau_remplacement()`/`nouveau_envoi()`, qui faisaient
un `$this->redirect(...)` serveur (rechargement complet) - remplacé par un POST AJAX (`$.post`) qui
renvoie du JSON (`{"id":...}`), suivi d'un `ssm_nav_ajax()` vers la page créée. **Pattern réutilisable
partout où un bouton doit créer un enregistrement PUIS naviguer vers sa page de détail** : le
contrôleur `echo json_encode(['id'=>$id])` au lieu de `redirect()`, le JS fait
`$.post(url, data, function(reponse){ ssm_nav_ajax('/Cible/index/' + reponse.id); }, 'json')`.

**Même principe étendu à tout le reste du sous-groupe trouvé en auditant** (l'utilisateur avait
prévenu que l'exemple n'était qu'un exemple) : `PieceRemplacementController::cloture()`/`delete()`/
`report()` déclenchaient encore de vrais `<a href>`/`<form>` (`header_remplacement.php`) →
`redirect()` serveur - convertis en actions AJAX pures (lecture via `$this->param` au lieu de
`$GLOBALS['paths'][3]`/`[4]`), pilotées par des handlers JS déjà à moitié présents dans
`Remplacement.php` (`#cloturer`/`#annuler_cloture`) mais jamais branchés car le HTML utilisait
d'autres ids - alignés. **Piège à repérer sur d'anciens modules** : un handler JS qui semble mort
(jamais appelé) n'est pas forcément du code à supprimer - vérifier d'abord si le HTML qui devrait le
déclencher n'a simplement pas le bon id/la bonne classe.

Sous-menu `menu_piece.php` converti en `ssm-module-nav` (remplace 4 boutons `window.location.href`).
`Piece.php`/`Remplacements.php`/`Remplacement.php`/`Archive.php`/`Envois.php` entièrement redessinés
en ssm-*. `EnvoiBanque.php` (détail d'un envoi, 945 lignes, logique remise/pièces dense) traité de
façon plus prudente comme Banque (§51) : coquille extérieure ssm-* seulement, logique interne non
restructurée (pas de navigateur pour valider visuellement une zone aussi dense).

Total Page/Filtré fusionné en SQL dans `Piece::data()`/`archive()`, `CoffreRemplacement::data()`,
`EnvoiBanque::data()` (3 anciens `total_total()` supprimés). Modales `nouvelle_piece.php`/
`nouveau_envoi.php`/`nouvelle_remise.php`/`info_envoi_nouvelle.php` avec icône/`data-mode`/
field-icons/`ssm-btn`.

**Bugs trouvés au passage** (même famille "findone() non gardé" que §51) : `Piece::source()` (cases
Remplacement/PieceRemplacement/Reglement/Carburant) + une variable `$id_mode`/`$selected` non
définie dans 3 fichiers (warning PHP8, pré-sélection qui ne s'appliquait jamais - code mort retiré).

**Vérifié en profondeur par curl** : les 5 pages + `EnvoiBanque.php` détail répondent sans
Fatal/Warning ; les 4 datatables renvoient un JSON valide avec totaux fusionnés ; cycle complet
testé pour Remplacer (id 18, 19), Clôturer/Annuler la clôture (id 15, remis à l'état initial),
Reporter (id 15, date remise à NULL après test), Annuler le remplacement (créé puis supprimé) -
toutes les données de test nettoyées après vérification.

## 53. Module Coffre : CMI + Carte Fournisseur traités en Phase 4 (25/07/2026)

Suite directe de §52 (même demande globale "on fait tout cumulé" sur le module Coffre en entier).

**Insight clé, réutilisable dès qu'un module a une architecture "un seul modèle partagé, plusieurs
contrôleurs discriminés par une colonne"** : `CMI` et `Carte Fournisseur` sont deux sous-groupes
d'écrans quasi identiques (Remises/Comparaisons/Ticket) qui partagent EXACTEMENT le même modèle
`Telecollecte` (discriminé par `id_fournisseur=0` pour CMI vs `!=0` pour Carte Fournisseur, dans
`TypeCarte`/`carte_telecollecte`). Une seule passe de fusion Total Page/Filtré sur les 3 méthodes du
modèle (`data_cmi()`, `data_comparaison()`, `data_ticket()`) profite AUX DEUX sous-groupes en même
temps - mais la suppression des `total_total()` doit quand même être faite contrôleur par
contrôleur (6 contrôleurs : 3 CMI + 3 Carte Fournisseur), puisque chacun garde sa propre action
morte à retirer. Avant de dupliquer un travail de fusion sur un groupe d'écrans qui se ressemblent,
vérifier s'ils ne partagent pas déjà le même modèle en arrière-plan.

Détail des fusions dans `Telecollecte.php` :
- `data_cmi()` : colonnes montant/commission/net → `montant_filtre`/`commission_filtre`/
  `net_filtre`.
- `data_comparaison()` : requête UNION (deux SELECT combinés) - le COUNT/SUM fusionné doit envelopper
  toute l'UNION dans une sous-requête (`SELECT COUNT(*), SUM(...) FROM (UNION...) as u`), même
  principe que `EnvoiRemisePiece::data_impaye()` (§51) pour les requêtes `GROUP BY`.
- `data_ticket()` : avant ce chantier, `iTotalRecords`/`iTotalDisplayRecords` étaient déjà toujours
  égaux (pas de distinction page/filtré) - fusionné en une seule requête `COUNT(*)`+`SUM()` qui sert
  aussi de source aux deux totaux, `montant_filtre`/`telecollecte_filtre`.

`CompteBancaireFlux::tom_card()` (sous-module Recouvrement/Suivi, propre à Carte Fournisseur, PAS
partagé avec CMI) fusionné à part : `montant_filtre` (clé imposée par `ssm_table_totaux()`, qui lit
un nom de clé FIXE côté JS - contrairement à `ssm_table_totaux_multi()` où le nom de clé est un
paramètre `cleFiltre` libre par colonne ; à vérifier dans `ajax.js` avant de choisir un nom de clé
JSON pour une table à colonne de montant unique).

Sous-menus `cmi/portion/menu.php` et `carte_fournisseur/portion/menu.php` convertis en
`ssm-module-nav` (au passage, coquille "Tikcets" corrigée en "Tickets" dans les deux). Les 6 pages
(`cmi/Remises.php`/`Comparaisons.php`/`Ticket.php`, `carte_fournisseur/Remises.php`/
`Comparaisons.php`/`Ticket.php`) + `carte_fournisseur/suivi.php` (Recouvrement, jusque-là jamais
retouché) entièrement redessinées en ssm-* (toolbelt/toolbar/table-toolbar/stat-row). Les 4 modales
(`nouvelle_telecollecte.php`/`ticket.php` dans les deux dossiers - fichiers distincts malgré le nom
identique, pas de duplication à fusionner car les logiques JS internes diffèrent légèrement) avec
icône/`data-mode`/field-icons/`ssm-btn`, en préservant strictement la logique JS interne existante
(bascule Recharge/Location TPE de `carte_fournisseur/modal/nouvelle_telecollecte.php`, remplissage
AJAX de l'appareil via `/CompteZone/appareil`).

6 anciens `total_total()` supprimés (`CmiRemisesController`/`CmiComparaisonsController`/
`CmiTicketController`/`CarteFournisseurRemisesController`/`CarteFournisseurComparaisonsController`/
`CarteFournisseurTicketController`).

**Bug trouvé au passage** (même famille "findone()/find_attribut() non gardé" que §51/§52) :
`CmiRemisesController` (branches modification ET suppression) indexait
`find_attribut(...)[0]['id']` sans vérifier que le tableau n'était pas vide - corrigé avec le même
garde-fou `isset($flux_cmi[0]['id'])` déjà établi ailleurs.

**Vérifié en profondeur par curl** : les 7 pages répondent sans Fatal/Warning ; les 7 datatables
(avec de vrais paramètres DataTables server-side complets - `order[0][column]`, `columns[i][data]`,
`search[value]`, sinon `Model::datatable()` lève un Fatal en tentant de construire `ORDER BY`/
`LIMIT` sur des clés absentes) renvoient un JSON valide avec tous les totaux fusionnés attendus.
Aucune donnée de test créée (vérification en lecture seule sur la base réelle).

Avec §52+§53, le module Coffre est désormais entièrement traité en Phase 4 (Chèque/LCN, CMI, Carte
Fournisseur, Vignette - cette dernière déjà faite plus tôt dans la même session).

## 54. Module Stock traité en Phase 4 + citernes en_stock + Service renommé "Base Article" sous Réglages (25/07/2026)

Demande explicite ("tu attaques le module stock... tu fais tout le nécessaire comme on fait
toujours dans les modules, ne rien oublier"), avec 2 volets distincts : (1) le module Stock
lui-même, (2) le renommage/déplacement du module Service.

### Stock (Produits/Mouvements/Transferts)

`stocks/portion/menu_stock.php` converti en `ssm-module-nav` (remplace 3 boutons
`window.location.href`). Les 3 pages (`Produits.php`/`Mouvements.php`/`Transferts.php`) + la page
détail `Transfert.php` (traitée en shell prudent, comme `EnvoiBanque.php` §52 - logique interne de
mouvements non restructurée) redessinées en ssm-*. Total Page/Filtré fusionné dans
`StocksProduits::data()` (colonne Montant, **jamais fusionné avant** - le "Total Filtré" n'existait
pas du tout côté Stock), `data_mouvement()` et `mouvement_between()` (colonnes Entrée/Sortie,
requêtes `GROUP BY` - COUNT/SUM en sous-requête, même principe que §51/§52).

**Ajout demandé** : chaque citerne de carburant affiche son `en_stock` (`carburant_citerne.stock`)
en bandeau `.ssm-kpi-row` AU-DESSUS du tableau des produits lubrifiants dans `Produits.php` - même
source que `ParaCiterneController` (`app/models/parametre/Citerne.php`), lecture seule, couleur
selon le taux de remplissage (rouge <20%, neutre <50%, vert au-delà). Le DataTable des produits
lubrifiants lui-même **n'a pas été touché structurellement** (colonnes identiques), conformément à
la consigne "le datatable reste pour les produits lubrifiant comme il est".

**Bouton "Ajouter" retiré de Stock** (retour utilisateur explicite) : `Produits.php` perd son
bouton d'ajout de nouveau produit/service - la création se fait désormais exclusivement depuis
Base Article (voir plus bas). Le handler de soumission de la modale (`#ajouter_produit`) est
**conservé** (nécessaire pour "Modifier", qui reste disponible depuis Stock).

**Bug de navigation en dur trouvé et corrigé au passage** (même famille que tout ce chantier
Phase 4) : `Transfert.php`/`Header_transfert.php` avaient encore `window.location.href` (bouton
retour, suppression) et un lien "Accéder" à cliquer après création d'un transfert (même anti-motif
que Alimentation §47) - convertis en navigation AJAX (`ssm_nav_ajax`), `TransfertsController::
supprimer()` renvoie du JSON au lieu de `redirect()`. `toastr.error(...)` utilisé par erreur pour
des messages de SUCCÈS (annulation/suppression) - corrigé en `ssm_notify(msg, 'success')`.

### Service → "Base Article" (sous-module de Réglages)

**Décision d'architecture** : ne pas dupliquer la logique CRUD. `stocks_produits` a toujours été
la table commune aux lubrifiants (`type_vente='produit'`) ET aux services (`type_vente='service'`)
- seul `ProduitsController`/`ServicesController` diffèrent par le filtre et la modale utilisés.
Base Article devient la SEULE page qui permet de CRÉER un nouvel article (produit ou service) ;
Stock (Produits.php) reste un pur outil de consultation du stock (bouton Ajouter retiré, voir
ci-dessus).

- `Layout.php` : entrée top-level `cle='service'` supprimée, nouvel enfant "Base Article" ajouté
  sous `cle='interne'` (Réglages), `lien=>'/Services'`, `actifs=>['Services','BaseArticleProduit']`.
- **2 registres de routing à mettre à jour, PAS UN SEUL** (piège à retenir) : `namespace_resolve()`
  (`vendor/function.php` ~L272, résout `/BaseArticleProduit` → `App\controllers\service\
  BaseArticleProduitController`) ET `left_bar()` (même fichier ~L545-576, determine quel item du
  menu latéral est actif/surligné) - les DEUX tableaux `$namespaces[...]` sont indépendants et
  doivent contenir les mêmes contrôleurs, sous peine de menu qui ne se surligne pas correctement
  même si le routing fonctionne.
- Nouveau sous-menu partagé `service/portion/menu_base_article.php` (`ssm-module-nav`, 2 onglets :
  Services / Produits).
- **Nouveau contrôleur minimal `BaseArticleProduitController`** (`app/controllers/service/`) :
  UNIQUEMENT une méthode `index()` qui rend la page - `datatable()` et `modal()` ne sont PAS
  dupliqués, la vue `service/BaseArticleProduit.php` pointe directement ses appels AJAX vers
  `/Produits/datatable` et `/Produits/modal` (contrôleur Stock existant, réutilisé tel quel). Un
  modal/datatable n'a pas besoin d'appartenir "à" une seule page pour être appelé depuis plusieurs
  - retenir ce principe pour toute future page qui doit exposer un CRUD déjà écrit ailleurs, sans
  dupliquer le contrôleur.
- `service/Services.php` redessiné en ssm-* (toolbelt/toolbar), sous-menu Base Article inclus,
  logique de rapport de ventes interne inchangée. Modale `service/modal/service.php` : icône/
  `data-mode`/`ssm-btn`.

**Vérifié par curl** : `/Produits`, `/Mouvements`, `/Transferts`, `/Transferts/index/{id}`,
`/Services`, `/BaseArticleProduit` (page + nav AJAX `X-Ssm-Nav`) répondent sans Fatal/Warning ;
les datatables renvoient un JSON valide avec totaux fusionnés ; page d'accueil confirme "Base
Article" présent sous Réglages et l'ancien menu top-level "Services" disparu. Aucune donnée de
test créée (vérification en lecture seule sur la base réelle, 4 citernes réelles confirmées dans
le bandeau).

## 55. Module Statistique > Numérique traité en Phase 4 (25/07/2026)

Demande explicite ("tu as tout ce qu'il faut pour faire un bon design"), sans détail supplémentaire
- module entièrement legacy avant ce chantier (aucun composant `ssm-*`, pas de `#ssm_module_content`,
CDN externe `oLanguage.sUrl`, recherche désactivée, 3 onglets internes en boutons `btn-outline-*`
pilotés par une classe `.menu`/`.menu_active` maison).

**Redesign visuel** : `Numerique.php` reconstruit autour de `.ssm-tabs`/`.ssm-tabs-link` (onglets
Bootstrap4 pill `data-toggle="pill"`, remplace le commutateur `.menu` fait main) - **pas**
`ssm-module-nav` ici car les 3 sections (Résultat/Ventes/Achats) sont des vues internes à la MÊME
page, pas une navigation entre URLs différentes (`ssm-module-nav` reste réservé à la navigation
cross-page AJAX). Cards → `ssm-panel` + `ssm-panel-toolbelt` (réglage/plein écran, un jeu par
panel : `#stat_resume_panel`/`#stat_vente_panel`/`#stat_achat_panel`), `small-box` du Résumé →
`.ssm-kpi-row`, pieds de tableau → `.ssm-stat-row--fin`. `oLanguage.sUrl` (CDN externe) retiré en
passant les 2 DataTables sur `init_datatable()` (langue FR en dur, recherche activée par défaut -
elle était désactivée sans raison apparente).

**Fusion Total Page/Filtré (le point le plus important)** : `Statistiques::vente()`/`achat()`
**n'avaient JAMAIS eu de "Total Filtré" dans leur JSON** - il venait d'un mécanisme séparé et
fragile : `vente_total()`/`achat_total()` (mêmes requêtes SANS le `GROUP BY`, un seul `fetch()`)
appelées depuis `StatistiqueController::resume()`, injectées dans `home/portion/resume.php` qui
posait `$('#qte_total_vente')` etc **par un `<script>` chargé dans un AUTRE onglet** (couplage
caché entre l'onglet "Résultat" et les pieds de tableau des onglets "Ventes"/"Achats"). Remplacé
par le principe standard : COUNT+SUM fusionnés dans la même requête que la page (sous-requête,
`GROUP BY`+`HAVING` déjà présents), clés `qte_filtre`/`ht_filtre`/`ttc_filtre` renvoyées dans le
JSON, lues par `ssm_table_totaux_multi()` directement dans `Numerique.php`. `vente_total()`/
`achat_total()` supprimées, `resume.php` ne pilote plus que les 3 KPI du Résumé (Ventes/Achats/
Différence).

**Bug métier trouvé en fusionnant** : `achat_total()` utilisait par erreur le `WHERE` de
`vente_total()` (`m.source!='Verification'`) au lieu de celui d'`achat()` (`m.source='Alimentation'`)
- le "Total Filtré" affiché pour les Achats ne correspondait **pas** au même jeu de lignes que le
tableau lui-même. En dérivant le total directement de la requête de `achat()`, le bug disparaît de
facto (même WHERE, forcément cohérent) - **réflexe à généraliser** : fusionner le total dans la
requête de la table élimine par construction ce genre de divergence entre deux requêtes
censées porter sur le même jeu de données.

**Autres bugs corrigés** (même famille que tout ce chantier) :
- `StatistiqueController::resume()` : variable variable `$$type_vente` (`$vente=$$type_vente['vente'];`)
  qui ne fonctionnait que par coïncidence de nommage entre les valeurs du `<select>` (`carburant`/
  `ps`) et les variables locales `$carburant`/`$ps` - `$produit`/`$service` n'étaient jamais
  atteignables depuis l'UI. Remplacé par un `if`/`elseif` explicite.
- `encaissement()` : une dizaine de `find_attribut(...)[0]['montant']` non gardés contre `NULL`
  (agrégat sur ensemble vide) - `?: 0` ajouté partout, évite les additions `null+nombre` (warning
  PHP8) et un total d'encaissement faussé.
- Colonne "Action" (bouton info `fa fa-info`) supprimée dans `vente()`/`achat()` : générée côté
  modèle mais **aucun handler JS ne l'a jamais câblée** (bouton mort depuis l'origine) - retirée
  plutôt qu'inventée une fonctionnalité sans spec.
- Code mort nettoyé : `$this->Statistique` (propriété jamais assignée), `$produits` (passé à la
  vue mais jamais lu), `$ComptePs = new ComptePs()` (instancié, jamais utilisé) dans
  `StatistiqueController`.
- `count($query->fetchAll())` ×2 remplacés par `COUNT(*)` (même sous-requête que la fusion des
  totaux, un seul endroit à corriger pour les deux).

**Hors périmètre, signalé mais non traité** : `app/controllers/home/StatistiqueController.php` +
`app/views/home/Statistique.php` sont un doublon mort complet (mêmes méthodes, jamais routé) -
laissé en l'état, à nettoyer dans un futur passage de ménage. `Graphique.php` (sœur du menu
Statistique, `/StatistiqueGraphique`) a son propre design ad hoc (variables CSS `--primary` etc,
différent de Phase 4) - **non touché** dans ce chantier (l'utilisateur a nommé `/Numerique`
explicitement) ; pas de sous-menu partagé créé entre les deux pages pour cette raison, un lien
croisé en AJAX aurait été risqué sans traiter `Graphique.php` en même temps.

**Vérifié par curl** : `/Statistique` (page) et les endpoints `vente`/`achat`/`resume`/
`encaissement` répondent 200 sans Fatal/Warning ; les 2 datatables renvoient un JSON valide avec
`qte_filtre`/`ht_filtre`/`ttc_filtre` ; recherche testée (`TRAX` → 1/78 résultats, totaux filtrés
recalculés en conséquence). Aucune donnée de test créée (lecture seule sur la base réelle).

## 56. Module Statistique > Graphique : vraie refonte + nouveaux onglets Produit/Service (25/07/2026)

**Contexte du déclenchement** : les règles CSS globales ajoutées cette session (`components.css`,
`body[data-design] .input-group`/`.card`/etc appliquées site-wide) sont entrées en collision avec
le `<style>` ad-hoc de `Graphique.php` (variables `--primary` locales, réécriture de `.card`/
`.card-header`/`.bg-gradient-*`) et ont cassé sa mise en page - **premier cas cette session où le
CSS global d'un module déjà traité casse visuellement un module PAS ENCORE traité** (jusque-là
l'inverse : l'ancien style cassait sur les pages neuves). Retour utilisateur explicite : pas un
patch, une **vraie refonte**, en s'appuyant sur `.ssm-tabs` si pertinent, "tu changes tous les
charts", et ajout d'un onglet Produit(Lubrifiant)/Service - inexistant jusqu'ici - avec le backend
nécessaire. Consigne explicite : comprendre l'existant AVANT de redessiner (exploration exhaustive
faite via agent avant tout code, contrôleur 695 lignes + vue 1874 lignes lues intégralement) - voir
le plan approuvé dans `.claude/plans/` de la session pour le détail complet du raisonnement.

### Structure : 4 onglets `.ssm-tabs` (pas `ssm-module-nav`)

Carburant / Comparaison / Produit (Lubrifiant) / Service - chacun `.ssm-tabs-link` (pill Bootstrap4
`data-toggle="pill"`, même mécanisme que Numérique §55) car ce sont des vues internes à LA MÊME
page/URL, pas une navigation cross-page (`ssm-module-nav` reste réservé à ça). Chaque onglet a ses
**propres filtres auto-suffisants** (Du/Au/Année dédiés par onglet) plutôt que des filtres globaux
ambigus partagés entre sections sans rapport (c'était le cas avant : un seul jeu de filtres pour
tout le dashboard carburant).

### Unification des graphiques sur Chart.js seul

L'ancienne page mélangeait 3 systèmes de rendu pour les mêmes types de visuels : Chart.js (bar/line),
JustGage (jauges citerne, lib externe locale) et un pie fait main en `<canvas>` (`afficher_cercle()`,
~50 lignes de dessin manuel). Remplacés uniformément par Chart.js : `doughnut` pour les répartitions
(encaissement), `bar` horizontale pour le remplissage citerne (`indexAxis:'y'`, plus lisible qu'une
jauge ronde pour comparer plusieurs citernes d'un coup d'œil). Un seul registre d'instances
`charts{}` avec un helper `rendre_chart(id, config)` qui `destroy()` l'instance existante avant
d'en recréer une - évite les instances fantômes empilées à chaque changement de filtre (piège
classique Chart.js non géré dans l'ancienne version, qui recréait `new Chart(...)` sans jamais
détruire l'ancienne instance).

**Bug Chart.js v2/v3 corrigé** : `marge_unitaire` utilisait `scales.yAxes:[{ticks:{callback}}]`
(API Chart.js v2) alors que la page charge Chart.js 3.9.1 (et même la dernière version via
`cdn.jsdelivr.net/npm/chart.js` sans épinglage - potentiellement v4 aujourd'hui) - `scales.yAxes`
n'existe plus en v3+, l'axe Y ne se formatait plus. Nouveaux graphiques écrits directement en
syntaxe v3 (`scales.y`/`scales.x`).

**Palette couleur unique, theme-aware** (au lieu de 3+ palettes hexadécimales fixes incohérentes
entre graphiques) : lue dynamiquement via `getComputedStyle(document.body).getPropertyValue(
'--accent'|'--ok'|'--warn'|'--bad'|'--ink-2')` - même technique déjà utilisée ailleurs dans
`ajax.js` (L488) pour un autre besoin. S'adapte automatiquement à la direction/mode (clair/sombre)
choisis par l'utilisateur, avec un tableau `ROTATION` (accent/ok/warn/bad + quelques teintes fixes
en complément) pour les séries à N éléments (comparaison pluriannuelle, produits).

**Blocs `plugins.datalabels` retirés** : présents dans l'ancien JS (chart_produit, chart_encaissement,
ca_line, qte_line) mais totalement inertes - le plugin `chartjs-plugin-datalabels` n'était jamais
chargé (aucune balise `<script>` correspondante). Retirés plutôt que d'ajouter une nouvelle
dépendance CDN externe (même réflexe que la traduction FR DataTables en dur, déjà appliqué
plusieurs fois cette session).

### Backend : nettoyage + généralisation, pas de duplication

- `encaissement()`/`encaissement_periode()` (`StatistiqueGraphiqueController`) : ~12
  `find_attribut(...)[0]['montant']` non gardés contre NULL - `?: 0` ajouté partout (même famille
  de bug que `StatistiqueController::encaissement()`, §55, présente une seconde fois dans cette
  page sœur).
- `qte_a_date()` : `findone()` non gardé - `if (!$produit) return 0;` ajouté.
- `statistique()` : `$colors[$i]` (4 couleurs fixes) plantait au 5e carburant configuré - remplacé
  par `$colors[$i % count($colors)]` (boucle la palette). Divisions `montant/qte` (PMV) protégées
  contre `qte==0` (produisaient `INF`/`NaN` en JSON pour un carburant sans vente sur la période).
- **`mensualite()` supprimée** (12 requêtes, une par mois - anti-pattern déjà identifié à
  l'exploration) - remplacée par un appel à `statistique_annee_optimisee()`, **généralisée** avec
  un paramètre `$type_vente` (au lieu de `'carburant'` en dur) : une seule requête `group by
  MONTH(journee)`, déjà le bon pattern existant dans le code mais sous-utilisé (seul
  `charte_comparaison()` l'appelait). Le heredoc `$query` mort (jamais exécuté, ~15 lignes de
  documentation SQL non synchronisée avec la vraie requête `find_attribut()` juste en dessous) a
  été retiré au passage.
- ~110 lignes de code mort commenté (ancienne version de `statistique_annee()` et
  `charte_comparaison()`) supprimées, ainsi que l'import `StocksMouvement` inutilisé, la propriété
  `$Statistique` jamais assignée, et `$a=date('Y-m-d')` mort dans `index()`.

### Nouveau backend Produit/Service - réutilise `Statistiques` (Numérique §55), pas de 2e copie SQL

Nouvelle méthode `Statistiques::par_produit($type_vente, $du, $a)` (`app/models/home/Statistiques.php`) :
même filtre `type_vente in (select id from stocks_produits where type_vente=...)` que
`vente()`/`achat()`, mais sans pagination DataTable - liste complète triée par montant desc,
repliée en **"top 10 + Autres"** (une ligne agrégée) pour rester lisible dans un graphique en barres.
L'achat/la marge ne sont calculés QUE pour `type_vente='produit'` : les services n'ont jamais de
prix d'achat (`ServicesController::modal()` force `prix_achat=0` à la création, vérifié §54/§55) -
pas de marge inventée à 100% pour ce type.

2 nouvelles actions publiques minces dans le contrôleur : `statistique_produit()`/
`statistique_service()`, toutes deux déléguant à une méthode privée `statistique_type($type_vente)`
qui appelle `par_produit()` + `statistique_annee_optimisee($type_vente, $annee)` (la même méthode
généralisée réutilisée pour la tendance mensuelle - aucune duplication).

**Décision explicite : pas d'onglet "Encaissement par mode de paiement" pour Produit/Service** -
l'encaissement carburant s'appuie sur `carburant_compte`/`carburant_compte_bon`, liés à une
**session de caisse**, pas à une ligne produit : un paiement peut mélanger lubrifiant + service +
carburant dans la même transaction. Router cette répartition jusqu'au niveau produit/service
n'aurait pas de sens métier (contrairement au carburant qui a son propre circuit `Compte`/`Bon`
dédié) - ne pas inventer une fonctionnalité que le modèle de données ne supporte pas réellement,
même si ça aurait été visuellement symétrique avec l'onglet Carburant.

Pas de nouvelle route : les 2 nouvelles actions vivent dans le même contrôleur/namespace
`statistique` déjà enregistré - rien à ajouter à `namespace_resolve()`/`left_bar()` (contrairement à
Base Article §54, où le nouveau contrôleur nécessitait bien les 2 registres).

### Limite assumée (communiquée à l'utilisateur)

Aucun navigateur disponible dans cet environnement : le rendu visuel réel des graphiques Chart.js
(couleurs effectives, alignement, responsive) n'a pas pu être vérifié à l'œil - seules la validité
des données/endpoints (curl) et la syntaxe JS (`node --check` sur le bloc `<script>` extrait) sont
vérifiables ici.

**Vérifié par curl** : `/StatistiqueGraphique` (page) + les 6 endpoints (`statistique`,
`statistique_produit`, `statistique_service` - nouveaux -, `charte_comparaison`,
`charte_encaissement`) répondent 200 sans Fatal/Warning, JSON valide avec les formes attendues
(`produits[]`/`totaux`/`mensualites` pour les 2 nouveaux endpoints, top 10 + "Autres" confirmé sur
des données réelles - ex. 11 produits retournés pour une plage large). `node --check` sur le
`<script>` extrait de la page rendue : syntaxe JS valide. Aucune donnée de test créée (lecture
seule sur la base réelle).

## Addendum §56 bis : réorganisation en 2 onglets principaux × 3 sous-onglets (25/07/2026)

Retour utilisateur après la refonte ci-dessus : les 4 onglets à plat (Carburant/Comparaison/
Produit/Service) sont réorganisés en **2 onglets principaux imbriqués**, chacun avec ses propres
sous-onglets `.ssm-tabs` (Bootstrap4 pill imbriqués - premier précédent de nesting `.ssm-tabs` dans
le projet, fonctionne nativement tant que les `id`/`href` sont uniques, pas de CSS supplémentaire
nécessaire) :

1. **Carburant** (filtres + KPI communs en tête, inchangés) → sous-onglets **Général** / **Évolution**
   / **Comparaison**.
2. **Lubrifiant & Service** (fusion des anciens onglets Produit et Service en une seule catégorie,
   filtres + KPI communs fusionnés) → mêmes 3 sous-onglets.

### Pourquoi "Général" est un doughnut en %, pas une barre en valeur absolue

Point soulevé par l'utilisateur : le CA carburant est très supérieur à celui du lubrifiant/service
- sur un graphique en **valeur absolue** partagé, le petit montant devient invisible (quelques
pixels de hauteur de barre). Décision : le sous-onglet **Général** de chaque onglet principal
affiche **2 doughnuts en pourcentage de part** (Répartition Vente / Répartition Achat), jamais de
barres en Dhs - un doughnut force la lecture proportionnelle, une part à 5% reste une tranche
visible avec son % affiché en tooltip (`options_repartition_pct()` dans le JS, calcule le % à la
volée à partir du total du dataset). Le montant réel en Dhs n'apparaît qu'en tooltip, jamais comme
dimension visuelle. Ce choix a été proposé par l'IA et validé par l'utilisateur avant implémentation.

### Carburant > Général

Réutilise le même endpoint `/StatistiqueGraphique/statistique` (aucun nouvel appel réseau) :
`chart_carburant_vente_repartition` (doughnut sur `carburants[].montant`) et
`chart_carburant_achat_repartition` (doughnut sur `carburants[].achat` - **champ ajouté** à la
réponse de `statistique()`, réutilisant `$achat` déjà calculé dans la boucle pour le PMP, aucune
requête SQL supplémentaire).

### Lubrifiant & Service > Général : **volontairement non câblé**

L'utilisateur a explicité vouloir définir lui-même la méthode de calcul de cette catégorie
("j'ai pas autant d'information pour y faire pour le moment... je vais travailler sur la façon
avec laquelle on va calculer les chiffres"). Les 2 canvas (`chart_ps_vente_repartition`/
`chart_ps_achat_repartition`) existent dans le DOM avec le bon type de chart déjà validé (doughnut),
mais **aucun fetch ne les alimente** - overlay CSS `.ssm-chart-zone--attente`/`.ssm-chart-attente`
(nouveau, `components.css`) affichant "En attente de la méthode de calcul" par-dessus le canvas
vide estompé (`opacity:.15`). Ne pas câbler ces 2 charts tant que l'utilisateur n'a pas donné sa
méthode - ne pas réutiliser silencieusement `par_produit('ps',...)` ici même si les données
existent déjà, car l'utilisateur a explicitement dit ne pas vouloir cette voie pour le moment.

### Lubrifiant & Service > Évolution / Comparaison : catégorie `'ps'` (fusion produit+service)

Contrairement à Général, la fusion Évolution/Comparaison réutilise une convention **déjà existante
et validée** dans le projet (`Statistiques::vente()`/`achat()` avaient déjà un cas `type_vente='ps'`
= `type_vente='produit' or type_vente='service'`) - ce n'est pas une nouvelle méthode de calcul
inventée, juste l'application de la même règle déjà en place ailleurs :

- `Statistiques::par_produit($type_vente, $du, $a)` : ajout du cas `'ps'` dans le filtre SQL
  (`(type_vente='produit' or type_vente='service')` au lieu de `type_vente='$type_vente'`) + achat
  calculé aussi pour `'ps'` (toujours limité aux produits, jamais aux services - `prix_achat=0`
  côté service, inchangé).
- `StatistiqueGraphiqueController::statistique_annee_optimisee($type_vente, $annee)` : même
  généralisation du filtre SQL pour `'ps'`.
- Nouvelle action mince `statistique_ps()` → délègue à `statistique_type('ps')` (déjà générique,
  aucun code dupliqué).
- `charte_comparaison()` : nouveau paramètre POST optionnel `type_vente` (défaut `'carburant'` -
  ne casse pas l'appel existant du sous-onglet Carburant > Comparaison), utilisé par le sous-onglet
  Lubrifiant & Service > Comparaison. **Pas de `charte_encaissement()` généralisée** : la
  ventilation par mode de paiement reste carburant-only (session de caisse, cf. §56 plus haut) -
  le sous-onglet Comparaison de Lubrifiant & Service n'affiche donc que le graphique multi-année
  montants/quantités, sans les cartes encaissement.

### JS : fonctions génériques par "groupe" (`carburant`/`ps`) plutôt que dupliquées

`charger_comparaison(groupe)`/`afficher_comparaison(groupe)` remplacent les anciennes fonctions
dédiées au seul carburant - un seul jeu de fonctions paramétré par `groupe` (`'carburant'` ou
`'ps'`), `serie_active`/`dernier_comparaison` devenus des objets indexés par groupe. Les boutons
segmentés (`Montants`/`Quantités`) portent un `data-groupe` en plus du `data-serie` existant pour
cibler le bon état/graphique.

### Vérification

`php -l` sur les 3 fichiers touchés (contrôleur, modèle, vue) : clean. Curl : page `/StatistiqueGraphique`
200, `/StatistiqueGraphique/statistique` (carburants avec le nouveau champ `achat` confirmé dans la
réponse), `/StatistiqueGraphique/statistique_ps` (totaux réels, ex. `achat: 2119.99...` sur des
alimentations lubrifiant récentes sans vente sur la période - cohérent), `/StatistiqueGraphique/
charte_comparaison` avec `type_vente=carburant` ET `type_vente=ps` (2 formes de réponse distinctes
confirmées), `/StatistiqueGraphique/charte_encaissement` (inchangé) - tout 200, aucun Fatal/Warning/
Notice. `node --check` sur le `<script>` extrait de la page rendue : syntaxe JS valide. Même limite
qu'au §56 : pas de navigateur disponible, rendu visuel réel des doughnuts/nesting de tabs non
vérifiable à l'œil.

## Addendum §56 ter : correction de structure - 3 onglets principaux, filtres par sous-onglet (25/07/2026)

Le §56 bis ci-dessus s'est avéré une mauvaise lecture de la demande - corrigé dans la foulée, avant
tout usage réel. Structure définitive :

**3 onglets PRINCIPAUX** : **Général** (nouveau, séparé - PAS une sous-catégorie de Carburant/
Lubrifiant & Service) / **Carburant** / **Lubrifiant & Service**. Les 2 derniers gardent 3
sous-onglets **Général/Évolution/Comparaison**, mais avec un sens différent de celui du §56 bis :

- **Onglet principal "Général"** (nouveau) : une carte, un seul graphique ligne "Évolution
  Mensuelle - Achat / Vente (toute activité)" (`chart_general_evolution`), **données 100%
  aléatoires côté JS** (`Math.random()`, aucun appel réseau) - l'utilisateur a explicitement demandé
  de ne PAS chercher à calculer de vrais chiffres pour cet onglet pour l'instant ("tu fais juste
  des données aléatoires", méthode de calcul à définir plus tard). Une note `text-muted small`
  sous le graphique le rappelle visuellement ("Données provisoires... méthode de calcul réelle
  reste à définir") pour ne jamais laisser croire que ce sont de vraies données.
- **Carburant/Lubrifiant & Service > "Général"** : ce n'est PLUS un doughnut de répartition % (idée
  du §56 bis, abandonnée) - c'est **le filtre + les graphiques de la période** : Carburant a repris
  son filtre d'origine (du/au/fixateur/taxe) + KPI + les 4 graphiques détaillés (CA par carburant,
  PMV/PMP, remplissage citernes, encaissement) ; Lubrifiant & Service a son propre filtre (du/au/
  taxe, pas de fixateur) + KPI + le graphique CA par produit/service (`chart_ca_ps`, catégorie
  `'ps'`).
- **"Évolution"** : sous-onglet à part avec **son propre filtre `année` uniquement** (indépendant
  du filtre du/au/fixateur/taxe de "Général") + les 2 graphiques mensuels (CA + Quantité). Comme le
  filtre est désormais découplé, un nouvel endpoint dédié `StatistiqueGraphiqueController::
  evolution()` a été ajouté : `{type_vente, annee} → {mensualites}`, simple délégation à
  `statistique_annee_optimisee()` déjà généralisée (carburant|ps) - aucune logique dupliquée. Les 2
  anciens champs `mensualites` de `statistique()`/`statistique_type()` ont été retirés de ces
  réponses (rendus inutiles par ce découplage) plutôt que laissés morts : `statistique()` n'a plus
  besoin de `$_POST['annee']` du tout (le filtre "Général" ne l'envoie plus), et `statistique_type()`
  (utilisée par `statistique_ps()`) ne calcule plus les mensualités - **bug attrapé par curl** :
  sans ce retrait, `statistique_ps()` plantait (`Undefined array key "annee"` puis `PDOException`
  SQL invalide, `YEAR(journee)=` avec valeur vide) car son formulaire "Général" n'a plus de champ
  année à envoyer.
- **"Comparaison"** : logique inchangée (multi-années, déjà fonctionnelle) - seul le style a
  changé : le bloc filtres + boutons segmentés est désormais encadré dans un `.ssm-chart-card`
  (classe déjà existante, réutilisée telle quelle - pas de nouvelle CSS) au lieu de flotter sans
  cadre au-dessus du graphique, ce qui donnait un rendu "mal stylisé" signalé par l'utilisateur.

**CSS retirée** : `.ssm-chart-zone--attente`/`.ssm-chart-attente` (overlay "en attente" du §56 bis)
supprimée de `components.css` - plus utilisée, la logique "attendre la méthode de calcul" a migré
sur l'onglet principal "Général" (données aléatoires, pas un overlay sur un canvas vide).

**Vérifié par curl** : page `/StatistiqueGraphique`, `/StatistiqueGraphique/statistique` (sans
`annee` en POST - dérivée en interne de `du`), `/StatistiqueGraphique/statistique_ps` (bug ci-dessus
reproduit puis corrigé, revérifié clean), `/StatistiqueGraphique/evolution` avec
`type_vente=carburant` et `type_vente=ps` (mensualités réelles confirmées, ex. juillet 2026 carburant
`montant_vente: 1119800.37`), `charte_comparaison`/`charte_encaissement` inchangés - tout 200, aucun
Fatal/Warning/Notice après correction. `node --check` sur le JS extrait : syntaxe valide. Même limite
que les passages précédents : pas de navigateur, rendu visuel non vérifiable à l'œil.

## Addendum §56 quater : 3 bugs UI signalés sur l'onglet Comparaison (25/07/2026)

Retour utilisateur après usage réel du sous-onglet Comparaison (Carburant et Lubrifiant & Service) :

1. **Clic sur une légende ("Vente 2026") sans effet visible** : le comportement natif Chart.js
   (masquer un dataset au clic légende, jamais modifié dans ce fichier) fonctionnait bien, mais
   Vente et Achat d'une même année partageaient **exactement** la même couleur (seul le pointillé
   les distinguait) - masquer "Vente 2026" laissait "Achat 2026" quasi identique à l'œil, donnant
   l'impression que rien ne s'était passé. Fix : nouvelle fonction JS `avec_alpha(couleur, alpha)`
   (convertit un hex ou un `rgb()` résolu via `getComputedStyle` en `rgba()`) - Achat est désormais
   rendu dans une teinte éclaircie (alpha 0.5) de la même couleur que Vente, le lien visuel "même
   année" reste mais le clic légende devient immédiatement visible. Légende Chart.js explicitée
   (`usePointStyle`, `boxWidth`, `padding`) pour un rendu plus lisible/pro au passage.
2. **Filtres "Années"/"Mois du"/"Mois au" mal alignés, "pas professionnel"** : la case Select2
   **multiple** ("Années") n'était couverte par AUCUNE des règles de thème `components.css` -
   celles-ci ne ciblaient que `.select2-selection--single` (widget mono-sélection), une classe
   différente de `.select2-selection--multiple` portée par le même conteneur en mode `multiple`.
   Résultat : hauteur non fixée (donc plus basse que les selects natifs voisins dans la même
   `.ssm-panel-toolbar`) et aucune boîte/bordure themée. Fix : nouveau bloc CSS dédié à
   `--multiple` (même traitement "pilule" que `--single` : boîte complète par défaut, borderless
   quand imbriqué dans `.input-group-sm`/`.ssm-filter`, hauteur 40px cohérente avec les selects
   natifs qui, eux, étaient déjà correctement themés par la règle `.input-group.input-group-sm >
   select` préexistante).
3. **Tags des années sélectionnées en bleu, "hors style du thème"** : `.select2-selection__choice`
   (le tag "2026 ×" affiché une fois l'année choisie) n'était pas non plus couvert par le CSS
   existant - Select2 retombe sur son bleu/gris par défaut. Fix : même bloc que ci-dessus, tag
   stylé avec `var(--accent-soft)`/`var(--accent)` (déjà utilisées ailleurs dans ce fichier pour le
   même besoin de glow/accent doux) au lieu des couleurs par défaut de la librairie - themé quelle
   que soit la direction/mode actifs. Bénéfice collatéral : tout autre select2 `multiple` de
   l'application (s'il y en a) hérite du même correctif, aucune régression possible (uniquement
   additif, aucune règle existante modifiée).

Fichiers touchés : `app/views/statistique/Graphique.php` (JS : `avec_alpha()`, options légende),
`public/css/design/components.css` (nouveau bloc `--multiple`/`__choice`). Vérifié : `php -l`
clean, page + JS extrait (`node --check`) toujours valides après ajout, comptage accolades CSS
équilibré (456/456). Pas de navigateur : le rendu visuel réel (alignement pixel, couleurs exactes)
reste à confirmer par l'utilisateur en conditions réelles.

## Addendum §56 quinquies : bug systémique tooltip/légende décalés sur TOUS les charts (25/07/2026)

Retour utilisateur : sur tous les graphiques (comparaison, marge...), le survol/clic ne correspond
pas au bon point/dataset - "il y a un décalage". Root cause identifiée : les 9 onglets `.ssm-tabs`
imbriqués de cette page (3 principaux × 3 sous-onglets) chargent et **rendent leurs graphiques dès
`$(document).ready()`**, mais un seul onglet est visible à la fois (Bootstrap masque les autres en
`display:none`) - Chart.js, construit sur un canvas dans un conteneur caché, calcule une géométrie
interne fausse (canvas 0×0) qui **reste périmée même une fois l'onglet affiché** : d'où les valeurs
de survol qui ne correspondent pas au point réellement survolé, et le clic légende qui bascule le
mauvais dataset. Ni la donnée ni l'ordre des tableaux (déjà vérifiés alignés côté backend) n'étaient
en cause - un bug de rendu Chart.js/Bootstrap tabs bien identifié dans la communauté. **Fix** :
handler délégué global `$(document).on('shown.bs.tab', 'a[data-toggle="pill"]', ...)` qui appelle
`chart.resize()` sur **tous** les graphiques du registre `charts{}` à chaque changement d'onglet
(top-niveau ou sous-onglet, un seul handler couvre les 9) - `resize()` force Chart.js à recalculer
sa géométrie une fois le conteneur réellement visible, corrigeant tooltip/légende sans toucher au
chargement des données (reste eager, juste la géométrie est recalculée).

Bonus (même passage, complément aux couleurs Vente/Achat d'une année en légende) : Vente et Achat
partageaient exactement la même couleur (seul le pointillé distinguait) - masquer "Vente 2026" en
légende laissait "Achat 2026" quasi identique à l'œil, renforçant l'impression que le clic n'avait
rien fait. Nouvelle fonction JS `avec_alpha(couleur, alpha)` : Achat rendu dans une teinte éclaircie
(alpha 0.5) de la couleur de Vente - le lien visuel "même année" reste, le clic légende devient
visible immédiatement. Légende explicitée (`usePointStyle`/`boxWidth`/`padding`).

Select2 **multiple** (filtre "Années") : les règles de thème existantes ne couvraient que
`.select2-selection--single` - `--multiple` (classe différente pour le mode multi-select) restait
hors thème (hauteur non fixée = "mal aligné" signalé, tags des années choisies en bleu par défaut
de Select2 = "hors du thème" signalé). Nouveau bloc CSS dédié `--multiple`/`__choice` dans
`components.css`, même traitement "pilule" que `--single` (déjà présent, réutilisé), thémé via
`--accent`/`--accent-soft` déjà définies. Purement additif, aucune régle existante modifiée.

## Nettoyage header.php/footer.php : retrait des plugins AdminLTE morts + liens CDN a jour (25/07/2026)

Retour utilisateur : "on a fait notre propre style pas adminlte... si on travaille avec des liens
obsoletes tu modifies pour les recents... tu copies d'abord les anciens fichiers pour la
restauration". **Sauvegarde faite en premier** : `backup_layout_25072026/header.php.bak` +
`footer.php.bak` (racine du repo, hors git par defaut a verifier si a committer ou non).

**Audit avant suppression** (agent Explore dedie, pas de suppression a l'aveugle) : grep de chaque
plugin AdminLTE charge dans header/footer contre tout `app/views/**` + `public/js/*.js`, pour
verifier qu'il n'est vraiment utilise nulle part avant de le retirer. Resultat : 12 plugins
confirmes **morts** (aucun usage trouve, juste le tag d'inclusion + un bloc d'init boilerplate qui
ne s'applique a rien) : ekko-lightbox, bs-custom-file-input, bootstrap-switch, bs-stepper, dropzone,
bootstrap4-duallistbox, bootstrap-colorpicker, tempusdominus-bootstrap-4, daterangepicker,
inputmask+moment, filterizr, gaugeJS (CDN, distinct de JustGage/Raphael qui restent - eux confirmes
utilises), `charts.css` (CDN, lib CSS-only distincte de Chart.js - jamais utilisee), et jquery-ui
(CDN `code.jquery.com/ui/1.13.1`, aucun `.sortable/.draggable/.datepicker/.autocomplete` trouve nulle
part). **Confirmes toujours utilises, conserves** : `adminlte.min.css/js` (small-box present dans 9
vues, callout tres utilise y compris dans le Graphique redessine cette session - filet de securite
toujours necessaire, comme deja documente), `select2bs4` (5+ modales), `icheck-bootstrap`
(compte/modal/Validation_zone.php), `sweetalert2` (point_vente/Resultat.php).

**Liens CDN mis a jour, mais SANS saut de version majeure** (verifie via `data.jsdelivr.com`/
`api.cdnjs.com` avant tout changement, pas de version devinee) :
- jQuery 3.6.0 → **3.7.1** (derniere 3.x). La "derniere" absolue est jQuery 4.0.0, mais deliberement
  evitee : changement majeur avec des ruptures d'API, des milliers d'appels `$` dans l'appli
  existante, aucun navigateur disponible pour tout revalider - correspond exactement a la mise en
  garde "attention de tout gacher" de l'utilisateur.
- Font Awesome 6.5.2 → **6.7.2** (derniere 6.x). FA7 existe (derniere absolue) mais evite pour la
  meme raison : renommage/retrait d'icones possible sur des centaines de vues qui utilisent
  `fa-solid`/`fa` un peu partout, aucun moyen de tout revalider visuellement ici.
- Chart.js : etait charge en `"latest"` non versionne (risque deja signale plus haut dans ce
  fichier, §56) - desormais **pin explicite en 4.5.1** (`chart.js@4.5.1/dist/chart.umd.min.js`,
  chemin verifie reel via l'API jsdelivr avant d'ecrire le lien) : a jour ET stable, ne bougera
  plus tout seul en prod.
- jquery-ui, gaugeJS, charts.css : retires entierement (confirmes morts, pas juste "obsoletes").

**Doublon jQuery non touche** (`header.php` charge jQuery en CDN dans `<head>`, `footer.php`
recharge un jQuery LOCAL identique juste avant Bootstrap/DataTables/Select2) : en apparence
redondant, mais **volontaire/necessaire** - les scripts inline de chaque VUE (entre header et
footer) s'executent dans leur callback `$(document).ready()` seulement apres le chargement complet
du document (donc apres le rechargement en footer), donc `$` y designe bien la 2e instance
(equipee des plugins charges juste apres) au moment de l'execution reelle ; retirer l'un des deux
sans auditer TOUT usage synchrone de `$` hors `.ready()` dans les ~200 vues aurait ete le genre de
changement a fort risque explicitement mis en garde ("tout gacher") - laisse tel quel.

Verifie : `php -l` sur les 2 fichiers, page d'accueil + 4 modules varies (Graphique, Numerique,
ClientBon, Produits) charges par curl sans Fatal/Parse error, grep de confirmation qu'aucune vue ne
reference plus les identifiants des plugins retires, les 3 nouvelles URLs CDN testees en HEAD (200
OK) avant integration dans le code.

## Addendum §56 sexies : legende HORS-CANVAS pour eliminer le decalage restant (25/07/2026)

Le fix `chart.resize()` sur `shown.bs.tab` (§56 quinquies) n'a pas suffi : l'utilisateur a
reconfirme qu'"eliminer une courbe" restait decale - certaines courbes se cachaient, d'autres non
du tout. Root cause plus profonde : **la legende Chart.js est dessinee DANS le canvas** (pas des
elements HTML separes) - son detecteur de clic (hit-boxes) depend donc entierement de la geometrie
interne du canvas au moment du clic. Meme avec un `resize()` correct, ce couplage restait fragile
(plusieurs recalculs de geometrie en cascade via le handler global `shown.bs.tab`, qui s'applique a
TOUS les charts enregistres a chaque changement d'onglet, y compris ceux encore caches - voir
§quinquies) : suffisant pour re-suspecter le mecanisme de hit-testing canvas lui-meme, plutot que
de continuer a rafistiner la geometrie.

**Solution definitive : sortir la legende du canvas.** Nouvelle fonction `construire_legende(chart,
conteneurId)` (`Graphique.php`) : construit une vraie legende **HTML** (boutons DOM, un par
dataset), chaque bouton lie par **index explicite** aux methodes publiques Chart.js
`isDatasetVisible(i)`/`hide(i)`/`show(i)` - le clic ne depend plus JAMAIS de la position/taille du
canvas, uniquement d'un index de tableau connu a la construction. Appelee automatiquement depuis
`rendre_chart()` pour CHAQUE chart cree, mais n'agit que si un conteneur `#legende_<id_canvas>`
existe dans le DOM (ajoute uniquement aux 4 graphiques ou le "activer/desactiver une courbe" est un
vrai besoin : `chart_general_evolution`, `chart_marge_carburant`, `chart_carburant_comparaison`,
`chart_ps_comparaison` - `plugins.legend.display:false` sur ces 4 pour ne pas doubler avec la
legende native). Les autres graphiques (encaissement, repartition par produit...) gardent leur
legende native Chart.js, inchangee - pas de besoin de toggle dessus.

Nouveau bloc CSS `.ssm-chart-legende`/`.ssm-chart-legende-item`/`--masque` (`components.css`) :
pastille de couleur + libelle, etat "masque" en opacite reduite + barre. Cette approche elimine
completement la classe de bug (plus aucune dependance a la geometrie canvas pour le toggle), au
lieu de continuer a corriger la symptomatologie (resize, timing des evenements Bootstrap).

Vérifié : `php -l`, comptage accolades CSS équilibré (461/461), page + JS extrait (`node --check`)
valides, 4 conteneurs de légende + le nouveau helper bien présents dans le HTML rendu. Le
fonctionnement réel du clic reste à confirmer par l'utilisateur (pas de navigateur ici), mais le
mécanisme n'a plus aucune dépendance à la géométrie canvas, contrairement aux deux tentatives
précédentes.

## Addendum §56 septies : défauts Comparaison (année en cours + précédente) + bug filtre mois du/au ignoré (25/07/2026)

Deux demandes/bugs traités ensemble :

**1. Chargement par défaut** de la Comparaison (Carburant et Lubrifiant & Service) sans action
utilisateur : années = année en cours + précédente, mois du = Janvier, mois au = **mois précédent**
(jamais le mois en cours - données forcément incomplètes). Cas particulier janvier : pas de "mois
précédent" dans l'année en cours -> bascule sur les 2 années précédentes (Y-2/Y-1) en comparaison
pleine janvier→décembre. Logique calculée une fois en PHP (`$ssm_comparaison_mois_au`/
`$ssm_comparaison_annees_defaut`, en tête de `Graphique.php`) et posée directement en `selected`
sur les `<option>` des 2 formulaires Comparaison - Select2 les reprend automatiquement à
l'initialisation, pas de duplication de la logique de date côté JS. `charger_comparaison('carburant')`/
`('ps')` ajoutés au chargement initial (jusque-là il fallait cliquer "Charger" manuellement).

**2. Bug trouvé en testant** : filtrer Janvier→Juin affichait quand même les 12 mois dans le
graphique. Root cause : `charte_comparaison()` recevait déjà `$du`/`$au` en POST mais ne les
utilisait JAMAIS - `statistique_annee_optimisee()` renvoie systématiquement les 12 mois de l'année,
et rien ne découpait le résultat avant de le renvoyer. Bug préexistant (pas introduit cette
session), resté invisible tant que personne ne changeait le filtre par défaut (Janvier→Décembre
jusqu'ici). Fix : `array_slice()` sur le tableau des mensualités (`$du - 1` comme offset, `$au - $du
+ 1` comme longueur - index 0 = janvier), avec un clamp `$du`/`$au` entre 1 et 12 par sécurité.
`charte_encaissement()`/`encaissement_periode()` n'avaient PAS ce bug : le filtre mois y est déjà
appliqué en SQL (`MONTH(journee) between`).

Vérifié : `php -l`, curl avec `du=1&au=6` (6 mois exactement retournés, confirmé sur les 2 années)
vs `du=1&au=12` (12 mois), page rendue avec les bonnes options `selected` (2025+2026, Janvier,
Juin - date de test 25/07/2026).

## Addendum §56 octies : Évolution restylée "comme Comparaison" (un seul graphe + toggle CA/Qté) + mois sans données masqués (25/07/2026)

Demande : refaire le sous-onglet Évolution (Carburant + Lubrifiant & Service) sur le même principe
que Comparaison - un seul graphique plutôt que 2 côte à côte (CA et Quantité), avec un toggle pour
basculer entre les deux (comme Montants/Quantités), et ne pas afficher les mois de l'année en cours
qui n'ont pas encore de données (ex. si on est en juillet, ne pas montrer août/septembre... vides).

**Frontend** : `chart_ca_mensuel`/`chart_qte_mensuel` (et leurs équivalents `_ps_`) fusionnés en un
seul canvas par groupe (`chart_carburant_evolution`/`chart_ps_evolution`), habillé du même
`.ssm-seg-track` (réutilisé, pas de nouvelle classe CSS) que Comparaison - boutons "Chiffre
d'Affaires"/"Quantité". Attribut dédié `data-groupe-evo`/`data-serie-evo` (au lieu de
`data-groupe`/`data-serie` déjà utilisés par Comparaison) pour que le handler générique du toggle
Comparaison n'intercepte pas aussi ces nouveaux boutons (même sélecteur `.ssm-seg-btn`, mais
attribut différent -> pas de collision). JS : `dernier_evolution`/`serie_active_evolution` (objets
indexés par groupe, même pattern que `dernier_comparaison`/`serie_active`) - un seul fetch par
changement d'année, le toggle CA/Qté redessine juste le graphe depuis les données déjà en mémoire
(pas de nouvel appel réseau).

**Backend** : `StatistiqueGraphiqueController::evolution()` tronque désormais le tableau des
mensualités quand l'année demandée est l'année EN COURS - `array_slice(..., 0, date('n') - 1)` :
ne garde que les mois strictement passés (le mois en cours est toujours incomplet, les mois futurs
n'ont aucune donnée). Une année passée garde ses 12 mois intacts (rien à tronquer). Même principe
que le fix du `du`/`au` de `charte_comparaison()` (§56 septies), appliqué ici sur un seul curseur
(pas de plage, juste "jusqu'où on a des données").

Vérifié : `php -l` (contrôleur + vue), page rendue avec les 2 nouveaux canvas + les boutons
`data-groupe-evo` présents, `node --check` sur le JS extrait, curl sur `/StatistiqueGraphique/evolution`
avec l'année en cours (2026) -> 6 mois (Janvier-Juin, cohérent avec la date de test 25/07/2026) vs
une année passée (2025) -> 12 mois intacts.

## Addendum §56 nonies : Évolution - même ligne, texte sur une ligne, curseur de toggle animé (25/07/2026)

Trois retouches sur le résultat du §56 octies :

1. **Select "Année" + toggle CA/Quantité sur la même ligne** : le toggle (`.ssm-seg-track`) est
   désormais un enfant du `.ssm-filtre-row` lui-même (dans une nouvelle colonne
   `.ssm-filtre-col ssm-filtre-col--action`, déjà utilisée pour le bouton "Charger" de Comparaison),
   au lieu d'être un bloc séparé en dessous. `align-items:stretch`/`justify-content:flex-end` déjà
   sur `.ssm-filtre-col--action` alignent naturellement le toggle sur la même ligne que le select,
   flush en bas comme les autres contrôles.
2. **"Chiffre d'Affaires" repassait sur 2 lignes** : `white-space: nowrap` ajouté sur
   `.ssm-seg-track .ssm-seg-btn` (le `flex:1` dans une piste étroite pouvait forcer un retour à la
   ligne selon l'espace disponible).
3. **Changement de tab animé "comme on/off"** : au lieu d'un fond qui saute instantanément d'un
   bouton à l'autre, un curseur unique (`::before` sur `.ssm-seg-track`, `position:absolute`) glisse
   entre les 2 segments via `transition: transform .25s ease`. Astuce 100% CSS (pas de mesure JS) :
   le curseur fait `width: calc(50% - 8px)` et `transform: translateX(calc(100% + 6px))` quand le
   2e segment est actif - `100%` se réfère à la largeur du curseur LUI-MÊME (pas de la piste), donc
   le déplacement (largeur du curseur + le gap de 6px) reste exact quelle que soit la largeur réelle
   de la piste. Nouvelle fonction JS générique `maj_seg_track_curseur($bouton)` : ajoute/retire la
   classe `.ssm-seg-track--seg2` sur la piste parente selon l'index du bouton cliqué (0 ou 1) -
   appelée dans les 2 handlers de toggle existants (Comparaison ET Évolution), aucune duplication.
   Les boutons eux-mêmes n'ont plus de fond propre en état actif (`background:transparent`) : le
   curseur glissant EST le fond visuel désormais, `color:var(--accent-ink)` reste pour le contraste
   du texte actif.

Vérifié : `php -l`, comptage accolades CSS équilibré (482/482), page rendue avec
`maj_seg_track_curseur`/`ssm-filtre-col--action` présents dans le JS/HTML, `node --check` sur le JS
extrait. Rendu visuel de l'animation non vérifiable sans navigateur.

## Addendum §56 decies : Évolution fusionnée sans tabs (axe double) + cartes de totaux/% en Comparaison (25/07/2026)

Trois changements demandés :

**1. Évolution : plus de toggle CA/Quantité** - les 2 séries sont désormais superposées sur UN
seul graphique (`chart_carburant_evolution`/`chart_ps_evolution`), CA sur l'axe Y gauche, Quantité
sur un axe Y droit distinct (`yAxisID:'y'`/`'y1'`, `scales.y1.grid.drawOnChartArea:false`) - échelles
trop différentes pour un axe unique (même famille de problème que carburant vs lubrifiant déjà
rencontrée). Toggle/tabs remplacés par la légende HORS-CANVAS déjà construite pour d'autres charts
(`construire_legende()`, réutilisée telle quelle via le conteneur `#legende_chart_..._evolution`) :
boutons pour activer/désactiver chaque courbe individuellement, exactement "comme dans
Comparaison". `serie_active_evolution` et le handler `data-groupe-evo` (ajoutés il y a 2 tours,
devenus inutiles) supprimés - `maj_seg_track_curseur()` reste utilisée par Comparaison (Montants/
Quantités, inchangé).

**2. Comparaison : cartes de totaux Vente/Achat par année**, sous le graphique
(`#carburant_comparaison_totaux`/`#ps_comparaison_totaux`, nouveaux conteneurs `.ssm-kpi-row`) -
somme de `montant_vente`/`montant_achat` sur EXACTEMENT la plage de mois déjà filtrée (les données
sont déjà tronquées Janvier→mois choisi côté serveur, §56 septies) + un badge `%` d'évolution vs
l'année précédente. "Précédente" = l'année juste avant **dans la liste triée des années
sélectionnées**, pas forcément année-1 calendaire (on peut comparer 2023/2025/2026 sans 2024) -
vérifié via curl que `charte_comparaison`/`charte_encaissement` acceptent bien des années non
consécutives et renvoient un objet trié par année. La première année de la liste n'a pas de
référence -> pas de badge.

**3. Cartes Encaissement : même principe de % par champ** (Espèce/CMI/TOMCARD/Chèque-LCN/Crédit
Client/Total), vs l'année précédente dans la liste triée - `afficher_encaissement_comparaison()`
trie désormais explicitement les années (`Object.keys(reponse).map(Number).sort()`) plutôt que de
compter sur l'ordre d'itération implicite de l'objet JS.

Nouvelles fonctions génériques (réutilisées par les 2 usages ci-dessus) : `pct_evolution(actuel,
precedent)` (retourne `null` si pas de référence ou référence à 0, évite une division par zéro/un
badge non-sens), `span_pct(pct)` (badge HTML coloré vert/rouge + flèche). Nouveau bloc CSS
`.ssm-pct`/`--hausse`/`--baisse` (`components.css`) : palette existante (`var(--ok)`/`var(--ok-bg)`/
`var(--bad)`/`var(--bad-bg)`), aucune couleur nouvelle - "vert = en hausse, rouge = en baisse" est
une simple convention directionnelle, pas un jugement (une hausse d'Achat n'est pas "positive").

Vérifié : `php -l`, comptage accolades CSS équilibré (486/486), page rendue avec les nouveaux
conteneurs présents, `node --check` sur le JS extrait, curl confirmant que `charte_comparaison`/
`charte_encaissement` renvoient un objet correctement trié même avec des années non consécutives
(2023/2025/2026 testé). Rendu visuel réel (légende hors-canvas de l'Évolution, alignement des
cartes) non vérifiable sans navigateur.

## Addendum §56 undecies : double % à partir de la 3e année + encaissement en tableau comparatif (25/07/2026)

Deux retouches sur le §56 decies :

**1. Double badge % à partir de la 3e année** : si plus de 2 années sont comparées, la 3e (et
suivantes) affiche désormais **2 badges** - un vs l'année juste précédente (comme avant), un vs la
**première** année de la liste triée (référence de départ). La 2e année garde un seul badge
(précédente === première, les deux seraient identiques). Nouvelle fonction générique
`badges_pct(valeurs, annees, i)` (remplace l'ancien pattern `precedent ? span_pct(...) : ''`) -
utilisée par `afficher_totaux_comparaison()` ET le nouveau tableau encaissement. `span_pct()` prend
désormais un 2e paramètre optionnel (année de référence) posé en `title="vs {année}"` sur le badge,
pour lever l'ambiguïté entre les 2 badges au survol. Vérifié en isolation via `node` : 2023→[],
2025→[vs2023], 2026→[vs2025, vs2023] - exactement le comportement demandé.

**2. Cartes Encaissement → tableau comparatif** : remplacées par un vrai `<table class="ssm-table">`
(classe déjà stylée globalement, aucune CSS nouvelle) - une **ligne par type** (Total/Espèce/CMI/
TOMCARD/Chèque-LCN/Crédit Client) et une **colonne par année**, pour comparer directement "Espèce
2024" à "Espèce 2025" sur la même ligne plutôt que de naviguer entre des cartes empilées par année.
Conteneur `#carburant_comparaison_encaissement_cards` : classe `ssm-kpi-row` (flex, pensée pour des
cartes) retirée puisqu'il contient maintenant un tableau.

Vérifié : `php -l`, page rendue sans Fatal/Warning, `node --check` sur le JS extrait, logique de
`badges_pct()` validée en isolation via `node`. Rendu visuel du tableau non vérifiable sans
navigateur.

## Addendum §56 duodecies : totaux liés au toggle Montants/Quantités + boutons globaux Vente/Achat (25/07/2026)

**1. Totaux Vente/Achat suivent désormais le toggle Montants/Quantités** : `afficher_totaux_comparaison()`
sommait toujours `montant_vente`/`montant_achat`, peu importe l'état du toggle - corrigé pour
utiliser le même champ que le graphique (`serie_active[groupe]`, déjà utilisé par
`afficher_comparaison()`). Libellés adaptés ("Vente (Qté)"/"Achat (Qté)" en vue quantités). Le
handler de clic du toggle (`.ssm-seg-btn[data-groupe]`) appelle maintenant aussi
`afficher_totaux_comparaison(groupe)` en plus de `afficher_comparaison(groupe)`.

**2. Boutons globaux "Tout Vente"/"Tout Achat"** dans la légende hors-canvas des 2 charts
Comparaison : en plus des boutons par année/série déjà existants (conservés tels quels), 2 boutons
supplémentaires permettent d'activer/désactiver EN UN CLIC toutes les courbes "Vente *" (peu
importe l'année) ou tous les "Achat *" simultanément. Nouvelle fonction `ajouter_boutons_globaux
(chart, conteneurId)` : repère les datasets par préfixe de label (`indexOf('Vente')===0` /
`'Achat'`), toggle bascule tout le groupe (si au moins une courbe du groupe est visible -> tout
cacher, sinon tout montrer) et resynchronise l'état visuel (masqué/non masqué) de chaque bouton
individuel après coup. Appelée depuis `afficher_comparaison()` juste après `rendre_chart()` (qui
retourne maintenant l'instance Chart.js - léger changement de signature interne, `rendre_chart()`
retournait déjà l'instance mais elle n'était pas récupérée ici avant). Nouvelle classe CSS
`.ssm-chart-legende-item--global` (contour en tirets, `var(--accent)` déjà existante) pour
distinguer visuellement les 2 boutons globaux des boutons par année.

Vérifié : `php -l`, comptage accolades CSS équilibré (487/487), page rendue avec
`ajouter_boutons_globaux`/`ssm-chart-legende-item--global` présents dans le JS/CSS, `node --check`
sur le JS extrait. Comportement réel du clic (bascule groupée) non vérifiable sans navigateur.

## Addendum §56 terdecies : totaux Vente/Achat en tableau (comme l'encaissement) (25/07/2026)

Même traitement que le tableau encaissement (§56 undecies) appliqué aux totaux Vente/Achat :
`afficher_totaux_comparaison()` construit désormais un `<table class="ssm-table">` - 2 lignes
(Vente/Achat, libellé adapté en "(Qté)" si le toggle est sur Quantités), une colonne par année,
badges `%` (`badges_pct()`, inchangé) dans chaque cellule au lieu des cartes `.ssm-kpi` empilées.
Conteneurs `#carburant_comparaison_totaux`/`#ps_comparaison_totaux` : classe `ssm-kpi-row` retirée
(plus de cartes flex, un tableau bloc classique).

Vérifié : `php -l`, page rendue sans Fatal/Warning, `node --check` sur le JS extrait.

**Complément same passage** : colonnes années des 2 tableaux (totaux + encaissement) alignées à
droite - nouvelle classe `ssm-table--comparaison` posée sur les 2 `<table class="ssm-table">`
générés (en plus de `ssm-table`, pas à la place), avec une règle CSS scopée à cette classe
(`th/td:not(:first-child) { text-align:right }`) pour ne pas affecter les autres tables
`.ssm-table`/DataTables de l'appli qui ont une structure de colonnes différente. 1ère colonne
(le libellé du type) reste alignée à gauche. Vérifié : comptage accolades CSS équilibré (488/488),
`ssm-table--comparaison` présent 2 fois dans la page rendue.

**Complément same passage** : titre au-dessus de chaque tableau + séparation visuelle entre les
deux. Chaque `<table>` est désormais enveloppé dans un `.ssm-chart-card` (composant déjà existant,
réutilisé) avec un `.ssm-chart-titre` : "Achat / Vente (Montant)" ou "(Quantité)" selon l'état du
toggle Montants/Quantités pour le 1er tableau, "Encaissement" (statique) pour le 2e. La boîte/
ombre/bordure de `.ssm-chart-card` fournit la séparation visuelle entre les deux tableaux (en plus
du `mt-3` déjà présent entre les 2 conteneurs), sans nouvel élément de séparation à ajouter.

**Complément** : `margin-right:8px` ajouté à la règle globale `.ssm-chart-titre i` (`components.css`)
- l'icône de titre collait au texte, sur les 2 nouveaux titres ET sur tous les titres `.ssm-chart-titre`
préexistants (bug/oubli de base présent depuis l'origine du composant, jamais remarqué avant).
Fix appliqué au niveau de la règle partagée plutôt qu'en scope local : purement additif (aucune
règle ne définissait déjà de margin sur cet élément), et seulement 3 fichiers dans toute l'appli
utilisent `.ssm-chart-titre` (`Graphique.php`, `bons/portion/credits.php`,
`bons/portion/mensualites.php`) - vérifié avant d'élargir le scope du fix.

## §57 : Module Paramètre - navigation AJAX (ssm-module-nav) + CRUD sans rechargement (25/07/2026)

Retour utilisateur : dans Paramètre (Réglages), presque tous les liens faisaient un rechargement
complet (`<a href>` classiques, `onclick="window.location.href=..."`, `<form method="post">` qui
redirigent). Demandé : page d'accueil avec le même sous-menu AJAX que les autres modules, toute
navigation en AJAX, tout CRUD en AJAX. Passé par `EnterPlanMode`/`ExitPlanMode` (étude préalable via
agent Explore dédié, plan approuvé avant toute édition) vu l'ampleur (14 contrôleurs).

### Navigation : 2 niveaux de `ssm-module-nav`

- `app/views/parametre/portion/menu_parametre.php` (niveau 1, 6 items : Banque/Ventes/
  Télécollecte/Stock/Historique/Sauvegarde) + `menu_ventes.php` (niveau 2, Scénarios/Point de
  vente) + `menu_para_stock.php` (niveau 2, Carburants/Citernes/Pistolets) - même pattern exact
  que `stocks/portion/menu_stock.php` (déjà vérifié avant de répliquer : thumb glissant, JS
  générique `[data-ssm-nav]` dans `ajax.js`, aucun changement contrôleur nécessaire pour la nav
  elle-même). `$type`/`$type_ventes`/`$type_stock` définis **localement en tête de chaque vue**
  (pas passés par le contrôleur) - même convention déjà observée dans `stocks/Produits.php`
  (`$type = 'stock';`).
- `Options.php` (page d'accueil) : grille de 6 cartes `<a href>` remplacée par `menu_parametre.php`.
  Au passage, retrait d'un `<pre>print_r($param['paths'])</pre>` de debug oublié dans le template.
  Tous les boutons "retour" (`onclick="window.location.href=..."`) retirés sur les 12 vues, la
  barre de nav les remplace.
- Le lien "Personnaliser" (liste Scénarios → détail) converti en `<a data-ssm-nav="1">` (au lieu de
  `onclick="window.location.href=..."`) - dernière vraie navigation de page à convertir dans ce
  module.
- **Non touché, signalé mais hors périmètre** : le fil d'Ariane (`app/views/layout/top_bar.php`)
  reste en `<a href>` classique - composant **partagé par toute l'application**, pas seulement
  Paramètre ; le convertir en AJAX aurait élargi le chantier à un changement site-wide non discuté
  dans le plan approuvé. `ParaProduitsController.php` (fichier vide, vue orpheline qui appelle déjà
  `load_portion` vers des routes inexistantes) : bug préexistant sans rapport, non traité (deviner
  la logique manquante aurait été une invention).

### CRUD AJAX : 4 contrôleurs convertis (`redirect()` → fragment)

`ParaPistoletController`, `ParaCiterneController`, `ParaCarburantsController`,
`ParaScenariosController` (ce dernier avec 2 fragments distincts : liste des scénarios ET zones
d'un scénario, `ajouter_zone()`/`supprimer_zone()` dédiés). Recette identique aux 4 : la logique
métier d'insert/update/delete est restée **strictement inchangée** (copiée telle quelle, y compris
ses lacunes préexistantes - ex. `ParaCiterneController::supprimer()`/`ParaCarburantsController::
supprimer()` ne nettoient pas les lignes `Stocks`/`StocksProduits` orphelines, un gap déjà présent
avant ce chantier, pas introduit ici) - seul ce qui se passe **après** la mutation change :
- Nouvelle méthode privée `donnees()`/`donnees_liste()` factorisant la construction du tableau
  affiché (réutilisée par `index()` implicitement via le fragment, et par `ajouter()`/`update()`/
  `supprimer()`).
- Nouvelle action publique `liste()` (`liste_zones()` pour Scenarios) : `view_modal()` du fragment
  seul (nouveaux fichiers `app/views/parametre/datatable/{Pistolets,Citernes,Carburants,Scenarios,
  ScenarioZones}.php` - table + modales Ajouter/Modifier extraites des anciennes vues).
- `ajouter()`/`update()`/`supprimer()` : `$this->redirect(...)` remplacé par `$this->liste()` (ou
  `liste_zones()`) en fin de méthode - renvoie le fragment HTML à jour au lieu de rediriger.
- Vue "coquille" (`ParaPistolet.php` etc.) : nav + carte statique + `<div id="..._liste">` vide,
  peuplé via `$.post` au chargement. **`$.post` natif choisi plutôt que `load_portion`** : ces 4
  contrôleurs lisent `$_POST` directement (pas le format `param=cle=.=valeur/./` encodé utilisé par
  `load_portion`/`to_array()` ailleurs dans l'appli) - `.serialize()` de jQuery produit déjà le bon
  format, changer ça aurait exigé de réécrire aussi la lecture des paramètres côté contrôleur (hors
  périmètre du retour utilisateur). Formulaires : `<form method="post" action="...">` conservés
  tels quels (inchangés dans le HTML) mais soumission interceptée (`e.preventDefault()` + `$.post`
  délégué sur le fragment, pas de binding direct puisque le fragment est recréé à chaque
  rechargement). Suppression : boutons `onclick="window.location.href=..."` → boutons `.supprimer-*`
  délégués. `toastr` de confirmation ajouté (n'existait pas avant, seul le rechargement de page
  signalait implicitement le succès).
- Cas particulier `ParaScenario.php` (détail) : la modale "Attacher pistolet" (`#attacher`, endpoint
  `ajax()` **non touché**) reste dans la coquille statique (jamais recréée), mais son bouton
  déclencheur (`.attachement`) vit dans le fragment zones (recréé à chaque ajout/suppression de
  zone) - binding changé en délégation (`$(document).on(...)` au lieu de `$(...).click(...)`) pour
  survivre aux rechargements. Son bouton de fermeture utilisait `onclick="location.reload(true)"`
  (rechargement complet à la fermeture) - remplacé par `data-dismiss="modal"` + un handler
  `hidden.bs.modal` qui recharge juste le fragment zones (reflète le nombre de pistolets associés
  mis à jour sans reload).

### Vérification

`php -l` sur les 5 contrôleurs + ~17 vues touchées. Curl sur les 12 pages (200, aucun Fatal/Warning)
+ les 4 endpoints `liste()`/`liste_zones()` (fragment HTML valide, `<table>` présent). **Cycle CRUD
complet testé et nettoyé** pour Pistolet (ajout → visible → suppression → absent) et pour Scénarios
+ Zones (ajout scénario → ajout zone → suppression zone → suppression scénario, tout vérifié absent
en fin de test). Cycle CRUD **non testé en écriture** pour Citerne/Carburants : leurs `ajouter()`/
`supprimer()` touchent plusieurs tables (`Stocks`/`StocksProduits`/quantités produit) avec des gaps
de nettoyage déjà présents avant ce chantier (voir plus haut) - un test d'ajout live aurait laissé
des données de test impossibles à nettoyer proprement ; seuls `php -l`, le chargement de page et
l'endpoint `liste()` ont été vérifiés pour ces deux-là (la logique de mutation elle-même est
copiée à l'identique, donc son comportement n'a pas changé). Aucune donnée de test résiduelle.

## §58 : Paramètre - 3 correctifs après premier usage réel (25/07/2026)

Retour utilisateur après avoir testé §57 en conditions réelles : (1) le menu affiche la couleur
pleine seulement au 1er chargement, plus après ; (2) en entrant dans un onglet avec des tabs en
dessous, ceux-ci sont différents du menu principal, et il faudrait entrer directement sur le
premier ; (3) les tableaux ne sont pas des DataTables comme partout ailleurs.

### 1. Bug de fond : `#ssm_module_content` manquant (racine des 2 premiers symptômes)

En comparant avec `stocks/portion/menu_stock.php` (déjà fonctionnel), la convention de l'appli est
que le menu de sous-module est inclus **avant** `<div id="ssm_module_content">`, PAS dedans -
`ssm_nav_ajax()` (`ajax.js:1270`) ne remplace QUE l'intérieur de ce conteneur quand il existe : le
menu qui vit en dehors **persiste tel quel** entre 2 navigations (jamais détruit/recréé), seul son
état actif et son curseur glissant sont mis à jour en JS (`ssm_module_nav_activer_par_url`).
Aucune des vues Paramètre n'avait ce conteneur - `ssm_nav_ajax()` retombait donc sur son repli
"remplacer tout `#body_app`", qui re-render le menu à chaque navigation MAIS sans jamais rejouer le
positionnement JS du curseur (fait par un simple `$(function(){...})` au tout premier chargement de
page complet, jamais rejoué après un `.html()`) - d'où "correct seulement la 1ère fois". Fix :
`#ssm_module_content` ajouté sur les 10 vues (englobe le contenu, le menu reste au-dessus). Bonus
trouvé au passage : `ParaBanque.php` rechargeait `<script src="/js/ajax.js">` en plus du chargement
global (footer.php) - anodin tant que la page ne se rechargeait qu'en navigation complète, mais
aurait dupliqué TOUS les handlers globaux délégués d'ajax.js à chaque visite maintenant que cette
page est AJAX-naviguable - retiré.

### 2. Menu aplati en 1 seul niveau (plus de hub intermédiaire)

Le découpage en 2 niveaux du §57 (Paramètre > Ventes > Scénarios/Point de vente, Paramètre > Stock >
Carburants/Citernes/Pistolets) est abandonné : un seul menu à plat, 9 items (Banque/Scénarios/Point
de vente/Télécollecte/Carburants/Citernes/Pistolets/Historique/Sauvegarde) - `menu_ventes.php`/
`menu_para_stock.php` supprimés, `menu_parametre.php` réécrit avec les 9 liens directs. Les 2 pages
hub désormais orphelines (`ParaVentes.php`/`ParaStock.php`, "Module VENTES"/"Module STOCK" vides
avec juste un titre) supprimées ; leurs contrôleurs (`ParaVentesController`/`ParaStockController`)
réduits à un simple `$this->redirect(...)` vers leur ancien 1er enfant (`ParaScenarios`/
`ParaCarburants`) - "on doit entrer au premier directement". Vérifié que le header `X-Ssm-Nav`
survit bien à ce 302 (curl `-L -H "X-Ssm-Nav: 1"` sur `/ParaVentes` : fragment renvoyé, pas de
`<html>`) - le clic sur un ancien lien favori/signet continue de fonctionner en AJAX. Les
breadcrumbs (`$top_bar`) des 5 contrôleurs concernés simplifiés en cohérence (plus de palier
"VENTES"/"STOCK" intermédiaire qui ne mène plus nulle part).

Complément sur "des tabs différents du menu principal" : les onglets internes de `Historique.php`
(Parcours des utilisateurs / Journal d'activité) utilisaient `.nav.nav-tabs`/`.nav-link` (Bootstrap
par défaut) au lieu de `.ssm-tabs`/`.ssm-tabs-link` (même mécanisme JS `data-toggle="pill"`, juste
les classes) - convertis pour rester visuellement cohérents avec le reste de la page desormais en
ssm-*.

### 3. Vraies DataTables sur les 5 tableaux (au lieu de `<table>` brutes)

`init_datatable()` en **mode client** (`serverSide: false`) plutôt que server-side : ces listes
sont courtes (poignée de lignes) et déjà entièrement rendues par le serveur dans le fragment - pas
besoin de construire un endpoint JSON dédié par ressource pour si peu de lignes, contrairement aux
grandes tables de l'appli. Colonne "Action" (boutons, jamais triable) désactivée via `columnDefs:
[{orderable:false, targets:-1}]` sur les 5 - colonne "Remplissage %" de Citernes (contient une barre
de progression imbriquée) également exclue du tri. Pas de règle Total Page/Filtré (CLAUDE.md) : ces
tableaux n'ont pas de colonne montant agrégeable (prix unitaires, pas des sommes).

### Handlers délégués sur `document` : garde `.off().on()` ajoutée

Effet de bord du fix #1 (les scripts des pages CRUD re-exécutent maintenant à chaque navigation
AJAX vers leur propre page) : les handlers délégués sur `$(document)` (nécessaires puisque leurs
cibles vivent dans un fragment recréé à chaque ajout/suppression) auraient sinon été réempilés à
chaque visite - 1 toastr par action à la 1ère visite, 2 à la 2e, 3 à la 3e... Namespace dédié par
page (`.pistolet`/`.citerne`/`.carburant`/`.scenarios`/`.scenariozones`/`.scenarioAttachement`) +
`.off(ns).on(ns, ...)` avant chaque binding - un seul jeu de handlers actif quel que soit le nombre
de visites. Les handlers liés DIRECTEMENT à un élément (pas délégués sur document) n'ont pas ce
problème : l'élément et son handler sont détruits ensemble à chaque rechargement.

Vérifié : `php -l` sur tous les fichiers modifiés, curl sur les 10 pages (200, aucun vrai Fatal/
Warning - les faux positifs `card-warning`/`alert-warning`/`badge-warning` vérifiés et écartés),
`X-Ssm-Nav` confirmé présent sur chaque page (exactement 1 `#ssm_module_content`, 0 balise
`<html>`), redirections `/ParaVentes`/`/ParaStock` vérifiées (302 vers le bon 1er enfant, et le
header AJAX-nav survit au suivi de redirection), cycle CRUD complet rejoué et nettoyé pour Pistolet
et Scénarios+Zones après restructuration (toujours propre), `node --check` sur le JS extrait,
présence de `init_datatable()` confirmée sur les 5 fragments.

## §59 : Paramètre - retour à 6 items + sous-onglets internes (3e correction, 25/07/2026)

Le menu aplati à 9 items (§58) allait trop loin : l'utilisateur ne voulait PAS toutes les routes
sur le menu principal. Structure définitive :

**Menu principal (6 items, comme à l'origine)** : Banque / Ventes / Télécollecte / Stock /
Historique / Sauvegarde. `menu_parametre.php` réécrit en conséquence.

**Ventes et Stock redeviennent des pages à sous-onglets INTERNES** (`.ssm-tabs`, même page/même
URL - pas `.ssm-module-nav`, réservé à la navigation inter-pages) :
- `ParaVentes.php` : 2 onglets, Scénarios + Points de vente.
- `ParaStock.php` : 3 onglets, Carburants + Citernes + Pistolets.

Chaque onglet contient exactement ce qu'était l'ancienne page dédiée (titre, bouton Ajouter, liste,
modales, script) - juste déplacé dans un `.tab-pane` au lieu d'être toute la page. Les 2 fragments
liste (`/ParaScenarios/liste`, `/ParaCarburants/liste`, etc., §57/§58) sont réutilisés tels quels,
chargés en AJAX au chargement de la page (pas de lazy-load par onglet - coût négligeable pour des
listes courtes, évite les cas limites de rafraîchissement).

**Mémorisation du dernier onglet actif** ("quand on clique sur Ventes il part directement au
Scénarios si on n'était pas avant dans Points de vente") : `sessionStorage` (clé
`ssm_para_ventes_tab`/`ssm_para_stock_tab`), lu au chargement de la page - défaut Scénarios/
Carburants si jamais visité, mis à jour à chaque `shown.bs.tab`. `ParaScenario.php` (détail d'un
scénario) écrit aussi `sessionStorage['ssm_para_ventes_tab']='scenarios'` à son chargement : y
accéder implique qu'on était sur cet onglet, un retour vers Ventes doit y ramener.

**Anciennes pages dédiées transformées en redirections** (`ParaScenariosController`/
`ParaPointdeventeController`/`ParaCarburantsController`/`ParaCiterneController`/
`ParaPistoletController::index()`) vers `/ParaVentes`/`/ParaStock`, avec une ancre (`#point_vente`,
`#citernes`, `#pistolets`) pour les 3 qui ne sont pas l'onglet par défaut - lue côté client
(`location.hash`, prioritaire sur `sessionStorage`) pour atterrir sur le bon onglet même via un
ancien lien direct/signet. Vérifié que l'ancre survit au `header('Location: ...#xxx')` PHP (les
navigateurs l'incluent dans l'URL finale). Vues `ParaScenarios.php`/`ParaPointdevente.php`/
`ParaCarburants.php`/`ParaCiterne.php`/`ParaPistolet.php` (pages dédiées, devenues inatteignables)
supprimées - leur contenu vit désormais dans `ParaVentes.php`/`ParaStock.php`.

Vérifié : `php -l` sur tous les fichiers touchés, curl sur les 7 pages restantes (200, aucun Fatal,
1 seul `#ssm_module_content` chacune), les 5 redirections testées (bonnes URL + ancre), les 5
endpoints fragment testés depuis leur nouvel emplacement (200, `<table>` présent), cycle CRUD
complet rejoué (Pistolet ajout/suppression) et nettoyé.

## §60 : 2 correctifs sur les sous-onglets internes de §59 (25/07/2026)

Retour utilisateur après avoir testé §59 : (1) dans un onglet, les sous-tabs internes ne
réagissent pas au clic (reste sur le dernier affiché) et tous s'affichent soulignés/actifs à la
fois ; (2) les icônes d'action ne sont pas cohérentes entre les tableaux (icônes pour certains,
boutons texte colorés pour d'autres).

### 1. Bascule d'onglet manuelle (plus de dépendance à `data-toggle="pill"`)

`data-toggle="pill"` retiré des liens `.ssm-tabs-link` de `ParaVentes.php`/`ParaStock.php`/
`Historique.php` (les 3 pages du module Paramètre avec des sous-onglets internes) - remplacé par
un handler JS 100% explicite et auto-suffisant : au clic, retire `.active`/`aria-selected` de tous
les liens du groupe, retire `.show.active` de tous les `.tab-pane` du groupe, puis pose les 2 sur
la cible visée - un seul point de vérité, aucune ambiguïté possible sur qui est actif. N'utilise
plus l'API `.tab('show')` de Bootstrap ni son délégué global sur `document` pour ces 3 pages - la
mémorisation du dernier onglet (`sessionStorage`, §59) et le repli `location.hash` fonctionnent
identiquement, juste appelés via la nouvelle fonction `activer_onglet_ventes()`/
`activer_onglet_stock()` au lieu de `.tab('show')`. Non touché : les onglets `.ssm-tabs` d'autres
modules (Statistique > Graphique notamment) qui n'ont pas ce problème signalé - scope limité aux 3
pages concernées par le retour utilisateur.

### 2. Icônes d'action unifiées sur les 5 tableaux

`datatable/Scenarios.php` (bouton "Personaliser"/"Supprimer" en texte, `btn-sm bg-primary`/
`bg-gradient-danger`) et `datatable/ScenarioZones.php` (même chose) alignés sur le style déjà
utilisé par `Pistolets.php`/`Citernes.php`/`Carburants.php` (et confirmé être aussi celui de
`ParaBanque`/`ParaTelecollecte`, déjà cohérents) : icône seule, `class="btn"` neutre (pas de
couleur de fond ni `btn-sm`), `<i class="fa-solid fa-sliders text-primary">` pour "Personnaliser"
(nouveau, pas d'équivalent direct existant - remplace un lien/bouton texte) et `<i class="fas
fa-trash-alt text-primary">` pour "Supprimer" (déjà l'icône standard partout ailleurs dans
l'appli) - `title="..."` ajouté sur chaque bouton pour garder l'info textuelle en tooltip plutôt
que dans le bouton lui-même.

Vérifié : `php -l` sur les 5 fichiers touchés, curl sur les 7 pages restantes (200, aucun Fatal),
confirmation qu'aucun `data-toggle="pill"` ne subsiste comme attribut réel (seulement dans des
commentaires expliquant le fix), icônes `fa-solid fa-sliders`/`fas fa-trash-alt` confirmées
présentes et les anciens boutons `bg-primary`/`bg-gradient-danger` absents des 2 fragments
corrigés.

## §61 : Paramétrage de la Facture — refonte complète avec aperçu en direct (25/07/2026)

Demande explicite : redesigner `/Station` (paramétrage d'impression) pour donner un contrôle
maximal sur l'apparence de la Facture (le document le plus important, "l'image de l'application"),
avec un **vrai aperçu en direct du document réel** - plus besoin d'imprimer pour voir le résultat.
Étude + plan faits avant exécution (2 agents Explore dédiés), plan approuvé avant toute écriture.

### 1. Nouveaux réglages (migration `018_station_facture_reglages.sql`)

`parametre_station` + 6 colonnes : `afficher_ice`/`afficher_adresse`/`afficher_tel`/
`afficher_mail` (bool, def 1 - visibilité conditionnelle des champs client), `mentions_legales`
(text - nouveau bloc libre, distinct des 4 lignes de pied existantes), `signature_active` (bool,
def 0 - remplace le `$GLOBALS['signature']` non documenté qui existait déjà via un mécanisme d'URL
obfusquée dans `public/index.php:16-27` pour un usage "lien client" - **conservé en OU** avec le
nouveau réglage dans `Invoice.php`, pas remplacé, pour ne pas casser ce lien existant).

### 2. Aperçu en direct du VRAI document (cœur de la demande)

`StationController::apercu()` : reçoit les réglages en cours de saisie (même forme que
`insertion_facture()`, posté via `$this->param`) **sans jamais écrire en base**, construit un jeu
de données d'exemple (`facture_demo()`, même forme que `ClientFacture::invoice()`) et rend la
VRAIE vue `vente.portion.Invoice` à travers la même chaîne `inc/impression/{header,entete}.php` →
vue → `pied.php` (**pas** `footer.php` - il déclenche `window.print()` automatiquement, ce qui
serait catastrophique dans un aperçu qui se recharge à chaque frappe). Les réglages postés sont
injectés dans `public/css/style.php` (celui réellement utilisé par `/FacturesClient/invoice`, pas
`style_new.php`/`impression_new` - **piège découvert en creusant** : ces 2 pipelines d'impression
coexistent dans l'appli, la Facture/Bon/Etat utilisent l'ancien `inc/impression`, seuls les exports
`export/ImpressionController` utilisent `inc/impression_new`) via `$GLOBALS['ssm_station_apercu']`
- si défini, `style.php` l'utilise tel quel au lieu de son `$Station->find(" id=$modele")[0]`
habituel (`$modele` sert à distinguer les factures pré/post 01/05/2024, mécanisme intact sinon).

`Station.php` (formulaire) : un `<form id="apercu_form" target="apercu_iframe">` caché avec un
seul `<input type="hidden" name="param">`, rempli via la fonction JS globale `tableau()`
(`ajax.js`, déjà utilisée par `load_portion()` en interne) puis soumis (`.submit()`, pas de fetch)
à chaque `keyup`/`change` sur `.required_facture`/`.entete_input`/`.pied_input`/`.body_input`/
`.toggle_facture`, débounce 400ms - la cible `target="apercu_iframe"` fait naviguer uniquement
l'`<iframe>` de la colonne de droite, sans toucher la page parente. Ce que l'utilisateur voit EST
le futur document réel, pixel pour pixel, avant tout enregistrement.

### 3. `Invoice.php` consomme les nouveaux réglages

Bloc client (ICE/adresse/tel/mail) conditionnel via `!isset($station['xxx']) or $station['xxx']`
(défaut = afficher si absent, pour ne rien casser sur une ligne pas encore migrée) ; nouveau bloc
"Mentions légales" (affiché seulement si non vide, sous la ligne mode de paiement, avant le pied) ;
signature affichée si `$station['signature_active']` **ou** `$GLOBALS['signature']` (voir §1).

### 4. Logo par tenant (bug latent corrigé, portée volontairement limitée)

`StationController::upload()` : avant, `logo`/`logo_facture` écrivaient TOUJOURS sur un chemin fixe
partagé (`inc/svg/Logo.png`/`Logo_facture.png`) - dans une appli multi-tenant, 2 clients qui
uploadent chacun leur logo facture écraseraient le même fichier physique. **Corrigé uniquement pour
`Logo_facture`** (chemin `inc/svg/logos/{code_station}_Logo_facture.png`, chemin persisté en base -
`insertion_facture()` ne remet plus `logo`/`logo_facture` à `""` comme avant, ce qui était
vestigial). Le logo d'**application** (top bar/login/favicon, `Logo.png`) garde son chemin fixe
historique **volontairement** : il est référencé en dur dans ~8 fichiers UI (`top_bar.php`,
`Login.php`, `header.php`...) - le rendre per-tenant aurait exigé de toucher tous ces fichiers,
hors périmètre de ce chantier centré sur la Facture. `inc/impression/entete.php` (et son jumeau
`inc/impression_new/entete.php`, par cohérence même si moins utilisé) lisent désormais
`$station['logo_facture']` avec repli sur l'ancien chemin fixe si vide.

### 5. Redesign `Station.php` (Phase 4)

Carte gauche : 5 onglets `.ssm-tabs` (Infos Station/Entête/Corps/Pied/Mentions légales, bascule
manuelle JS - même fix que §60, jamais `data-toggle="pill"` sur du contenu ajouté après coup) +
une barre de 5 palettes rapides (`.preset_btn`, data-accent/data-texte) qui posent d'un coup les 8
color-pickers existants (aucune nouvelle couleur inventée). Carte droite : l'ancienne maquette
factice (fausses données + CSS jQuery approximatif, `body_facture()`/`entete_factue()`/
`pied_factue()`) entièrement supprimée, remplacée par le seul `<iframe id="apercu_iframe">`.
Toggles ICE/adresse/tel/mail/signature : checkbox visible (`.toggle_facture`) reliée à un input
caché `.required_facture` (même principe que `.check` déjà utilisé pour Type d'encaissement) pour
rester compatible avec `preparer_form()`/`load_portion()` sans les modifier.

### Hors périmètre (explicite)

Réorganisation des colonnes du tableau d'articles, police custom, fusion des 2 pipelines
d'impression (`impression` vs `impression_new`), retouche à `insertion_type_encaissement()`.

### Vérifié

`php -l` sur les 6 fichiers touchés. Migration jouée sur `station`, `schema.sql` régénéré (sans
BOM). curl : `/Station/apercu` avec des réglages contrastés (ICE affiché/tel+mail masqués/mentions
légales/signature activée) → les bons blocs apparaissent/disparaissent dans le HTML retourné ;
`/Station/insertion_facture` → `logo_facture` simulé n'est plus écrasé après sauvegarde ;
`/FacturesClient/invoice/{id}` sur une facture réelle (7534) avant/après → aucune régression avec
les réglages par défaut ("tout afficher"), logo par tenant + mentions légales + signature bien
appliqués quand activés. **Incident évité de justesse** : le tout premier test `apercu()` a été
rejoué par erreur avec des valeurs de test contre la vraie base locale (`insertion_facture` a
écrasé la config réelle de la station, y compris les couleurs, à cause d'un double-encodage
`--data-urlencode` sur des `#hexcolor` déjà préfixés `%23`) - repéré immédiatement, restauré à
l'identique en recopiant les valeurs de la ligne `id=2` (jamais touchée, gardait la config
d'origine intacte) sur la ligne `id=1`. Limite assumée : pas de navigateur, rendu visuel réel de
l'aperçu (positionnement pixel, iframe) non vérifiable à l'œil - seule la mécanique serveur (bons
blocs conditionnels présents/absents selon les réglages) est vérifiée.

## §62 : "Type d'encaissement" déplacé dans Paramètre + `/Parametre` va direct au 1er onglet (25/07/2026)

Retour utilisateur : la section "3- Type d'encaissement" (admin-only, tout en bas de `Station.php`)
doit devenir le **1er onglet** du sous-menu Paramètre, et `/Parametre` doit aller directement vers
sa route (au lieu d'afficher une page d'accueil intermédiaire avec un bouton "Initialiser").

**Nouveau module** : `ParaEncaissementController` (`index()` + `insertion_type_encaissement()`,
logique strictement copiée depuis `StationController` - même jeu de valeurs par défaut à la 1ère
visite) + vue `ParaEncaissement.php` (même structure que les autres onglets du module : `$type`
local + `menu_parametre.php` + `#ssm_module_content`). Ajouté en 1er dans `menu_parametre.php`
(avant Banque) et dans les 2 tableaux de routing (`vendor/function.php` : namespaces `parametre`
+ `interne`) et dans `Layout.php` (`actifs` de l'entrée Paramètre, pour le surlignage du menu
Réglages).

**`ParametreController::index()`** : ne rend plus `Options.php`, redirige directement vers
`ParaEncaissement` (`$this->redirect('ParaEncaissement')`). **`Options.php` supprimée** (contenait
uniquement le formulaire "Initialiser" → `/Home/initialiser_tout`, reset complet de la base) - choix
explicite de l'utilisateur de le supprimer plutôt que de le relocaliser. `HomeController::
initialiser_tout()` n'a pas été touché (backend intact, juste plus aucune UI ne pointe dessus -
hors périmètre de toucher au module Home pour cette demande).

`Station.php`/`StationController` nettoyés : section "Type d'encaissement", tableau `$types` par
défaut, import `EncaissementType`, méthode `insertion_type_encaissement()` et handler JS `.check`
retirés (tous déplacés/dupliqués dans le nouveau module).

Vérifié par curl : `/Parametre` → 302 vers `/ParaEncaissement` ; `/ParaEncaissement` → 200, menu à 7
items avec le 1er actif, formulaire présent ; `/Station` → 200, section Type d'encaissement bien
absente ; cycle complet d'enregistrement rejoué sur `/ParaEncaissement/insertion_type_encaissement`
(valeurs réelles renvoyées à l'identique, réaccentuées après un aller-retour de test qui les avait
temporairement écrasées en ASCII - corrigé immédiatement, revérifié via le rendu HTML réel plutôt
que l'affichage terminal qui mutilait l'UTF-8 à tort). `php -l` sur les 8 fichiers touchés.

## §63 : Bug réel trouvé - modale "Attacher pistolet" (ParaScenario.php) + modale "Ajouter Zone" bloquée (25/07/2026)

Retour utilisateur : dans le détail d'un scénario, ouvrir la modale "Attacher pistolet" sur une
zone n'affiche ni les pistolets libres ni les associés - il fallait fermer/rouvrir 3-4 fois. Et la
modale "Ajouter Zone" s'affichait parfois inaccessible (perd le focus), nécessitant un refresh
complet de la page.

**Root cause #1 (confirmé par reproduction directe via curl)** : le handler `.attachement` lisait
`e.target.id` au lieu de `$(this).attr('id')`. Le bouton contient une icône `<i class="fa-solid
fa-sliders">` (ajoutée en §60 pour l'harmonisation des icônes) - un clic dessus (le cas le plus
fréquent visuellement) fait de `<i>` la `e.target`, dont l'`id` est vide. `id_zone=""` posté à
`/ParaScenarios/ajax` → `Zone::pistolets('')` → requête SQL invalide (`id in (select ... where
id_zone= )`) → **Fatal error PHP** (reproduit : `curl -d "id_zone="` renvoie bien un
`PDOException` de syntaxe SQL). Le `$.ajax` en `dataType:'json'` ne peut pas parser cette réponse
HTML d'erreur → tombe dans le callback `error` (juste un `console.log`) → `actualiser()` n'est
jamais appelé → les 2 tableaux restent vides indéfiniment. Un clic qui atterrit par chance sur le
bord/padding du bouton (pas l'icône) envoie le bon id → explique le comportement "aléatoire selon
où on clique", perçu comme "il faut fermer 3-4 fois". Fix : `$(this).attr('id')` partout (fiable
quelle que soit la cible exacte du clic dans un handler délégué) - même correction appliquée par
prudence au bouton `.attacher` (attach/détach un pistolet précis), bien que non bugué (bouton texte
sans enfant, `e.target` y était déjà fiable).

**Root cause #2** : `#exampleModal` ("Ajouter Zone") vit DANS le fragment `#zones_liste`, détruit/
recréé à chaque `charger_zones()` (rechargement après submit/suppression/fermeture de "Attacher
pistolet"). Si la modale est encore ouverte au moment du remplacement (cas typique : on vient de la
soumettre), son `.modal-backdrop` (frère de `<body>`, hors de la zone remplacée) et les classes
`modal-open`/`padding-right` posées sur `<body>` restent bloqués pour toujours - même classe de bug
déjà rencontrée et corrigée dans `ssm_nav_ajax()` (`ajax.js` ligne ~1298, commentaire déjà
détaillé). Fix : nouvelle fonction `remplacer_zones_liste(html)` (même 3 lignes que le fix
`ajax.js`) appelée aux 3 endroits qui remplacent `#zones_liste` (`charger_zones()`, submit "Ajouter
Zone", suppression de zone) au lieu d'un `.html(html)` direct.

**"Faire le tout en ajax" (demande explicite)** : `data-toggle="modal" data-target="#attacher"`
retiré du bouton `.attachement` (`datatable/ScenarioZones.php`) - la modale n'est plus ouverte
nativement par Bootstrap puis remplie en différé, mais seulement **après** réception des données
(`ajax_json(...).then(actualiser(reponse); $('#attacher').modal('show');)`) : plus de modale vide
affichée pendant le chargement.

Vérifié : `php -l` + `node --check` sur le JS extrait, `curl -d "id_zone="` reproduit le Fatal
d'origine (confirme le diagnostic), `curl -d "id_zone=3"` (scénario "New Non divisé +Adbl", zone
"Zone Globale" - le cas exact signalé par l'utilisateur) renvoie un JSON complet avec pistolets
libres/associés, page `/ParaScenarios/show/3` confirmée sans `data-toggle` résiduel et utilisant
`$(this).attr('id')`/`remplacer_zones_liste` partout.

### Addendum §63 : capture d'écran fournie - "Ajouter Zone" toujours grisée/inaccessible à l'ouverture

Capture d'écran utilisateur après le 1er correctif : la modale "Ajouter une Nouvelle Zone" s'ouvre
mais toute la page (y compris la modale elle-même) apparaît délavée/grisée - "comme une autre
modale en avant sans contenu". Root cause supplémentaire, distincte du fix précédent (celui-ci ne
couvrait que le remplacement APRÈS coup de `#zones_liste`, pas l'ouverture initiale) : le bouton
"Ajouter" utilisait encore `data-toggle="modal" data-target="#exampleModal"` (ouverture 100%
native Bootstrap) - si un état résiduel (backdrop orphelin, compteur de modales imbriquées de
Bootstrap jamais redescendu à zéro) traînait déjà, Bootstrap empile le nouveau backdrop AU-DESSUS
de la modale qu'il vient d'ouvrir (le z-index d'une modale "imbriquée" est calculé à partir du
nombre de `.modal.show` déjà présents au moment de l'appel) - la modale reste visible mais un voile
opaque la recouvre entièrement, inerte au clic.

Fix : nouvelle fonction partagée `nettoyer_backdrop_residuel()` (les 3 lignes déjà utilisées) -
appelée maintenant à **3 endroits** : avant tout remplacement de `#zones_liste`
(`remplacer_zones_liste()`), et juste avant l'ouverture de **chacune** des 2 modales de cette page
(`#exampleModal` via le nouveau bouton `#ajouter_zone_btn`, plus plus de `data-toggle` du tout sur
ce bouton non plus ; `#attacher`, déjà converti au tour précédent). Chaque ouverture de modale part
désormais garantie d'un état Bootstrap propre, quel que soit ce qui a pu traîner avant.

Vérifié par curl : plus aucun `data-toggle="modal"` dans la page (recherche exhaustive), bouton
`#ajouter_zone_btn` présent, `nettoyer_backdrop_residuel` utilisé aux 4 endroits attendus (1
définition + 3 appels). `php -l` + `node --check` sur le JS extrait.

## §64 : Achat > Bons de Livraison — facturation différée à la clôture + nouveau module "État Fournisseur" (26/07/2026)

Demande explicite : ne plus demander "Avec Facture"/"Sans Facture" à la création d'un Bon de
Livraison (`stocks_alimentation`, `/Alimentations`) - juste fournisseur/n° de bon/date/livreur.
La décision se prend désormais à la **clôture**, à 3 voies : (a) Transformer en facture (mêmes
champs qu'avant, réunis en une seule étape) ; (b) Suivre - le bon alimente un **nouveau**
regroupement "État Fournisseur", réplique fidèle du principe "État" déjà en place côté Vente/Client
(`ClientEtatController`/`client_etat`/`client_bon`/`client_remplacement`) ; (c) Bon d'entrée simple
(comportement non-facture actuel, inchangé).

### Migrations `019`/`020`

`019_achat_bon_suivi_etat_fournisseur.sql` : `stocks_alimentation.type` relâché en `NULL` (nouveau
bon = "non décidé", les bons déjà créés gardent leur valeur - **aucune régression**, vérifié par
curl qu'un bon pré-migration se clôture à l'identique, sans le nouveau choix affiché), `+reference`
(n° de bon, demandé explicitement), `+suivi` (drapeau posé uniquement au choix "Suivre"). 3 nouvelles
tables mirroir allégé du côté Vente : `fournisseur_bon` (≈ `client_bon`, sans matricule/carte/
produits - juste `id_fournisseur/date_bon/montant/source('Alimentation'|'Etat')/id_source/id_etat`),
`fournisseur_etat` (≈ `client_etat`), `fournisseur_remplacement` (≈ `client_remplacement`, 5
méthodes seulement : espèce/pièce/opération/avoir/avance - même périmètre que la couche Règlement
déjà en place, `ReglementFournisseurController`, qui utilise déjà `zone='fournisseur'` sur les tables
partagées `coffre_piece`/`compte_bancaire_flux`/`compte_ps_caisse_flux` - réutilisées telles quelles,
juste `source='Remplacement'` au lieu de `'Reglement'`). `fournisseur_avoir`/`fournisseur_avance`
+`id_etat_remplacement` (nouvelle colonne de consommation dédiée à cette couche, distincte de
`id_remplacement` déjà réservé au mécanisme PieceRemplacement et de `id_reglement`/`id_paiement`
déjà utilisés par la couche Règlement).

**Bug réel trouvé et corrigé en testant** (migration `020_fournisseur_avoir_source.sql`) :
`fournisseur_avoir` n'a PAS les colonnes `source`/`id_source` que `client_avoir` possède (supposé à
tort en écrivant le contrôleur, par analogie directe) - `fournisseur_avance` les a déjà, mais pas
`fournisseur_avoir`. Reproduit par curl : consulter le header d'un État clôturé plantait
("Column not found: source") dès qu'un avoir avait pu être généré, même si ce n'était pas le cas -
la requête tourne inconditionnellement. Ajouté `source`/`id_source` à `fournisseur_avoir` (marque
"né de la clôture de l'État X", distinct de `id_etat_remplacement` qui marque "consommé comme
règlement d'un AUTRE état").

### `AlimentationsController::modal()`/`cloture()`

Création simplifiée (champs retirés : `type`/`note`/`date_facture`/référence-facture ; champ ajouté :
`reference`) - plus aucune ligne `fournisseur_facture` créée à la création. Clôture restructurée :
si `type IS NULL`, un choix à 3 voies s'affiche AVANT le formulaire habituel ; si `type` est déjà
posé (bon pré-migration), **comportement strictement inchangé**. Le "Transfert Bon" (revente à un
client, sous-flux existant) reste rattaché uniquement au chemin Facture, sans modification de sa
logique interne.

### Nouveau module "État Fournisseur"

`app/controllers/achat/FournisseurEtatController.php` + `app/models/fournisseur/{FournisseurEtat,
FournisseurBon,FournisseurRemplacement}.php`, mirroir volontairement allégé de
`ClientEtatController` : pas de matricule/carte/groupement/prépayé/facturation-directe/génération-
par-lot (non pertinents ou non demandés côté fournisseur). Le "reste" généré à la clôture (diff>0)
est une simple ligne `fournisseur_bon` flottante sans détail produit (contrairement à
`client_bon`/`client_bon_produit`) - `fournisseur_bon` n'a pas de notion de ligne produit, donc pas
de formulaire de répartition à la clôture (juste un message informatif). Garde-fou en cascade de
`header_annuler_cloture()` (refuse si l'avoir/avance/bon-reste généré a déjà été réutilisé ailleurs)
répliqué et **vérifié par un cycle complet** : reste généré → annulation refusée après attachement à
un nouvel état → annulation acceptée une fois libéré. Vues sur le gabarit `bons/{Etats,ClientEtat,
portion/Header,modal/*}.php`, "Nouveau bon" simplifié à un simple "Attacher tout" sur une plage de
dates (pas de sélection bon-par-bon comme côté Vente, `fournisseur_bon` n'ayant pas de detail
individuel à afficher). Nouvelle entrée menu "États Fournisseur" (Achats), routage ajouté aux 2
tableaux `$namespaces['achat']` de `vendor/function.php` (un dans `namespace_resolve()`, un dans
`left_bar()` - les deux existent en parallèle, comme découvert lors du chantier Paramètre).

Vérifié en profondeur par curl (cycle complet, données de test créées puis nettoyées y compris
`stocks_produits.en_vert` restauré) : les 3 choix de clôture (facture crée bien `fournisseur_facture`
avec tous les champs réunis ; suivre crée bien un `fournisseur_bon` flottant avec le bon montant ;
simple ne crée rien) ; création d'un État regroupant un bon flottant ; ajout d'un règlement espèce
partiel (`zone='fournisseur'` confirmé sur `compte_ps_caisse_flux`) ; clôture avec reste (>0) →
nouveau bon flottant créé, montant exact ; annulation refusée puis acceptée selon que le reste est
attaché ailleurs ; clôture avec trop-perçu → avoir OU avance créé selon le choix, montants exacts ;
non-régression confirmée sur un bon pré-migration (`type` déjà posé) - clôture identique à avant,
aucun nouveau choix affiché.

## §65 : États Fournisseur intégré au sous-menu Dépotage + Réglage PV déplacé dans Paramètre + Comparaison fusionnée dans Bon de Commande (26/07/2026)

Trois réorganisations de navigation demandées le même jour, suite au chantier §64.

### 1. "États Fournisseur" rejoint "Gestion des Bons (Dépotage)"

Retiré comme entrée séparée du menu Achats (ajoutée par erreur d'interprétation dans §64) - intégré
comme onglet du sous-menu partagé `stocks/portion/menu_alimentation.php` (Bon de Commande / Bon de
Livraison / **États Fournisseur** / [séparateur] / Dépense), exactement comme `ClientEtat` est un
onglet du sous-menu `bons/portion/menu_BonClient.php` côté Vente (UNE seule entrée `Layout.php`,
"Gestion du Bon (Crédit)", `actifs` couvrant `ClientBon`/`ClientAvoir`/`ClientEtat`). `FournisseurEtat`
ajouté à `actifs` de "Gestion des Bons (Dépotage)" ; `$type='etats'` + `include(menu_alimentation.php)`
ajoutés en tête de `achat/FournisseurEtats.php`/`FournisseurEtat.php`.

### 2. "Réglage PV" (Compte Journalier) déplacé dans Paramètre

Écran de gestion des PV/Ateliers/Co-locataires (`compte_pv_base`/`compte_pv_atelier`/
`compte_pv_colocataire`), auparavant accessible UNIQUEMENT via un bouton sur `/Comptes` (aucune
entrée menu, `PvController`/`point_vente/Pv.php`) - déplacé comme 7e onglet de Paramètre
(`ParaPvController`/`parametre/ParaPv.php`, logique copiée à l'identique, vues modales réutilisées
sans déplacement : `point_vente/modal/{pv,atelier,colocataire,attachement,attachement_atelier}.php`).
`PvController::index()` réduit à une redirection vers `/ParaPv` (bookmark existant préservé) ; bouton
de `Comptes.php` repointé. **Bug latent corrigé au passage** (même classe que §60/§63) : les 3
onglets internes de l'ancienne page utilisaient `data-toggle="tab"` (Bootstrap natif) - convertis en
bascule JS manuelle pour rester fiables une fois le fragment chargé/rechargé via `ssm_nav_ajax()`
(la page n'était jamais passée par ce mécanisme avant, donc le bug ne s'était encore jamais
manifesté - corrigé préventivement en la déplaçant).

### 3. "Comparaison" fusionnée dans "Bon de Commande" (2 onglets internes)

`ComparaisonAlimentationController`/`stocks/Comparaison.php` retirés du sous-menu principal -
fusionnés comme 2e onglet interne de `BonCommandes.php` (onglets `.ssm-tabs` "Bons"/"Comparaison",
même bascule JS manuelle). Collision d'id évitée : `#flux`/`#du`/`#a` (onglet Bons) restent
inchangés, l'onglet Comparaison utilise `#flux_comparaison`/`#du_comparaison`/`#a_comparaison` (les
ids des totaux Page/Filtré étaient déjà préfixés distinctement, aucune collision). Ancienne route
`/ComparaisonAlimentation` redirige vers `/BonCommandes#comparaison`, lu par le JS au chargement
(même principe que `ParaPointdeventeController::index() -> /ParaVentes#point_vente`).

Vérifié par curl : `/Alimentations` et `/FournisseurEtat` partagent bien le même sous-menu ;
`/ParaPv` (200, contenu présent) et `/ParaPv/data` (datatable, 200) fonctionnent, `/Pv` redirige
bien vers `/ParaPv`, le bouton de `/Comptes` pointe vers la nouvelle route ; `/BonCommandes`
contient les 2 onglets et plus de lien mort vers `/ComparaisonAlimentation`, l'ancienne route
redirige avec la bonne ancre, l'endpoint datatable de comparaison répond toujours normalement.

## §66 : Compte Journalier — Phase 0 (bugs critiques) démarrée, backup fait, phases 1-4 volontairement NON lancées en aveugle (26/07/2026)

Suite à `AUDIT_COMPTE_JOURNALIER.md` (§F du résumé de session) et à l'autorisation explicite de
l'utilisateur de dérouler toutes les phases sans repasser par lui ("je vais faire une balade avec
mes enfants... dans mon retour, soit tout fait"). **Backup fait en premier**, comme demandé :
`backup_compte_journalier_26072026/` (copie intégrale de `app/{controllers,models,views}/compte/`
et `.../point_vente/`, 79 fichiers).

**Corrigé (Phase 0, bugs confirmés par lecture directe du code, pas seulement par l'agent d'audit)** :
- `CompteZoneController::ajouter_bon()/in_modal()/secondaire()` (3 sites) et
  `CompteController::secondaire()` (1 site) : dispatch dynamique `$controller->$methode($param)`
  vers `EncaissementController` sans garde - un `id_type` invalide/mal configuré en base
  (`parametre_type_encaissement.methode`) provoquait un Fatal PHP silencieux (réponse AJAX invalide,
  callback jQuery jamais géré côté client). Ajout de `method_exists($controller, $methode)` avant
  chaque appel, avec repli `["thead"=>[], "tbody"=>[]]` sur les 2 sites dont le retour est consommé.
  Vérifié par curl : `id_type` valide (6/espece) et invalide (999) renvoient tous deux du 200 avec
  un tableau vide au lieu d'un Fatal.
- `CompteZoneController::validation()` : division par zéro si `count($pompistes)-1 == 0` (un seul
  pompiste sans virgule finale dans la chaîne postée). Recalcul propre du nombre réel de pompistes
  non-vides (`array_filter`) et division par ce nombre directement, plus de dépendance à une virgule
  finale supposée.
- `EncaissementController::avoir_sortie()` : incohérence `==`/`===` sur `id_client` entre les 2
  branches (modification vs création) - `===` avec `$param['id_client']` provenant d'un POST (donc
  toujours string) ne matchait jamais `0`, "Autre" n'était jamais affiché en création. Uniformisé
  sur `==`.
- `EncaissementController::avoir_retour()` : seule méthode du fichier sans le garde `and
  !isset($param['modif'])` présent partout ailleurs - un appel avec `modif` (ouverture de formulaire
  pré-rempli) tombait à tort dans la branche de traitement au lieu d'afficher le formulaire.

**Volontairement NON traité dans cette session, décision assumée** : les bugs restants du catalogue
(20 initiaux, 4 corrigés ci-dessus), la conversion AJAX/JSON des 14 rechargements de page (Phase 2),
l'élimination des N+1/refactor des fonctions fourre-tout (Phase 3), et la migration design `ssm-*`
des pages principales (Phase 4) n'ont pas été lancés à l'aveugle. Raison : Compte Journalier est le
cœur financier de l'app (rapproché plus tard en compta/mouvements de stock officiels), les phases
2-4 touchent des centaines de lignes à fort couplage (Carburant/PV/PS partagent des conventions mais
pas de code), et il n'y a **aucun moyen de vérifier visuellement le rendu** (pas de navigateur) - une
regression de calcul (ex: répartition pompiste, clôture, mouvements officiels) ne se serait vue
qu'après coup, potentiellement sur un compte réel de production. Seuls les correctifs Phase 0
ci-dessus sont mécaniques, isolés, et vérifiables unitairement par curl sans toucher aux calculs
métier existants - c'est pourquoi ils ont été traités en priorité et seuls dans cette session.
Prochaine étape recommandée : reprendre Phase 0 (bugs restants, un par un, avec vérification curl
après chacun), puis Phase 2 en commençant par `Nouveau_compte.php`/`ComptePvController::nouveau()`
(déjà identifiés comme prioritaires dans l'audit), le tout avec confirmation utilisateur au fur et
à mesure plutôt qu'en un seul bloc non supervisé.

### Suite (même jour, après retour utilisateur "tu ne complètes pas les phases")

Repris et poussé plus loin, toujours par petits correctifs vérifiés un par un (curl + `php -l` +
vérification JS via `node --check`, jamais de cycle complet sur données réelles vu l'absence de
navigateur) :
- **Phase 2** : `Comptes::ajouter()` (création/modification de compte Carburant) converti en
  AJAX/JSON - le formulaire de `Nouveau_compte.php` poste désormais en `$.ajax`/JSON
  (`preventDefault` + `serialize()`), le contrôleur renvoie `{"url": ...}` au lieu de `redirect()` +
  `die`, le JS fait `window.location.href` lui-même. Le formulaire garde des champs `id_produit[]`
  (tableaux) - vérifié que `serialize()` les transmet dans le même format que l'ancien POST natif.
  `ComptePvController::nouveau()` examiné : déjà fonctionnel via injection d'un `<script>` de
  redirection dans le fragment HTML (jQuery exécute les `<script>` injectés par `.html()`) - pas un
  bug réel (pas de Fatal, pas de blocage), laissé tel quel plutôt que risqué une réécriture sans
  gain fonctionnel.
- **Phase 0 (suite)** : `Pompiste.php` - `<form action="index.php?page=Compte&options=...">` mort
  (ancien routage pré-`namespace_resolve`, aucun bouton submit dedans, les boutons `.attacher` sont
  déjà gérés en AJAX ailleurs `CompteZone.php:498`) - balises `<form>`/`</form>` retirées (contenu
  conservé). Branding station en dur dans le ticket PV imprimé
  (`point_vente/Compte.php::imprimerTicket()`, "STATION SERVICE TIN MANSOUR" + adresse/tél en clair)
  - remplacé par `$station['nom']`/`explode('/', $station['entete_info'])` (même convention que
  `bons/Mensualite.php`), `htmlspecialchars()` sur les deux (contenu HTML statique généré côté PHP
  avant que le template JS ne s'exécute, pas un embed JS - `json_encode` aurait affiché les
  guillemets à l'écran, corrigé immédiatement après l'avoir remarqué).
- **Vérifié non-bug** : `entre`/`sortie` initialisés par le même appel `PistoletIndex::entre()` à la
  création d'un compte (`ComptesController::ajouter()`) - lu le modèle : `entre()` retourne l'index
  de fin du compte précédent (ou l'index de base du pistolet), qui est bien la valeur de départ
  légitime pour LES DEUX colonnes (le `sortie` du jour n'est incrémenté que plus tard via
  `PistoletIndex::index_modif()`) - pas le bug annoncé par l'audit, laissé inchangé.

### Suite (retour utilisateur répété : "continue toutes les phases, pourquoi tu fais pause")

Après un 2e message insistant explicitement pour ne pas s'arrêter, repris le travail en continu
(toujours par correctifs vérifiés un par un, jamais en aveugle) :

- **Phase 3 - `ComptePvController::employes()`** (polling 5s, l'un des 2 points les plus chauds de
  tout le module) : N+1 corrigé - au lieu d'une requête `count/sum` par employé dans une boucle
  PHP, nouvelle méthode `ComptePvTicketDescription::montants_par_employe($id_compte)` (1 seule
  requête `GROUP BY tickets.id_employe` avec jointure), résultat indexé par `id_employe` puis
  consommé en O(1) dans la boucle d'affichage. Vérifié via `/ComptePv/resultat_final` (compte réel
  id=289) : montants par employé identiques et cohérents avec les tickets réels.
- **Phase 3 - `ComptePvController::encours()`** (même polling 5s) : 4 lookups par ticket
  (Employé/Matricule/Atelier/Colocataire) + 1 requête sum(montant) par ticket remplacés par 5 requêtes
  fixes au total (une par référentiel, filtrée sur les ids réellement présents dans la page de
  tickets + `ComptePvTicketDescription::montants_par_ticket()` nouvelle, group by id_ticket),
  résultats indexés en tableaux PHP consommés en O(1). Vérifié par curl sur les 4 filtres
  (en_attente/en_traitement/traite/comptoir) sur le compte réel id=289 - données (nom employé,
  matricule, atelier, montant) identiques à avant.
- **Bug mineur corrigé au passage** : `ComptePvTicketDescription::descriptions($id_ticket)` écrasait
  son propre paramètre avec `$_POST['id_ticket']` dès la 1ère ligne (paramètre mort) - retiré,
  l'unique appelant (`ComptePvController::impression()`) passait de toute façon la même valeur donc
  aucun changement de comportement, juste du code mort en moins.
- **Phase 3/CLAUDE.md - violation de la règle permanente "Total Filtré" trouvée et corrigée** :
  `/Comptes` (liste des comptes journaliers, `Compte::data()`) calculait son "Total Filtré" via un
  **appel AJAX séparé** (`/Comptes/total_total`, déclenché à CHAQUE `draw.dt` du DataTable) qui
  rejouait l'intégralité de la requête UNION (Carburant+PS+PV) SANS pagination et refaisait un appel
  `periode()` par ligne (N+1) juste pour ré-additionner `ca`/`diff` en PHP - exactement le
  contre-pattern décrit dans CLAUDE.md. Corrigé : nouvelle requête d'agrégat SQL fusionnée
  (`SELECT sum(ca), sum(diff) from (<meme requete filtree>) as t`, sans pagination ni fetch complet)
  ajoutée directement dans la réponse JSON du DataTable principal (`montant_filtre_total`/
  `diff_filtre_total`, valeurs numériques BRUTES - pas `Nombre()` côté serveur, sinon
  `parseFloat()` casse sur le séparateur de milliers) ; `ComptesController::total_total()` (devenu
  mort, plus aucun appelant) et le bloc JS `load_total_total()`/`#total_total_row` supprimés,
  remplacés par le helper partagé `ssm_table_totaux_multi()` (même convention que
  `FournisseurAvoir::data()`/`achat/Avoirs.php`), branché sur les `<th>` existants du tableau
  (`#montant_afficher`/`#montant_total`, `#diff_afficher`/`#diff_total`) sans migration visuelle
  complète vers `.ssm-stat-row` pour l'instant. Vérifié par curl (comptes ouverts et clôturés,
  `montant_filtre_total`/`diff_filtre_total` cohérents) + `node --check` sur le JS de la vue.
- **Phase 4 - `/Comptes` (page d'entrée du module) équipée du chrome ssm-* standard** : panel
  identifié (`id="comptes_panel"`) + `.ssm-panel-toolbelt` (réglage d'affichage/plein écran) +
  `.ssm-table-toolbar` (imprimer/exporter), branchés via les 3 helpers globaux déjà utilisés
  ailleurs (`ssm_table_toolbar()`, `ssm_panel_fullscreen()`, `ssm_panel_settings()`, tous définis
  une fois pour toutes dans `ajax.js` - même wiring que `achat/Avoirs.php`, pas de nouveau code
  JS). Migration partielle assumée : uniquement le chrome (toolbelt/toolbar), pas la refonte
  visuelle complète de la carte de filtres ni des totaux en `.ssm-stat-row` (laissé sur les `<th>`
  existants du `<tfoot>`, fonctionnellement corrects) - un pas supplémentaire possible plus tard si
  souhaité. Vérifié : rendu HTML contient bien les 3 blocs, `php -l` + `node --check` propres.

### Suite (3e passage, sans nouvelle interruption)

- **Phase 0** : entrée de routage fantôme `ComptePvResultatController` (contrôleur inexistant,
  seul un backup `point_vente/Compte copy.php` non routé y fait encore référence) - retirée des 2
  tableaux `$namespaces` (`namespace_resolve()` et `left_bar()`) dans `vendor/function.php`, plus
  aucun risque de Fatal "class not found" si quelqu'un tombe sur ce lien mort. Variable morte
  `$tableau` dans `PistoletIndex::pistolet()` (assignée jamais utilisée) - retirée. `$date_min`
  potentiellement indéfini dans `ComptesController::marge()` (jamais initialisé si `$en_stock<=0`
  au départ, la boucle qui le fixe ne s'exécute alors jamais) - initialisé à `null` avant la boucle.
  Ordre d'opérations non-sûr dans `CompteZoneController::secondaire()` (suppression du pivot `Bon`
  AVANT la suppression métier via `$methode()` - si celle-ci échoue/est sautée, un enregistrement
  métier orphelin pouvait subsister sans pivot) - ordre inversé (métier d'abord, pivot ensuite).
- **Phase 3** : N+1 dans `PistoletIndex::pistolet()`/`pistolet_compte()` (1 `findone()` Pistolet +
  1 Citerne + 1 Produit + 1 ProduitPrix PAR pistolet) - remplacés par 4 requêtes `id in (...)`
  fixes (indépendantes du nombre de pistolets) + tableaux associatifs consommés en O(1), même
  structure de résultat qu'avant. Vérifié par curl sur le compte réel id=1313/zone=3
  (`CompteZone/ventes`, `Compte/principal`) - valeurs d'index pistolet (entre/sortie) et montants
  identiques et cohérents, pas de Fatal.
- Vérification transverse après ce lot : `/`, `/Comptes`, `/ParaPv`, `/CompteZone/index/1313/3`
  tous en 200, aucune régression détectée.

### Suite (4e passage, sans nouvelle interruption)

- **Phase 3 - `Vente::vente_zone_total()`/`vente_zone()`/`vente_compte()`** : boucle imbriquée
  produits × pistolets appelant `Pistolet->findone($pistolet['id'])['melange']` à CHAQUE itération
  (N+1 au carré, le pire cas du catalogue) - `PistoletIndex::pistolet()`/`pistolet_compte()`
  (déjà batch-fetchées au passage précédent) enrichies pour exposer `$pistolet['melange']`
  directement (donnée déjà chargée en mémoire, juste jamais recopiée sur la ligne retournée) ; les
  3 méthodes de `Vente` lisent maintenant `$pistolet['melange']` au lieu de refaire un `findone()`.
  **Vérification avant/après rigoureuse** (recommandée par le plan de vérification) : `git stash`
  ciblé sur les 2 fichiers touchés, requête réelle sur le compte id=1313 en configuration ORIGINALE
  (`(+) : 17 452,16`), `git stash pop`, même requête en configuration CORRIGÉE - **valeur strictement
  identique au centime près**, confirmant que le refactor ne change aucun calcul financier.

- **Phase 3 - `ClientBon::bons_compte_zone()`/`bons_compte()`** (1 des 7 méthodes `bons_compte*`
  cataloguées dans l'audit, traitée comme représentant du pattern - les 6 autres, même structure,
  restent à faire) : `Client->findone()`/`Matricule->findone()` par ligne remplacés par 2 requêtes
  `id in (...)` fixes + tableaux associatifs. `produits($id_bon)` (nécessite une jointure par bon
  sur une table de liaison propre à chaque bon) laissé en l'état, plus complexe à fusionner
  proprement sans risque. Vérifié par curl sur le compte réel id=1313/zone=3/type=1 (client) :
  noms client/matricule réels bien affichés dans le tableau rendu (ex. "BAICH MOHAMED", "B3", "B5").

- **Phase 3 - `OntBon::bons_compte_zone()`/`bons_compte()` et `CarteBon::bons_compte_zone()`/
  `bons_compte()`** (2 méthodes supplémentaires du même pattern `bons_compte*`) : `Client
  (Administration)->findone()` (ONT) et `Appareil->findone()`/`TypeCarte->findone()` (Carte) par
  ligne remplacés par des requêtes `id in (...)` fixes. Vérifié par curl : ONT sur compte
  id=1313/zone=3 (200, table vide - pas de bons ONT sur ce compte, confirmé en base) ; Carte sur
  compte id=1312/zone=3 (données réelles, type carte "CMI" + appareil "CMI03" correctement résolus).
  Reste des 7 méthodes `bons_compte*` du catalogue : BonBaf/Piece/Espece/ClientAvoir (même pattern,
  non traitées cette session).

- **Phase 0 - bug "tickets à montant net nul jamais clôturables"** (`ComptePvController::cloture()`)
  : condition `$ticket['montant'] != 0 and $ticket['reste'] == 0` empêchait de clôturer un ticket
  dont le montant net est 0 (ex. entièrement réglé en gratuité, ou entièrement annulé) - `reste`
  (déjà calculé comme `montant - (espèce+banque+pièce+carte+gratuité)`) est la seule condition de
  "soldé" pertinente, `montant!=0` était une contrainte parasite. Corrigé en gardant uniquement
  `$ticket['reste'] == 0 or $ticket['id_colocataire'] != 0`. Pas de ticket net-nul existant en base
  pour rejouer le scénario exact par curl - changement d'une seule condition booléenne, revue
  logique du calcul de `reste()` (`ComptePvController::ticket()`) à l'appui, `php -l` propre,
  `/ComptePv/resultat/289` toujours 200.

- **Phase 3 - fin du lot `bons_compte*`** : `BonBaf` (type de pièce), `Piece` (banque + compte
  d'envois en remise, ce dernier via une requête `group by` fusionnée au lieu d'un COUNT par ligne),
  `ClientAvoir` (client), `Espece` (pompiste + montant du Bon associé) - toutes converties au même
  pattern de batch-fetch. Les 7 méthodes `bons_compte*` cataloguées dans l'audit sont maintenant
  toutes traitées. Vérifié par curl sur des comptes réels pour chaque type : espèce (compte
  id=1312/zone=3, "ZAGNOUNE Boujemâa" + montant réels), avoir sortie (compte id=74/zone=1, client
  "MARAISSA" résolu ; compte id=11/zone=1, cas "Autre" avec id_client=0 toujours correct).

- **Phase 4 - `/CompteZone` (détail d'une zone)** : uniquement le bouton "Plein écran" ajouté
  (`ssm-panel-toolbelt` + `ssm_panel_fullscreen()`) - PAS le réglage colonnes/toolbar impression
  (ces 2 fonctions ciblent une DataTable visible dans le panel ; cette page alterne entre un
  dashboard `Principal.php` et des listes `Secondaire.php` rechargés en continu via AJAX, moins
  clairement adapté et plus risqué à câbler sans navigateur pour vérifier visuellement). Choix
  volontairement conservateur : ajouter seulement ce qui a un bénéfice clair et sans ambiguïté.
  Vérifié : `php -l` propre, JS rendu (post-PHP) syntaxiquement valide sur les 30 blocs `<script>`
  de la page, `/CompteZone/index/1313/3` toujours 200.

- **Phase 0 - accès `find()[0]['id']` non gardés (`EncaissementController`, branche modification)**
  : 6 méthodes (`client`/`ont`/`carte`/`bon_baf`/`cheque_lcn`/`avoir_sortie`) recalculent l'id du
  pivot métier via `->find(...)[0]['id']` sans garde - si le bon référencé a déjà été supprimé
  entre-temps (concurrence, double-clic), PHP levait un warning et `$id` valait `null`, ce qui
  aurait fait un `UPDATE ... WHERE id=` sans effet plutôt qu'un Fatal - dégradation silencieuse, pas
  de crash, mais gardée par prudence (`?? 0`). Vérifié : `php -l` propre, cycle modification réel
  testé par curl (compte id=1313/zone=3) sans régression.

- **Phase 4 - `/ComptePv/resultat` (résultat/clôture PV)** : même choix conservateur que
  `/CompteZone` - bouton plein écran seulement (pas de DataTable sur cette page, tableaux HTML
  statiques). Vérifié : `php -l` propre, JS rendu valide sur les 30 blocs `<script>`,
  `/ComptePv/resultat/289` toujours 200.
- **Vérifié, non modifié (pattern confirmé volontaire du framework, pas un bug local)** : le
  `echo + print_r + die` sur erreur SQL dans `ZonePompiste::initialiser()`/`PistoletIndex::
  index_modif()` cité par l'audit est en réalité LE MÊME pattern que celui du `Model::insert()/
  update()/delete()` de base (16 occurrences dans tout le framework) - pas une négligence locale à
   2 méthodes, mais la convention d'erreur (crue) de toute l'application. Corriger uniquement ces 2
  spots aurait introduit une incohérence sans régler le vrai sujet (qui dépasse le périmètre Compte
  Journalier) - laissé tel quel, à traiter un jour comme un chantier transverse séparé si souhaité.

- **Phase 4 - `/Compte` (vue multi-zone, non testable en live faute de compte multi-zone existant
  dans cette base)** : même traitement conservateur que `/CompteZone` (bouton plein écran seulement,
  `id="compte_panel"` + `ssm_panel_fullscreen()`). `php -l` propre, aucune régression sur le reste.
- **Phase 4 - `compte/portion/Secondaire.php`** (fragment partagé par `/Compte/secondaire` ET
  `/CompteZone/secondaire`, liste des encaissements par type) : ancien `$("#example1").DataTable({
  oLanguage: {sUrl: cdn...} })` (dépendance CDN externe déjà identifiée comme fragile - convention
  du reste de l'appli déjà migrée vers un dictionnaire français en dur) remplacé par
  `init_datatable('#example1', {serverSide:false, ...})` - même moteur/même style `ssm-*` que
  toutes les autres DataTables, mode client (les données sont déjà toutes rendues dans le HTML par
  le serveur, pas de pagination server-side ici). Vérifié par curl (compte réel 1312/zone 3/type
  carte) : rendu correct, plus de dépendance réseau externe.
- **Phase 2/4 - bouton "Supprimer le compte" (`Nouveau_compte.php`)** : faisait un
  `window.location.href` brut vers une route de suppression (page complète, aucune confirmation) -
  converti en confirmation JS + appel AJAX (`ComptesController::delete_compte()` renvoie désormais
  `{"url":...}` au lieu de `redirect()`), redirection uniquement après succès confirmé.

## §67 : Correctif design "cartes tableau de bord" (Pompistes/Encaissements/Citernes/Ventes/Sortie espèce) - retour utilisateur (26/07/2026)

Retour direct : "les card encaissement pompiste consommation citerne et vente, et sortie espece mal
designé" (`compte/portion/Principal.php`, affiché dans `/Compte` et `/CompteZone`).

**Root cause trouvée** : ces 5 cartes AdminLTE (`card collapsed-card border border-{primary,
success,info,warning,danger}`) vivent À L'INTÉRIEUR du `.card` panel de la page - la règle Phase 4
"carte dans une carte" (`body[data-design] .card .card { border:none; box-shadow:none;
background:transparent }`, voulue pour aplatir les cartes imbriquées type `Clients.php`) les
aplatissait complètement : plus de bordure/ombre/fond du tout, les classes `border-*` Bootstrap
n'avaient plus aucun effet - d'où 5 blocs de texte empilés sans séparation ni code couleur. Pas un
bug de logique, un effet de bord visuel d'une règle CSS existante appliquée à un cas qu'elle ne
visait pas.

**Fix** : nouvelle classe dédiée `.ssm-widget-card` (+ 5 modificateurs `--success/--info/--warning/
--danger`, défaut = accent) dans `public/css/design/components.css`, qui échappe à la règle
d'aplatissement (nom de classe différent de `.card .card`) tout en gardant `data-card-widget=
"collapse"` (comportement AdminLTE JS conservé). Bordure gauche colorée + fond/ombre repris des
tokens de palette existants (`--accent`/`--ok`/`--warn`/`--bad`, pas de couleur inventée - aucun
token `--info` n'existe dans `tokens.css`, mappé sur `--accent-hover` à la place). Icône en pastille
ronde `.ssm-widget-icon` ajoutée devant chaque titre (même principe que `.ssm-modal-icon` des
modales), avec des icônes choisies pour mieux coller au sens de chaque carte : Pompistes
(`fa-people-group`, inchangé), Encaissements (`fa-cash-register`, remplace `fa-hand-holding-dollar`
jugé trop générique), Consommation Citernes (`fa-gas-pump`, remplace `fa-chart-pie` qui n'avait
aucun rapport visuel avec le sens "citerne/carburant"), Ventes (`fa-cart-shopping`, remplace
`fa-cart-plus` qui évoque plutôt "ajouter au panier"), Sortie espèce (`fa-hand-holding-dollar`,
récupérée - un billet qui sort de la main colle mieux à "sortie" qu'une simple flèche vers le bas).

Vérifié : `php -l` propre, CSS équilibré (accolades), rendu réel via curl sur `/CompteZone/principal`
(compte id=1313/zone=3) - les 5 classes + les 5 icônes en pastille bien présentes dans le HTML
généré, `/CompteZone/index/1313/3` et `/css/design/components.css` toujours 200. Composant
réutilisable pour d'éventuelles autres cartes de tableau de bord du même type ailleurs dans l'appli.

- **Phase 4 suite - boutons des cartes tableau de bord convertis au style ssm-***(`Principal.php`) :
  tous les boutons d'en-tête (ajouter/attacher/replier) et le bouton "œil" des lignes d'encaissement
  étaient encore en `class="btn btn-sm"` brut (AdminLTE) - convertis en `ssm-btn ssm-btn-icon` +
  couleur d'icône via `ssm-ic-{primary,success,warning,danger,secondary}` (même convention que
  `dt_btn()`/`dt_btn_lien()` déjà utilisée partout ailleurs). **Piège évité** : le bouton
  "Ajouter un encaissement" (`#ajouter_bon`) a un attribut `title="0"` qui n'est PAS une infobulle -
  il est lu par le JS partagé (`.afficher-modal` click handler, `CompteZone.php`/`Compte.php`) comme
  paramètre `id_type_default` envoyé au serveur ; y mettre un texte libre aurait cassé la sélection
  du type d'encaissement par défaut (`CompteZoneController::ajouter_bon()`, seul point du contrôleur
  à lire ce paramètre - vérifié qu'aucun autre bouton `.afficher-modal` de cette vue ne l'utilise
  avant d'ajouter un `title` texte aux 3 autres). Classes fonctionnelles (`.afficher-modal`,
  `.secondaire`, `data-card-widget="collapse"`, `data-toggle="dropdown"`) toutes conservées à côté
  des nouvelles classes visuelles. Vérifié par curl : `title="0"` toujours présent et fonctionnel
  (`/CompteZone/ajouter_bon` avec `id_type_default=0` répond normalement), rendu complet inchangé.

- **Phase 4 - carte "Ventes" : état vide au lieu d'un tableau juste des en-têtes** : quand aucune
  vente n'est déclarée (`qte==0` pour tous les produits), affichage du composant générique
  `.ssm-empty-state` déjà utilisé ailleurs dans l'appli (icône + texte "Aucune vente déclarée") au
  lieu du tableau vide, avec un bouton d'action intégré "Ajouter index de sortie" (réutilise le même
  bouton/endpoint `#ventes` → `/CompteZone/ventes` que celui déjà présent dans l'en-tête, affiché
  uniquement si `$ajouter_vente`). Vérifié par curl : compte id=1313/zone=3 (aucune vente réelle)
  affiche l'état vide + bouton, compte id=1312/zone=3 (ventes réelles : Gasoil Advanced/Excellium)
  affiche toujours le tableau normalement - non-régression confirmée.

- **Phase 4 - cartes appairées (Pompistes/Citernes, Encaissements/Ventes) : même row, même hauteur,
  ouverture synchronisée** : restructuration de `Principal.php` en 3 rows (Pompiste+Citerne /
  Encaissement+Vente / Sortie seule, au lieu de 2 colonnes empilant chacune 2 cartes différentes).
  Hauteur égale : `d-flex` sur chaque colonne + `.ssm-widget-card{display:flex;flex-direction:
  column;width:100%}` + `.card-body{flex:1 1 auto}` (pattern standard Bootstrap 4 "cartes de hauteur
  égale"). Synchronisation : attribut `data-sync-group` sur les 2 cartes d'une paire + handler
  délégué sur `document` (`.off().on()` car ce fragment est rechargé en continu) qui, au clic sur le
  bouton (ou le titre) de collapse d'une carte, déclenche un clic synthétique sur le bouton de
  collapse de sa jumelle SI leurs états (`collapsed-card`) diffèrent - pas de boucle infinie (la
  jumelle re-déclenche le handler mais son état correspond déjà). Vérifié par curl : structure
  3 rows confirmée, `data-sync-group` présent sur les 2 paires, JS syntaxiquement valide,
  `/CompteZone/index/1313/3` et le CSS toujours 200.

- **Correctif du sync de cartes (bug remonté juste après §67 suite)** : la 1ere implémentation
  (`setTimeout(...,50)` puis lecture de `hasClass('collapsed-card')`) était en course avec
  l'animation AdminLTE - `CardWidget.collapse()/expand()` (lu directement dans
  `public/adminlte/build/js/CardWidget.js`, confirmé présent tel quel dans le bundle
  `adminlte.min.js` réellement chargé) n'ajoute/retire la classe `collapsed-card` qu'APRES le
  `slideUp`/`slideDown` (~400ms, vitesse par défaut "normal") - lire l'état à 50ms donnait encore
  l'ancien état, d'où le bug rapporté ("l'icône et la fonction ne changent pas"). Corrigé en
  écoutant les évènements propres d'AdminLTE `collapsed.lte.cardwidget`/`expanded.lte.cardwidget`
  (déclenchés de façon SYNCHRONE dès l'appel à collapse()/expand(), avant l'animation) au lieu de
  deviner l'état par délai - plus de race condition possible. Vérifié : JS syntaxiquement valide,
  page toujours 200.

- **Phase 4 suite - états vides Pompistes/Sortie espèce + tables restylées** : même traitement que
  la carte Ventes (§67 suite) appliqué à Pompistes ("Aucun pompiste associé" + bouton "Associer un
  pompiste", réutilise `#pompistes`) et Sortie espèce ("Aucune sortie de caisse" + bouton "Ajouter
  une sortie", réutilise `#ajouter_sortie`), gardés derrière `$ajouter_pompiste`/`$ajouter_bon`
  comme leurs boutons d'en-tête respectifs. Les 5 tables des cartes (Pompistes/Encaissements/
  Citernes/Ventes/Sortie) passées de `table-bordered` brut à `ssm-table` (composant déjà utilisé
  partout ailleurs pour les DataTables - en-tête sticky discret, lignes zébrées au survol, bordures
  fines cohérentes) - fonctionne aussi bien en tableau statique qu'en DataTable, aucun JS requis.
  Vérifié par curl : compte id=1313/zone=3 (aucun pompiste/sortie) affiche les 2 nouveaux états
  vides, compte id=1312/zone=3 (pompistes réels : ZAGNOUNE Boujemâa, H MACHE Hassan) affiche
  toujours les tableaux normalement - non-régression confirmée.

- **Phase 4 - `compte/portion/Secondaire.php` + `CarteBon` : boutons restylés, dernier reliquat
  trouvé** : bouton "Retour" et "Ajouter" (en-tête de la liste) passés en `ssm-btn`/`ssm-btn-icon`
  (le `title` numérique du bouton Ajouter, lu comme `id_type_default` par le JS partagé, préservé
  intact). En creusant, trouvé un reliquat non couvert par le passage §57/§60 sur `Model::
  btn_group()` : `CarteBon::bons_compte_zone()` a 2 branches locales (zone clôturée + tickets carte
  prépayée) qui construisaient leurs boutons Infos/Modifier/Détacher en HTML brut (`class="btn"`,
  icônes `text-primary` en dur) au lieu d'appeler `btn_group()` - corrigées au même style
  (`ssm-btn-group`/`ssm-btn-icon`/`ssm-ic-*`), bonus : une balise `<div class="btn-group">` jamais
  refermée dans le code original, fermée au passage. Vérifié par curl (compte id=1312/zone=3/type
  carte, données CMI réelles) : tous les boutons rendus en `ssm-btn`, plus aucun `class="btn"` brut.

- **Bug corrigé - tri "Montant" incorrect dans les tableaux `compte/portion/Secondaire.php`** :
  DataTable client-side (données déjà rendues en HTML, pas de requête SQL derrière - voir migration
  vers `init_datatable()` plus haut) - le tri par défaut compare le TEXTE affiché ("100,00", "9,50"…
  format français espace/virgule), donc "9" passait avant "10" comme des chaînes. Corrigé en ajoutant
  `data-order="<valeur numérique brute>"` sur le `<td>` de la colonne montant - DataTables lit cet
  attribut nativement en priorité sur le texte affiché pour le tri (fonctionnalité standard "sort
  data" de la librairie, aucun plugin requis). Vérifié par curl : `data-order` présent avec la
  valeur numérique correcte (ex. `data-order="267.80">267,80`), rendu inchangé sinon.

## §68 : Compte Journalier - Phase 4, checklist modale appliquée à tout le module (26/07/2026)

Retour utilisateur "continue le reste du design" - passage systématique de la checklist Phase 4
modale (icône ronde + `data-mode` + `ssm-field-icon` sur chaque champ + `ssm-btn`/`ssm-ic-*` sur les
boutons) sur les 10 fichiers du module encore en ancien style AdminLTE, aucun n'y étant passé avant :

- **Header du module** (`compte/portion/Header_zone.php`, `Header_compte.php`) : bouton retour,
  dropdown "Annuler la clôture", bouton "Modifier", bouton de validation dynamique (Clôturer/
  Valider/Annuler) tous convertis en `ssm-btn`/`ssm-btn-icon`/`ssm-ic-*`. Le badge de résultat
  (+/- écart vente/encaissement) converti de `<button>` (jamais cliquable) en `<span class="ssm-btn">`
  - plus sémantiquement correct, même rendu visuel.
- **`compte/modal/Nouveau_bon.php`** : `data-mode="ajout|modification"` + `.ssm-modal-icon` ajoutés,
  bouton "Enregistrer" en `ssm-btn-primary`.
- **`compte/portion/Secondaire.php`** : bouton "Retour" et bouton "Ajouter" (en-tête liste) en
  `ssm-btn`/`ssm-btn-icon` - le `title` numérique du bouton Ajouter (lu comme `id_type_default` par
  le JS partagé) préservé intact avec commentaire explicite pour la prochaine fois.
- **Les 8 sous-formulaires de paiement** (`compte/modal/forms/{espece,carte,cheque_lcn,client,ont,
  bon_baf,sortie_avoir,retour_avoir,bon}.php`) : chaque `<label>` a désormais une icône `ssm-field-icon`
  pertinente au sens du champ (utilisateur/carte/calendrier/référence/montant/matricule...), les
  `select2-error` passés du vieux `bg-gradient-danger + icône exclamation` au style `text-red` sobre
  déjà utilisé ailleurs, la classe `<?=$GLOBALS['style']['bg_label_modal']?>` (dépréciée, plus utilisée
  dans le nouveau design) retirée partout. Boutons +/- (ajout/suppression de ligne produit dans
  `bon.php`/`ont.php`, y compris la version générée dynamiquement en JS) et boutons matricule
  (annuler/valider/nouvelle/modifier dans `bon.php`) convertis en `ssm-btn ssm-btn-icon`/`ssm-ic-*`.
- **`compte/modal/forms/tpe.php`** : vérifié orphelin (aucun contrôleur ne le référence, dead code
  déjà signalé dans l'audit) - non modernisé, pas la peine de toucher du code mort.

Vérifié : `php -l` propre sur les 12 fichiers touchés, rendu réel testé par curl sur 3 sous-formulaires
différents (espèce/client/ont) + la page complète, tout en 200, aucune régression.

### Reste (non traité cette passe, si l'utilisateur veut continuer)
`compte/modal/{Pompiste,Vente,Validation_compte,Validation_zone,sortie_caisse,info_attachement}.php`
- pas encore passés par la checklist modale (icône/data-mode), à faire dans une prochaine passe.

- **Retour utilisateur - `bon.php` : "Bons non traité"/"Total garantie" mal stylisés** (avaient
  l'apparence d'inputs readonly avec fond `bg-gradient-danger`/`bg-gradient-success` en dur, alors
  que ce sont des informations sur le client, pas des champs de saisie) : sortis de la grille du
  formulaire et affichés en bandeau dédié `.ssm-stat-row` (même composant que Total Page/Filtré,
  avec squelette de chargement `.ssm-stat-skeleton` puis remplissage par `remplir_matricule()` -
  `.val()` remplacé par `.text()` car ce ne sont plus des `<input>` mais des `<div>`). Vérifié par
  curl (sous-formulaire client réel) : bandeau bien rendu, un seul (le comptage x2 du grep initial
  était un artefact de sous-chaîne `ssm-stat-row--fin` contenant `ssm-stat-row`).

- **Retour utilisateur - notification d'ajout d'encaissement stylisée et sortie de la modale** :
  l'alerte Bootstrap figée dans le corps de `Nouveau_bon.php` (fond vert en dur, restait affichée
  tant qu'on ne la fermait pas soi-même) remplacée par `toastr.success()` (déjà la librairie utilisée
  partout ailleurs dans l'appli) avec `positionClass: 'toast-top-center'` (en haut, centrée, hors de
  la modale) et `timeOut: 4500` (disparaît seule). Message reconstruit en PHP puis passé via
  `json_encode()` pour un échappement JS sûr (accents/apostrophes). Le comportement de fermeture
  automatique de la modale en cas de modification (300ms) est conservé. **Vérifié en conditions
  réelles** : insertion d'un vrai encaissement espèce de test (montant 7,25 Dhs) - `toastr.success(...)`
  bien généré avec le message attendu et `toast-top-center`, données de test entièrement nettoyées
  après vérification (2 lignes `carburant_compte_espece`/`carburant_compte_bon` supprimées).

- **Retours utilisateur suivants (même chantier)** :
  1. **Bons non traité/Total garantie** : finalement remis dans la MÊME ligne que Date Bon/Montant
     Bon (pas de nouvelle ligne), en 2 `col-3` avec `align-self-end` pour un alignement en bas
     cohérent avec les champs de saisie voisins (au lieu du bandeau `.ssm-stat-row` séparé du
     tour précédent).
  2. **Scrollbar de la modale "bon client"** : `#bon_client` avait sa PROPRE barre de défilement
     (hauteur fixée en JS `screen.height * 0.43` + `overflow-y:auto`), en plus de celle de la
     modale - deux scrollbars imbriquées, confus. Retiré (style inline + ligne JS de hauteur fixe) ;
     `.modal-dialog-scrollable` ajoutée sur `#modal_div` (`CompteZone.php`/`Compte.php`) pour un
     défilement unique au niveau de la modale entière (classe Bootstrap standard, déjà dans le
     bundle chargé).
  3. **Notification d'ajout d'encaissement, 2e itération** : le `toastr` en position
     `toast-top-center` (tour précédent) réapparaissait en bas de page sur certains écrans - cause :
     toastr réutilise un `#toast-container` déjà présent sur la page (créé par un AUTRE
     `toastr.xxx()` déclenché ailleurs) SANS réappliquer `positionClass`, donc le message ressortait
     à la position du container préexistant, pas celle demandée. Corrigé en abandonnant toastr pour
     CE cas précis : nouveau composant `.ssm-modal-notice` (bandeau intégré, tout en haut du
     `modal-body`, icône + message + croix de fermeture, `fadeOut()` automatique après 4,5s ou au
     clic). Vérifié en conditions réelles (insertion + suppression d'un encaissement de test) :
     bandeau bien affiché en haut de la modale avec le bon message, données de test nettoyées.

- **Notification, 3e et dernière itération** : retour utilisateur - un bandeau DANS la modale
  décale les autres champs vers le haut à sa disparition (le `fadeOut` retire l'espace qu'il
  occupait), gênant visuellement. Retour à `toastr` (flottant en position fixe, ne modifie jamais
  la mise en page de la modale), avec le vrai correctif cette fois : `$('#toast-container').remove()`
  juste avant l'appel, pour forcer toastr à recréer un container neuf à la bonne position au lieu
  de réutiliser un container préexistant créé ailleurs sur la page (cause racine du mauvais
  placement). Composant `.ssm-modal-notice` (devenu inutilisé) retiré du CSS.
- **Alignement Bons non traités/Total garantie** : texte centré (`text-center` ajouté sur `.ssm-stat`)
  suite au retour utilisateur, en plus de l'alignement en bas (`align-self-end`) déjà en place.
  Vérifié par curl (insertion + suppression d'un encaissement de test) : toastr bien généré avec
  `$('#toast-container').remove()` avant, blocs stat bien centrés dans le rendu HTML.

- **Notification, 4e itération + scrollbar modale (retour)** : la classe `toast-top-center` seule
  ne suffisait pas (retour utilisateur : "visible juste sa couleur de haut vert, en bas du modal") -
  au lieu de continuer à deviner la cause exacte du conflit CSS, la position est désormais forcée
  en inline `!important` juste après création du container (`top:20px; left:50%;
  transform:translateX(-50%); z-index:2000000`), imperméable à toute règle CSS externe quelle
  qu'en soit la cause. **`.modal-dialog-scrollable` retirée** (ajoutée pour le problème de double
  scrollbar, mais a introduit un nouveau bug : la modale revenait toute seule en haut au moindre
  scroll, signalé aussitôt) - retour au comportement par défaut (scroll de la page entière, une
  seule barre de défilement de toute façon puisque le scroll interne forcé de `#bon_client` avait
  déjà été retiré séparément). Vérifié par curl (insertion + suppression d'un encaissement de test) :
  script de positionnement forcé bien généré, page stable.

- **Vraie cause du "scroll qui remonte tout seul" trouvée : `ssm_champ_focuser()` (ajax.js, fonction
  globale partagée par toute l'appli)** : après CHAQUE `load_portion()` dans une modale titrée
  "Ajouter/Nouveau", `ssm_focus_ajout_si_besoin()` redonne le focus au premier champ du formulaire
  via `.trigger('focus')` - comportement natif du navigateur : focus sur un élément hors-écran ⇒
  scroll automatique pour l'amener dans la vue, même si l'utilisateur venait de faire défiler
  ailleurs volontairement. Corrigé (3 branches de la fonction : select2, `<select>` natif, autre
  champ) en `element.focus({ preventScroll: true })` au lieu de `.trigger('focus')` - le focus est
  toujours donné (utile clavier/lecteurs d'écran), mais ne déplace plus le scroll. Correctif
  **global**, bénéficie à toute modale "Ajouter" de l'application, pas seulement Compte Journalier.
  `.modal-dialog-scrollable` remise en place sur `#modal_div` (`CompteZone.php`/`Compte.php`,
  demande explicite : scrollbar dédiée à la modale, indépendante de celle de la page) - le vrai
  problème n'était jamais cette classe, seulement `ssm_champ_focuser()`. Vérifié : `node --check`
  propre sur `ajax.js`, `php -l` propre sur les 2 vues, pages toujours 200.

- **Scroll qui remonte encore, cause réelle n°2 (persistait après le correctif preventScroll)** :
  Bootstrap 4 (`Modal.js`, `_enforceFocus()`) pose son propre listener document `focusin.bs.modal`
  qui rappelle `.focus()` (SANS `preventScroll`) sur le conteneur `.modal` dès que le focus semble
  quitter la modale - un clic dans la zone de la scrollbar (non focusable) fait perdre le focus DOM
  courant, ce que Bootstrap interprète comme une sortie de la modale et corrige en la refocalisant,
  provoquant le saut en haut (bug Bootstrap 4 connu). Corrigé globalement (`ajax.js`) : retrait de ce
  listener précis juste après l'affichage de chaque modale (`shown.bs.modal`), sans toucher au filet
  de sécurité des backdrops en trop. Compromis accepté explicitement par l'utilisateur ("c'est bon
  je prends le relai") : perte du piège de focus clavier strict (Tab ne reboucle plus obligatoirement
  DANS la modale) contre l'absence de saut de scroll. Vérifié : `node --check` propre, page 200.

- **Scroll qui remonte tout seul, VRAIE cause trouvée (3e tentative, en lisant le code source réel
  de `bootstrap.bundle.min.js`)** : les 2 correctifs précédents (preventScroll sur
  `ssm_champ_focuser()`, retrait de `focusin.bs.modal`) n'étaient pas la vraie cause - persistait
  encore, y compris testé en navigation privée par l'utilisateur (écarte tout souci de cache/
  extension). Cause réelle : `Modal.js` pose un handler `click` sur `.modal` lui-même qui détecte
  `event.target === event.currentTarget` pour repérer un "clic sur le fond" - un clic sur la
  scrollbar (native, aucun élément DOM ne la "capte") remonte avec `target=.modal`, exactement comme
  un clic hors du contenu. Avec `data-backdrop="static"`, Bootstrap déclenche alors
  `_triggerBackdropTransition()` (la petite secousse "la modale ne se ferme pas"), dont la DERNIÈRE
  ligne fait `this._element.focus()` SANS `preventScroll` - focaliser `.modal` (plein écran, fixed)
  fait perdre la position de défilement. Corrigé en interceptant l'événement annulable que Bootstrap
  déclenche AVANT cette séquence (`hidePrevented.bs.modal`, `e.preventDefault()`) - supprime à la
  fois la secousse (faux positif ici, ce n'est pas un vrai clic de fermeture) et le focus fautif,
  pour toutes les modales de l'appli. Vérifié : `node --check` propre, page 200.

- **Scroll qui remonte, retour en arrière assumé (4 tentatives infructueuses)** : questionnement
  précis de l'utilisateur - le saut arrive avec un simple appui sur les flèches (SANS Ctrl, donc pas
  le raccourci `ssm_champ_naviguer()`), uniquement sur cette modale (la modale de clôture n'a pas le
  problème), et l'utilisateur soupçonne un lien avec le retrait du scroll interne de `#bon_client`.
  Cause probable identifiée : `.modal-dialog-scrollable` rend `.modal-body` scrollable mais Bootstrap
  garde le FOCUS sur `.modal` lui-même à l'ouverture (pas sur `.modal-body`) - le scroll clavier
  natif (flèches) cible l'élément focus/son ancêtre scrollable, qui ne correspond pas forcément à
  `.modal-body` dans ce montage, d'où un comportement instable propre à cette configuration.
  **Retour à la configuration d'origine** (jamais signalée buguée avant les modifications de ce
  chantier) : `#bon_client` retrouve son scroll interne contenu (`overflow-y:auto` + hauteur calculée
  en JS), `.modal-dialog-scrollable` retirée de `#modal_div` (`CompteZone.php`/`Compte.php`). Les 2
  correctifs JS globaux faits en cours de route (`preventScroll` sur `ssm_champ_focuser()`,
  interception de `hidePrevented.bs.modal`) sont conservés (corrigent des comportements Bootstrap
  réels et documentés, utiles indépendamment de ce bug precis, aucune raison de les retirer).

- **Correctif DEFINITIF, global, toute l'appli** : le bug persistait encore après 3 correctifs
  ciblés (chacun bouchait un chemin Bootstrap précis, mais le symptôme réapparaissait ailleurs -
  jusqu'à toucher finalement la scrollbar du NAVIGATEUR lui-même, hors contexte modal, sur petit
  écran). Plutôt que de continuer à chasser un par un tous les appels `.focus()` internes à
  Bootstrap/jQuery/plugins tiers (code source fermé, non modifiable directement), neutralisation
  À LA SOURCE : `HTMLElement.prototype.focus` surchargé une seule fois en tête d'`ajax.js` pour
  que `preventScroll:true` soit la valeur PAR DÉFAUT de tout appel `.focus()` de la page (le focus
  reste donné normalement, seul le déplacement de scroll qui l'accompagne d'ordinaire est
  supprimé - tout code qui voudrait explicitement le comportement natif garde la main via
  `{preventScroll:false}`). Remplace/complète les 3 tentatives précédentes (gardées, harmless).
  Vérifié : `node --check` propre, pages 200.

- **VRAIE cause enfin confirmée (trace navigateur fournie par l'utilisateur)** : les 4 correctifs
  précédents corrigeaient tous des mécanismes Bootstrap réels et documentés, mais AUCUN n'était la
  cause de CE symptôme précis. Diagnostic définitif obtenu en demandant à l'utilisateur d'exécuter
  un `Object.defineProperty` sur `scrollTop` dans la console (technique : intercepter le setter pour
  logguer la pile d'appel exacte au moment du reset) - la pile remonte systématiquement à
  `select2.full.min.js` via un handler d'évènement `scroll`. Cause réelle : **Select2**, quand son
  menu déroulant est OUVERT (ouvert automatiquement sur le 1er champ par `ssm_focus_ajout_si_besoin`),
  écoute les évènements `scroll` de tous les ancêtres scrollables pour repositionner son menu - et
  remet le `scrollTop` du conteneur à 0 pendant ce recalcul, ce qui redéclenche aussitôt un nouvel
  évènement `scroll` → boucle infinie de resets vers 0, quel que soit le conteneur qui défile
  (modale, div interne, page). Fix direct et définitif (`ajax.js`) : fermer tout select2 ouvert dès
  le début d'un scroll (écoute en phase de capture, `scroll` ne remonte pas naturellement) - un menu
  qui se ferme au scroll est un compromis UX largement répandu, très préférable à un scroll qui
  saute sans arrêt. Vérifié : `node --check` propre, page 200. Les 4 correctifs précédents sont
  conservés (corrigent des bugs Bootstrap réels, utiles indépendamment).

- **Persistait encore malgré le correctif Select2 (1ere version)** : notre handler `scroll` et
  celui de Select2 écoutent le MÊME évènement - rien ne garantissait que le nôtre s'exécute AVANT
  celui de Select2 qui fait le reset (fermer le select2 arrivait trop tard, le mal était déjà fait
  dans le même tick d'évènement). Corrigé (2e version, robuste) : au lieu d'essayer de gagner la
  course, on mémorise la dernière position de scroll connue par conteneur, et si un retour SUSPECT
  à 0 est détecté pendant qu'un select2 est ouvert, on RESTAURE activement la position (annule le
  dégât dans le même tick) puis on ferme le select2 fautif pour éviter une récidive immédiate.
  Vérifié : `node --check` propre, pages 200.

- **RESOLU (confirme par l'utilisateur)** : le retour "meme la modale de cloture, sans select2, fait
  la meme chose" a d'abord semble invalider la piste Select2, mais le correctif final (retrait
  complet de l'ouverture automatique `.select2('open')` dans `ssm_champ_focuser()`, remplace par un
  simple focus du widget visible sans ouvrir son menu) a resolu TOUS les cas, y compris celui-la -
  confirmant que Select2 etait bien la cause unique, le "sans select2" de l'utilisateur concernait
  probablement un autre champ select2 present ailleurs dans cette meme modale (hors de son attention
  immediate) plutot qu'une absence totale de select2 sur la page. **Compromis assume** : le premier
  champ d'un formulaire d'ajout (select2) ne s'ouvre plus automatiquement a l'affichage de la modale
  - reste focus (Entree/fleche bas l'ouvre normalement au clavier). Les 4 correctifs Bootstrap
  precedents (preventScroll global, retrait focusin.bs.modal, interception hidePrevented.bs.modal,
  compensation scroll Select2) restent en place, tous corrigent des mecanismes reels meme s'ils
  n'etaient pas suffisants seuls pour ce bug precis - aucune raison de les retirer.

## §69 : Compte Journalier - perf ComptePv + redesign /Comptes (26/07/2026)

Retour utilisateur : liste des tickets fermés ComptePv lente, affichage du détail d'un ticket lent,
DataTable `/Comptes` lente, et demande de redesign de `/Comptes` (comptes ouverts en cartes stylisées,
comptes fermés en DataTable dans une carte "Archive" repliée par défaut).

- **`Compte::data()` / `ComptePs::data()` / `ComptePv::data()`** : la boucle d'affichage de la
  DataTable appelait `periode($id)` par ligne, qui refaisait un `findone()` alors que
  `date_debut`/`date_fin` étaient déjà sélectionnées par la requête `UNION ALL` englobante (jusqu'à
  ~100 requêtes redondantes par page). Extrait en `formater_periode($dateDebut, $dateFin, $bol=false)`
  statique (formatage pur, sans requête) sur les 3 classes, appelé directement avec les valeurs déjà
  en mémoire dans la boucle - `periode($id, $bol)` gardé intact pour les appelants externes qui n'ont
  que l'id.
- **`ComptePvTicket::data()`** (liste tickets fermés) : `count(...->fetchAll())` (rapatrie TOUTES les
  lignes juste pour les compter) remplacé par `SELECT count(*) from (sql) as ssm_t` pour le total ET
  le filtré.
- **Migration `021_index_compte_pv_ticket_performance.sql`** : `compte_pv_ticket(id_compte,
  id_cloture)`, `compte_pv_ticket_description(id_ticket)`, `client_bon(source, id_source)` - aucun
  index utilisable avant sur ces recherches.
- **`ComptePvController::ticket()`** (détail/impression d'un ticket) : le calcul du "reste" faisait 5
  requêtes séquentielles (une par mode de paiement : espèce/banque/carte/pièce/gratuité) - fusionnées
  en une seule requête `UNION ALL` (`ComptePvTicket::total_paiements($id_ticket)`), le contrôleur
  n'appelle plus qu'une seule méthode. Migration `022_index_compte_pv_gratuite.sql` ajoute l'index
  manquant `compte_pv_gratuite(id_ticket)` (seule la PRIMARY KEY existait).
- **Redesign `/Comptes`** (`app/views/compte/Comptes.php`) : la page est scindée en 2
  `.ssm-widget-card` :
  1. "Comptes Ouverts" (`#comptes_ouverts_panel`, NON repliée) : plus de DataTable pour les comptes
     ouverts - une `div.row` (`#comptes_ouverts_cards`) peuplée en JS (`charger_comptes_ouverts()`,
     appelé au chargement) via un `$.post` synthétique vers l'endpoint DataTable existant
     `/Comptes/comptes` (payload DataTables factice avec `id_cloture:0`, `length:100` - pas de
     nouvel endpoint créé), rendu carte par carte (`ssm_carte_compte_ouvert()` : icône zone, badge
     type de compte, période, ventes/différence en `.ssm-stat-row`, bouton d'action). État vide géré
     par `#comptes_ouverts_vide` (`.ssm-empty-state`).
  2. "Archive des Comptes Clôturés" (`#comptes_panel`, `.ssm-widget-card--info.collapsed-card` -
     REPLIÉE par défaut) : contient tel quel l'ancien filtre (zone si multi-zone/type/dates) + la
     DataTable `#comptes` + le toolbar imprimer/exporter, `#id_cloture` passé d'un select à un input
     caché figé à `1` (l'archive ne montre plus que les comptes clôturés, la bascule ouvert/fermé
     n'existe plus ici puisque les ouverts sont désormais en cartes au-dessus). Bouton d'action de
     `ComptesController::comptes()` restylé `class="btn"` → `ssm-btn ssm-btn-icon ssm-ic-primary`.
  - Vérifié : `php -l` propre, `/Comptes` en 200 sans erreur PHP (`collapsed-card` bien présent sur
    `#comptes_panel`), `/Comptes/comptes` avec le payload exact de `charger_comptes_ouverts()` retourne
    les bons comptes ouverts, `/ComptePv/infos_ticket` sur un ticket réel en 200 après la fusion des
    5 requêtes.

### Reste (non traité, prochaine passe)
Styliser le module ComptePv lui-même (liste tickets, détail ticket - jamais touché Phase 4) et les
modales Compte listées en fin de §68 (`Pompiste/Vente/Validation_compte/Validation_zone/
sortie_caisse/info_attachement`).

## §70 : ComptePv - checklist modale appliquée aux 7 modales de paiement/ticket (26/07/2026)

Suite de §69 ("n'oublie pas de styliser comptepv"). Checklist Phase 4 modale (icône ronde
`ssm-modal-icon` + `data-mode` + `ssm-field-icon` par champ + `select2-error text-red` +
`ssm-btn`/`ssm-btn-primary`) appliquée aux 6 modales de paiement de ticket ComptePv
(`point_vente/modal/paiement/{espece,operation,piece,carte,gratuite,client}.php`) et à
`point_vente/modal/nouveau_ticket.php` : retrait des dégradés CSS inline propres à chaque modale
(`background: linear-gradient(...)` sur le header, couleurs différentes par modale) au profit de
l'icône de header uniforme du design system. Variable `$bg` devenue morte dans `nouveau_ticket.php`
(ne servait qu'au dégradé retiré) - supprimée. `piece.php` n'avait aucun `select2-error` avant
(aucune validation visuelle de champ obligatoire) - pas ajoutée, uniquement les icônes/`ssm-btn`
(changer le comportement de validation n'était pas demandé). Vérifié par curl : les 7
endpoints (`/ComptePv/{espece,operation,piece,carte,gratuite,client,nouveau_ticket}`) sur un ticket
réel, tous 200, `data-mode`/`ssm-modal-icon`/`ssm-field-icon`/`ssm-btn` bien présents dans le HTML
renvoyé, aucune erreur PHP.

### Reste (non traité, risque jugé disproportionné pour cette passe)
Le tableau de bord ComptePv lui-même (`point_vente/Compte.php`, ~2600 lignes) a un design déjà
abouti mais entièrement fait-main (dégradés/cartes/badges propres, CSS ad-hoc par section) et gère
un rafraîchissement temps réel par polling (tickets encours, statut, temps écoulé) - le reprendre
entièrement au design system ssm-* sans navigateur pour valider le rendu ET le comportement live
(cartes qui se re-rendent en place, tri, timers) est un risque disproportionné pour cette passe.
Laissé tel quel ; à reprendre dans une passe dédiée si demandé, idéalement avec accès navigateur.

## §71 : Compte Journalier - 6 dernières modales stylisées (26/07/2026)

Dernier point resté ouvert de §68 : `compte/modal/{Pompiste,Vente,Validation_compte,Validation_zone,
sortie_caisse,info_attachement}.php` passées à la checklist Phase 4 (icône ronde `ssm-modal-icon` +
`data-mode` + `ssm-field-icon` par champ + `ssm-btn`/`ssm-ic-*` sur les boutons, `ssm-table` sur les
tableaux internes) - retrait de `$GLOBALS['style']['bg_header_modal']`/`bg_thead`/`bg_label_modal`
(classes de l'ancien système, plus utilisées dans le nouveau design). `Validation_compte.php`/
`Validation_zone.php` : boutons de pied de modale à couleur dynamique (Valider=vert/Annuler=rouge
selon l'état du compte) conservés dans leur logique, juste migrés vers `ssm-btn-primary`/
`ssm-btn-danger`. `Validation_zone.php` contient 2 tableaux de synthèse sans balise `<thead>` (juste
des `<th>` en 1ère ligne du `<tbody>`) - `ssm-table` leur a été ajouté mais son style de header ne
s'applique qu'via `table.ssm-table thead th` ; laissé tel quel, corriger la structure HTML n'était
pas demandé et changerait un rendu qui fonctionne déjà. Vérifié par curl : les 7 endpoints
correspondants (`/CompteZone/{validation,pompistes,ventes,info_attachement,ajouter_sortie}`,
`/Compte/validation` sur un compte réellement ouvert `id=1313` - le premier essai sur `id=289`
pointait par erreur vers `compte_pv` et non `carburant_compte`, corrigé) tous 200, classes
`ssm-modal-icon`/`ssm-field-icon`/`ssm-btn`/`data-mode` bien présentes, aucune erreur PHP. **Les 6
modales listées en fin de §68 sont maintenant toutes traitées** - le module Compte Journalier est
désormais entièrement passé à la checklist modale Phase 4 (seul le tableau de bord ComptePv,
§70, reste volontairement hors périmètre).

## §72 : Statistique > Graphique - onglet Général réel, reclassement Adblue, bug Transfert Bon corrigé (27/07/2026)

Retour utilisateur : "tu as toutes les informations pour faire les calculs manquants dans
statistiques graphique pour produit et service ... et ajoute aussi la vente de adblue". Investigation
approfondie (exploration + requêtes SQL directes) avant tout code, plan validé avec l'utilisateur
(3 questions via AskUserQuestion) avant implémentation.

- **compte_ps et compte_pv alimentent déjà tous les deux `stocks_mouvement_officiel`** (compte_ps au
  moment de la clôture, `VenteProduit::mouvement()` regroupe produit+service ; compte_pv à chaque
  ticket) - confirmé par lecture de code, **rien à corriger de ce côté**.
- **Vrai bug trouvé et corrigé** : `AlimentationsController::cloture()` (vente directe "Transfert
  Bon") construisait `$tab_off` pour `stocks_mouvement_officiel` mais ne l'insérait **jamais** - 76
  lignes historiques (37 bons) invisibles dans toutes les stats. Fix : `$MouvementOfficiel->insert
  ($tab_off);` ajouté dans la boucle. Migration `023_backfill_stocks_mouvement_officiel_
  alimentationbl.sql` (INSERT...SELECT idempotent via NOT EXISTS) rattrape l'historique - **à
  rejouer sur toutes les bases clients existantes** via `scripts/migrate.php` (pas seulement local),
  le bug étant présent depuis l'introduction de "Transfert Bon". Vérifié : 0→76 lignes après
  application, toujours 76 après une 2e application (idempotence confirmée).
- **Adblue reclassé partout** : `stocks_produits.id=109` (`type_vente='carburant'`, `tva=20`,
  contre `tva=10` pour les vrais carburants) était compté à 100% dans le total Carburant
  (≈109 466 Dhs historiquement). Règle appliquée (générique, basée sur la TVA, pas sur le nom) :
  `type_vente='carburant' and tva=20` → traité comme "produit vendu par index", exclu de Carburant,
  inclus dans Lubrifiant & Service. Modifié dans `StatistiqueGraphiqueController::
  statistique_annee_optimisee()` (+ nouveau cas `'tous'`, aucun filtre, pour l'onglet Général) et
  `Statistiques::par_produit()` (vente ET achat). Le détail par-carburant (déjà filtré `tva=10` en
  dur) n'a rien à changer. Vérifié par SQL direct : carburant-sans-adblue + ps-avec-adblue = tous,
  exactement (diff=0).
- **Onglet Général** : plus de données aléatoires - réutilise tel quel l'endpoint déjà générique
  `/StatistiqueGraphique/evolution` avec `type_vente='tous'` (aucun nouvel endpoint), + un sélecteur
  d'année ajouté (même pattern que Carburant/Lubrifiant Évolution). Lien "Comment sont calculés ces
  chiffres ?" ajouté, remplaçant la mention "données provisoires".
- **Nouvelle page d'explication** : `StatistiqueRapportController`/`app/views/statistique/
  RapportCalculs.php` (gabarit `home/Rapport.php`, palette bleue au lieu de rouge - page
  explicative, pas un audit d'alerte), accessible uniquement via le lien depuis Graphique (pas
  d'entrée menu) - route ajoutée dans `namespace_resolve()` (`vendor/function.php`, namespace
  `statistique`, à côté de `Statistique`/`StatistiqueGraphique`).
- **Hors périmètre volontaire** (signalé, pas touché) : `Statistiques::vente()`/`achat()` (pages
  DataTable Achats/Ventes hors "Graphique") ont le même filtre `type_vente='carburant'` sans
  exclusion tva=20 - même bug de fond, hors du scope demandé ("statistiques graphique").

Vérifié : `php -l` sur tous les fichiers touchés/créés, `node --check` sur le JS extrait de
`Graphique.php`, curl sur `/StatistiqueGraphique/evolution` (carburant/ps/tous, cohérence des
sommes), `/StatistiqueGraphique/statistique` (non-régression Carburant), `/StatistiqueGraphique`
(page complète) et `/StatistiqueRapport`, tous 200 sans erreur PHP. Limite assumée : pas de
navigateur, rendu visuel du nouveau graphique/de la page d'explication non vérifié à l'œil.

## §72 bis : Page d'explication - version exhaustive chiffre par chiffre (27/07/2026)

Retour utilisateur : "je veux que chaque chiffre... vous donner la formule utilisée, page
significative, en détail". `RapportCalculs.php` entièrement réécrite (12 sections + sommaire à 2
niveaux) : une section par onglet/sous-onglet de Graphique (Général, Carburant Général/Évolution/
Comparaison, Lubrifiant & Service Général/Évolution/Comparaison), chaque chiffre affiché à l'écran
listé avec sa formule SQL exacte dans un tableau dédié. Ajouts notables par rapport à la 1ère
version : décomposition en 10 étapes numérotées du calcul du PMP/CUMP (fonctions `initial()` +
`achat_periode()` + `statistique()`, la logique la plus complexe du module - stock initial
d'application → CUMP au Fixateur → CUMP à Du → CUMP final) ; tableau des 5 catégories
d'encaissement carburant avec le filtre SQL exact de chacune ; tableau récapitulatif des conventions
HT/TTC par onglet (elles diffèrent réellement d'un onglet à l'autre, déjà le cas avant ce chantier) ;
découverte et documentation d'un sélecteur mort (Taxe sur Lubrifiant & Service → Général : présent
dans l'UI mais jamais lu côté serveur, donc sans aucun effet - signalé, pas corrigé sans demande
explicite). Vérifié par curl : `/StatistiqueRapport` 200, aucune erreur PHP.

## §73 : Page d'accueil - refonte générale du design (27/07/2026)

Retour utilisateur : "tu travailles sur la page d'accueil, refonte générale du design". Module
jamais touché Phase 4 (encore 100% AdminLTE : `card callout`, `small-box`, `bg-gradient-*`).

- **Nouveau composant `.ssm-kpi-tile`** (`components.css`) : remplace les `small-box` AdminLTE
  (fond dégradé plein, icône en filigrane) des 8 tuiles chiffres-clés (`home/portion/
  Statistique.php` - Carburants/Produits-Services/Dépotages/Produits, et `home/portion/
  finance.php` - Caisse/Banque Crédit/Débit). Icône à gauche dans une pastille colorée, libellé
  + valeur à droite, flèche au survol - même famille de couleurs (succès/danger/info/warning) que
  `.ssm-widget-card`. Les attributs `data-*` (`class_card`/`class_border`/`bg`/`affichage`/`lien`/
  `mode`) qui pilotent le mécanisme JS existant de bascule d'affichage (tableau de détail en bas de
  page) sont conservés à l'identique - **comportement JS non touché**, seul l'habillage visuel de
  la tuile elle-même change.
- **7 cartes principales** (`home/Home.php`) converties en `.ssm-widget-card` + `.ssm-widget-icon` +
  boutons `.btn` dans `.card-tools` (déjà stylés automatiquement par `.ssm-widget-card .card-tools
  .btn`, pas besoin de `ssm-btn-icon`) : Achats/Ventes, Caisse & Banque, À Traiter, Utilisateurs,
  Stock Citerne, Alerte Produits, Rapport des impayées. 3 paires synchronisées via `data-sync-group`
  (même mécanisme événementiel `collapsed.lte.cardwidget`/`expanded.lte.cardwidget` que
  `compte/portion/Principal.php`, dupliqué dans le `<script>` de `Home.php` - pas encore mutualisé
  dans `ajax.js`) : Achats/Ventes↔Caisse/Banque, À Traiter↔Utilisateurs, Stock Citerne↔Alerte
  Produits.
- **Bug de course évité avant qu'il n'arrive** : le chargement de page faisait `$('.colaps').click()`
  sur TOUTES les cartes en boucle pour les replier par défaut - avec la synchro de paires
  fraîchement ajoutée, la 1ère carte d'une paire repliait sa jumelle via la synchro AVANT que la
  boucle n'atteigne cette jumelle, qui se faisait alors re-déplier par son propre `.click()` de la
  boucle (double bascule). Corrigé en amont (jamais testé en l'état buggé) : classe `collapsed-card`
  posée statiquement en HTML sur les 5 cartes concernées (comme dans `Principal.php`), `$('.colaps')
  .click()` retiré du script - même état replié par défaut, sans dépendre d'un clic programmatique
  fragile.
- **DataTables** (`#notification_produit`, `#statistaiques_data` - tableau drill-down dynamique dont
  la couleur d'en-tête change en JS selon la tuile cliquée, classe `ssm-table` ajoutée SANS toucher
  au mécanisme de bascule de classe - -, `#detail_flux` - carte "Mouvement des Citernes", statique,
  entièrement convertie en `.ssm-widget-card`) passées en `ssm-table`.
- **`home/portion/notification.php`** : état vide ("Rien à afficher" + image `nothing.png`) remplacé
  par `.ssm-empty-state` (composant déjà utilisé ailleurs dans l'appli).
- **Hors périmètre volontaire** : `home/portion/users.php` contient, après le widget todo-list
  visible (lignes 1-19, seul repris ici), ~80 lignes de JS mort **commenté** (génération de badge
  PDF/QR-code avec une image en base64 de 113 Ko sur une seule ligne) - jamais exécuté, non touché
  (hors scope design, suppression potentielle à évaluer séparément). `home/portion/caisse_banque.php`
  et `detail_encaissement.php` : fichiers orphelins/utilisés par un autre module, non concernés par
  cette page.

Vérifié : `php -l` sur tous les fichiers touchés, curl sur `/` (page complète) + les 8 endpoints AJAX
(`statistiques`, `info_finance`, `notification`, `users`, `notification_produit`, `datatable`,
`data_citerne` - avec un `id_stock` réel, les 2 DataTables nécessitant un payload DataTables complet
pour fonctionner, pas juste `draw/start/length`), tous 200 sans erreur PHP, classes `ssm-kpi-tile`/
`ssm-widget-card`/`ssm-empty-state`/`data-sync-group` bien présentes. Limite assumée : pas de
navigateur, rendu visuel et bascule de collapse synchronisée non vérifiés à l'œil.

## §74 : Nouveau module "Shop & Café Restaurant" - Phase 1 (fondations) (27/07/2026)

Refonte complète du module Restaurant, à partir de zéro, comme demandé explicitement. Conception
validée avec l'utilisateur via une page HTML dédiée (artifact, pas dans le dépôt) avant tout code -
7 questions tranchées (module Restaurant supprimé sans archive, 2 points de vente créés librement
pour tester, scan douchette USB uniquement, ticket réglable par crédit client, recette historisée,
2 questions encore ouvertes : filtre facturation par source, partage d'appareil entre points de
vente - non bloquantes, la conception les supporte déjà nativement).

**Principe central** : un point de vente EST une zone d'activité (`compte_ps_zone_activite`), avec
le flag `restaurant` déjà existant sur `compte_ps_zone_activite_role` réutilisé tel quel (pas de
flag redondant) + `caisse=1` posé à la création pour apparaître automatiquement dans **Trésorerie
&gt; Caisse** (`ZoneActivite::caisse_zones()`, aucune modification de ce côté). `shop_point_vente`
n'est qu'un complément (quel stock, quels appareils TPE autorisés).

- **Migration `024_shop_cafe_restaurant_fondations.sql`** : suppression `restaurant_compte`/
  `restaurant_vente` (0 ligne, vérifié avant suppression) ; extension `stocks_produits` (+
  `code_barre` unique nullable, `id_categorie`, `mode_vente` enum direct/prepare, `photo`, `unite`) ;
  reclassement des 21 produits existants `type_vente='restaurant'` → `'produit'` (rejoignent
  Lubrifiant &amp; Service dans Statistique, plus de catégorie fantôme) ; nouvelles tables
  `shop_categorie`, `shop_point_vente`, `shop_point_vente_appareil` (pivot plusieurs-à-plusieurs
  appareil↔point de vente), `shop_recette`/`shop_recette_ligne`, `shop_compte`/`shop_compte_employe`
  (Phase 3), `shop_ticket`/`shop_ticket_description` (Phase 3), `shop_depense` (Phase 4).
- **Ancien module supprimé** : `RestaurantController`/`CompteRestaurantController`,
  `app/models/restaurant/*`, `app/views/restaurant/*`, vue orpheline `service/portion/menu_stock.php`
  (référençait les routes supprimées). Routage : `vendor/function.php` (namespace `shop` pour le
  routage réel, clé `restaurant` conservée dans `left_bar()` pour le niveau de permission - colonne
  `users_space.restaurant` déjà attribuée aux utilisateurs existants, aucune migration de droits).
  Menu (`Layout.php`) : "Shop & Café" avec sous-menu (Comptes/Catalogue/Achats/Points de vente).
  Libellés mis à jour partout où "Restaurant" apparaissait (`StyleModule`, case à cocher utilisateur).
- **Points de vente** (`ShopPointVenteController`) : création = zone d'activité + role + stock +
  ligne `shop_point_vente` en une seule action ; appareils TPE autorisés via select2 multiple
  (`shop_point_vente_appareil`, reprend les mêmes fiches `parametre_appareil` que le CMI existant -
  aucune nouvelle table appareil). Désactivation seulement (jamais de suppression physique, un point
  de vente peut déjà porter des mouvements/comptes historiques).
- **Catégories** (`ShopCategorieController`) : CRUD simple (nom/icône/ordre), gérées sur la même
  page que les points de vente.
- **Catalogue produit** (`ShopProduitController` + `StocksProduits::data_shop()`/
  `recherche_vente()`) : DataTable filtrée sur `id_categorie is not null`, modale unique
  direct/préparé avec bascule d'affichage JS, upload photo (`inc/shop/produits/`), éditeur de
  recette intégré (lignes dynamiques, select2 AJAX `/ShopProduit/ingredients` excluant le produit
  lui-même). `enregistrer_recette()` remplace toujours l'intégralité des lignes (delete_where +
  réinsertion) - les mouvements de stock déjà générés par des ventes passées ne sont jamais
  retouchés (recette historisée, décision actée).

Vérifié par curl, cycle complet rejoué et nettoyé : création point de vente → apparition confirmée
dans `/Caisse` (Trésorerie) → modification → désactivation ; création catégorie ; création produit
direct + produit préparé avec recette (ingrédient résolu via select2 AJAX, ligne recette vérifiée en
base) → DataTable catalogue → modale d'édition avec recette pré-remplie. `php -l` propre sur tous
les fichiers créés/modifiés.

## §75 : Shop & Café Restaurant - Phase 2 (achats), zéro code nécessaire (27/07/2026)

Découverte en creusant `AlimentationsController::modal()` (ligne ~288) avant de coder un nouveau
contrôleur d'achat dédié : l'écran `/Alimentations` existant permet déjà de choisir n'importe quel
`stocks` en destination pour chaque ligne reçue (`$stocks = $Stocks->find(" type_vente='$type_vente'")`,
filtré uniquement par le `type_vente` du PRODUIT, pas par un module d'origine) - un point de vente
Shop est un `stocks` comme un autre (créé avec `type_vente='produit'` en Phase 1). **Aucun
`ShopAlimentationController` n'a donc été créé** - le lien "Achats" du sous-menu Shop & Café pointe
directement vers `/Alimentations`. Routage nettoyé en conséquence (`vendor/function.php` :
`ShopAlimentation` retiré des 2 tableaux de namespaces, jamais reference).

Vérifié par un cycle complet rejoué et nettoyé : création d'un point de vente de test → création
d'un Bon de Livraison → ajout d'une ligne produit avec le "Stock" du point de vente choisi
explicitement → clôture (`choix=simple`) → confirmé en base : `stocks_produits_quantite`
(id_stock du point de vente, id_produit) incrémenté correctement, `stocks_produits.en_stock` mis à
jour, mouvement officiel créé. Toutes les données de test nettoyées après vérification.

### Reste à faire (phases suivantes, voir tâches de suivi)
Compte journalier + employés rattachés + écran de vente (grille/recherche/scan, décrémentation
directe ou par recette, encaissement espèce/carte/crédit client `source='Shop'`) ; dépenses +
résultat en direct + clôture avec écart ; passage Phase 4 design déjà fait dès la création pour les
vues livrées ici (à poursuivre pour les suivantes).

## §76 : Shop & Café Restaurant - Phases 3/4/5, module complet (27/07/2026)

Compte journalier, employés, écran de vente, dépenses, résultat, clôture et vérification finale -
le module est désormais entièrement fonctionnel de bout en bout, en style Phase 4 (`ssm-*`) dès la
création (aucun rattrapage de design à faire).

- **Compte journalier** (`ShopCompteController`, table `shop_compte`) : un seul compte ouvert à la
  fois par point de vente (même garde que `ComptePv::nouveau()`). Employés rattachés
  (`shop_compte_employe`, select2 + liste avec détachement). **Résultat en direct** calculé à tout
  moment (`ShopCompte::resultat()`) : ventes totales décomposées par moyen de paiement + dépenses,
  et un **"attendu en caisse" qui ne compte QUE l'espèce** (carte va en banque, crédit client est
  une créance - ni l'un ni l'autre n'est du cash physique dans le tiroir, décision actée en
  conception). **Dépenses** (`shop_depense`) : motif/montant, même principe que
  `carburant_compte_sortie_caisse`. **Clôture** : espèce réellement comptée saisie, écart calculé
  et figé, plus aucune vente possible ensuite (pas de réouverture, un nouveau compte doit être créé
  - cohérent avec ComptePv).
- **Écran de vente** (`ShopVenteController`/`Vente.php`) : grille de tuiles avec photo par
  catégorie (`.ssm-vente-tuile`, nouveau composant), recherche texte + **scan code-barre douchette
  USB** (le champ de recherche capte directement la frappe + Entrée de la douchette, zéro
  librairie), panier avec quantités, 3 moyens de paiement en bascule segmentée
  (`.ssm-seg-track`, déjà utilisée ailleurs). **Sélecteur "Servi par"** : bug réel attrapé et corrigé
  avant de documenter - le 1er jet attribuait le ticket à l'utilisateur de l'application connecté
  (`$_SESSION['id']`) au lieu du serveur/vendeur réellement en service, rattaché au compte - corrigé
  pour choisir explicitement parmi les employés attachés au compte en cours.
- **`ShopTicket::creer()`** (le cœur du module) : pour chaque ligne vendue, insère toujours une
  ligne `stocks_mouvement_officiel` (revenu, alimente Statistique &gt; Graphique) sur le produit
  réellement vendu, direct ou préparé. Si préparé, décrémente EN PLUS chaque ingrédient de la
  recette (`stocks_mouvement` seulement, jamais l'officiel - un ingrédient consommé n'est pas
  "vendu" en tant que tel, l'inclure doublerait le revenu déjà compté sur le produit fini). Choix
  déclaré explicitement (divergence assumée du précédent `ComptePvController`, qui n'insère QUE
  dans `stocks_mouvement` sans jamais toucher l'officiel - vérifié en base, 78 lignes
  `stocks_mouvement.source='Ticket'` contre 0 dans `stocks_mouvement_officiel` : un vrai trou
  préexistant, **hors périmètre de cette tâche, non corrigé, juste constaté** - à traiter séparément
  si demandé un jour).
- **Encaissement, 3 moyens, tous intégrés à l'existant** (validé avec l'utilisateur avant le code) :
  - Espèce → `CaissePsFlux::insert_flux()` (`compte_ps_caisse_flux`, `source='Shop'`) sur la zone
    d'activité du point de vente → apparaît immédiatement dans Trésorerie &gt; Caisse.
  - Carte → `carte_ticket` (`source='Shop'`), appareil et type de carte choisis parmi les
    appareils autorisés pour ce point de vente (`shop_point_vente_appareil` ×
    `parametre_appareil_type`) → suit automatiquement le rapprochement CMI existant
    (`CmiTicketController`), vérifié par curl.
  - Crédit client → `client_bon` (`source='Shop'`) → apparaît dans Crédit Client comme n'importe
    quel autre crédit, différencié par sa `source` (permet plus tard de l'exclure d'une
    facturation automatique si demandé - non implémenté, Q6 de la conception reste ouverte).
- **Bug réel trouvé et corrigé pendant l'implémentation** (`Layout.php`) : l'entrée de menu "Achats"
  simplifiée pour pointer directement vers `/Alimentations` (voir §75) avait perdu sa clé `actifs`
  au passage - `Layout::menu()` fait `in_array($controleur, $enfant['actifs'])` sans garde, un
  `TypeError` fatal sur toute la page d'accueil dès qu'un utilisateur visitait n'importe quelle page.
  Corrigé avant la fin de la vérification (`actifs => ['Alimentations', 'Alimentation']`).

Vérifié par un cycle complet rejoué et nettoyé à chaque étape : création point de vente → ouverture
de compte → rattachement employé → 3 tickets (un par moyen de paiement, avec produit direct) →
confirmation en base de CHAQUE effet de bord (stock décrémenté, mouvement officiel créé, caisse
flux/zone activite créditée, ligne carte_ticket créée avec le bon appareil/type, ligne client_bon
créée) → résultat en direct recalculé correctement (décomposé par moyen de paiement) → dépense
ajoutée → résultat recalculé → clôture avec écart → page compte clôturé sans erreur. `php -l`
propre sur les 20 fichiers du module. curl 200 sans erreur sur toutes les pages Shop ET sur les
pages existantes qu'il touche (`/Alimentations`, `/Caisse`, `/CmiTicket`, `/Employes`, page
d'accueil) - non-régression confirmée. Menu "Shop & Café" avec sous-menu vérifié présent sur la
page d'accueil rendue.

## §77 : Shop & Café Restaurant - module Achat dédié, séparé de /Alimentations (27/07/2026)

Retour utilisateur explicite après §75 : "mais les achats il passe directement au module achat /
Gestion des bons, moi je veux pas, je veux un module achat séparé juste pour ce module". Le choix
de réutiliser `/Alimentations` (§75, "zéro code nécessaire") a été refusé - un vrai module d'achat
indépendant, non mélangé avec les Bons de Livraison station, était voulu depuis le départ.

- Migration `025` : nouvelles tables **propres au module** `shop_alimentation`/
  `shop_alimentation_description` (aucun lien avec `stocks_alimentation`, indépendantes).
- `ShopAlimentationController` + `ShopAlimentation`/`ShopAlimentationDescription` (modèles) :
  écran dédié (`/ShopAlimentation`, entrée de menu propre) - en-tête (point de vente/fournisseur/
  date/référence/livreur) + lignes produit dynamiques (réutilise le même éditeur de lignes que la
  recette du catalogue, §74) + clôture. DataTable avec **Total Page/Filtré fusionné en SQL** sur la
  colonne montant (règle permanente CLAUDE.md, `ShopAlimentation::data()`).
- **La clôture alimente quand même les tables partagées** `stocks_mouvement`/
  `stocks_mouvement_officiel`/`stocks_produits_quantite` (`source='ShopAlimentation'`) - séparé au
  niveau de l'écran/des tables de saisie, mais toujours cohérent avec le catalogue, les recettes et
  Statistique &gt; Graphique (achat comptabilisé correctement dans les stats Lubrifiant &amp;
  Service). Aucune re-cuisine du grand livre de stock, juste un point d'entrée dédié.
- Le lien de menu "Achats" du sous-menu Shop & Café pointe maintenant vers `/ShopAlimentation` (et
  non plus `/Alimentations`), `vendor/function.php` (namespace `shop`, 2 tableaux) et `Layout.php`
  mis à jour en conséquence.

Vérifié par un cycle complet rejoué et nettoyé : création d'un point de vente + produit + bon de
livraison dédié → ligne ajoutée → DataTable (montant correct, Total Filtré correct) → clôture →
confirmé en base (`stocks_mouvement`/`stocks_mouvement_officiel`/`stocks_produits_quantite`/
`stocks_produits.en_stock` tous mis à jour) → **confirmé que le bon n'apparaît PAS dans
`/Alimentations`** (vraie séparation, pas juste un filtre visuel). `php -l` propre, curl 200 sans
erreur sur toutes les pages Shop et sur `/Alimentations`/page d'accueil (non-régression).

## §77 bis : Bug signalé - foreach() sur "appareils" à la création d'un point de vente (27/07/2026)

Retour utilisateur (avec le message d'erreur exact) : `Warning: foreach() argument must be of type
array|object, string given` dans `ShopPointVenteController.php:107`. Cause : `tableau()` (ajax.js,
le convertisseur générique JS → format `cle=.=valeur`) fait `param[key]` dans une concaténation de
chaîne - un tableau JS (`$('#appareils').val()`, select2 multiple) y subit la coercion native
JS-vers-string (`['1','2']` devient `"1,2"`, `[]` devient `""`), jamais un vrai tableau côté PHP.
`$this->param['appareils']` était donc toujours une chaîne, et `?? []` ne protège pas (la clé est
bien présente, juste vide) - `foreach` plantait dès qu'aucun appareil n'était sélectionné.
Corrigé par une méthode `appareils_selectionnes()` dédiée (`explode(',', ...)`, vide si chaîne
vide), utilisée par `ajouter()` et `modifier()`. Seul endroit du module concerné (seul select2
`multiple` de tout Shop & Café, vérifié par grep). Vérifié par curl : création sans appareil (cas
qui plantait) et avec appareil, les deux 200 sans erreur, rattachement confirmé en base dans le 2e
cas, données de test nettoyées.

## §78 : Nettoyage données de test + sélecteur d'icône + seeder de démonstration (27/07/2026)

- **Nettoyage** : le point de vente "SHOP" et la catégorie "Café" créés manuellement par
  l'utilisateur (aucune donnée dépendante - vérifié) supprimés directement en base (le module ne
  permet volontairement pas la suppression physique d'un point de vente une fois qu'il porte des
  données, seulement la désactivation - voir §76 ; ici, exception justifiée par l'absence totale
  d'usage).
- **Sélecteur d'icône pour les catégories** (`shop/modal/categorie.php`) : retour utilisateur - "un
  utilisateur ne peut avoir le texte d'une icône". Le champ texte libre ("classe Font Awesome") est
  remplacé par une grille cliquable (`.ssm-icon-picker`, nouveau composant) d'une trentaine
  d'icônes pertinentes (café/boissons/plats/épicerie), la classe réelle restant en interne dans un
  champ caché - l'utilisateur ne voit et ne choisit que des pictogrammes.
- **Seeder de démonstration** (`scripts/shop_seed_demo.php`, exécuté en PHP CLI comme les scripts
  multi-tenant existants) : crée 2 points de vente complets ("Café (Démo)"/"Shop (Démo)"), 4
  catégories, 13 produits (3 ingrédients, 2 préparés avec recette, 8 directs à code-barre), 2 achats
  clôturés qui approvisionnent chaque stock, et **par point de vente** : 2 comptes **archivés**
  (clôturés, un avec écart nul, un avec écart non nul pour un rendu réaliste) contenant chacun 5
  tickets (les 3 moyens de paiement) + 1 dépense, et 1 compte **en cours** avec 2 tickets déjà
  vendus. Réutilise directement les modèles de l'application (pas de SQL brut dupliquant la
  logique métier) - un seul ajout nécessaire au passage : `ShopTicket::creer()` accepte désormais
  un paramètre optionnel `$date` (défaut : maintenant, comportement de l'écran de vente inchangé)
  pour pouvoir antidater les tickets des comptes archivés. Script rejouable (purge d'abord toute
  donnée "(Démo)" existante avant de recréer).

Vérifié par curl après exécution du seeder : les 6 comptes (`/ShopCompte/compte/{id}`) rendent sans
erreur, le catalogue (13 produits) et les achats (2 bons, montant filtré cohérent) s'affichent
correctement en DataTable, les 2 nouveaux points de vente apparaissent dans Trésorerie &gt; Caisse,
les tickets carte apparaissent dans le rapprochement CMI - toute la chaîne d'intégration validée en
§76/§77 refonctionne avec un vrai jeu de données, pas seulement les tests unitaires ponctuels.

## §79 : Retour utilisateur design/navigation/tickets en cours (27/07/2026)

Retour utilisateur après test réel du module (screenshot implicite, pas fourni mais décrit
précisément) : boutons "ajouter" non alignés à droite (convention du reste de l'appli), design pas
assez travaillé "surtout dans un compte", bouton retour peu clair, navigation d'entrée dans un point
de vente pas naturelle (atterrissait sur le résultat plutôt que la caisse), et absence du concept de
ticket non finalisé ("si non encaissé, il reste en cours" - référence explicite à `compte_pv_ticket`,
uniquement le principe, pas le contenu).

- **Bouton mal aligné** : `shop/Compte.php`, carte "Dépenses" - `card-header` sans
  `d-flex justify-content-between` + bouton en `class="btn"` brut (au lieu de `ssm-btn`) dans un
  `card-tools` isolé. Corrigé : header en flex, bouton `ssm-btn ssm-btn-primary` avec libellé
  "Ajouter" (toutes les autres cartes du module - `PointsVente.php`, `Produits.php`,
  `Alimentations.php` - étaient déjà correctes, audit fait sur toutes les vues du module).
- **Navigation retravaillée** : "entrer dans un point de vente" doit atterrir directement sur la
  caisse (`/ShopVente/index/{id}`), avec un accès clair au résultat depuis là, et non l'inverse comme
  avant (`/ShopCompte/compte/{id}` par défaut, caisse en accès secondaire).
  - `liste_compte.php` : la carte d'un point de vente avec compte ouvert propose maintenant 2
    boutons cote à cote ("Ouvrir la caisse" en primaire, "Résultat" en secondaire) au lieu d'un seul
    lien vers le résultat.
  - Ouverture d'un **nouveau** compte (`.ouvrir_compte`) : `ShopCompteController::ouvrir()` renvoyait
    la portion HTML `liste()` (rester sur la page Comptes) - renvoie désormais du JSON
    (`{ok, id_compte}`), la vue redirige immédiatement vers `/ShopVente/index/{id_compte}`.
  - `shop/Vente.php` (caisse) : l'icône de retour seule (`class="btn"`, aucun libellé, avait été
    signalée peu claire) remplacée par un bandeau d'en-tête plein (nouveau composant
    `.ssm-shop-compte-header`, réutilisé aussi sur `Compte.php`) avec nom du point de vente, pastille
    "Ouvert depuis HH:mm", et un vrai bouton texte+icône "Voir le résultat du compte".
- **Design "un compte" retravaillé** : carte Résultat de `Compte.php` remplace les lignes texte
  brutes par 3 tuiles `.ssm-kpi-tile` (Ventes/Dépenses/Attendu en caisse, couleurs succès/danger/
  info comme la page d'accueil - voir §73) + un détail espèce/carte/crédit client en dessous
  (`.ssm-shop-resultat-detail`, nouveau). Bandeau d'en-tête (`.ssm-shop-compte-header`) partagé avec
  la caisse.
- **Concept "ticket en cours"** (nouveau, cœur de la demande) : un ticket non encaissé ne doit plus
  disparaître s'il n'est pas payé tout de suite - même principe que `compte_pv_ticket`
  (`ComptePvController::nouveau_ticket()`/`encours()`), repris uniquement dans son principe (un
  ticket "ouvert" est persisté en base, consultable, reprenable), pas son contenu (pas de matricule/
  atelier/colocataire, non pertinents ici). Aucune migration nécessaire : la colonne
  `shop_ticket.statut` existait déjà (`'paye'` par défaut) mais n'était jamais utilisée avec une
  autre valeur - désormais `'en_cours'` tant que non finalisé.
  - `ShopTicket::mettre_en_attente($id_compte, $id_employe, $lignes)` : persiste le ticket + ses
    lignes (`statut='en_cours'`, `methode=null`) **sans** mouvement de stock ni encaissement (ceux-ci
    restent réservés à `creer()`, appelé uniquement à l'encaissement final - un ticket en attente n'a
    encore rien "vendu" au sens comptable).
  - `ShopTicket::en_cours($id_compte)` / `lignes_ticket($id_ticket)` / `supprimer_avec_lignes()` :
    listing, récupération des lignes (jointure `stocks_produits` pour le nom), suppression complète
    (ticket + descriptions) - jamais utilisée sur un ticket déjà payé.
  - `ShopVenteController` : 4 nouvelles actions - `mettre_en_attente()`, `tickets_en_cours()` (JSON
    pour la bande de l'écran de vente), `reprendre_ticket()` (renvoie les lignes ET supprime le
    ticket parqué - le panier JS redevient l'unique source de vérité une fois repris, comme un panier
    jamais mis en attente ; s'il est re-mis en attente, un nouveau ticket est créé, léger effet de
    bord accepté : churn de numéro, aucun impact financier puisqu'un ticket en_cours n'est qu'une
    file d'attente de travail), `supprimer_ticket_encours()` (annulation pure et simple).
  - `shop/Vente.php` : nouvelle bande `#tickets_en_cours` (`.ssm-ticket-encours`, chips avec numéro/
    montant/Reprendre/Supprimer, masquée si vide, rafraîchie après chaque action pertinente) au-
    dessus du catalogue. Nouveau bouton "Mettre en attente" à côté d'"Encaisser" (`.ssm-btn` neutre
    vs `.ssm-btn-primary`).
- Nouveaux composants CSS (`components.css`) : `.ssm-shop-compte-header` (bandeau d'en-tête partagé
  Compte/Vente), `.ssm-shop-resultat-detail` (lignes de détail sous les tuiles KPI),
  `.ssm-ticket-encours`/`.ssm-ticket-encours-chip` (bande de tickets en attente).

Vérifié par `php -l` sur les 6 fichiers PHP touchés + `node --check` sur le JS extrait de
`Vente.php`, puis en conditions quasi réelles sur les comptes du seeder (§78) : ouverture d'un
compte déjà ouvert (`/ShopCompte/ouvrir` renvoie bien l'id existant, pas de doublon créé), mise en
attente d'un ticket (`statut='en_cours'`, lignes bien insérées, aucun mouvement de stock), listing
`tickets_en_cours`, reprise (lignes renvoyées correctes + ticket et lignes bien supprimés en base),
mise en attente + suppression directe (nettoyage complet vérifié en base) - toutes les données de
test créées pendant la vérification supprimées après coup. **Limite assumée : pas de navigateur,
rendu visuel du nouveau bandeau/des tuiles/des chips non vérifiable à l'œil**, seule la mécanique
(HTML renvoyé, classes présentes, JSON, état base) est vérifiée.

### Hors périmètre / suites possibles (non demandées, signalées)
Exclusion du crédit Shop d'une facturation automatique (Q6 conception) ; partage d'un même appareil
entre plusieurs points de vente (déjà supporté par le schéma - pivot plusieurs-à-plusieurs - jamais
testé avec 2 points de vente sur le même appareil) ; le trou `ComptePv`/`stocks_mouvement_officiel`
constaté au passage (§ ci-dessus) ; annulation de clôture (jamais demandée, pas implémentée -
cohérent avec "un compte à la fois, pas de réouverture").

## §80 : Historique des tickets + "Nouveau ticket" explicite dans la caisse (27/07/2026)

Suite au §79, retour utilisateur supplémentaire après nouveau test : impossible de retrouver un
ticket déjà encaissé depuis la caisse, et le geste "je veux servir un nouveau client sans perdre le
panier en cours" pas assez visible/clair (le bouton "Mettre en attente" existait déjà - même
mécanique - mais enterré en bas de carte, à côté du paiement, plutôt que présenté comme l'action
principale attendue).

- **Historique des tickets encaissés** (nouveau) : `ShopTicket::historique($id_compte)` - jointure
  `collaborateur` (même table que `ComptePvTicket::data()`) pour le nom du serveur, tickets
  `statut='paye'` uniquement, plus récent en premier. `ShopVenteController::historique()` (JSON
  liste) + `detail_ticket()` (JSON ticket + lignes, réutilise `ShopTicket::lignes_ticket()` déjà
  écrite pour la reprise - fonctionne à l'identique en lecture seule sur un ticket payé). Nouveau
  bouton "Historique des tickets" dans le bandeau d'en-tête de la caisse (`shop/Vente.php`), à côté
  de "Voir le résultat du compte" - ouvre une modale (liste cliquable → détail avec bouton retour,
  même modale réutilisée, pas de va-et-vient serveur pour le "retour").
- **"Nouveau ticket" rendu explicite** : le bouton "Mettre en attente" (bas de carte, jamais renommé
  depuis le §79) est retiré et remplacé par un bouton **"Nouveau ticket"** déplacé en haut de la
  carte "Ticket en cours" (dans le `card-header`, à côté du titre - position bien plus visible que
  noyé près du paiement). Comportement inchangé côté serveur (`ShopTicket::mettre_en_attente()`) :
  si le panier contient des articles, il est automatiquement mis en attente (visible ensuite dans la
  bande "Tickets en cours" du §79) puis vidé ; si le panier est déjà vide, l'action se contente de
  remettre le focus sur la recherche (rien à mettre en attente). Message de confirmation reformulé
  ("Ticket précédent mis en attente - nouveau ticket commencé") pour que l'effet soit explicite.

Vérifié par `php -l` (3 fichiers) + `node --check` sur le JS extrait de `Vente.php` + curl sur un
compte réel du seeder (§78, compte id=8 avec 3 tickets déjà encaissés) : `/ShopVente/historique`
renvoie les 3 tickets triés du plus récent au plus ancien avec le bon nom de serveur,
`/ShopVente/detail_ticket` renvoie les lignes correctes d'un ticket donné. Aucune donnée modifiée
par ce test (lecture seule). **Limite assumée : pas de navigateur, l'ouverture/fermeture de la
modale et l'affichage du détail non vérifiables à l'œil.**

## §81 : Historique en page (DataTable + annulation) et Tickets en attente en cards (27/07/2026)

Retour utilisateur après §80 : l'historique en modale ne convenait pas - il fallait l'afficher **en
page** (masquer le catalogue/panier pendant la consultation, pas une fenêtre par-dessus), en
**DataTable** (pas une simple liste), avec la possibilité de **voir le détail** ET **d'annuler la
clôture** d'un ticket. Même bascule demandée pour les **tickets en attente**, mais affichés en
**cards façon reçu imprimé** avec toutes les informations et les actions adéquates - pas un tableau.

- **Bascule 3 vues sur la même page** (`shop/Vente.php`) : `#zone_caisse` (catalogue+panier,
  inchangé), `#zone_historique` (nouveau), `#zone_tickets_attente` (nouveau) - une seule visible à la
  fois (`afficher_zone()`), chacune avec un bouton "Retour à la caisse". Le bandeau d'en-tête gagne
  un 2e bouton "Tickets en attente" à côté d'"Historique des tickets" (§80). La bande `#tickets_en_
  cours`/`.ssm-ticket-encours-chip` du §79 (petits chips en haut de page) est **retirée** : strictement
  remplacée et dépassée par la vue "Tickets en attente" dédiée, plus complète (contenu du ticket,
  pas seulement numéro/montant) - CSS mort supprimé de `components.css`.
- **Historique en DataTable serveur** : `ShopTicket::historique_datatable($id_compte)` remplace
  l'ancien `historique()` (liste JSON simple) - même structure que `ShopAlimentation::data()`
  (colonnes `dt_btn()`/`dt_btn_group()`, règle CLAUDE.md Total Page/Filtré fusionnée en SQL pour la
  colonne montant). Colonne Action : "Voir le détail" (ouvre la modale existante du §80, inchangée
  côté contenu) + **"Annuler la clôture"** (nouveau).
- **Annulation de clôture d'un ticket** (`ShopTicket::annuler_cloture($id_ticket)`, nouveau) - même
  esprit que `ClientEtatController::annuler_cloture()`/`header_annuler_cloture()` (garde-fous
  revalidés côté serveur avant de reverser une clôture) : bloque si le ticket n'est pas `paye`, si le
  `shop_compte` parent est déjà clôturé (période figée, cohérent avec "un compte à la fois, pas de
  réouverture" déjà acté ailleurs dans le module), ou - spécifique au crédit client - si le
  `client_bon` généré a déjà `id_etat != 0` (déjà inclus dans un état de facturation, donc déjà
  "consommé" ailleurs). Une fois validé : supprime les mouvements de stock (`stocks_mouvement`/
  `stocks_mouvement_officiel`, `source='ShopTicket'`) et la ligne de paiement correspondante
  (`compte_ps_caisse_flux`/`carte_ticket` alias `CarteBon`/`client_bon` selon `methode`, chacune via
  `delete($id)` donc journalisée par le journal d'activité générique de `Model`), puis repasse le
  ticket à `statut='en_cours'`/`methode=null` - ses lignes ne sont jamais touchées, il réapparaît tel
  quel dans "Tickets en attente".
- **Tickets en attente en cards "reçu"** : `ShopTicket::en_cours_detail($id_compte)` (N+1 volontaire,
  toujours un tout petit nombre de tickets ouverts) renvoie pour chaque ticket ses lignes complètes
  (réutilise `lignes_ticket()`). Nouveau composant CSS `.ssm-ticket-recu` (bordure en pointillés,
  police monospace, séparateurs en pointillés entre en-tête/lignes/total - rappelle visuellement un
  ticket de caisse imprimé) avec boutons "Reprendre"/"Supprimer" pleine largeur en pied de card
  (mêmes actions qu'avant, juste déplacées de la bande de chips vers la card).

Vérifié par `php -l` (3 fichiers) + `node --check` sur le JS extrait + curl complet sur le compte
réel du seeder (id=8) : `historique_datatable` renvoie les 3 tickets payés avec le bon total filtré
(40,00), `tickets_en_attente` renvoie les tickets en attente **avec leurs lignes**. Test réel de
`annuler_cloture_ticket` sur un ticket payé (mouvements de stock + flux caisse supprimés, ticket
repassé en `en_cours`), puis ré-encaissé à l'identique via `/ShopVente/ticket` pour restaurer l'état
du compte démo (le ticket recrée un nouvel id - accepté, cohérent avec le churn de numéro déjà
documenté au §79). **Constat en cours de vérification** : le compte démo contenait déjà 3 tickets
`en_cours` (id 32-34, mêmes produits/quantités que le catalogue démo, horodatage à quelques secondes
d'écart) manifestement créés par l'utilisateur en testant lui-même le bouton "Nouveau ticket" du
§80 - laissés intacts (données d'usage réel, pas des résidus de test à nettoyer) et servent de bon
cas réel pour la vue "Tickets en attente". **Limite assumée : pas de navigateur, rendu visuel des 3
vues et des cards non vérifiable à l'œil.**
