Files
Ferme/frontend/pages/entry-exit/entry/[id].vue
tristan 8f88abab46 feat : Reception.validatedAt + statut entrées + mode consultation
- Backend : champ Reception.validatedAt (timestamp) avec PreUpdate + helpers
  isFullyConfirmed/tryValidate ; sync EDNOTIF déclenche tryValidate sur
  les receptions impactées ; expose Supplier.name dans le groupe bovine:read.
- Migration : ajout colonne validated_at sans backfill (les receptions
  remontent en attente jusqu'au prochain sync).
- Front /entry-exit : remplace Historique par 'Entrées validées' (filtre
  exists[validatedAt]=true), ajoute filtres et colonne Statut sur les
  deux tableaux, retire Fournisseur, layout 2x2 (entrées + sorties).
- Front /entry-exit/entry/{id} : mode consultation quand entryCompleted=true
  (formulaire + actions masqués, colonne EDNOTIF par bovin) ; ajoute
  colonnes Vendeur et Cause dans le récap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:31:16 +02:00

378 lines
13 KiB
Vue

<template>
<div class="px-[86px]">
<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"
size="44"
class="cursor-pointer text-primary-500 absolute -left-[60px]"
/>
<div>
<h1 class="font-bold text-3xl uppercase text-primary-500">
Entrée bovins {{ reception?.identificationNumber ?? '' }}
</h1>
</div>
</div>
<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>
<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"
>
<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"
v-model:per-page="recapPerPage"
:columns="recapColumns"
:items="savedBovines"
:total-items="savedBovinesTotal"
:loading="savedBovinesLoading"
: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-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"
size="24"
class="cursor-pointer text-red-500 hover:text-red-700"
@click="confirmDeleteBovine(item)"
/>
</template>
</UiDataTable>
<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"
:disabled="savedBovinesTotal === 0 || isValidating"
:loading="isValidating"
@click="validateEntry"
>
Valider l'entrée
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
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 { getSupplierList } from '~/services/supplier'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const route = useRoute()
const router = useRouter()
const api = useApi()
const receptionId = computed(() => Number(route.params.id))
const reception = ref<ReceptionData | null>(null)
const suppliers = ref<SupplierData[]>([])
const {
items: savedBovines,
totalItems: savedBovinesTotal,
page: recapPage,
perPage: recapPerPage,
filters: recapFilters,
loading: savedBovinesLoading,
reload: reloadSavedBovines
} = useDataTableServerState<BovineData>(
'bovines',
{
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 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 '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
const confirmDeleteBovine = async (bovine: BovineData) => {
const confirmed = window.confirm(`Supprimer le bovin ${bovine.nationalNumber} ?`)
if (!confirmed) return
await api.delete(`bovines/${bovine.id}`)
reloadSavedBovines()
}
const validateEntry = async () => {
if (savedBovinesTotal.value === 0 || isValidating.value) return
const message = savedBovinesTotal.value !== declaredCount.value
? `Attention : ${savedBovinesTotal.value} bovins saisis sur ${declaredCount.value} déclarés. Êtes-vous sûr de vouloir valider l'entrée ?`
: `Êtes-vous sûr de vouloir valider l'entrée ?`
if (!window.confirm(message)) return
isValidating.value = true
try {
await api.patch(`receptions/${receptionId.value}`, { entryCompleted: true })
router.push('/entry-exit')
} finally {
isValidating.value = false
}
}
type EntryCause = 'A' | 'N' | 'P'
interface FormState {
nationalNumber: string
entryCause: EntryCause
arrivalDate: string
supplierId: string | number | null
}
const entryCauseOptions = [
{ value: 'A', label: 'Achat' },
{ value: 'N', label: 'Naissance' },
{ value: 'P', label: 'Prêt ou pension' }
]
const initialForm = (): FormState => ({
nationalNumber: '',
entryCause: 'A',
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
supplierId: reception.value?.supplier?.id ?? null
})
const form = reactive<FormState>(initialForm())
const supplierOptions = computed(() =>
suppliers.value.map(s => ({ value: s.id, label: s.name }))
)
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
const isFormValid = computed(() =>
form.nationalNumber.trim() !== ''
&& !!form.entryCause
&& !!form.arrivalDate
&& form.supplierId !== null
)
const resetForm = () => {
Object.assign(form, initialForm())
}
const loadReception = async () => {
reception.value = await api.get<ReceptionData>(`receptions/${receptionId.value}`)
resetForm()
}
const focusFirstField = () => {
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
el?.focus()
}
const addBovine = async () => {
submitted.value = true
if (!isFormValid.value || isAdding.value) return
isAdding.value = true
try {
const payload = {
nationalNumber: form.nationalNumber.trim(),
entryCause: form.entryCause,
arrivalDate: form.arrivalDate,
supplier: `/api/suppliers/${form.supplierId}`,
reception: `/api/receptions/${receptionId.value}`
}
await api.post<BovineData>('bovines', payload, {
headers: { 'Content-Type': 'application/ld+json' }
})
reloadSavedBovines()
resetForm()
submitted.value = false
await nextTick()
focusFirstField()
} finally {
isAdding.value = false
}
}
onMounted(async () => {
suppliers.value = await getSupplierList()
await loadReception()
reloadSavedBovines()
})
</script>