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>
This commit is contained in:
2026-05-04 14:07:34 +02:00
parent 209b14eb56
commit 7d69860edc
3 changed files with 84 additions and 55 deletions

View File

@@ -1,32 +1,32 @@
<template> <template>
<form class="space-y-6" :class="{ submitted }" @submit.prevent="submit"> <form class="space-y-6" @submit.prevent="submit">
<div class="grid grid-cols-2 gap-x-12 gap-y-6"> <div class="grid grid-cols-4 gap-x-12 gap-y-6">
<UiNumberInput <UiNumberInput
v-model="form.receivedWeight" v-model="form.receivedWeight"
label="Poids d'arrivée (kg)" label="Poids d'arrivée (kg)"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="0" :min="0"
:step="1" :step="1"
required
/> />
<UiNumberInput <UiNumberInput
v-model="form.pricePerKg" v-model="form.pricePerKg"
label="Prix d'achat (kg)" label="Prix au kg"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="0" :min="0"
:step="0.01" :step="0.01"
required
/> />
<UiSelect <UiSelect
v-model="form.buildingId" v-model="form.buildingId"
label="Bâtiment" label="Bâtiment"
:options="buildingOptions" :options="buildingOptions"
required
/> />
<UiSelect <UiSelect
v-model="form.buildingCaseId" v-model="form.buildingCaseId"
label="Case" label="Case"
:options="caseOptions" :options="caseOptions"
:disabled="form.buildingId === null" :disabled="form.buildingId === null"
required
/> />
</div> </div>
@@ -66,15 +66,14 @@ interface FormState {
} }
const form = reactive<FormState>({ const form = reactive<FormState>({
receivedWeight: props.bovine.receivedWeight, receivedWeight: props.bovine.receivedWeight ?? null,
pricePerKg: props.bovine.pricePerKg, pricePerKg: props.bovine.pricePerKg ?? null,
buildingId: props.bovine.buildingCase?.building?.id buildingId: props.bovine.buildingCase?.building?.id
?? props.bovine.effectiveBuilding?.id ?? props.bovine.effectiveBuilding?.id
?? null, ?? null,
buildingCaseId: props.bovine.buildingCase?.id ?? null buildingCaseId: props.bovine.buildingCase?.id ?? null
}) })
const submitted = ref(false)
const isSaving = ref(false) const isSaving = ref(false)
const buildingOptions = computed(() => const buildingOptions = computed(() =>
@@ -101,13 +100,15 @@ watch(() => form.buildingId, (newId) => {
}) })
const submit = async () => { const submit = async () => {
submitted.value = true const payload: Record<string, unknown> = {}
if ( if (form.receivedWeight != null) payload.receivedWeight = form.receivedWeight
form.receivedWeight === null if (form.pricePerKg != null) payload.pricePerKg = form.pricePerKg
|| form.pricePerKg === null if (form.buildingCaseId != null) {
|| form.buildingId === null payload.buildingCase = `/api/building_cases/${form.buildingCaseId}`
|| form.buildingCaseId === null }
) {
if (Object.keys(payload).length === 0) {
emit('saved', props.bovine)
return return
} }
@@ -115,11 +116,8 @@ const submit = async () => {
try { try {
const updated = await api.patch<BovineData>( const updated = await api.patch<BovineData>(
`bovines/${props.bovine.id}`, `bovines/${props.bovine.id}`,
{ payload,
receivedWeight: form.receivedWeight, { toastSuccessMessage: `Bovin ${props.bovine.nationalNumber} enregistré.` }
pricePerKg: form.pricePerKg,
buildingCase: `/api/building_cases/${form.buildingCaseId}`
}
) )
emit('saved', updated) emit('saved', updated)
} finally { } finally {

View File

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

View File

@@ -14,36 +14,49 @@
<div v-if="loading" class="text-center text-slate-500">Chargement</div> <div v-if="loading" class="text-center text-slate-500">Chargement</div>
<div v-else class="space-y-3"> <div v-else>
<UiAccordion <div class="mb-4 max-w-[200px]">
v-for="bovine in sortedBovines" <UiTextInput
:key="bovine.id" v-model="searchQuery"
:model-value="openId === bovine.id" placeholder="N° national"
@update:model-value="onToggle(bovine.id, $event)" size="compact"
> inputmode="numeric"
<template #header> pattern="[0-9]*"
<span class="flex items-center gap-3 normal-case"> inputClass="text-xl"
<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 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>
</div> </div>
</template> </template>
@@ -53,6 +66,7 @@ import type { BovineData } from '~/services/dto/bovine-data'
import type { BuildingData } from '~/services/dto/building-data' import type { BuildingData } from '~/services/dto/building-data'
import type { ReceptionData } from '~/services/dto/reception-data' import type { ReceptionData } from '~/services/dto/reception-data'
import { getBuildingList } from '~/services/building' import { getBuildingList } from '~/services/building'
import BovineInfoForm from '~/components/entry-exit/bovine-info-form.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -65,15 +79,22 @@ const bovines = ref<BovineData[]>([])
const buildings = ref<BuildingData[]>([]) const buildings = ref<BuildingData[]>([])
const loading = ref(true) const loading = ref(true)
const openId = ref<number | null>(null) 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({ useHead({
title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim() title: () => `Saisie information bovin ${reception.value?.identificationNumber ?? ''}`.trim()
}) })
const isSaisi = (bovine: BovineData) => const isSaisi = (bovine: BovineData) =>
bovine.receivedWeight !== null bovine.receivedWeight != null
&& bovine.pricePerKg !== null && bovine.pricePerKg != null
&& bovine.buildingCase !== null && bovine.buildingCase != null
const sortedBovines = computed(() => { const sortedBovines = computed(() => {
const pending = bovines.value.filter(b => !isSaisi(b)) const pending = bovines.value.filter(b => !isSaisi(b))
@@ -81,6 +102,14 @@ const sortedBovines = computed(() => {
return [...pending, ...done] 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) => { const onToggle = (bovineId: number, value: boolean) => {
openId.value = value ? bovineId : null openId.value = value ? bovineId : null
} }