diff --git a/docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md b/docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md new file mode 100644 index 0000000..ea02972 --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md @@ -0,0 +1,187 @@ +# Saisie information bovin (post-EDNOTIF) + +## Contexte + +Sur la page `/entry-exit`, le tableau "Entrées validées" liste les receptions +dont les bovins sont tous confirmés EDNOTIF (`reception.validatedAt` non null). +Une fois cette validation acquise, l'utilisateur doit encore renseigner pour +chaque bovin quatre informations métier qui ne viennent pas d'EDNOTIF : + +- poids d'arrivée +- prix d'achat au kg +- bâtiment +- case + +Cette spec décrit l'écran de saisie et le composant accordéon qu'il introduit. + +## Périmètre + +- Nouvelle page accessible uniquement via clic sur une ligne d'"Entrées + validées" — pas d'entrée dans la nav globale. +- Aucun changement d'entité Doctrine, aucune migration : les quatre champs + existent déjà sur `Bovine` (`receivedWeight`, `pricePerKg`, `buildingCase`). +- Le champ `building` (legacy XLSX) n'est pas écrit. Côté affichage, + `getEffectiveBuilding()` continue de dériver le bâtiment effectif depuis + `buildingCase`. +- `pricePerKg` reste protégé par `ROLE_BUREAU` côté API. La page exige + `ROLE_ADMIN` ; la hiérarchie Symfony fait que `ROLE_ADMIN` hérite + `ROLE_BUREAU`, donc pas de cas particulier. +- Pas de gestion de "session interrompue" : chaque accordéon validé + individuellement est persisté immédiatement. + +## Routing & navigation + +- Page : `frontend/pages/entry-exit/bovine-info/[id].vue` où `[id]` est le + `receptionId`. +- Sur `frontend/pages/entry-exit/index.vue`, le tableau "Entrées validées" + reçoit `row-clickable` et `@row-click="goToBovineInfo"`. Le handler pousse + vers `/entry-exit/bovine-info/{reception.id}`. +- Le bouton flèche-retour de la page renvoie vers `/entry-exit`. + +## Composant `UiAccordion` + +Fichier : `frontend/components/ui/UiAccordion.vue`. Réutilisable, sans logique +métier. + +**Props** +- `modelValue: boolean` — état ouvert/fermé, supporte `v-model`. + +**Slots** +- `#header` — contenu libre du header (badge, titre, etc., aligné à gauche). +- default — corps de l'accordéon, rendu uniquement quand ouvert. + +**Comportement** +- Click sur le header → emit `update:modelValue` avec la valeur inversée. +- Header : `bg-slate-100`, padding identique aux headers `UiDataTable` + (`px-4 py-3`), texte semi-bold uppercase. +- Chevron à droite (`mdi:chevron-down`), rotation 180° quand ouvert, + transition CSS courte. +- Pas d'animation de hauteur au déploiement (pour rester simple) — on rend ou + pas via `v-if`. + +## Page `bovine-info/[id].vue` + +**Layout** — copie du pattern `entry-exit/entry/[id].vue` : +`
` + bandeau titre avec flèche retour absolue, titre +`

Saisie information bovin {{ reception.identificationNumber }}

`. +Pas de sous-titre. + +**Chargement (`onMounted`)** + +1. `GET receptions/{id}` → alimente le titre. +2. `GET bovines?reception={id}&itemsPerPage=200` (pas de pagination — on + suppose qu'une réception a au plus quelques dizaines de bovins). +3. `GET buildings` — la réponse contient `buildingCases` imbriqués + (`BuildingData.buildingCases`). On dérive de là à la fois la liste de + bâtiments (selector "Bâtiment") et l'index des cases par bâtiment. + +**État local par bovin** (`Map`) : + +```ts +type FormState = { + receivedWeight: number | null + pricePerKg: number | null + buildingId: number | null // UI-only, drive le filtre Case + buildingCaseId: number | null + submitted: boolean // pour les borders rouges au submit + isSaving: boolean +} +``` + +Initialisé depuis l'API : `receivedWeight`, `pricePerKg` directement, +`buildingCaseId = bovine.buildingCase?.id`, +`buildingId = bovine.effectiveBuilding?.id`. + +**Source de vérité du badge** : `bovine.receivedWeight != null +&& bovine.pricePerKg != null && bovine.buildingCase != null` — donc calculé +sur les valeurs *persistées*, pas sur le `FormState` en cours d'édition. Vert +"Saisie" si les trois sont non-null (le bâtiment est dérivé de la case), +sinon jaune "Attente saisie". + +> Note : on garde 4 champs côté UI mais 3 conditions backend, parce que +> `building` n'est pas persisté indépendamment. + +**Tri** — non-saisis (badge jaune) en haut puis saisis (badge vert) en bas, +ordre d'API préservé à l'intérieur de chaque groupe. Le tri est calculé +- au chargement initial, +- après chaque PATCH OK (le bovin qui vient d'être saisi descend dans le + groupe vert). + +Il ne se recompute pas pendant qu'un accordéon est ouvert et en cours +d'édition — sinon les bovins sauteraient de position au moindre changement +de l'état "saisi/non-saisi", ce qui ne se produit ici que sur un PATCH +réussi. + +**Open state** — `ref` qui contient l'id du bovin +actuellement ouvert. Un seul accordéon ouvert à la fois. + +- Initialisation : id du premier bovin non-saisi de la liste triée, ou + `null` si tout est déjà saisi. +- Click sur un header autre que celui ouvert → ferme l'ouvert, ouvre le + cliqué. +- Click sur le header ouvert → ferme (open = `null`). +- Validation OK d'un accordéon → ferme l'actuel, ouvre l'id du prochain + non-saisi de la liste (recalculée). Si plus de non-saisi → `null`. + +## Formulaire par accordéon + +Tous les champs `required`, validation au submit (pattern `submitted` flag + +`.submitted :invalid` du CSS global). + +| Champ | Composant | Type / format | +| ------------------ | -------------------- | ------------------------ | +| Poids d'arrivée | `UiNumberInput` | entier kg | +| Prix d'achat (kg) | `UiNumberInput` | float, step 0.01 | +| Bâtiment | `UiSelect` | options = liste building | +| Case | `UiSelect` | options = cases du building sélectionné | + +- Watch sur `buildingId` : si l'utilisateur change le bâtiment et que la case + actuellement sélectionnée n'appartient pas au nouveau, on remet + `buildingCaseId = null`. +- Bouton `Valider` centré, `bg-primary-500`, désactivé pendant `isSaving`. + +**Soumission** + +``` +PATCH /bovines/{id} +{ + receivedWeight, + pricePerKg, + buildingCase: `/api/building_cases/${buildingCaseId}` +} +Content-Type: application/ld+json +``` + +À la réponse OK, on remplace le bovin dans la liste locale par la version +retournée par l'API (qui contient `buildingCase` hydraté pour recomputer le +badge), puis on déclenche la transition d'état (resort + open suivant). + +En cas d'erreur HTTP, le toast par défaut de `useApi` suffit ; on garde +l'accordéon ouvert et le `FormState` intact. + +## Hors périmètre + +- Pas de bulk-save (pas de "Tout valider"). +- Pas de tracking "modifié non sauvé" / warning au unload — chaque accordéon + est validé explicitement, pas d'autosave. +- Pas de tests automatisés ajoutés dans ce lot (cohérent avec le reste de la + feature entry-exit). +- Pas d'exposition de cet écran ailleurs que via le tableau "Entrées + validées". + +## Critères d'acceptation + +- Cliquer sur une ligne du tableau "Entrées validées" ouvre la page + `/entry-exit/bovine-info/{id}`. +- La page liste tous les bovins de la réception, non-saisis en haut. +- Au chargement, un seul accordéon est ouvert : le premier non-saisi (ou + aucun si tout est déjà saisi). +- Cliquer sur un autre header ferme l'ouvert et ouvre le cliqué. +- Soumettre un accordéon avec un champ vide affiche les borders rouges + (`submitted` flag) et bloque la requête. +- Soumettre un accordéon valide PATCH le bovin et, après réponse OK, ferme + l'accordéon, met le badge en vert et ouvre le suivant non-saisi. +- Recharger la page après une saisie partielle réaffiche les valeurs + pré-remplies et le bon badge pour chaque bovin. +- `php-cs-fixer` et `make test` restent verts (pas de code backend modifié, + donc rien à régresser).