[#256] Créer une nouvelle réception (étape 3 - bovin) (!11)

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       256           | Créer une nouvelle réception (étape 3 - bovin)                |

## Description de la PR

## Modification du .env

## Check list

- [x ] Pas de régression
- [ ] TU/TI/TF rédigée
- [x ] TU/TI/TF OK
- [x ] CHANGELOG modifié

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Ferme/pulls/11
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
This commit is contained in:
kevin
2026-02-05 09:29:29 +00:00
committed by Autin
parent 80d87b7c9b
commit e9948d6ac3
21 changed files with 890 additions and 110 deletions
@@ -0,0 +1,183 @@
<template>
<div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
class="flex flex-col items-center gap-16">
<h1 class="text-4xl uppercase font-bold">Sélection des marchandises réceptionnnées</h1>
<div
class="flex flex-row gap-8 items-center">
<div
v-for="type in bovineType"
:key="type.id"
class="mt-8 flex flex-row mb-2 gap-6">
<UiNumberInput
:label="type.label"
:code="type.code"
v-model="bovineQuantities[String(type.id)]"
:placeholder="0"
:min="0"
:max="10"
/>
</div>
<div
class="mt-8 flex flex-row mb-2 gap-6">
<UiNumberInput
label="Autres"
v-model="otherQuantity"
/>
</div>
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser
</button>
</div>
</template>
<script setup lang="ts">
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
import {getBovineTypeList} from "~/services/bovine-type";
import {RECEPTION_TYPE_CODES} from "~/utils/constants";
import {useReceptionStore} from '~/stores/reception'
import {
createReceptionBovine,
deleteReceptionBovine,
getReceptionBovineList,
updateReceptionBovine
} from "~/services/reception-bovine";
import {computed, onMounted, reactive, ref, watch} from "vue";
const toast = useToast()
const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([])
const receptionStore = useReceptionStore()
const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0)
const receptionId = computed(() => receptionStore.current?.id ?? null)
const receptionIri = computed(() =>
receptionId.value ? `/api/receptions/${receptionId.value}` : null
)
const totalBovines = computed(() => {
const base = Object.values(bovineQuantities).reduce((sum, value) => {
return sum + (value ?? 0)
}, 0)
return base + (otherQuantity.value ?? 0)
})
const loadBovineType = async () => {
isLoadingBovineType.value = true
try {
bovineType.value = await getBovineTypeList()
} finally {
isLoadingBovineType.value = false
}
}
onMounted(async () => {
await loadBovineType()
})
watch(
() => receptionId.value,
async (id) => {
if (!id || !receptionIri.value) {
return
}
const selectionMap: Record<string, number | null> = {}
for (const type of bovineType.value) {
selectionMap[String(type.id)] = 0
}
const existing = await getReceptionBovineList(receptionIri.value)
for (const selection of existing) {
const bovineTypeId = String(selection.bovineType.id)
selectionMap[bovineTypeId] = selection.quantity ?? 0
}
for (const key of Object.keys(bovineQuantities)) {
delete bovineQuantities[key]
}
Object.assign(bovineQuantities, selectionMap)
const existingOther = receptionStore.current?.bovineDetail
const parsedOther =
typeof existingOther === 'string' && existingOther.trim() !== ''
? Number(existingOther)
: 0
otherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0
},
{immediate: true}
)
async function syncBovineSelections(receptionIri: string) {
const existing = await getReceptionBovineList(receptionIri)
const existingMap = new Map<string, { id: number; quantity: number | null }>()
for (const selection of existing) {
const bovineTypeId = String(selection.bovineType.id)
existingMap.set(bovineTypeId, {
id: selection.id,
quantity: selection.quantity ?? 0
})
}
// Supprime les entrées supprimées ou modifiées
for (const [bovineTypeId, entry] of existingMap.entries()) {
const selectedQuantity = bovineQuantities[bovineTypeId] ?? 0
if (!selectedQuantity) {
await deleteReceptionBovine(entry.id)
existingMap.delete(bovineTypeId)
continue
}
if (selectedQuantity !== entry.quantity) {
await updateReceptionBovine(entry.id, {quantity: selectedQuantity})
existingMap.set(bovineTypeId, {
id: entry.id,
quantity: selectedQuantity
})
}
}
// Crée les entrées manquantes
for (const [bovineTypeId, quantity] of Object.entries(bovineQuantities)) {
if (!quantity) {
continue
}
if (existingMap.has(bovineTypeId)) {
// Déjà à jour
continue
}
await createReceptionBovine({
reception: receptionIri,
bovineType: `/api/bovine_types/${bovineTypeId}`,
quantity
})
}
}
async function goNext() {
if (!receptionStore.current || !receptionIri.value) {
return
}
// @TODO Ajouter un composable pour le toaster qui gère les key i18n
if (totalBovines.value > 52) {
toast.error({
title: 'Erreur',
message: ('Le total des bovins ne peut pas dépasser 52.')
})
return
}
const nextStep = receptionStore.current.currentStep + 1
await syncBovineSelections(receptionIri.value)
await receptionStore.updateReception(receptionStore.current.id, {
merchandiseType: null,
merchandiseDetail: null,
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
currentStep: nextStep
})
}
</script>
@@ -142,7 +142,8 @@ import type {DriverData} from '~/services/dto/driver-data'
import {getDriverList} from '~/services/driver'
import type {VehicleData} from '~/services/dto/vehicle-data'
import {getVehicleList} from '~/services/vehicle'
import {SUPLLIER_CODE} from "~/utils/constants";
import {RECEPTION_TYPE_CODES, SUPLLIER_CODE} from "~/utils/constants";
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine";
type ReceptionFormData = {
licensePlate: string
@@ -222,6 +223,18 @@ const filteredVehicles = computed<VehicleData[]>(() => {
)
})
const selectedReceptionType = computed(() =>
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
)
// Supprime les données bovines si on change de type de réception
const clearReceptionBovines = async (receptionIri: string) => {
const existing = await getReceptionBovineList(receptionIri)
for (const selection of existing) {
await deleteReceptionBovine(selection.id)
}
}
// Hydrate le formulaire depuis la réception en cours
watch(
() => receptionStore.current,
@@ -509,6 +522,16 @@ async function validate() {
return
}
const previousTypeCode = receptionStore.current.receptionType?.code ?? null
const nextTypeCode = selectedReceptionType.value?.code ?? null
const receptionIri = `/api/receptions/${receptionStore.current.id}`
if (
previousTypeCode === RECEPTION_TYPE_CODES.BOVINS &&
nextTypeCode === RECEPTION_TYPE_CODES.MERCHANDISES
) {
await clearReceptionBovines(receptionIri)
}
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep,
@@ -1,10 +1,9 @@
<template>
<div class="flex flex-col items-center gap-16">
<!-- @TODO voir pour séparer dans un composant au moment de l'implémentation des Bovins -->
<div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
class="flex flex-col gap-16 items-center w-full">
<h1 class="text-4xl uppercase font-bold">Sélectionner des marchandises réceptionnnées</h1>
<h1 class="text-4xl uppercase font-bold">Sélection des marchandises réceptionnnées</h1>
<UiSelect
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
@@ -12,7 +11,6 @@
:options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))"
wrapper-class="w-[550px]"
/>
<div
v-if="selectedMerchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
@@ -69,25 +67,27 @@
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser</button>
>Peser
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getBuildingList } from '~/services/building'
import { getMerchandiseTypeList } from '~/services/merchandise-type'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
import type { BuildingData } from '~/services/dto/building-data'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
import { getPelletTypeList } from '~/services/pellet-type'
import {computed, onMounted, ref} from 'vue'
import {getBuildingList} from '~/services/building'
import {getMerchandiseTypeList} from '~/services/merchandise-type'
import type {MerchandiseTypeData} from '~/services/dto/merchandise-type-data'
import type {BuildingData} from '~/services/dto/building-data'
import type {PelletTypeData} from '~/services/dto/pellet-type-data'
import {getPelletTypeList} from '~/services/pellet-type'
import {
createReceptionPelletBuilding,
deleteReceptionPelletBuilding,
getReceptionPelletBuildingList
} from '~/services/reception-pellet-building'
import { useReceptionStore } from '~/stores/reception'
import { MERCHANDISE_TYPE_CODES, RECEPTION_TYPE_CODES } from '~/utils/constants'
import {useReceptionStore} from '~/stores/reception'
import {MERCHANDISE_TYPE_CODES, RECEPTION_TYPE_CODES} from '~/utils/constants'
import ReceptionBovineReceived from "~/components/reception/reception-bovine-received.vue";
const receptionStore = useReceptionStore()
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
@@ -173,7 +173,6 @@ onMounted(async () => {
}
selectedPelletBuildingIds.value = selectionMap
})
// Enregistre les sélections et passe à l'étape suivante
async function goNext() {
if (!receptionStore.current) {
@@ -191,6 +190,8 @@ async function goNext() {
buildings: isGranule.value
? []
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
bovineDetail: null,
bovinesTypes: null,
currentStep: nextStep
})
@@ -208,7 +209,6 @@ async function clearPelletSelections(receptionIri: string) {
await deleteReceptionPelletBuilding(selection.id)
}
}
// Synchronise les associations granulés/bâtiments avec l'état du formulaire
async function syncPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
@@ -227,7 +227,7 @@ async function syncPelletSelections(receptionIri: string) {
const desiredEntries: Array<{ pelletTypeId: string; buildingId: string }> = []
for (const [pelletTypeId, buildingIds] of Object.entries(selectedPelletBuildingIds.value)) {
for (const buildingId of buildingIds) {
desiredEntries.push({ pelletTypeId, buildingId })
desiredEntries.push({pelletTypeId, buildingId})
}
}
+91
View File
@@ -0,0 +1,91 @@
<template>
<div :class="['flex flex-row items-center gap-2', wrapperClass]">
<label
v-if="label"
:for="id"
class="text-xl text-bold flex items-center gap-2"
:class="labelClass"
>
<span
v-if="label">
{{ label }}
</span>
<span
v-if="code" class="text-neutral-600">
({{ code }})
</span>
</label>
<input
:id="id"
type="number"
:value="modelValue ?? ''"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
v-bind="attrs"
class="border-b border-black text-xl bg-transparent w-48"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass
]"
@keydown="onKeydown"
@input="onInput"
>
</div>
</template>
<script setup lang="ts">
import {computed, useAttrs} from 'vue'
defineOptions({inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
code?: string
modelValue: number | string | null | undefined
min?: number | string
max?: number | string
step?: number | string
disabled?: boolean
wrapperClass?: string
labelClass?: string
inputClass?: string
}>(),
{
min: undefined,
max: undefined,
step: undefined,
disabled: false,
wrapperClass: '',
labelClass: '',
inputClass: ''
}
)
const emit = defineEmits<{
(event: 'update:modelValue', value: number | null): void
}>()
const attrs = useAttrs()
const isEmpty = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '')
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.value === '') {
emit('update:modelValue', null)
return
}
const numeric = Math.max(0, Number(target.value))
emit('update:modelValue', Number.isNaN(numeric) ? null : numeric)
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === '-' || event.key === 'e' || event.key === 'E') {
event.preventDefault()
}
}
</script>