feat : Ajout de zod, création d'un composant de chargement loading-dots.vue et finalisation du flow d'une reception

This commit is contained in:
2026-01-13 15:52:47 +01:00
parent cfe7baa4ae
commit 6dab1d789a
19 changed files with 547 additions and 165 deletions

View File

@@ -1,36 +1,107 @@
<template>
<h1>Formulaire</h1>
<button
@click="validate"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
>Valider
</button>
<form @submit.prevent="validate">
<div class="grid grid-cols-1 items-start gap-8 mb-16">
<h1 class="font-bold text-5xl uppercase">Réception</h1>
<div class="flex flex-col">
<label for="license-plate" class="font-bold uppercase text-xl mb-4">Immatriculation</label>
<input
id="license-plate"
v-model="form.licensePlate"
type="text"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
/>
<p v-if="fieldErrors.licensePlate" class="text-red-600 text-sm">{{ fieldErrors.licensePlate }}</p>
</div>
<div class="flex flex-col">
<label for="reception-date" class="font-bold uppercase text-xl mb-4">Date de reception</label>
<input
id="reception-date"
v-model="form.receptionDate"
type="date"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
/>
<p v-if="fieldErrors.receptionDate" class="text-red-600 text-sm">{{ fieldErrors.receptionDate }}</p>
</div>
</div>
<div class="flex justify-center">
<button
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Valider
</button>
</div>
<p v-if="errorMessage" class="text-red-600 mt-4">{{ errorMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { z } from 'zod'
import { mapZodErrors } from '~/utils/zod-errors'
import { useReceptionStore } from '~/stores/reception'
type ReceptionFormData = {
licensePlate: string
receptionDate: string
}
const receptionStore = useReceptionStore()
const isLoading = ref<boolean>(false)
const errorMessage = ref<string | null>(null)
const { errorMessage: storeErrorMessage, current: storeReception } = storeToRefs(receptionStore)
const form = reactive<ReceptionFormData>({
licensePlate: '',
receptionDate: ''
})
const fieldErrors = reactive<Partial<Record<keyof ReceptionFormData, string>>>({
licensePlate: undefined,
receptionDate: undefined
})
const errorMessage = computed(() => storeErrorMessage.value)
const formSchema = z.object({
licensePlate: z
.string()
.min(1, 'Immatriculation requise.')
.max(20, 'Immatriculation trop longue (20 caracteres max).'),
receptionDate: z
.string()
.min(1, 'Date de reception requise.')
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date de reception invalide.')
})
watch(
storeReception,
(reception) => {
form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? ''
},
{ immediate: true }
)
async function validate() {
if (!receptionStore.current) {
errorMessage.value = 'Réception introuvable.'
return
}
isLoading.value = true
try {
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep
})
} catch (error) {
errorMessage.value = error.error ?? 'Erreur inconnue.'
} finally {
isLoading.value = false
fieldErrors.licensePlate = undefined
fieldErrors.receptionDate = undefined
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim()
const result = formSchema.safeParse({
licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate
})
if (!result.success) {
const errors = mapZodErrors<ReceptionFormData>(result.error)
fieldErrors.licensePlate = errors.licensePlate ?? 'Formulaire invalide.'
fieldErrors.receptionDate = errors.receptionDate ?? 'Formulaire invalide.'
return
}
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep,
licensePlate: normalizedLicensePlate || null,
receptionDate: normalizedReceptionDate || null
})
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center mt-[164px] gap-32">
<div class="flex gap-8 items-center justify-center">
<!--@TODO Prendre en compte que l'on peut aussi décharger de la marchandise-->
<h1 class="text-3xl uppercase font-bold">Décharger les bêtes</h1>
<UiLoadingDots />
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Suivant</button>
</div>
</template>
<script setup lang="ts">
import { useReceptionStore } from '~/stores/reception'
const receptionStore = useReceptionStore()
async function goNext() {
if (!receptionStore.current) {
return
}
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep
})
}
</script>

View File

@@ -1,41 +1,110 @@
<template>
<div v-if="weightData">
<p>{{ weightData.weight }} kg</p>
<p>DSD : {{ weightData.dsd }}</p>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase">{{ title }}</h1>
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<div
v-if="errorMessage || showLoadingBox"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<p v-if="errorMessage" class="text-red-500">{{ errorMessage }}</p>
<UiLoadingDots v-else />
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl">
{{ displayWeight }} kg
</div>
<div class="grid grid-cols-2 border border-black text-center">
<p class="border-r border-black py-3 text-4xl font-bold">DSD</p>
<p class="py-3 text-4xl">{{ displayDsd }}</p>
</div>
</div>
</div>
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="getReceptionWeight"
>Peser</button>
<div class="flex justify-center mt-[54px]">
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="getReceptionWeight"
>{{ displayWeight !== null ? 'refaire une pesee' : 'peser' }}</button>
<button
v-if="displayWeight !== null"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="validateWeight"
>Valider la pesée</button>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { getWeight } from '~/services/reception'
import type { WeightData } from '~/services/dto/weight-data'
import { createWeight, updateWeight } from '~/services/weight'
import { useReceptionStore } from '~/stores/reception'
const isLoading = ref(false)
const props = defineProps<{
mode: 'gross' | 'tare'
}>()
const weightData = ref<WeightData | null>(null)
const errorMessage = ref<string | null>(null)
const localErrorMessage = ref<string | null>(null)
const receptionStore = useReceptionStore()
const { current: storeReception, errorMessage: storeErrorMessage } = storeToRefs(receptionStore)
const errorMessage = computed(() => localErrorMessage.value ?? storeErrorMessage.value)
const currentWeightEntry = computed(
() => storeReception.value?.weights?.find((entry) => entry.type === props.mode) ?? null
)
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
const title = computed(() => (props.mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide'))
const showLoadingBox = computed(() => displayWeight.value === null && !errorMessage.value)
async function getReceptionWeight() {
isLoading.value = true
localErrorMessage.value = null
try {
weightData.value = await getWeight()
} catch (error) {
localErrorMessage.value = error?.data?.error ?? error?.message ?? 'Erreur inconnue.'
}
}
if (receptionStore.current) {
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
dsd: weightData.value?.dsd ?? null,
weight: weightData.value?.weight ?? null,
currentStep: nextStep
async function validateWeight() {
localErrorMessage.value = null
const existingEntry = currentWeightEntry.value
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
try {
if (existingEntry?.id) {
await updateWeight(existingEntry.id, {
type: props.mode,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
} else {
await createWeight({
reception: `/receptions/${storeReception.value.id}`,
type: props.mode,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
}
} catch (error) {
errorMessage.value = error.error
} finally {
isLoading.value = false
localErrorMessage.value = error?.data?.error ?? error?.message ?? 'Erreur inconnue.'
return
}
// @TODO Voir comment mettre en place la genération du bon, la validation de la reception et le dernier step
const nextStep = storeReception.value.currentStep + 1
await receptionStore.updateReception(storeReception.value.id, {
currentStep: nextStep,
isValid: props.mode === 'tare' ? true : storeReception.value.isValid
})
await receptionStore.loadReception(storeReception.value.id)
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex items-center gap-2 text-sm uppercase">
<span class="loader-dots">
<span class="loader-dot"></span>
<span class="loader-dot"></span>
<span class="loader-dot"></span>
</span>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.loader-dots {
display: inline-flex;
gap: 4px;
align-items: center;
}
.loader-dot {
width: 20px;
height: 20px;
border-radius: 9999px;
background: currentColor;
animation: loader-bounce 1s infinite ease-in-out;
}
.loader-dot:nth-child(2) {
animation-delay: 0.15s;
}
.loader-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes loader-bounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>