- 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>
378 lines
13 KiB
Vue
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>
|