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