Compare commits
14 Commits
476502c91c
...
feat/entre
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b81de1248 | |||
| 7d69860edc | |||
| 209b14eb56 | |||
| 2166fe2685 | |||
| 92c8c6a704 | |||
| 9af05ff449 | |||
| fff6fa7e17 | |||
| cae04ed489 | |||
| 6134fc3107 | |||
| 4513dcdc5c | |||
| 3e1760ca98 | |||
|
|
961fa63f3d | ||
| bebfabcacc | |||
| 8f88abab46 |
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.0.93'
|
||||
app.version: '0.0.94'
|
||||
|
||||
598
docs/superpowers/plans/2026-05-04-bovine-info-saisie.md
Normal file
598
docs/superpowers/plans/2026-05-04-bovine-info-saisie.md
Normal 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).
|
||||
187
docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md
Normal file
187
docs/superpowers/specs/2026-05-04-bovine-info-saisie-design.md
Normal 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` 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` :
|
||||
`<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).
|
||||
127
frontend/components/entry-exit/bovine-info-form.vue
Normal file
127
frontend/components/entry-exit/bovine-info-form.vue
Normal 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>
|
||||
34
frontend/components/ui/UiAccordion.vue
Normal file
34
frontend/components/ui/UiAccordion.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Type de bovin' })
|
||||
|
||||
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
|
||||
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
|
||||
const router = useRouter()
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Types de bovins' })
|
||||
|
||||
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Transporteur' })
|
||||
|
||||
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
|
||||
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
|
||||
import {computed} from "vue";
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Transporteurs' })
|
||||
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Client' })
|
||||
|
||||
import {computed, reactive, ref, watch} from "vue"
|
||||
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
|
||||
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Adresse client' })
|
||||
|
||||
import type { AddressData, AddressPayload } from "~/services/address"
|
||||
import { createAddress, getAddress, updateAddress } from "~/services/address"
|
||||
import { getCustomer, updateCustomer } from "~/services/customer"
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Clients' })
|
||||
|
||||
import type { CustomerData } from '~/services/dto/customer-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Fournisseur' })
|
||||
|
||||
import {computed, reactive, ref, watch} from "vue"
|
||||
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
|
||||
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Adresse fournisseur' })
|
||||
|
||||
import type {AddressData, AddressPayload} from "~/services/address";
|
||||
import {createAddress, getAddress, updateAddress} from "~/services/address";
|
||||
import {getSupplier, updateSupplier} from "~/services/supplier";
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Fournisseurs' })
|
||||
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Utilisateur' })
|
||||
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ROLE } from '~/utils/constants'
|
||||
import { createUser, updateUser, getUser } from '~/services/auth'
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Utilisateurs' })
|
||||
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { ROLE } from '~/utils/constants'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
157
frontend/pages/entry-exit/bovine-info/[id].vue
Normal file
157
frontend/pages/entry-exit/bovine-info/[id].vue
Normal 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="px-[86px]">
|
||||
<div class="flex items-center justify-start gap-6 relative">
|
||||
<div class="flex items-center justify-start gap-6 relative" :class="{ 'mb-8': isConsultationMode }">
|
||||
<Icon
|
||||
@click="router.push('/entry-exit')"
|
||||
name="gg:arrow-left-o"
|
||||
@@ -13,74 +13,52 @@
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 mt-1 mb-8">
|
||||
<p v-if="!isConsultationMode" class="text-sm text-slate-600 mt-1 mb-8">
|
||||
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
|
||||
:class="{ submitted }"
|
||||
@submit.prevent="addBovine"
|
||||
>
|
||||
<UiTextInput
|
||||
v-model="form.nationalNumber"
|
||||
label="Numéro national"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.entryCause"
|
||||
label="Cause d'entrée"
|
||||
:options="entryCauseOptions"
|
||||
required
|
||||
/>
|
||||
<UiDateMaskedInput
|
||||
v-model="form.arrivalDate"
|
||||
label="Date d'entrée"
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.supplierId"
|
||||
label="Vendeur"
|
||||
:options="supplierOptions"
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.buildingId"
|
||||
label="Bâtiment"
|
||||
:options="buildingOptions"
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.caseId"
|
||||
label="Case"
|
||||
:options="caseOptions"
|
||||
:disabled="!form.buildingId"
|
||||
/>
|
||||
<UiNumberInput
|
||||
v-model="form.receivedWeight"
|
||||
label="Poids (kg)"
|
||||
wrapperClass="flex-col"
|
||||
labelClass="font-bold uppercase text-xl text-primary-700"
|
||||
:min="1"
|
||||
/>
|
||||
<UiNumberInput
|
||||
v-model="form.pricePerKg"
|
||||
label="Prix au kilo (€)"
|
||||
wrapperClass="flex-col"
|
||||
labelClass="font-bold uppercase text-xl text-primary-700"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="flex justify-center mb-12">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
:disabled="isAdding"
|
||||
:loading="isAdding"
|
||||
@click="addBovine"
|
||||
<template v-if="!isConsultationMode">
|
||||
<form
|
||||
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
|
||||
:class="{ submitted }"
|
||||
@submit.prevent="addBovine"
|
||||
>
|
||||
Ajouter
|
||||
</UiButton>
|
||||
</div>
|
||||
<UiTextInput
|
||||
v-model="form.nationalNumber"
|
||||
label="Numéro national"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.entryCause"
|
||||
label="Cause d'entrée"
|
||||
:options="entryCauseOptions"
|
||||
required
|
||||
/>
|
||||
<UiDateMaskedInput
|
||||
v-model="form.arrivalDate"
|
||||
label="Date d'entrée"
|
||||
required
|
||||
/>
|
||||
<UiSelect
|
||||
v-model="form.supplierId"
|
||||
label="Vendeur"
|
||||
:options="supplierOptions"
|
||||
required
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="flex justify-center mb-12">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
:disabled="isAdding"
|
||||
:loading="isAdding"
|
||||
@click="addBovine"
|
||||
>
|
||||
Ajouter
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UiDataTable
|
||||
v-model:page="recapPage"
|
||||
@@ -89,29 +67,67 @@
|
||||
:items="savedBovines"
|
||||
:total-items="savedBovinesTotal"
|
||||
:loading="savedBovinesLoading"
|
||||
:show-actions="true"
|
||||
:show-actions="!isConsultationMode"
|
||||
>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput v-model="recapFilters.nationalNumber" placeholder="N° National" size="compact" />
|
||||
</template>
|
||||
<template #header-workNumber>
|
||||
<UiTextInput v-model="recapFilters.workNumber" placeholder="N° Travail" size="compact" />
|
||||
</template>
|
||||
<template #header-bovineType.label>
|
||||
<UiTextInput v-model="recapFilters['bovineType.label']" placeholder="Race" size="compact" />
|
||||
</template>
|
||||
<template #header-sex>
|
||||
<UiTextInput v-model="recapFilters.sex" placeholder="Sexe" size="compact" />
|
||||
</template>
|
||||
<template #header-birthDate>
|
||||
<UiDateMaskedInput v-model="birthDateFilter" placeholder="Né le" size="compact" />
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Entrée le" size="compact" />
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput :model-value="''" placeholder="Vendeur" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-entryCause>
|
||||
<UiTextInput :model-value="''" placeholder="Cause" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-ednotifConfirmedAt>
|
||||
<UiTextInput :model-value="''" placeholder="EDNOTIF" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<UiTextInput :model-value="''" placeholder="Action" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-birthDate="{ item }">
|
||||
{{ formatDate(item.birthDate) }}
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-finalPrice="{ item }">
|
||||
{{ formatPrice(item.finalPrice) }}
|
||||
</template>
|
||||
<template #cell-pricePerKg="{ item }">
|
||||
{{ formatPrice(item.pricePerKg) }}
|
||||
</template>
|
||||
<template #cell-buildingCase.building.label="{ item }">
|
||||
{{ item.effectiveBuilding?.label ?? '—' }}
|
||||
</template>
|
||||
<template #cell-buildingCase.caseNumber="{ item }">
|
||||
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||||
</template>
|
||||
<template #cell-bovineType.label="{ item }">
|
||||
{{ item.bovineType?.label ?? '—' }}
|
||||
</template>
|
||||
<template #cell-supplier.name="{ item }">
|
||||
{{ supplierName(item.supplier) }}
|
||||
</template>
|
||||
<template #cell-entryCause="{ item }">
|
||||
{{ entryCauseLabel(item.entryCause) }}
|
||||
</template>
|
||||
<template #cell-ednotifConfirmedAt="{ item }">
|
||||
<span
|
||||
v-if="item.ednotifConfirmedAt"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Validé
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
|
||||
>
|
||||
En attente
|
||||
</span>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
@@ -122,7 +138,7 @@
|
||||
</template>
|
||||
</UiDataTable>
|
||||
|
||||
<div class="flex justify-center mt-8">
|
||||
<div v-if="!isConsultationMode" class="flex justify-center mt-8">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||
@@ -140,9 +156,7 @@
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import type { BuildingData } from '~/services/dto/building-data'
|
||||
import { getSupplierList } from '~/services/supplier'
|
||||
import { getBuildingList } from '~/services/building'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -153,38 +167,95 @@ const receptionId = computed(() => Number(route.params.id))
|
||||
|
||||
const reception = ref<ReceptionData | null>(null)
|
||||
const suppliers = ref<SupplierData[]>([])
|
||||
const buildings = ref<BuildingData[]>([])
|
||||
|
||||
const {
|
||||
items: savedBovines,
|
||||
totalItems: savedBovinesTotal,
|
||||
page: recapPage,
|
||||
perPage: recapPerPage,
|
||||
filters: recapFilters,
|
||||
loading: savedBovinesLoading,
|
||||
reload: reloadSavedBovines
|
||||
} = useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{ reception: receptionId.value },
|
||||
{ initialPerPage: 50 }
|
||||
{
|
||||
reception: receptionId.value,
|
||||
nationalNumber: '',
|
||||
workNumber: '',
|
||||
'bovineType.label': '',
|
||||
sex: '',
|
||||
'birthDate[after]': '',
|
||||
'birthDate[strictly_before]': '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const birthDateFilter = computed<string>({
|
||||
get: () => (recapFilters.value['birthDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
recapFilters.value['birthDate[after]'] = ''
|
||||
recapFilters.value['birthDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
recapFilters.value['birthDate[after]'] = value
|
||||
recapFilters.value['birthDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const arrivalDateFilter = computed<string>({
|
||||
get: () => (recapFilters.value['arrivalDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
recapFilters.value['arrivalDate[after]'] = ''
|
||||
recapFilters.value['arrivalDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
recapFilters.value['arrivalDate[after]'] = value
|
||||
recapFilters.value['arrivalDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const isAdding = ref(false)
|
||||
const isValidating = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const recapColumns = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '90px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '110px' },
|
||||
{ key: 'sex', label: 'Sexe', width: '60px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '90px' },
|
||||
{ key: 'receivedWeight', label: 'Poids', width: '70px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
|
||||
{ key: 'pricePerKg', label: 'Prix/kg', width: '80px' },
|
||||
{ key: 'finalPrice', label: 'Prix total', width: '90px' },
|
||||
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
|
||||
{ key: 'buildingCase.caseNumber', label: 'Case', width: '60px' }
|
||||
]
|
||||
const isConsultationMode = computed(() => reception.value?.entryCompleted === true)
|
||||
|
||||
const recapColumns = computed(() => {
|
||||
const cols: Array<{ key: string; label: string; width: string }> = [
|
||||
{ key: 'nationalNumber', label: 'N° National', width: '100px' },
|
||||
{ key: 'workNumber', label: 'N° Travail', width: '110px' },
|
||||
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
|
||||
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||
{ key: 'birthDate', label: 'Né le', width: '75px' },
|
||||
{ key: 'arrivalDate', label: 'Entrée le', width: '75px' },
|
||||
{ key: 'supplier.name', label: 'Vendeur', width: '150px' },
|
||||
{ key: 'entryCause', label: 'Cause', width: '100px' }
|
||||
]
|
||||
if (isConsultationMode.value) {
|
||||
cols.push({ key: 'ednotifConfirmedAt', label: 'EDNOTIF', width: '110px' })
|
||||
}
|
||||
return cols
|
||||
})
|
||||
|
||||
const entryCauseLabel = (code: string | null | undefined) => {
|
||||
if (!code) return '—'
|
||||
return entryCauseOptions.find(o => o.value === code)?.label ?? code
|
||||
}
|
||||
|
||||
const supplierName = (supplier: BovineData['supplier']) => {
|
||||
if (supplier && typeof supplier === 'object') return supplier.name
|
||||
return '—'
|
||||
}
|
||||
|
||||
const formatDate = (date: string | null | undefined) => {
|
||||
if (!date) return '—'
|
||||
@@ -193,11 +264,6 @@ const formatDate = (date: string | null | undefined) => {
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
const formatPrice = (price: number | null | undefined) => {
|
||||
if (price === null || price === undefined) return '—'
|
||||
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||
}
|
||||
|
||||
const confirmDeleteBovine = async (bovine: BovineData) => {
|
||||
const confirmed = window.confirm(`Supprimer le bovin ${bovine.nationalNumber} ?`)
|
||||
if (!confirmed) return
|
||||
@@ -231,10 +297,6 @@ interface FormState {
|
||||
entryCause: EntryCause
|
||||
arrivalDate: string
|
||||
supplierId: string | number | null
|
||||
buildingId: string | number | null
|
||||
caseId: string | number | null
|
||||
receivedWeight: number | null
|
||||
pricePerKg: number | null
|
||||
}
|
||||
|
||||
const entryCauseOptions = [
|
||||
@@ -247,11 +309,7 @@ const initialForm = (): FormState => ({
|
||||
nationalNumber: '',
|
||||
entryCause: 'A',
|
||||
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
|
||||
supplierId: reception.value?.supplier?.id ?? null,
|
||||
buildingId: reception.value?.buildings?.[0]?.id ?? null,
|
||||
caseId: null,
|
||||
receivedWeight: null,
|
||||
pricePerKg: null
|
||||
supplierId: reception.value?.supplier?.id ?? null
|
||||
})
|
||||
|
||||
const form = reactive<FormState>(initialForm())
|
||||
@@ -260,30 +318,13 @@ const supplierOptions = computed(() =>
|
||||
suppliers.value.map(s => ({ value: s.id, label: s.name }))
|
||||
)
|
||||
|
||||
const buildingOptions = computed(() =>
|
||||
buildings.value.map(b => ({ value: b.id, label: b.label }))
|
||||
)
|
||||
|
||||
const caseOptions = computed(() => {
|
||||
const building = buildings.value.find(b => b.id === Number(form.buildingId))
|
||||
if (!building?.buildingCases) return []
|
||||
return [...building.buildingCases]
|
||||
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
|
||||
.map(c => ({
|
||||
value: c.id,
|
||||
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
|
||||
}))
|
||||
})
|
||||
|
||||
watch(() => form.buildingId, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) form.caseId = null
|
||||
})
|
||||
|
||||
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
|
||||
|
||||
const isFormValid = computed(() =>
|
||||
form.nationalNumber.trim() !== ''
|
||||
&& !!form.entryCause
|
||||
&& !!form.arrivalDate
|
||||
&& form.supplierId !== null
|
||||
)
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -306,16 +347,13 @@ const addBovine = async () => {
|
||||
|
||||
isAdding.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload = {
|
||||
nationalNumber: form.nationalNumber.trim(),
|
||||
entryCause: form.entryCause,
|
||||
arrivalDate: form.arrivalDate,
|
||||
supplier: `/api/suppliers/${form.supplierId}`,
|
||||
reception: `/api/receptions/${receptionId.value}`
|
||||
}
|
||||
if (form.receivedWeight !== null) payload.receivedWeight = form.receivedWeight
|
||||
if (form.pricePerKg !== null) payload.pricePerKg = form.pricePerKg
|
||||
if (form.arrivalDate) payload.arrivalDate = form.arrivalDate
|
||||
if (form.supplierId !== null) payload.supplier = `/api/suppliers/${form.supplierId}`
|
||||
if (form.caseId !== null) payload.buildingCase = `/api/building_cases/${form.caseId}`
|
||||
|
||||
await api.post<BovineData>('bovines', payload, {
|
||||
headers: { 'Content-Type': 'application/ld+json' }
|
||||
@@ -332,10 +370,7 @@ const addBovine = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
[suppliers.value, buildings.value] = await Promise.all([
|
||||
getSupplierList(),
|
||||
getBuildingList()
|
||||
])
|
||||
suppliers.value = await getSupplierList()
|
||||
await loadReception()
|
||||
reloadSavedBovines()
|
||||
})
|
||||
|
||||
@@ -23,6 +23,25 @@
|
||||
row-clickable
|
||||
@row-click="goToEntry"
|
||||
>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="entryFilters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="entryDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-declaredCount>
|
||||
<UiTextInput :model-value="''" placeholder="Déclarés" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-registeredBovineCount>
|
||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-status>
|
||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-identificationNumber="{ item }">
|
||||
{{ item.identificationNumber }}
|
||||
</template>
|
||||
@@ -35,81 +54,92 @@
|
||||
<template #cell-registeredBovineCount="{ item }">
|
||||
{{ item.registeredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-status="{ item }">
|
||||
<span
|
||||
v-if="!item.entryCompleted"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
||||
>
|
||||
Attente saisie
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
|
||||
>
|
||||
Attente EDNOTIF
|
||||
</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées validées</h2>
|
||||
<UiDataTable
|
||||
v-model:page="validatedPage"
|
||||
v-model:per-page="validatedPerPage"
|
||||
:columns="validatedColumns"
|
||||
:items="validated"
|
||||
:total-items="totalValidated"
|
||||
:loading="validatedLoading"
|
||||
row-clickable
|
||||
@row-click="goToBovineInfo"
|
||||
>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="validatedFilters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="validatedDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-registeredBovineCount>
|
||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-validatedAt>
|
||||
<UiTextInput :model-value="''" placeholder="Validée le" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-status>
|
||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-identificationNumber="{ item }">
|
||||
{{ item.identificationNumber }}
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-registeredBovineCount="{ item }">
|
||||
{{ item.registeredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-validatedAt="{ item }">
|
||||
{{ formatDate(item.validatedAt) }}
|
||||
</template>
|
||||
<template #cell-status>
|
||||
<span
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Validée
|
||||
</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 mb-16 grid grid-cols-2 gap-8">
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties en attente</h2>
|
||||
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
||||
À venir
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mt-12 mb-16">
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Historique</h2>
|
||||
<UiDataTable
|
||||
v-model:page="historyPage"
|
||||
v-model:per-page="historyPerPage"
|
||||
:columns="historyColumns"
|
||||
:items="history"
|
||||
:total-items="totalHistory"
|
||||
:loading="historyLoading"
|
||||
>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="historyFilters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateMaskedInput v-model="historyDateFilter" placeholder="Date" size="compact" />
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput
|
||||
v-model="historyFilters['supplier.name']"
|
||||
placeholder="Fournisseur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-registeredBovineCount>
|
||||
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-confirmedBovineCount>
|
||||
<UiTextInput :model-value="''" placeholder="Confirmés" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-status>
|
||||
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-identificationNumber="{ item }">
|
||||
{{ item.identificationNumber }}
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-registeredBovineCount="{ item }">
|
||||
{{ item.registeredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-confirmedBovineCount="{ item }">
|
||||
{{ item.confirmedBovineCount ?? 0 }} / {{ item.registeredBovineCount ?? 0 }}
|
||||
</template>
|
||||
<template #cell-status="{ item }">
|
||||
<span
|
||||
v-if="(item.confirmedBovineCount ?? 0) >= (item.registeredBovineCount ?? 0) && (item.registeredBovineCount ?? 0) > 0"
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
|
||||
>
|
||||
Confirmée
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
|
||||
>
|
||||
EDNOTIF en attente
|
||||
</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties validées</h2>
|
||||
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
||||
À venir
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -124,38 +154,41 @@ const {
|
||||
totalItems: totalEntries,
|
||||
page: entryPage,
|
||||
perPage: entryPerPage,
|
||||
filters: entryFilters,
|
||||
loading: entriesLoading,
|
||||
reload
|
||||
} = useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
'isValid': 'true',
|
||||
'entryCompleted': 'false',
|
||||
'receptionType.code': 'BOVINS'
|
||||
'exists[validatedAt]': 'false',
|
||||
'receptionType.code': 'BOVINS',
|
||||
'identificationNumber': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 5 }
|
||||
)
|
||||
|
||||
const {
|
||||
items: history,
|
||||
totalItems: totalHistory,
|
||||
page: historyPage,
|
||||
perPage: historyPerPage,
|
||||
filters: historyFilters,
|
||||
loading: historyLoading,
|
||||
reload: reloadHistory
|
||||
items: validated,
|
||||
totalItems: totalValidated,
|
||||
page: validatedPage,
|
||||
perPage: validatedPerPage,
|
||||
filters: validatedFilters,
|
||||
loading: validatedLoading,
|
||||
reload: reloadValidated
|
||||
} = useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
'isValid': 'true',
|
||||
'entryCompleted': 'true',
|
||||
'exists[validatedAt]': 'true',
|
||||
'receptionType.code': 'BOVINS',
|
||||
'identificationNumber': '',
|
||||
'supplier.name': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
{ initialPerPage: 5 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
@@ -164,37 +197,49 @@ const addOneDay = (dateString: string): string => {
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const historyDateFilter = computed<string>({
|
||||
get: () => (historyFilters.value['receptionDate[after]'] as string) ?? '',
|
||||
const entryDateFilter = computed<string>({
|
||||
get: () => (entryFilters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
historyFilters.value['receptionDate[after]'] = ''
|
||||
historyFilters.value['receptionDate[strictly_before]'] = ''
|
||||
entryFilters.value['receptionDate[after]'] = ''
|
||||
entryFilters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
historyFilters.value['receptionDate[after]'] = value
|
||||
historyFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
entryFilters.value['receptionDate[after]'] = value
|
||||
entryFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const validatedDateFilter = computed<string>({
|
||||
get: () => (validatedFilters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
validatedFilters.value['receptionDate[after]'] = ''
|
||||
validatedFilters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
validatedFilters.value['receptionDate[after]'] = value
|
||||
validatedFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const entryColumns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '80px' },
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1fr' },
|
||||
{ key: 'declaredCount', label: 'Déclarés', width: '85px' },
|
||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' }
|
||||
{ key: 'declaredCount', label: 'Déclarés', width: '75px' },
|
||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '70px' },
|
||||
{ key: 'status', label: 'Statut', width: '1fr' }
|
||||
]
|
||||
|
||||
const historyColumns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '110px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '110px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1fr' },
|
||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '80px' },
|
||||
{ key: 'confirmedBovineCount', label: 'Confirmés', width: '110px' },
|
||||
{ key: 'status', label: 'Statut', width: '170px' }
|
||||
const validatedColumns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
||||
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' },
|
||||
{ key: 'validatedAt', label: 'Validée le', width: '75px' },
|
||||
{ key: 'status', label: 'Statut', width: '1fr' }
|
||||
]
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
const formatDate = (date: string | null | undefined) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
if (isNaN(d.getTime())) return date
|
||||
@@ -209,8 +254,12 @@ 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()
|
||||
reloadHistory()
|
||||
reloadValidated()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Accueil' })
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center pb-16 gap-12">
|
||||
@@ -15,7 +16,7 @@
|
||||
EXPÉDITIONS<br>EN ATTENTE
|
||||
</template>
|
||||
</card-link>
|
||||
<card-link link="/entry-exit" iconName="mdi:swap-horizontal-bold">
|
||||
<card-link link="/entry-exit" iconName="mdi:swap-horizontal-circle-outline">
|
||||
<template #label>
|
||||
Entrée<br>Sortie
|
||||
</template>
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Bovins' })
|
||||
|
||||
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
|
||||
import type { BovinePayload } from '~/services/dto/bovine-data'
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Bâtiments' })
|
||||
|
||||
import type {BuildingData} from "~/services/dto/building-data"
|
||||
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
|
||||
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Cases' })
|
||||
|
||||
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
@@ -147,6 +147,8 @@
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Inventaire' })
|
||||
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import type { InventoryExportFilters } from '~/components/inventory/inventory-export-modal.vue'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Connexion' })
|
||||
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { getUsers } from '~/services/auth'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Réception' })
|
||||
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Validation réception' })
|
||||
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
|
||||
@@ -226,6 +226,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Modifier réception' })
|
||||
|
||||
import { usePdfPrinter } from '#imports'
|
||||
import { computed } from 'vue'
|
||||
import UpdateBovin from '~/components/reception/update-bovin.vue'
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Réceptions en attente' })
|
||||
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { deleteReception } from '~/services/reception'
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Scanner' })
|
||||
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
|
||||
import { createBovine } from '~/services/bovine'
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Expédition' })
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useShipmentStore } from '~/stores/shipment'
|
||||
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Validation expédition' })
|
||||
|
||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||
|
||||
@@ -197,6 +197,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Modifier expédition' })
|
||||
|
||||
import { usePdfPrinter } from '#imports'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import UpdateWeight from '~/components/commun/update-weight.vue'
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Expéditions en attente' })
|
||||
|
||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { deleteShipment } from '~/services/shipment'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export interface BovineBuildingRef {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BovineBuildingCaseRef {
|
||||
id: number
|
||||
caseNumber: number | null
|
||||
building: BovineBuildingRef | null
|
||||
}
|
||||
@@ -18,7 +20,7 @@ export interface BovineData {
|
||||
buildingCase: BovineBuildingCaseRef | null
|
||||
building: BovineBuildingRef | null
|
||||
effectiveBuilding: BovineBuildingRef | null
|
||||
supplier: string | null
|
||||
supplier: { id: number; name: string } | string | null
|
||||
workNumber: string | null
|
||||
birthDate: string | null
|
||||
bovineType: { id: number; label: string; code: string } | null
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ReceptionData {
|
||||
currentStep: number
|
||||
isValid: boolean
|
||||
entryCompleted?: boolean
|
||||
validatedAt?: string | null
|
||||
registeredBovineCount?: number
|
||||
confirmedBovineCount?: number
|
||||
declaredBovineCount?: number
|
||||
|
||||
26
migrations/Version20260430090000.php
Normal file
26
migrations/Version20260430090000.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260430090000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Ajout de reception.validated_at (timestamp de validation complète : entrée terminée + tous bovins confirmés EDNOTIF).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception ADD validated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception DROP validated_at');
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ class Bovine
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
private ?Supplier $supplier = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
@@ -32,6 +33,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'reception')]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['validatedAt'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'identificationNumber' => 'ipartial',
|
||||
'supplier.name' => 'ipartial',
|
||||
@@ -115,6 +117,10 @@ class Reception
|
||||
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
||||
private bool $entryCompleted = false;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['reception:read', 'reception-bovine:read'])]
|
||||
private ?DateTimeImmutable $validatedAt = null;
|
||||
|
||||
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
|
||||
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
|
||||
#[Context(
|
||||
@@ -295,6 +301,45 @@ class Reception
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getValidatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->validatedAt;
|
||||
}
|
||||
|
||||
public function setValidatedAt(?DateTimeImmutable $validatedAt): self
|
||||
{
|
||||
$this->validatedAt = $validatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFullyConfirmed(): bool
|
||||
{
|
||||
if ($this->bovines->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
foreach ($this->bovines as $bovine) {
|
||||
if (null === $bovine->getEdnotifConfirmedAt()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function tryValidate(): void
|
||||
{
|
||||
if ($this->entryCompleted && null === $this->validatedAt && $this->isFullyConfirmed()) {
|
||||
$this->validatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->tryValidate();
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function getRegisteredBovineCount(): int
|
||||
{
|
||||
|
||||
@@ -54,11 +54,11 @@ class Supplier
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['supplier:read', 'reception:read'])]
|
||||
#[Groups(['supplier:read', 'reception:read', 'bovine:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
|
||||
#[Groups(['supplier:read', 'reception:read', 'supplier:write', 'bovine:read'])]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
|
||||
@@ -52,7 +52,8 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
||||
}
|
||||
|
||||
$seen = [];
|
||||
$seen = [];
|
||||
$impactedReceptions = [];
|
||||
foreach ($inventory->animals as $animal) {
|
||||
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
|
||||
if (null === $nationalNumber || '' === $nationalNumber) {
|
||||
@@ -77,9 +78,17 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||
// voit ce bovin remonter dans l'inventaire.
|
||||
if (null === $bovine->getEdnotifConfirmedAt()) {
|
||||
$bovine->setEdnotifConfirmedAt(new DateTimeImmutable());
|
||||
$reception = $bovine->getReception();
|
||||
if (null !== $reception) {
|
||||
$impactedReceptions[$reception->getId()] = $reception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($impactedReceptions as $reception) {
|
||||
$reception->tryValidate();
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
|
||||
if (isset($seen[$nationalNumber])) {
|
||||
|
||||
Reference in New Issue
Block a user