Compare commits

...

9 Commits

Author SHA1 Message Date
7d69860edc feat(front) : retouches UX saisie bovin (filtre, toast, partial save, fix isSaisi)
- isSaisi : != null couvre les champs absents du JSON (API Platform strip null)
- UiNumberInput : ne réécrit target.value que si réellement clampé (fix saisie décimaux)
- Form : champs optionnels, payload partiel, toast de confirmation
- Page : filtre N° national au-dessus de la liste

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:07:34 +02:00
209b14eb56 feat(front) : clic sur entrée validée → page saisie info bovin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:34:24 +02:00
2166fe2685 feat(front) : page saisie information bovin (accordéons)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:33:55 +02:00
92c8c6a704 feat(front) : sous-composant bovine-info-form (4 champs + valider)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:33:24 +02:00
9af05ff449 feat(front) : composant UiAccordion réutilisable
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:32:41 +02:00
fff6fa7e17 feat(front) : id dans BovineBuildingRef et BovineBuildingCaseRef
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:32:25 +02:00
cae04ed489 feat(api) : exposer BuildingCase.id et Building.id dans bovine:read
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:31:25 +02:00
6134fc3107 docs : plan saisie information bovin post-EDNOTIF
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:29:13 +02:00
4513dcdc5c docs : spec saisie information bovin post-EDNOTIF
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:21:30 +02:00
10 changed files with 1116 additions and 3 deletions

View File

@@ -0,0 +1,598 @@
# Saisie information bovin (post-EDNOTIF) — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> **Mode utilisateur :** L'utilisateur souhaite valider chaque étape avant exécution (cf. memory `feedback_step_by_step_validation`). Avant chaque task, présenter ce qui va être fait et attendre OK explicite.
**Goal:** Ajouter un écran de saisie post-EDNOTIF (poids, prix/kg, bâtiment, case) accessible depuis le tableau "Entrées validées", structuré en accordéons-par-bovin.
**Architecture:** Un nouveau composant `UiAccordion` réutilisable. Une nouvelle page Nuxt `entry-exit/bovine-info/[id].vue` qui charge la réception et ses bovins, instancie un accordéon par bovin et délègue la saisie à un sous-composant `bovine-info-form.vue`. Pas de nouvel endpoint, pas de migration : on PATCH les `Bovine` existants (`receivedWeight`, `pricePerKg`, `buildingCase`). Mini ajustement backend : exposer les ids de `BuildingCase` et `Building` dans le groupe de sérialisation `bovine:read`, sinon on n'a pas de quoi pré-remplir les selectors.
**Tech Stack:** Symfony 8 + API Platform 4 (annotations Groups) ; Nuxt 4 + Vue 3 + Tailwind ; pas de tests automatisés (cohérent avec le reste de la feature entry-exit, cf. spec).
**Spec source:** `docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md`
**Branche de travail:** `feat/entree-sortie` (déjà créée).
---
## Synthèse du file-mapping
| Fichier | Type | Responsabilité |
| --- | --- | --- |
| `src/Entity/BuildingCase.php` | Modify | Ajouter `bovine:read` au groupe de `id` |
| `src/Entity/Building.php` | Modify | Ajouter `bovine:read` au groupe de `id` |
| `frontend/services/dto/bovine-data.ts` | Modify | Ajouter `id` à `BovineBuildingRef` et `BovineBuildingCaseRef` |
| `frontend/components/ui/UiAccordion.vue` | Create | Composant réutilisable, header en slot, body en slot, v-model boolean |
| `frontend/components/entry-exit/bovine-info-form.vue` | Create | Sous-composant : 4 champs + bouton Valider, émet `saved` |
| `frontend/pages/entry-exit/bovine-info/[id].vue` | Create | Page : header, fetch, tri, état d'ouverture, rendu liste d'accordéons |
| `frontend/pages/entry-exit/index.vue` | Modify | Ajouter `row-clickable` + `@row-click` au tableau "Entrées validées" |
---
## Task 1 : Exposer les ids `BuildingCase` et `Building` dans `bovine:read`
**Contexte :** Quand l'API normalise un `Bovine` avec le groupe `bovine:read`, l'embedded `buildingCase` ne contient que `caseNumber` et `building.label`. Pas d'ids → pas de pré-remplissage possible. On ajoute le groupe `bovine:read` aux deux propriétés `id` concernées (zéro changement de schéma, juste un attribut PHP).
**Files:**
- Modify: `src/Entity/BuildingCase.php:42`
- Modify: `src/Entity/Building.php:36`
- [ ] **Step 1 : Patch `BuildingCase.id`**
```php
// src/Entity/BuildingCase.php — remplacer
#[Groups(['building:read', 'building_case:read'])]
private ?int $id = null;
// par
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
private ?int $id = null;
```
- [ ] **Step 2 : Patch `Building.id`**
```php
// src/Entity/Building.php — remplacer
#[Groups(['building:read', 'building:summary', 'reception:read'])]
private ?int $id = null;
// par
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
private ?int $id = null;
```
- [ ] **Step 3 : Vider le cache (les groupes sont compilés)**
```bash
make cache-clear
```
- [ ] **Step 4 : Vérifier que les tests existants passent**
```bash
make test
```
Attendu : 9/9 tests OK (aucun changement de comportement, juste une exposition supplémentaire).
- [ ] **Step 5 : Vérification manuelle rapide**
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
'http://localhost:8080/api/bovines/1' | jq '.buildingCase'
```
Attendu : la réponse contient `id` (numérique) en plus de `caseNumber`, et `buildingCase.building` contient `id` en plus de `label`. Si le bovin n'a pas de buildingCase, ce sera `null` — prendre un id de bovin qui en a un (sinon ignorer cette étape).
- [ ] **Step 6 : Commit**
```bash
git add src/Entity/BuildingCase.php src/Entity/Building.php
git commit -m "feat(api) : exposer BuildingCase.id et Building.id dans bovine:read"
```
---
## Task 2 : Compléter le DTO frontend `BovineData`
**Files:**
- Modify: `frontend/services/dto/bovine-data.ts`
- [ ] **Step 1 : Ajouter `id` aux deux interfaces de référence**
Remplacer le bloc en haut du fichier :
```ts
export interface BovineBuildingRef {
id: number
label: string
}
export interface BovineBuildingCaseRef {
id: number
caseNumber: number | null
building: BovineBuildingRef | null
}
```
- [ ] **Step 2 : Vérifier que TypeScript ne casse pas**
```bash
cd frontend && npx vue-tsc --noEmit 2>&1 | head -40
```
Attendu : pas d'erreur (les autres pages qui consomment `BovineData` ne lisaient pas l'`id` depuis ces sous-objets ; ajouter un champ ne casse rien).
Si erreurs inattendues, les corriger en touchant seulement les call-sites pointés par tsc.
- [ ] **Step 3 : Commit**
```bash
git add frontend/services/dto/bovine-data.ts
git commit -m "feat(front) : id dans BovineBuildingRef et BovineBuildingCaseRef"
```
---
## Task 3 : Créer `UiAccordion`
**Files:**
- Create: `frontend/components/ui/UiAccordion.vue`
- [ ] **Step 1 : Écrire le composant**
```vue
<template>
<div class="overflow-hidden">
<button
type="button"
class="flex w-full items-center justify-between gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-left"
@click="toggle"
>
<span class="flex-1">
<slot name="header" />
</span>
<Icon
name="mdi:chevron-down"
size="24"
class="shrink-0 transition-transform"
:class="{ 'rotate-180': modelValue }"
/>
</button>
<div v-if="modelValue" class="border border-t-0 border-slate-200 px-6 py-6">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const toggle = () => emit('update:modelValue', !props.modelValue)
</script>
```
- [ ] **Step 2 : Vérifier l'auto-import**
Nuxt auto-importe les composants de `components/ui/` avec le préfixe `Ui` (cf. CLAUDE.md). Donc `<UiAccordion />` sera utilisable sans import explicite. Pas d'action ici, juste validation mentale.
- [ ] **Step 3 : Commit**
```bash
git add frontend/components/ui/UiAccordion.vue
git commit -m "feat(front) : composant UiAccordion réutilisable"
```
---
## Task 4 : Créer `bovine-info-form.vue` (sous-composant)
**Contexte :** Encapsule l'état local et le formulaire d'un bovin. Reçoit le bovin et la liste de bâtiments, émet `saved` avec le bovin mis à jour. Permet à la page parent de rester lisible.
**Files:**
- Create: `frontend/components/entry-exit/bovine-info-form.vue`
- [ ] **Step 1 : Écrire le composant**
```vue
<template>
<form class="space-y-6" :class="{ submitted }" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-x-12 gap-y-6">
<UiNumberInput
v-model="form.receivedWeight"
label="Poids d'arrivée (kg)"
:min="0"
:step="1"
required
/>
<UiNumberInput
v-model="form.pricePerKg"
label="Prix d'achat (kg)"
:min="0"
:step="0.01"
required
/>
<UiSelect
v-model="form.buildingId"
label="Bâtiment"
:options="buildingOptions"
required
/>
<UiSelect
v-model="form.buildingCaseId"
label="Case"
:options="caseOptions"
:disabled="form.buildingId === null"
required
/>
</div>
<div class="flex justify-center">
<UiButton
type="submit"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
:disabled="isSaving"
:loading="isSaving"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import type { BuildingData } from '~/services/dto/building-data'
const props = defineProps<{
bovine: BovineData
buildings: BuildingData[]
}>()
const emit = defineEmits<{
saved: [bovine: BovineData]
}>()
const api = useApi()
interface FormState {
receivedWeight: number | null
pricePerKg: number | null
buildingId: number | null
buildingCaseId: number | null
}
const form = reactive<FormState>({
receivedWeight: props.bovine.receivedWeight,
pricePerKg: props.bovine.pricePerKg,
buildingId: props.bovine.buildingCase?.building?.id
?? props.bovine.effectiveBuilding?.id
?? null,
buildingCaseId: props.bovine.buildingCase?.id ?? null
})
const submitted = ref(false)
const isSaving = ref(false)
const buildingOptions = computed(() =>
props.buildings.map(b => ({ value: b.id, label: b.label }))
)
const caseOptions = computed(() => {
if (form.buildingId === null) return []
const building = props.buildings.find(b => b.id === form.buildingId)
if (!building?.buildingCases) return []
return building.buildingCases.map(c => ({
value: c.id,
label: c.caseNumber !== null ? `Case ${c.caseNumber}` : (c.code ?? `#${c.id}`)
}))
})
watch(() => form.buildingId, (newId) => {
if (form.buildingCaseId === null) return
const building = props.buildings.find(b => b.id === newId)
const caseStillValid = building?.buildingCases?.some(c => c.id === form.buildingCaseId)
if (!caseStillValid) {
form.buildingCaseId = null
}
})
const submit = async () => {
submitted.value = true
if (
form.receivedWeight === null
|| form.pricePerKg === null
|| form.buildingId === null
|| form.buildingCaseId === null
) {
return
}
isSaving.value = true
try {
const updated = await api.patch<BovineData>(
`bovines/${props.bovine.id}`,
{
receivedWeight: form.receivedWeight,
pricePerKg: form.pricePerKg,
buildingCase: `/api/building_cases/${form.buildingCaseId}`
},
{ headers: { 'Content-Type': 'application/merge-patch+json' } }
)
emit('saved', updated)
} finally {
isSaving.value = false
}
}
</script>
```
> Note : on utilise `application/merge-patch+json` comme content-type côté API Platform pour les PATCH (la convention par défaut). `useApi.patch` a déjà ce content-type par défaut — la ligne `headers` est ici **à supprimer** si `useApi.patch` le pose déjà. Vérifier dans `composables/useApi.ts` à l'étape suivante.
- [ ] **Step 2 : Vérifier le content-type par défaut de `useApi.patch`**
```bash
grep -n "patch" frontend/composables/useApi.ts | head -10
```
- Si `useApi.patch` injecte déjà `application/merge-patch+json`, **retirer** le bloc `headers` du composant ci-dessus.
- Sinon, le garder.
- [ ] **Step 3 : Commit**
```bash
git add frontend/components/entry-exit/bovine-info-form.vue
git commit -m "feat(front) : sous-composant bovine-info-form (4 champs + valider)"
```
---
## Task 5 : Créer la page `bovine-info/[id].vue`
**Files:**
- Create: `frontend/pages/entry-exit/bovine-info/[id].vue`
- [ ] **Step 1 : Écrire la page**
```vue
<template>
<div class="px-[86px]">
<div class="flex items-center justify-start gap-6 relative mb-8">
<Icon
@click="router.push('/entry-exit')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500 absolute -left-[60px]"
/>
<h1 class="font-bold text-3xl uppercase text-primary-500">
Saisie information bovin {{ reception?.identificationNumber ?? '' }}
</h1>
</div>
<div v-if="loading" class="text-center text-slate-500">Chargement</div>
<div v-else class="space-y-3">
<UiAccordion
v-for="bovine in sortedBovines"
:key="bovine.id"
:model-value="openId === bovine.id"
@update:model-value="onToggle(bovine.id, $event)"
>
<template #header>
<span class="flex items-center gap-3 normal-case">
<span class="font-bold text-base">{{ bovine.nationalNumber }}</span>
<span
v-if="isSaisi(bovine)"
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
>
Saisie
</span>
<span
v-else
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
>
Attente saisie
</span>
</span>
</template>
<BovineInfoForm
:bovine="bovine"
:buildings="buildings"
@saved="onSaved"
/>
</UiAccordion>
</div>
</div>
</template>
<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import type { BuildingData } from '~/services/dto/building-data'
import type { ReceptionData } from '~/services/dto/reception-data'
import { getBuildingList } from '~/services/building'
const route = useRoute()
const router = useRouter()
const api = useApi()
const receptionId = computed(() => Number(route.params.id))
const reception = ref<ReceptionData | null>(null)
const bovines = ref<BovineData[]>([])
const buildings = ref<BuildingData[]>([])
const loading = ref(true)
const openId = ref<number | null>(null)
useHead({
title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim()
})
const isSaisi = (bovine: BovineData) =>
bovine.receivedWeight !== null
&& bovine.pricePerKg !== null
&& bovine.buildingCase !== null
const sortedBovines = computed(() => {
const pending = bovines.value.filter(b => !isSaisi(b))
const done = bovines.value.filter(b => isSaisi(b))
return [...pending, ...done]
})
const onToggle = (bovineId: number, value: boolean) => {
openId.value = value ? bovineId : null
}
const onSaved = (updated: BovineData) => {
const idx = bovines.value.findIndex(b => b.id === updated.id)
if (idx === -1) return
bovines.value[idx] = updated
// Ouvrir le prochain non-saisi dans la nouvelle liste triée
const next = sortedBovines.value.find(b => !isSaisi(b) && b.id !== updated.id)
openId.value = next?.id ?? null
}
const loadBovines = async () => {
type Hydra = { 'hydra:member'?: BovineData[] }
const response = await api.get<BovineData[] | Hydra>(
'bovines',
{ reception: receptionId.value, itemsPerPage: 200 }
)
if (Array.isArray(response)) {
bovines.value = response
} else if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
bovines.value = response['hydra:member']
} else {
bovines.value = []
}
}
onMounted(async () => {
try {
const [r, , b] = await Promise.all([
api.get<ReceptionData>(`receptions/${receptionId.value}`),
loadBovines(),
getBuildingList()
])
reception.value = r
buildings.value = b
const firstPending = sortedBovines.value.find(bv => !isSaisi(bv))
openId.value = firstPending?.id ?? null
} finally {
loading.value = false
}
})
</script>
```
> Note de style : `BovineInfoForm` est référencé sans import — Nuxt auto-importe les composants `components/entry-exit/*.vue` avec un PascalCase basé sur le nom de fichier (à confirmer ; sinon, ajouter `import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue'`).
- [ ] **Step 2 : Vérifier l'auto-import**
```bash
cd frontend && npm run dev
```
Aller sur `http://localhost:3000/entry-exit/bovine-info/<id>` (id d'une réception validée). Si erreur "BovineInfoForm is not defined", ajouter l'import explicite. Si rendu OK, continuer.
- [ ] **Step 3 : Commit**
```bash
git add frontend/pages/entry-exit/bovine-info/'[id].vue'
git commit -m "feat(front) : page saisie information bovin (accordéons)"
```
---
## Task 6 : Câbler la navigation depuis le tableau "Entrées validées"
**Files:**
- Modify: `frontend/pages/entry-exit/index.vue`
- [ ] **Step 1 : Ajouter la fonction de navigation**
Dans le `<script setup>`, sous le `goToEntry` existant, ajouter :
```ts
const goToBovineInfo = (reception: ReceptionData) => {
router.push(`/entry-exit/bovine-info/${reception.id}`)
}
```
- [ ] **Step 2 : Activer le clic sur la table validée**
Dans le `<template>`, sur le `<UiDataTable>` du bloc "Entrées validées" (celui avec `v-model:page="validatedPage"`), ajouter les deux props :
```vue
<UiDataTable
v-model:page="validatedPage"
v-model:per-page="validatedPerPage"
:columns="validatedColumns"
:items="validated"
:total-items="totalValidated"
:loading="validatedLoading"
row-clickable
@row-click="goToBovineInfo"
>
```
- [ ] **Step 3 : Smoke test manuel**
Dev server toujours en marche. Sur `/entry-exit`, cliquer sur une ligne dans "Entrées validées" → la page `/entry-exit/bovine-info/{id}` s'ouvre, titre correct, premier accordéon non-saisi ouvert.
- [ ] **Step 4 : Commit**
```bash
git add frontend/pages/entry-exit/index.vue
git commit -m "feat(front) : clic sur entrée validée → page saisie info bovin"
```
---
## Task 7 : Vérification fonctionnelle complète
Pas de code. Juste un parcours manuel en mode admin.
- [ ] **Step 1 : Cas non-saisi → saisi**
- Aller sur `/entry-exit`, cliquer sur une entrée validée avec au moins un bovin non saisi.
- Vérifier : premier non-saisi ouvert, badge jaune pour les non-saisis, badge vert pour les déjà-saisis.
- Saisir les 4 champs, cliquer Valider.
- Vérifier : accordéon se ferme, badge passe vert, l'accordéon suivant non-saisi s'ouvre.
- [ ] **Step 2 : Champs invalides**
- Ouvrir un accordéon vide, cliquer Valider sans rien remplir.
- Vérifier : bordures rouges, pas de requête réseau (DevTools).
- [ ] **Step 3 : Bâtiment change → case reset**
- Choisir un bâtiment, choisir une case, changer de bâtiment.
- Vérifier : la case repasse à vide.
- [ ] **Step 4 : Reload sur saisie partielle**
- Saisir les 4 champs d'un bovin, valider (badge vert).
- Recharger la page (F5).
- Vérifier : ce bovin a un badge vert au chargement, il est positionné en bas, fermé. Le premier non-saisi suivant est ouvert.
- [ ] **Step 5 : Tout saisi**
- Saisir tous les bovins.
- Vérifier : tous fermés, tous verts, pas d'accordéon ouvert. Pas de toast / pas de redirection.
- [ ] **Step 6 : Bouton retour**
- Cliquer la flèche retour : retour à `/entry-exit`.
Si un point échoue, debug puis corriger. Une fois OK, le boulot est fini — pas de commit supplémentaire (chaque task a déjà été commitée).
---
## Hors plan (à faire si bug remonté)
- Pagination des bovins si une réception en a > 200 (pour l'instant `itemsPerPage=200` couvre largement le besoin métier).
- Animation d'ouverture/fermeture de l'accordéon (pour l'instant `v-if` brut, sans transition).
- Tests unitaires Vitest (cohérent avec l'absence de tests frontend dans le repo).

View File

@@ -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``[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` :
`<div class="px-[86px]">` + bandeau titre avec flèche retour absolue, titre
`<h1>Saisie information bovin {{ reception.identificationNumber }}</h1>`.
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<bovineId, FormState>`) :
```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<number | null>` 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).

View File

@@ -0,0 +1,127 @@
<template>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid grid-cols-4 gap-x-12 gap-y-6">
<UiNumberInput
v-model="form.receivedWeight"
label="Poids d'arrivée (kg)"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="0"
:step="1"
/>
<UiNumberInput
v-model="form.pricePerKg"
label="Prix au kg"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="0"
:step="0.01"
/>
<UiSelect
v-model="form.buildingId"
label="Bâtiment"
:options="buildingOptions"
/>
<UiSelect
v-model="form.buildingCaseId"
label="Case"
:options="caseOptions"
:disabled="form.buildingId === null"
/>
</div>
<div class="flex justify-center">
<UiButton
type="submit"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
:disabled="isSaving"
:loading="isSaving"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import type { BuildingData } from '~/services/dto/building-data'
const props = defineProps<{
bovine: BovineData
buildings: BuildingData[]
}>()
const emit = defineEmits<{
saved: [bovine: BovineData]
}>()
const api = useApi()
interface FormState {
receivedWeight: number | null
pricePerKg: number | null
buildingId: number | null
buildingCaseId: number | null
}
const form = reactive<FormState>({
receivedWeight: props.bovine.receivedWeight ?? null,
pricePerKg: props.bovine.pricePerKg ?? null,
buildingId: props.bovine.buildingCase?.building?.id
?? props.bovine.effectiveBuilding?.id
?? null,
buildingCaseId: props.bovine.buildingCase?.id ?? null
})
const isSaving = ref(false)
const buildingOptions = computed(() =>
props.buildings.map(b => ({ value: b.id, label: b.label }))
)
const caseOptions = computed(() => {
if (form.buildingId === null) return []
const building = props.buildings.find(b => b.id === form.buildingId)
if (!building?.buildingCases) return []
return building.buildingCases.map(c => ({
value: c.id,
label: c.caseNumber !== null ? `Case ${c.caseNumber}` : (c.code ?? `#${c.id}`)
}))
})
watch(() => form.buildingId, (newId) => {
if (form.buildingCaseId === null) return
const building = props.buildings.find(b => b.id === newId)
const caseStillValid = building?.buildingCases?.some(c => c.id === form.buildingCaseId)
if (!caseStillValid) {
form.buildingCaseId = null
}
})
const submit = async () => {
const payload: Record<string, unknown> = {}
if (form.receivedWeight != null) payload.receivedWeight = form.receivedWeight
if (form.pricePerKg != null) payload.pricePerKg = form.pricePerKg
if (form.buildingCaseId != null) {
payload.buildingCase = `/api/building_cases/${form.buildingCaseId}`
}
if (Object.keys(payload).length === 0) {
emit('saved', props.bovine)
return
}
isSaving.value = true
try {
const updated = await api.patch<BovineData>(
`bovines/${props.bovine.id}`,
payload,
{ toastSuccessMessage: `Bovin ${props.bovine.nationalNumber} enregistré.` }
)
emit('saved', updated)
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="overflow-hidden">
<button
type="button"
class="flex w-full items-center justify-between gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide text-left"
@click="toggle"
>
<span class="flex-1">
<slot name="header" />
</span>
<Icon
name="mdi:chevron-down"
size="24"
class="shrink-0 transition-transform"
:class="{ 'rotate-180': modelValue }"
/>
</button>
<div v-if="modelValue" class="border border-t-0 border-slate-200 px-6 py-6">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const toggle = () => emit('update:modelValue', !props.modelValue)
</script>

View File

@@ -108,7 +108,9 @@ const onInput = (event: Event) => {
numeric = Math.min(max, numeric)
}
target.value = String(numeric)
if (numeric !== parsed) {
target.value = String(numeric)
}
emit('update:modelValue', numeric)
}

View File

@@ -0,0 +1,157 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-start gap-6 relative mb-8">
<Icon
@click="router.push('/entry-exit')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500 absolute -left-[60px]"
/>
<h1 class="font-bold text-3xl uppercase text-primary-500">
Saisie information bovin {{ reception?.identificationNumber ?? '' }}
</h1>
</div>
<div v-if="loading" class="text-center text-slate-500">Chargement</div>
<div v-else>
<div class="mb-4 max-w-[200px]">
<UiTextInput
v-model="searchQuery"
placeholder="N° national"
size="compact"
inputmode="numeric"
pattern="[0-9]*"
inputClass="text-xl"
/>
</div>
<div class="space-y-3">
<UiAccordion
v-for="bovine in filteredBovines"
:key="bovine.id"
:model-value="openId === bovine.id"
@update:model-value="onToggle(bovine.id, $event)"
>
<template #header>
<span class="flex items-center gap-3 normal-case">
<span class="font-bold text-base">{{ bovine.nationalNumber }}</span>
<span
v-if="isSaisi(bovine)"
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
>
Saisie
</span>
<span
v-else
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
>
Attente saisie
</span>
</span>
</template>
<BovineInfoForm
:bovine="bovine"
:buildings="buildings"
@saved="onSaved"
/>
</UiAccordion>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import type { BuildingData } from '~/services/dto/building-data'
import type { ReceptionData } from '~/services/dto/reception-data'
import { getBuildingList } from '~/services/building'
import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue'
const route = useRoute()
const router = useRouter()
const api = useApi()
const receptionId = computed(() => Number(route.params.id))
const reception = ref<ReceptionData | null>(null)
const bovines = ref<BovineData[]>([])
const buildings = ref<BuildingData[]>([])
const loading = ref(true)
const openId = ref<number | null>(null)
const searchQueryRaw = ref('')
const searchQuery = computed<string>({
get: () => searchQueryRaw.value,
set: (value) => {
searchQueryRaw.value = value.replace(/\D/g, '')
}
})
useHead({
title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim()
})
const isSaisi = (bovine: BovineData) =>
bovine.receivedWeight != null
&& bovine.pricePerKg != null
&& bovine.buildingCase != null
const sortedBovines = computed(() => {
const pending = bovines.value.filter(b => !isSaisi(b))
const done = bovines.value.filter(b => isSaisi(b))
return [...pending, ...done]
})
const filteredBovines = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return sortedBovines.value
return sortedBovines.value.filter(b =>
b.nationalNumber.toLowerCase().includes(query)
)
})
const onToggle = (bovineId: number, value: boolean) => {
openId.value = value ? bovineId : null
}
const onSaved = (updated: BovineData) => {
const idx = bovines.value.findIndex(b => b.id === updated.id)
if (idx === -1) return
bovines.value[idx] = updated
const next = sortedBovines.value.find(b => !isSaisi(b) && b.id !== updated.id)
openId.value = next?.id ?? null
}
const loadBovines = async () => {
type Hydra = { 'hydra:member'?: BovineData[] }
const response = await api.get<BovineData[] | Hydra>(
'bovines',
{ reception: receptionId.value, itemsPerPage: 200 }
)
if (Array.isArray(response)) {
bovines.value = response
} else if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
bovines.value = response['hydra:member']
} else {
bovines.value = []
}
}
onMounted(async () => {
try {
const [r, , b] = await Promise.all([
api.get<ReceptionData>(`receptions/${receptionId.value}`),
loadBovines(),
getBuildingList()
])
reception.value = r
buildings.value = b
const firstPending = sortedBovines.value.find(bv => !isSaisi(bv))
openId.value = firstPending?.id ?? null
} finally {
loading.value = false
}
})
</script>

View File

@@ -80,6 +80,8 @@
:items="validated"
:total-items="totalValidated"
:loading="validatedLoading"
row-clickable
@row-click="goToBovineInfo"
>
<template #header-identificationNumber>
<UiTextInput
@@ -252,6 +254,10 @@ const goToEntry = (reception: ReceptionData) => {
router.push(`/entry-exit/entry/${reception.id}`)
}
const goToBovineInfo = (reception: ReceptionData) => {
router.push(`/entry-exit/bovine-info/${reception.id}`)
}
onMounted(() => {
reload()
reloadValidated()

View File

@@ -1,8 +1,10 @@
export interface BovineBuildingRef {
id: number
label: string
}
export interface BovineBuildingCaseRef {
id: number
caseNumber: number | null
building: BovineBuildingRef | null
}

View File

@@ -33,7 +33,7 @@ class Building
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read', 'building:summary', 'reception:read'])]
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]

View File

@@ -39,7 +39,7 @@ class BuildingCase
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read', 'building_case:read'])]
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
private ?int $id = null;
#[ORM\Column]