# Cahier de recette — Lot L4.7 « Refonte UX/UI, écrans v3 »

> **Statut au 15/08 : dix écrans déployés sur staging, aucun recetté par Enguerran.**
>
> Ce cahier couvre l'application des maquettes v3 aux écrans du produit. Il est
> tenu à jour **à chaque PR**, au même titre que les tests.
>
> Staging : `https://staging.efektiv-academie-dev.com` — authentification du site
> `efektiv:staging2026`. Bundle servi au 15/08 : `index-pDmOJyU3.js`.

## ⚠️ À lire avant de recetter

**Ce cahier fait foi, pas la maquette.** Plusieurs écrans s'écartent volontairement
du dessin, parce que le dessin contredisait une décision déjà prise ou une réalité
du serveur. Chaque écart est listé, avec sa justification et sa trace.

Un testeur qui recette maquette en main signalera ces écarts comme des défauts.
**Ce n'en sont pas.** Ils sont regroupés en §9.

Convention : ✅ conforme · 🔴 défaut · ⚪ non joué · 🟡 conforme avec réserve.

---

## S1 — Paramètres (`/admin/parametres`, onglet Général)

**Profil** : admin. **PR** : FRONT#164. **Maquette** : `6-parametres-efektiv-v3`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S1.1 | Ouvrir Paramètres | Trois onglets : **Général** · Points · Chat. Le premier s'appelle « Général », plus « Paramètres » — un onglet ne répète pas le titre de sa page | ⚪ |
| S1.2 | Lire l'onglet Général | Trois sections nommées : **Bannière du catalogue**, **Seuils de pilotage**, **Visibilité** | ⚪ |
| S1.3 | Modifier l'accroche | Le compteur de caractères suit la frappe ; l'**aperçu de bannière** à droite suit aussi, sans enregistrer | ⚪ |
| S1.4 | Ne rien modifier | La barre du bas dit « Aucune modification en attente. » ; **Enregistrer et Annuler sont inertes** | ⚪ |
| S1.5 | Modifier deux champs | La barre passe au rose et annonce « **2 modifications non enregistrées.** » — au pluriel accordé, jamais « modification(s) » | ⚪ |
| S1.6 | Taper puis remettre la valeur d'origine | La barre **redevient inerte** : elle mesure un écart, pas un champ touché | ⚪ |
| S1.7 | Cliquer Annuler | Les champs reprennent la valeur du serveur | ⚪ |
| S1.8 | Enregistrer | « Modifications enregistrées » s'affiche, la barre redevient inerte, et **seules les valeurs modifiées** sont envoyées | ⚪ |
| S1.9 | Basculer « Vitrine publique » puis enregistrer, et ouvrir `/catalogue` **déconnecté** | Vitrine coupée → le catalogue public n'est plus accessible ; réactivée → il revient | ⚪ |
| S1.10 | Modifier un seuil et quitter l'onglet du navigateur sans enregistrer | Le navigateur **demande confirmation** avant de perdre la saisie | ⚪ |
| S1.11 | Lire la section Seuils | Un encadré bleu prévient que modifier ces seuils **recalcule le tableau de bord pour tout le monde, y compris sur les périodes passées** | ⚪ |
| S1.12 | Les deux délais | Libellés **en français** (« Formation affectée mais non débutée »), champ nombre avec l'unité « jours » **à l'intérieur** du champ | ⚪ |

**Piège de recette** : les compteurs 60/60/60/400 empêchent la frappe mais **ne
sont pas validés par le serveur**. Un appel direct passe outre. C'est documenté,
pas un défaut d'écran.

---

## S2 — Studio tuto (`/admin/cours/nouveau-tuto`)

**Profil** : admin. **PR** : FRONT#166. **Maquette** : `maquette-tuto-efektiv-v3`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S2.1 | Ouvrir depuis « + Créer » → « Un tuto » | En-tête : badge **Tuto**, fil d'Ariane « Cours › Nouveau contenu », titre éditable, sous-titre | ⚪ |
| S2.2 | Survoler le titre | Un filet en pointillés apparaît ; au clic, le champ prend le focus. Hors survol, **aucune boîte** | ⚪ |
| S2.3 | Lire la carte | Deux colonnes : **Contenu** (lien vidéo, description) · **Classement et diffusion** (catégorie, balises, processus) | ⚪ |
| S2.4 | Le champ vidéo | **Un champ de LIEN** (`https://vimeo.com/…`), aide « Collez le lien Vimeo ou YouTube ». **Aucune zone de dépôt de fichier** — voir §9.1 | ⚪ |
| S2.5 | Coller un lien invalide et publier | Le serveur refuse, le message s'affiche **sous le champ**, la saisie est conservée | ⚪ |
| S2.6 | La description | Mention « Facultatif », compteur `0/160` | ⚪ |
| S2.7 | Catégorie et balises | Sélecteurs **cherchables** (on tape pour filtrer), pas des listes à dérouler — voir §9.2 | ⚪ |
| S2.8 | Sous-catégorie avant catégorie | Le second sélecteur est **désactivé** tant qu'aucune catégorie n'est choisie | ⚪ |
| S2.9 | Les trois actions | **Aperçu apprenant** · **Enregistrer le brouillon** · **Publier le tuto**, en pastilles ; la principale en dégradé rose, jamais en bleu | ⚪ |
| S2.10 | Publier un tuto puis ouvrir la liste Cours | Il y figure avec son badge **Tuto** — pas d'écran de liste séparé | ⚪ |
| S2.11 | Enregistrer le brouillon | Le tuto existe en brouillon, **invisible côté apprenant** | ⚪ |

---

## S3 — Invitations · onglet Inviter (`/pilotage/invitations`)

**Profils** : admin, Manager RH, manager. **PR** : FRONT#167. **Maquette** : `4-invitations-efektiv-v3`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S3.1 | Ouvrir l'onglet | Deux colonnes : la composition à gauche, **l'aperçu de l'e-mail** à droite | ⚪ |
| S3.2 | Taper une adresse et valider par **Entrée** | Elle devient un **jeton** ; le champ se vide ; le compteur passe à `1/5` | ⚪ |
| S3.3 | Taper une adresse et **quitter le champ** sans valider | Elle devient quand même un jeton — rien ne se perd en silence | ⚪ |
| S3.4 | Saisir `pas-une-adresse` | **Refusée à la saisie**, message qui nomme l'adresse fautive. Aucun jeton créé | ⚪ |
| S3.5 | **Coller** une liste séparée par des retours à la ligne | Chaque adresse devient un jeton — voir §9.4 | ⚪ |
| S3.6 | Saisir deux fois la même adresse, en changeant la casse | Le doublon est **refusé** : deux jetons enverraient deux e-mails à la même personne | ⚪ |
| S3.7 | Coller six adresses | Les **cinq premières sont conservées**, la sixième refusée avec le motif. L'ancien écran rejetait toute la saisie | ⚪ |
| S3.8 | Retour arrière sur un champ vide | Le dernier jeton est retiré | ⚪ |
| S3.9 | Aucun destinataire | Le bouton **Envoyer l'invitation est inerte** — on ne clique plus pour apprendre que c'était vide | ⚪ |
| S3.10 | **Sans cours** sélectionné, lire l'aperçu | Sujet : « **Vous avez été invité à rejoindre la plateforme** » | ⚪ |
| S3.11 | Ajouter **un** cours | Le sujet devient « Vous avez été invité à **une formation** » | ⚪ |
| S3.12 | Ajouter **trois** cours | Le sujet devient « Vous avez été invité à **3 formations** » — voir §9.3 | ⚪ |
| S3.13 | Lire la mention sous le sujet | « Sujet composé par la plateforme selon le nombre de cours ouverts : non modifiable » | ⚪ |
| S3.14 | Envoyer une invitation à **une personne sur 3 formations**, puis relever la boîte du destinataire | **UN SEUL e-mail**, listant les trois formations. Pas trois messages | ⚪ |
| S3.15 | Le message par défaut | Pré-rempli, compteur `/400`, ses retours à la ligne **rendus dans l'aperçu** | ⚪ |
| S3.16 | Réinitialiser | Destinataires et cours vidés ; le message garde son modèle | ⚪ |
| S3.17 | Profils | L'onglet est visible pour **admin, Manager RH, sub_manager, manager général** — plus aucune énumération de rôles | ⚪ |

**Le point qui compte** : S3.14. C'est la vérification que l'envoi reste **un
e-mail par personne** (EA-044/#54), quel que soit le nombre de formations.

---

## S4 — Invitations · onglet Suivi

**Profils** : admin (voit tout), autres (voient leurs envois). **PR** : FRONT#169. **Maquette** : `5-invitations-suivi-efektiv-v3`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S4.1 | Ouvrir l'onglet | Tableau : Invité · Compte · Cours associés · **Statut** · Envoyée · Lien | ⚪ |
| S4.2 | Lire la mention en tête | « **Une ligne par formation : un envoi à plusieurs formations produit plusieurs lignes, mais un seul e-mail.** » | ⚪ |
| S4.3 | Une invitation non acceptée, non expirée | Statut « **En attente** », pastille ambre | ⚪ |
| S4.4 | Une invitation acceptée | Statut « **Acceptée** », pastille verte | ⚪ |
| S4.5 | Une invitation expirée et non acceptée | Statut « **Expirée** », pastille rouge | ⚪ |
| S4.6 | Une invitation **acceptée dont le lien a expiré** | Statut « **Acceptée** », jamais « Expirée » — le lien expire, pas l'acceptation. Sinon on relance quelqu'un déjà entré | ⚪ |
| S4.7 | La colonne Envoyée | Date en clair **et** « il y a N jours » en dessous | ⚪ |
| S4.8 | Une ligne sans compte associé | La colonne Invité montre **l'adresse seule**, pas une ligne qui commence par un blanc | ⚪ |
| S4.9 | Une ligne sans cours | « Aucun cours associé », en gris | ⚪ |
| S4.10 | Copier le lien d'onboarding | Le bouton devient « **Copié !** » quelques secondes ; le lien colle correctement | ⚪ |
| S4.11 | Une ligne dont le compte est **déjà actif** | **Aucun lien** (un tiret) : ce lien permettrait de redéfinir un mot de passe (#273) | ⚪ |
| S4.12 | Chercher un nom, une adresse, un cours | La recherche part au **serveur** : elle porte sur l'ensemble, pas sur la page | ⚪ |
| S4.13 | Recherche sans résultat | « Aucune invitation ne correspond à cette recherche. » | ⚪ |
| S4.14 | Aucune invitation envoyée | « Aucune invitation envoyée pour l'instant. » — message **distinct** du précédent | ⚪ |
| S4.15 | Lire l'en-tête | « **N invitations pour M personnes** » — chiffres SERVIS par le serveur, calculés sur le périmètre filtré **sans pagination** | ⚪ |
| S4.16 | Les lignes d'une même personne | **Regroupées** sous un en-tête qui porte son nom, son adresse et « *n* invitations sur cette page » | ⚪ |
| S4.17 | Lire la mention sous les filtres | Elle dit que le regroupement porte sur **cette page**, et que les totaux portent sur l'ensemble — voir §9.5 | ⚪ |
| S4.18 | Filtrer sur **Statut = Expirée** | La liste ET les totaux se restreignent : le filtre part au **serveur** | ⚪ |
| S4.19 | Cocher **deux** valeurs du même filtre | Les deux sont retenues (`filters[statut]=Acceptée,Expirée`) | ⚪ |
| S4.20 | Filtrer sur **Compte** | Trois choix : `Inexistant` · `En attente de validation` · `Existante`. **Aucune fusion** — arbitrage du 14/08, voir §9.14 | ⚪ |
| S4.21 | Filtrer depuis la page 3 | On revient à la **page 1** : le périmètre a changé | ⚪ |
| S4.22 | Filtre sans résultat | « Aucune invitation ne correspond à ces filtres. » — message **distinct** de celui de la recherche | ⚪ |
| S4.23 | Cliquer « **Effacer les filtres** » | Toutes les pastilles se relèvent, la liste revient au périmètre entier | ⚪ |
| S4.24 | Colonne Statut | Elle affiche le statut **servi par le serveur**, celui-là même que le filtre sélectionne — jamais une dérivation d'écran | ⚪ |
| S4.25 | **Avant** le déploiement de l'API EA-014 sur staging | **Aucune pastille, aucun total** : l'écran est exactement celui d'hier. ⚠️ **À jouer EN PREMIER** : BACK#409 est mergée sur `development`, mais le déploiement de l'API staging n'est pas dans le train du front. Si les pastilles n'apparaissent pas, ce scénario est PASSANT, pas défaillant — c'est que l'API n'est pas encore à jour. Voir §9.5 | ⚪ |
| S4.26 | Chercher un bouton d'export | **Il n'y en a toujours pas** — EA-022 (BACK#32), voir §10 | ⚪ |

---

## S5 — Utilisateurs et sa fenêtre de création (`/admin/utilisateurs`)

**Profil** : admin. **PR** : FRONT#162. **Maquettes** : `1-utilisateurs`, `2-modale-creer-utilisateur`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S5.1 | Ouvrir la liste | En-tête v3 : titre, **compteur de comptes**, sous-titre | ⚪ |
| S5.2 | Colonne Manager, personne sans manager | **Cellule vide** — jamais « ⚠ Aucun » (décision du 13/08) | ⚪ |
| S5.3 | Colonne État | Pastille **doublée du mot** : la couleur seule ne se lit pas de la même façon selon les yeux | ⚪ |
| S5.4 | Ouvrir la fenêtre de création | Sections nommées, dans l'ordre : qui est cette personne → ce qu'elle peut faire → comment elle entre. **Le rôle n'est plus en premier** | ⚪ |
| S5.5 | Champs obligatoires manquants | Le bouton est inerte **et une note dit ce qui manque**, à gauche des boutons | ⚪ |
| S5.6 | Créer un utilisateur | Créé avec mot de passe, connexion immédiate possible (processus admin, distinct de l'invitation) | ⚪ |

---

## S6 — Fenêtre de création d'un cours (`/admin/cours`)

**Profil** : admin. **PR** : FRONT#163. **Maquette** : `3-modale-creer-cours`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S6.1 | Menu « + Créer » | Trois entrées **décrites** : Un cours · Un tuto · Un parcours. Chacune ouvre un écran adapté | ⚪ |
| S6.2 | Choisir « Un cours » | La fenêtre s'ouvre, **sections nommées** | ⚪ |
| S6.3 | Titre ou catégorie manquants | Bouton inerte + « Renseignez le titre et la catégorie pour continuer. » | ⚪ |
| S6.4 | Saisir des espaces comme titre | Toujours refusé : « non vide » au sens du navigateur ne vaut pas titre | ⚪ |
| S6.5 | En-tête de la fenêtre | Rappelle le **type créé** (« Cours »), non modifiable — arbitrage du 14/08. **À développer** | ⚪ |

---

## S7 — Inscriptions · onglet Inscrire (`/pilotage/inscriptions`)

**Profils** : admin, Manager RH, manager d'équipe (chacun voit **son** périmètre
d'apprenants). **PR** : FRONT#171. **Maquette** : `6-inscriptions-efektiv-v3`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S7.1 | Ouvrir l'onglet | Deux colonnes : composition à gauche, **Récapitulatif** + aperçu de l'e-mail à droite. Pied collant avec Réinitialiser / Inscrire | ⚪ |
| S7.2 | Libellés des deux sélecteurs | « **Apprenants à inscrire** » et « **Cours à leur ouvrir** ». Plus aucun « le(s) » | ⚪ |
| S7.3 | Dérouler un sélecteur | Tout est en **français** : « Choisir dans la liste… », « Rechercher », « Tout sélectionner », « Aucun résultat ». Aucun « Select… » anglais | ⚪ |
| S7.4 | Taper dans le champ Rechercher du sélecteur | La liste se filtre. Le filtrage est **honnête** : l'API sert la liste entière en une fois, il n'y a pas de page à tronquer | ⚪ |
| S7.5 | Sélectionner 3 apprenants | Le compteur du champ affiche « **3 sélectionnés** » ; le récapitulatif affiche Apprenants **3** | ⚪ |
| S7.6 | Sélectionner 2 cours | Récapitulatif : Cours **2** ; pied : « **3 apprenants × 2 cours** » | ⚪ |
| S7.7 | Lire le total du récapitulatif | « Inscriptions à créer : **au plus 6** » — jamais « 6 inscriptions créées ». Voir §9.8 | ⚪ |
| S7.8 | Lire l'encadré bleu | Il explique **pourquoi** « au plus » : une inscription déjà existante est conservée, l'avancement n'est pas remis à zéro | ⚪ |
| S7.9 | Ligne « Notification » du récapitulatif | « **Toujours envoyée** ». **Aucun interrupteur** : c'est volontaire, voir §9.9 | ⚪ |
| S7.10 | Aperçu de l'e-mail, ligne « À » | Les **noms** des apprenants choisis (l'API ne sert pas leur adresse — §9.10) | ⚪ |
| S7.11 | Aperçu, ligne « Sujet » avec **1** cours | « Vous avez été invité à une formation » — le sujet réel, composé par le serveur | ⚪ |
| S7.12 | Aperçu, ligne « Sujet » avec **3** cours | « Vous avez été invité à 3 formations » | ⚪ |
| S7.13 | Écrire dans Message | L'aperçu suit à la frappe ; le compteur affiche « n/400 » | ⚪ |
| S7.14 | Rien de sélectionné | Bouton **Inscrire inerte** ; le pied dit « Sélectionnez au moins un apprenant et un cours. » | ⚪ |
| S7.15 | Un apprenant, **aucun** cours | Bouton toujours inerte : inscrire à rien n'inscrit à rien | ⚪ |
| S7.16 | **Vider le message**, puis Inscrire | Erreur **sous le champ Message**, aucun appel au serveur. Le serveur exige le message : sans ce contrôle, l'envoi partait pour revenir en 422 | ⚪ |
| S7.17 | Envoyer une composition valable | Bandeau vert « **Dernier envoi** » : *n* inscriptions créées · *n* déjà existantes, conservées · *n* apprenants prévenus par e-mail | ⚪ |
| S7.18 | Réinscrire les **mêmes** personnes aux **mêmes** cours | Le bilan affiche **0 créée** et *n* **déjà existantes, conservées**. C'est la preuve que l'avancement n'a pas été effacé | ⚪ |
| S7.19 | Accord des nombres du bilan | « **1** inscription créée » au singulier, « **2** inscriptions créées » au pluriel. Jamais « (s) » | ⚪ |
| S7.20 | Après un envoi réussi | Les deux sélections sont vidées, le message reprend son modèle, le bilan reste affiché | ⚪ |
| S7.21 | Cliquer **Réinitialiser** | Sélections vidées, bilan effacé, message conservé (c'est un modèle) | ⚪ |
| S7.22 | Compte d'un **manager d'équipe** | La liste ne contient que **ses** apprenants (`parent_id`). Périmètre inchangé par la refonte | ⚪ |
| S7.23 | Périmètre sans aucun apprenant | « Aucun apprenant n'est disponible dans votre périmètre. » — pas une liste vide muette | ⚪ |

---

## S8 — Inscriptions · onglet Demandes (`/pilotage/inscriptions`)

**Profils** : admin, Manager RH, manager d'équipe (chacun voit **son** périmètre).
**PR** : FRONT#173. **Maquette** : `7-inscriptions-demandes-efektiv-v3`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S8.1 | Ouvrir l'onglet **en tant qu'admin** | La liste se charge. ⚠️ Avant correctif, l'onglet restait en **chargement perpétuel** pour l'admin et le manager d'équipe : une énumération de rôles en dur ne posait pas l'état | ⚪ |
| S8.2 | Lire la mention en tête | « Cette liste réunit toutes les inscriptions : celles qu'un apprenant a demandées **et celles qu'un manager a créées depuis l'onglet Inscrire**. » Voir §9.12 | ⚪ |
| S8.3 | Colonnes du tableau | Apprenant (pastille d'initiales + nom) · Cours demandé · **Demandée le** · Statut · Action | ⚪ |
| S8.4 | Colonne « Demandée le » | Date en clair **et** « il y a N jours » en dessous. Nouvelle : `created_at` était servi, jamais affiché | ⚪ |
| S8.5 | Une inscription sans date | Un tiret, jamais « Invalid Date » | ⚪ |
| S8.6 | Statut d'une demande en attente | « **À traiter** », pastille ambre, **et la ligne entière est teintée** | ⚪ |
| S8.7 | Statut inattendu servi par l'API | Rendu **tel quel**, en gris neutre. Jamais « Refusée » : le ternaire d'avant rangeait toute valeur inconnue dans le dernier cas, en rouge | ⚪ |
| S8.8 | Ligne « À traiter » | **Accepter** et **Refuser** en clair, plus le menu · | ⚪ |
| S8.9 | Ligne déjà décidée | Pas de bouton en clair, mais le menu **reste** : revenir sur une décision est une capacité de l'écran, une maquette qui ne la dessine pas ne la retire pas | ⚪ |
| S8.10 | Accepter une demande « À traiter » | Part **sans confirmation** — c'est le geste attendu. Message de succès, la ligne passe à « Acceptée » | ⚪ |
| S8.11 | Changer une décision **déjà prise** | Fenêtre de confirmation qui **nomme la conséquence** : « … a déjà été prévenu par e-mail … Changer la décision lui enverra un second e-mail, qui dira l'inverse du premier. » | ⚪ |
| S8.12 | Annuler cette fenêtre | Rien n'est envoyé, la ligne ne bouge pas | ⚪ |
| S8.13 | Traiter une demande depuis la **page 3** | On **reste** page 3. Avant, chaque décision renvoyait à la page 1 | ⚪ |
| S8.14 | Décision refusée par le serveur | Message d'erreur visible. Avant, l'échec partait dans la console et la ligne gardait son statut sans un mot | ⚪ |
| S8.15 | Trier par Apprenant, Cours, **Demandée le**, Statut | Le tri part au **serveur** (les quatre clés sont acceptées) ; la flèche de la colonne triée s'allume | ⚪ |
| S8.16 | Cliquer deux fois sur la même colonne | Le sens s'inverse | ⚪ |
| S8.17 | Rechercher un nom, une adresse, un cours | La recherche part au **serveur** : elle porte sur l'ensemble, pas sur la page | ⚪ |
| S8.18 | Recherche sans résultat | « Aucune inscription ne correspond à cette recherche. » | ⚪ |
| S8.19 | Aucune inscription du tout | « Aucune inscription pour l'instant. » — message **distinct** du précédent | ⚪ |
| S8.20 | Pied du tableau | « *n* inscriptions » — le total du paginateur, pas le nombre de lignes affichées | ⚪ |
| S8.21 | Chercher les pastilles de filtre et l'export | **Il n'y en a pas** — c'est volontaire, voir §9.11 | ⚪ |
| S8.22 | Ouvrir l'onglet et compter les appels réseau | **Un seul**. Il y en avait trois, identiques | ⚪ |

---

## S12 — Pilotage · Tableau de bord (`/pilotage/dashboard`)

**Profils** : admin, Manager RH, manager, manager d'équipe, directeur.
**PR** : FRONT#175. **Maquette** : `1-Tableau de bord`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S12.1 | Ouvrir l'écran | Titre + sous-titre, barre de filtres (Période · Société · Équipe + bascule Formations/Tutos), **une ligne de contexte**, deux blocs d'indicateurs, panneau « Par module » | ⚪ |
| S12.2 | Lire la ligne de contexte | Périmètre · *n* apprenants comptés · date de mise en service du tracking · comptes exclus — **le tout sur une ligne**, en encadré | ⚪ |
| S12.3 | Les deux blocs | Chacun porte son **unité en pastille** : « personnes distinctes » et « couples apprenant × formation » | ⚪ |
| S12.4 | Entre les deux blocs | L'avertissement « **les deux ne s'additionnent pas** » est toujours là | ⚪ |
| S12.5 | Carte « Apprenants inscrits » | Le dénominateur est sous le chiffre : « sur *n* apprenants du périmètre » | ⚪ |
| S12.6 | Cartes avec taux | Pourcentage à côté du nombre **et** barre de progression | ⚪ |
| S12.7 | Une valeur **absente** de la réponse | Un **tiret**, jamais un zéro. ⚠️ Le scénario qui compte : « 0 apprenant a démarré » se lit comme un désengagement total, alors que c'est un trou de donnée | ⚪ |
| S12.8 | Un **vrai zéro** servi | « 0 », en gris — c'est une information, pas un trou | ⚪ |
| S12.9 | Bloc « En retard » avec au moins une personne | Lien « **Voir les personnes à relancer** » | ⚪ |
| S12.10 | Suivre ce lien | La liste des collaborateurs s'ouvre **déjà filtrée** sur les personnes en retard, et **le dit** en tête | ⚪ |
| S12.11 | Depuis cette liste, « Voir tout le monde » | Le filtre ET l'URL sont retirés ensemble | ⚪ |
| S12.12 | Bloc « Abandons » **à zéro** | **Aucun lien** : il mènerait à une liste vide | ⚪ |
| S12.13 | En **Manager RH** | Le lien de relance mène à « Tous les collaborateurs » ; pour les autres rôles, à « Mes collaborateurs » | ⚪ |
| S12.14 | Colonnes du tableau « Par module » | **Module · Inscrits · Démarrés · Terminés · Complétion**. Ni Vues, ni Actifs, ni Temps moy., ni Quiz moy. — voir §9.15 | ⚪ |
| S12.15 | Lire la note sous le tableau | Elle **nomme** les indicateurs non mesurés, pour qu'on ne les cherche pas | ⚪ |
| S12.16 | Filtrer un module | Le tableau se restreint **sans appel réseau** : il arrive entier dans la réponse | ⚪ |
| S12.17 | Filtre sans résultat | « Aucun module ne correspond à ce filtre. » — distinct de « Aucun module sur cette période. » | ⚪ |
| S12.18 | Pied du tableau | « *n* modules », et « *n* sur *N* » quand un filtre est actif | ⚪ |
| S12.19 | Trier une colonne | Le tri se fait **côté client** : aucun rechargement des indicateurs | ⚪ |
| S12.20 | Ouvrir le sélecteur Période | « Depuis le début » n'apparaît qu'**après** le premier chargement — c'est la date de mise en service du tracking, que seul le serveur connaît | ⚪ |
| S12.21 | Choisir « Depuis le début » | La requête part avec **cette date**, pas une date arbitraire | ⚪ |
| S12.22 | Compter les appels réseau à l'ouverture | **Un seul** appel au tableau de bord. ⚠️ Le calcul des funnels est lourd : un second appel se voit à l'usage | ⚪ |
| S12.23 | Chercher un bouton d'export | **Il n'y en a pas** — BACK#221, voir §10 | ⚪ |

---

## S13 — Pilotage · Listes de collaborateurs

**Écrans** : Mes collaborateurs · Mes équipes · Tous les collaborateurs — **un seul
composant**, donc à recetter sur au moins deux des trois.
**PR** : FRONT#176. **Maquettes** : `2-collaborateurs`, `3-tous-collaborateurs`.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S13.1 | Ouvrir l'écran | Titre + **pastille de décompte** (« 4 personnes ») + sous-titre + bouton **Inviter un collaborateur** | ⚪ |
| S13.2 | Cliquer « Inviter un collaborateur » | Ouvre **Invitations**, pas une création de compte : c'est l'unique porte d'entrée d'un manager (décision F9) | ⚪ |
| S13.3 | Ligne de mesure | « Données d'activité disponibles depuis le … » | ⚪ |
| S13.4 | Chaque ligne | **Pastille d'initiales** devant le nom ; le nom ouvre le cockpit | ⚪ |
| S13.5 | Colonne E-mail | L'adresse est un **lien mailto** — le geste qui suit « qui est en retard » | ⚪ |
| S13.6 | Colonnes Retard / Abandons, valeur > 0 | Nombre en **pastille colorée** | ⚪ |
| S13.7 | Les mêmes colonnes à zéro | Zéro **effacé** (gris). Sans cette distinction, la colonne se lit d'un bloc et le signal passe inaperçu | ⚪ |
| S13.8 | Filtre « Retard » | Deux choix : **Avec retard** · **2 retards ou plus**. Pas de « Sans retard » — voir §9.16 | ⚪ |
| S13.9 | Appliquer un filtre | Une **puce** apparaît sous la barre d'outils, avec « Tout réinitialiser » | ⚪ |
| S13.10 | Liste vide **sans filtre** | « Aucun collaborateur ne vous est rattaché. » + bouton **Inviter** | ⚪ |
| S13.11 | Idem, en **Manager RH** | Un second bouton « Voir tous les collaborateurs ». ⚠️ Absent sur l'écran global lui-même, où il tournerait en rond | ⚪ |
| S13.12 | Liste vide **après filtrage** | « Aucun collaborateur ne correspond à ces filtres. » + bouton **Réinitialiser les filtres** — message et geste **distincts** de S13.10 | ⚪ |
| S13.13 | Cliquer « Réinitialiser les filtres » | Recherche et filtres vidés, la liste revient | ⚪ |
| S13.14 | Bouton Exporter sur liste vide | **Désactivé** | ⚪ |
| S13.15 | Menu « Colonnes » | Toujours là : masquer, réordonner, réinitialiser (EA-021) | ⚪ |
| S13.16 | Arriver depuis le tableau de bord (« Voir les personnes à relancer ») | La liste est **déjà filtrée** et le dit ; « Voir tout le monde » retire filtre **et** URL | ⚪ |

---

## S9 — Écarts assumés avec les maquettes

**Aucun de ces points n'est un défaut.** Chacun est une décision tracée.

### 9.1 — Le tuto n'a pas de dépôt de fichier

La maquette dessine « Déposez la vidéo ici · MP4, MOV ou WebM · 2 Go maximum ».
**La plateforme n'héberge aucune vidéo** : 192 leçons sur 282 en lien externe,
zéro en fichier. `StoreTutorial` exige `youtube_url` et n'accepte pas de
multipart. La maquette v3 a été redessinée depuis une version antérieure à la
correction et a perdu celle-ci.

➡️ **BACK#349** (rouverte) — la maquette est à corriger, pas l'écran.

### 9.2 — Les sélecteurs de référentiel sont cherchables

La maquette montre des listes déroulantes simples et une liste à cocher pour les
processus. Les référentiels s'allongent : une liste sans recherche devient
impraticable. Décision **BACK#359**, appliquée partout.

### 9.3 — Le sujet de l'e-mail d'invitation n'est pas celui de la maquette

La maquette affiche un sujet fixe (« Une invitation à rejoindre la communauté
Efektiv Académie »). **Le serveur ne l'envoie jamais** : il le compose selon le
nombre de cours (0 → « rejoindre la plateforme », 1 → « une formation », n → « n
formations »). L'aperçu montre le vrai. Un aperçu qui montre autre chose que
l'e-mail réel fait décider sur une information fausse.

### 9.4 — Coller une liste d'adresses

Un champ d'une ligne **supprime les retours à la ligne** de la valeur collée :
`a@x.fr⏎b@x.fr` y deviendrait une seule adresse invalide. Le champ lit donc le
presse-papiers directement. À vérifier en recette (S3.5) : c'est un
comportement de navigateur, pas une logique métier.

### 9.5 — Le regroupement du Suivi porte sur la page, pas sur l'ensemble

**Cet écart a changé de nature le 15/08.** Tant que `invitedGuests` paginait à 10
en dur, filtres et regroupement étaient entièrement suspendus. EA-014 (BACK#23) a
mis l'endpoint au contrat : `per_page` borné, `filters[]` appliqués **côté
serveur**, `totals` calculés hors pagination. Ils sont livrés.

Ce qui subsiste, et qui est écrit à l'écran :

- le **regroupement reste à l'échelle de la page**. Au-delà du maximum de
  `per_page`, une personne peut être coupée entre deux pages : la liste groupée
  ne montrera que la page. Les **totaux de l'en-tête, eux, ne mentent pas** — ils
  viennent du serveur. Un regroupement serveur sera une suite si la volumétrie
  l'impose ;
- **aucun filtrage local**, jamais : l'écran affiche ce que le serveur lui rend,
  sans en retirer une ligne. Un filtre posé sur les lignes chargées dirait « 2 »
  là où il y en a trente.

**Avant le déploiement de BACK#409, l'écran n'affiche ni pastilles ni totaux** —
et c'est délibéré. Un serveur d'avant EA-014 ignore `filters[]` sans protester :
des pastilles y auraient l'air d'agir sans rien filtrer. L'écran ne les propose
qu'après avoir vu `totals`, preuve que l'endpoint est au contrat. **Aucun ordre
de déploiement n'est donc imposé** ; le train front/back n'est qu'un confort de
recette.

➡️ **FRONT#168** ↔ **BACK#23**. L'export reste dehors : EA-022 (**BACK#32**).

### 9.6 — « Enregistrer le brouillon » est conservé sur le studio tuto

La maquette ne montre que deux boutons. Sans celui-ci, on ne peut plus s'arrêter
en cours de route. **Une maquette ajoute, elle ne retire pas.**

### 9.7 — Le type de contenu reste dans le menu « + Créer »

La maquette le remet dans la fenêtre. Les trois natures ouvrent **trois écrans
différents** : le rendre changeable dans la fenêtre ferait perdre la saisie.
Arbitrage du 14/08 : **rappel non modifiable dans l'en-tête**.

### 9.8 — « Inscriptions à créer » annonce un plafond, pas un total

La maquette affiche « Inscriptions créées : 6 », obtenu en multipliant les deux
sélections. Ce nombre est un **plafond** : `InvitationService::assignCourses()`
**préserve** une inscription qui existe déjà au lieu de la recréer (BACK#54 /
EA-044 — la recréer effaçait l'avancement). Inscrire 3 personnes à 2 cours peut
donc n'en créer aucune.

L'écran annonce « **au plus 6** » avant l'envoi, et le **vrai** nombre après :
la réponse du serveur porte `enrollments_created` et `enrollments_preserved`,
que l'écran jetait — il affichait le même message qu'on ait créé six inscriptions
ou zéro.

### 9.9 — L'onglet Inscrire n'a pas d'interrupteur de notification

La maquette dessine « Prévenir les apprenants par e-mail ». Le serveur envoie
**dans tous les cas** : `sendOneInvitation()` est appelé sans condition et aucun
paramètre de `POST /invite-mail` n'en dispense (`message` y est même `required`).

Une bascule qui ne coupe rien ferait croire à une inscription silencieuse pendant
que le message part quand même — et on ne s'en apercevrait que dans la boîte de
l'apprenant. Le récapitulatif **constate** donc : « Notification · Toujours
envoyée ». Un test verrouille la décision côté front.

➡️ **FRONT#170** ↔ **BACK#405**.

### 9.10 — Les sélecteurs n'ont pas de seconde ligne

La maquette montre l'adresse sous chaque personne et « Réglementaire · 5 supports »
sous chaque cours. `manager/learnerList` ne sert que `id` + `name`,
`manager/coursesList` que `id`, `slug`, `name` : **il n'y a rien à afficher**, et
deux homonymes restent indistinguables. C'est aussi pourquoi l'aperçu de l'e-mail
montre des noms et non des adresses.

➡️ **FRONT#170** ↔ **BACK#406**.

### 9.11 — L'onglet Demandes n'a ni pastilles de filtre, ni export

La maquette dessine quatre pastilles chiffrées (À traiter · Acceptées ·
Refusées · Toutes), un badge sur l'onglet, et un export.

`requestOfEnrollments` **lit** `$request->status` à sa première ligne et ne s'en
sert jamais ; aucun compteur par statut n'est servi ; la requête pagine à **20 en
dur**. Filtrer les 20 lignes chargées afficherait « 2 » là où il y en a trente :
ça ne filtre pas, ça tronque en silence. Même défaut que §9.5, sur une autre
requête. La recherche et le tri, eux, partent au serveur — ils restent.

➡️ **FRONT#172** ↔ **BACK#410**. Un test verrouille la décision.

### 9.12 — « Demandes » liste aussi ce que personne n'a demandé

`requestOfEnrollments` sert **toutes** les inscriptions sans distinguer leur
origine : celles qu'un apprenant a demandées (créées `Pending` par défaut) et
celles qu'un manager a créées depuis l'onglet Inscrire (créées `Approved`). Une
ligne « Acceptée » n'a donc pas forcément été demandée, ni acceptée par
quiconque — et l'écran ne peut pas afficher le « par Recette Admin » de la
maquette, puisque la décision n'écrit aucun auteur.

L'écran le **dit en toutes lettres** en tête de liste plutôt que de laisser lire
« 9 inscriptions » comme neuf personnes qui ont demandé quelque chose.

➡️ **FRONT#172** ↔ **BACK#411**.

### 9.13 — Pas de sélection multiple ni d'actions en lot

Décision du 12/08 : **BACK#60 (EA-050)**, jalon V2 après septembre. La colonne de
cases à cocher et la barre d'actions groupées de la maquette ne sont pas livrées.

### 9.14 — Le filtre Compte a trois valeurs, la maquette n'en offrait que deux

Arbitrage d'Enguerran du 14/08. La maquette propose « Existant » / « Nouveau » ;
le serveur sert trois états, et « **En attente** » (invitation envoyée, non
validée par le destinataire) n'entre dans aucun des deux : un compte invité
**existe** mais reste inactif tant que l'invitation n'est pas acceptée
(décision F9). Le ranger avec « Existant » ferait croire que la personne s'est
connectée ; avec « Nouveau », qu'il n'y a pas de compte.

C'est donc **la maquette qui s'ouvre à trois choix**, pas le serveur qui en
fusionne deux. Le libellé affiché dit la nuance — « En attente de validation » —
pour ne pas se confondre avec le « En attente » du filtre Statut, qui parle de
l'invitation.

### 9.15 — Le tableau « Par module » n'a que cinq colonnes, la maquette en dessine neuf

`DashboardMetrics::perModule()` ne sert que sept clés — `course_id`,
`course_name`, `unit`, `enrolled`, `started`, `completed`, `completion_rate`.
L'écran en déclarait neuf : **Vues**, **Actifs**, **Temps moy.** et **Quiz moy.**
n'étaient donc jamais remplies, et la DataTable rendait quatre colonnes de
cellules vides sans la moindre erreur.

La maquette v3 les redessine telles quelles — « — » partout dans ses données
d'exemple : l'associé a dessiné ce qu'il voyait, il ne demande pas ces colonnes.

**BACK#328** tranche : « pas de colonne vide livrée ». Côté écran, la règle porte
sur la **donnée** et non sur une liste de noms — une colonne n'est rendue que si
au moins une ligne porte sa clé. Le jour où l'API les sert, elles reparaissent
seules. La note sous le tableau **nomme** les indicateurs manquants.

Même méthode pour la **catégorie du module** (« Métier › Relation client » dans
la maquette) : `perModule()` ne sert que le nom. Ajouté au CA de BACK#328.

➡️ **BACK#328**. Export du tableau : **BACK#221**.

### 9.16 — « Sans retard » et « Sans abandon » ne sont pas proposés

La maquette `2-collaborateurs-efektiv-v3` offre trois choix par filtre :
« tous », « Avec retard », « **Sans retard** ». Le troisième n'est pas livré.

`CollaboratorMetrics::filterAtLeast()` ne sait faire qu'un `>= N` : il n'existe
**aucun filtre d'égalité à zéro** sur ces compteurs. « Sans retard » serait donc
un choix sans effet — et un filtre qui ne filtre pas est pire qu'un filtre
absent, puisqu'on lui fait confiance.

L'écran propose donc « **Avec retard** » et « **2 retards ou plus** », les deux
moitiés qui agissent. Un test verrouille l'absence de la troisième.

➡️ **BACK#422**.

---

## S10 — Fonctions suspendues, à recetter quand le back sera fait

| Fonction | Écran | Front | Back |
|---|---|---|---|
| Export de la vue courante | Suivi · Demandes | **FRONT#168** | **BACK#32** (EA-022) |
| Quatre réglages administrables sans champ (badge d'usage, horizon cockpit) + écriture par l'endpoint validé | Paramètres | **FRONT#165** | ~~BACK#59, #61, #183~~ → **BACK#413 livré**, voir §SB2 |
| Bornes d'invitation validées côté serveur | Inviter | — | **BACK#403** |
| Interrupteur « Prévenir les apprenants par e-mail » | Inscrire | **FRONT#170** | **BACK#405** |
| Seconde ligne des sélecteurs (adresse, catégorie) + adresses réelles dans l'aperçu | Inscrire | **FRONT#170** | **BACK#406** |
| Pastilles de filtre chiffrées, badge de l'onglet, export | Demandes | **FRONT#172** | **BACK#410** |
| Auteur de la décision (« par … ») + distinction demande / affectation | Demandes | **FRONT#172** | **BACK#411** |
| Sélection multiple et actions en lot | Demandes | — | **BACK#60** (jalon V2) |
| Quatre colonnes du tableau « Par module » + catégorie | Tableau de bord | — | **BACK#328** |
| Export du tableau « Par module » | Tableau de bord | — | **BACK#221** |
| Filtres « Sans retard » / « Sans abandon » | Listes de collaborateurs | — | **BACK#422** |

**Trouvé en lisant le contrôleur** (aucun volet front) : **BACK#412** — refuser
une demande crédite l'apprenant des points d'inscription, et chaque décision
renvoie un e-mail même quand elle ne change rien.

**Décision du 14/08 sur le filtre Compte** (FRONT#168) : trois valeurs, aucune
fusion — `Inexistant`, `En attente` (*invitation envoyée, non validée par le
destinataire*), `Existante`.

---

## SB1 — `/moi/profil`, l’adresse unique (BACK#396)

**Profil** : tous. **PR** : BACK#396. Se recette **à l’API**, avant l’écran
unique (FRONT#154).

> Préfixe `SB` (section back) volontaire : la numérotation `S1…S11` suit les
> écrans et se décale à chaque PR de refonte — le 15/08, l’insertion de
> `S8 Demandes` a décalé toutes les suivantes. Un identifiant back ne peut pas
> entrer en collision avec elle.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| SB1.1 | `GET /api/moi/profil` avec le jeton de **chacun des rôles** | 200, et **son** profil : `id`, prénom, nom, e-mail, `bio`, `avatar`, `avatar_url`, `roles` | ⚪ |
| SB1.2 | `PUT /api/moi/profil` avec un prénom, pour chaque rôle | 200, valeur enregistrée et relue | ⚪ |
| SB1.3 | **Cloisonnement** : jeton de A, corps contenant `id` et `user_id` de B | Le profil de **B est intact** ; la modification atterrit sur **A**. C'est le point que le relecteur cherche en premier | ⚪ |
| SB1.4 | `GET /api/moi/profil?user_id=<autre>` | Renvoie **son propre** profil : la route n'accepte aucune cible | ⚪ |
| SB1.5 | Sans jeton | **401** sur la lecture comme sur l'écriture | ⚪ |
| SB1.6 | Saisir une **bio**, enregistrer, **recharger**, relire | La bio est là — pour **tous les rôles**. Le défaut vécu (saisie qui disparaît en silence chez le sous-manager) ne se voyait qu'après cet aller-retour | ⚪ |
| SB1.7 | Prendre l'**e-mail d'un autre** compte | **422**, e-mail inchangé | ⚪ |
| SB1.8 | Réenregistrer **son propre** e-mail sans le changer | **200** — l'unicité ignore sa propre ligne | ⚪ |
| SB1.9 | Téléverser un avatar PNG valide | 200, fichier stocké, `avatar_url` exploitable | ⚪ |
| SB1.10 | Téléverser une image **au-delà de la limite** (`AVATAR_MAX_KB`, 2 Mo par défaut) | **422**. Avant #396, **aucune limite** n'existait | ⚪ |
| SB1.11 | Envoyer un fichier **non-image** derrière un préfixe `data:image/png` | **422** — le type est lu sur les octets décodés, pas sur le préfixe déclaré par l'appelant | ⚪ |
| SB1.12 | Modifier son nom **sans toucher à la photo** | L'avatar est **conservé** — sinon chaque enregistrement l'effacerait, invisible jusqu'au rechargement | ⚪ |
| SB1.13 | Envoyer `avatar: null` | La photo est retirée | ⚪ |
| SB1.14 | Ouvrir les **sept anciennes adresses** (`admin/my-profile`, `manager/my-profile`, `submanager-my-profile`, `director-my-profile`, `manager-general-profile`, `learner/my-profile`, `teacher/my-profile`) | Elles **répondent toujours**. Leur retrait est une PR distincte, **après** la bascule du front | ⚪ |
| SB1.15 | Sur une base contenant des bios/avatars anciens, jouer la migration | Rien n'est perdu : les valeurs des **sept** tables de profil sont remontées sur `users` | ⚪ |

**Vérifié en développement** (à re-constater sur staging) : **1193 tests verts**,
dont 17 propres à #396 ; migration **rejouée sur MySQL 9.6** avec des données
réelles — bios et avatars repris depuis deux tables distinctes
(`teacher_profiles`, `director_profiles`), colonnes d'origine conservées.

**Le test de cloisonnement a été vérifié par mutation** : en rendant volontairement
le contrôleur vulnérable (cible lue dans le corps de la requête), SB1.3 et SB1.4
passent au rouge. Un test de sécurité qui ne peut pas échouer ne prouve rien.

**Deux points laissés ouverts, à trancher** :

1. **Les champs morts** (téléphone, adresse, poste, LinkedIn, centres d'intérêt…) :
   gérés par le `handleChange` des écrans, affichés par aucun. `/moi/profil` ne les
   sert ni ne les accepte — les servir à moitié était le seul choix à écarter. Ils
   entrent dans l'écran unique, ou ils sortent du code.
2. **`bio` et `avatar` restent aussi dans les sept tables de profil**, le temps de la
   bascule. Leur retrait accompagnera celui des sept routes.

---

## S11 — Non-régression transverse

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| S11.1 | Parcourir les dix écrans en **1280 px** | Aucun débordement horizontal | ⚪ |
| S11.2 | Idem en **375 px** | Colonnes empilées, barres d'action pleine largeur, aucun débordement | ⚪ |
| S11.3 | Naviguer **au clavier seul** sur Paramètres, Inviter, Inscrire et Demandes | Tous les champs atteignables ; la bascule Vitrine, les jetons, les sélecteurs et les en-têtes de tri prennent le focus visiblement | ⚪ |
| S11.4 | Boutons principaux des dix écrans | **Dégradé rose** du système. Aucun bouton bleu Bootstrap | ⚪ |
| S11.5 | Champs et libellés | Libellé à gauche, compteur ou mention à droite, jamais collés | ⚪ |
| S11.6 | Liste Cours | Cours, tutos et parcours dans **une** liste, badge par nature | ⚪ |
| S11.7 | Champ de recherche du **Suivi** | Hauteur et anneau de focus du système (roses), pas ceux de Bootstrap. La classe `.controle` vit désormais dans les primitives partagées | ⚪ |
| S11.8 | Titre de colonne « INVITATION » de l'onglet **Inviter** | Petites capitales grises du système — `.studio-colonne__titre` était enfermée sous `.admin-nouveau-tuto`, elle ne s'appliquait pas | ⚪ |
| S11.9 | Tableau des **Demandes** avec un intitulé de cours très long | Il s'abrège, la colonne d'action reste entièrement visible, aucun défilement horizontal | ⚪ |
| S11.10 | Statuts et actions du même écran | Le mot du bouton annonce le statut qu'il produit : **Accepter → Acceptée**, **Refuser → Refusée** | ⚪ |

---

## SB2 — Réglages administrés côté serveur (BACK#413)

**Profil** : admin. **PR** : BACK#413. Se recette **à l'API**, avant que l'écran
Paramètres ne bascule (FRONT#165) — c'est ce que le front consommera.

> Préfixe `SB` (section back) volontaire : la numérotation `S1…S11` suit les écrans
> et se décale à chaque PR de refonte — le 15/08, l'insertion de `S8 Demandes` a
> décalé toutes les suivantes, et cette section s'est retrouvée à porter un `S11`
> déjà pris par « Non-régression transverse ». Un identifiant back ne peut pas
> entrer en collision avec elle.

Base de départ : un environnement fraîchement migré, sans réglage posé à la main.

| # | Scénario | Attendu | Résultat |
|---|---|---|---|
| SB2.1 | `GET /api/admin/delays` en admin | **Six** clés dans `data`, chacune un entier. `late_start_days=30`, `abandoned_after_days=60`, `usage_window_days=90`, `usage_oneshot_max=1`, `usage_occasional_max=3`, `cockpit_export_months=24` | ⚪ |
| SB2.2 | Lire `fields` dans la même réponse | Pour chaque clé : `min`, `max`, `unit_label`, `label`, `help`, `group_label` — **en français**, jamais une clé de traduction brute (`common.settings.…`) | ⚪ |
| SB2.3 | Comparer `fields.*.min/max` aux refus du serveur | Les bornes affichées sont **celles qui valident** : `usage_window_days` à 0 est refusé (min 1), `cockpit_export_months` à 121 aussi (max 120) | ⚪ |
| SB2.4 | `PUT /api/admin/delays` avec les quatre nouvelles clés | 200, valeurs relues depuis la base dans la réponse | ⚪ |
| SB2.5 | `PUT /api/admin/delays` avec `usage_window_days = 0` | **422**, et la valeur en base **n'a pas bougé** | ⚪ |
| SB2.6 | `POST /api/admin/update-setting` avec `late_start_days = -5` | **422** — c'était accepté et stocké avant #413. C'est le durcissement de la porte historique | ⚪ |
| SB2.7 | `POST /api/admin/update-setting` avec `cockpit_export_months = "abc"` | **422** | ⚪ |
| SB2.8 | `POST /api/admin/update-setting` avec `late_start_days = 45` | **200** — l'écran actuel passe encore par cette route et ne doit pas casser | ⚪ |
| SB2.9 | `POST /api/admin/update-setting` avec `banner_text_1` | **200** — les réglages sans endpoint dédié ne sont pas concernés par le durcissement | ⚪ |
| SB2.10 | Poser `cockpit_export_months = 36`, puis exporter l'historique en rôle managérial | L'horizon suit le réglage : il ne retombe plus sur la constante 24 | ⚪ |
| SB2.11 | Modifier un réglage à la main en base, puis rejouer les migrations | La valeur posée **survit** : le seed ne réécrit que les clés absentes | ⚪ |

**Vérifié en développement** (à re-constater sur staging) : suite complète
**1193 tests verts**, dont 17 propres à #413 ; migration et idempotence **rejouées
sur MySQL 9.6** (règle SQLite ≠ MySQL) — la valeur `120` posée par un admin a
survécu au rejeu de `up()`, sans ligne dupliquée.

**Deux points à trancher, pas des défauts d'écran** :

1. **Seul l'`admin` peut LIRE `/admin/delays`** (middleware `RoleMiddleware:admin`).
   Le Manager RH, qui pose pourtant des surcharges individuelles, ne peut pas
   consulter la valeur globale dont elles héritent. `can_edit_global` est servi et
   juste, mais il vaut toujours `true` tant que la lecture reste fermée — il ne
   répond donc pas encore au CA de FRONT#165 (« un Manager RH ne voit pas un bouton
   qui échouera en 403 »).
2. Le durcissement **valide sans refuser** les six clés sur `update-setting`. Le
   refus sec viendra avec la PR de retrait, **après** la bascule du front.

---

## Journal

| Date | Écrans ajoutés | Bundle staging |
|---|---|---|
| 14/08 | Utilisateurs, modale cours, Paramètres, Studio tuto, Inviter, Suivi | `index-DAh3Ph7v.js` |
| 15/08 | Inscriptions · Inscrire | `index-C3_SBsf4.js` |
| 15/08 | Inscriptions · Demandes | `index-DBZoyEc7.js` |
| 15/08 | Suivi · filtres, regroupement, totaux (EA-014) | `index-oypzQZ2W.js` |
| 15/08 | Pilotage · Tableau de bord | `index-Cfl1U2yF.js` |
| 15/08 | Pilotage · Listes de collaborateurs | `index-pDmOJyU3.js` |

**Contre-recette multi-testeurs** : la feuille `L4.7` de
`CONTRE_RECETTE_L0_L4.xlsx` porte une ligne par scénario. Elle est **dérivée de
ce fichier**, jamais retapée — `python3 scripts/contre_recette_l47.py` la
régénère. Un scénario ajouté ici y apparaît à la prochaine exécution ; les deux
ne peuvent pas diverger.

**Tenue à jour** : ce cahier s'enrichit à chaque PR de refonte. Une PR qui livre
un écran ajoute sa section de scénarios et, s'il y a lieu, ses écarts en §9 et
ses fonctions suspendues en §10.
