feat : dernier push avant finalisation de affichage reception finie

This commit is contained in:
2026-02-23 17:44:33 +01:00
parent 0d258ae9c6
commit d47f237bac
11 changed files with 473 additions and 204 deletions

View File

@@ -1,13 +1,10 @@
<template>
<form @submit.prevent="validate">
<div
class="flex flex-col items-center gap-16">
<div
class="flex flex-row gap-6 items-center">
<form>
<div class="flex flex-row justify-between gap-x-12 font-bold uppercase mb-8">
<div
v-for="type in bovineType"
:key="type.id"
class="flex flex-row mb-2 gap-6 ">
>
<UiNumberInput
:label="type.label"
:code="type.code"
@@ -16,52 +13,47 @@
:placeholder="0"
:min="0"
:max="10"
wrapperClass="w-44"
/>
</div>
<div
class=" flex flex-row mb-2 gap-6">
<UiNumberInput
label="Autres"
v-model="otherQuantity"
:disabled="!auth.isAdmin"
/>
</div>
<UiNumberInput
label="Autres"
v-model="otherQuantity"
:disabled="!auth.isAdmin"
wrapperClass="w-44"
/>
</div>
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
import {getBovineTypeList} from "~/services/bovine-type";
import {
createReceptionBovine,
deleteReceptionBovine,
getReceptionBovineList,
updateReceptionBovine
} from "~/services/reception-bovine";
import {createReceptionBovine, deleteReceptionBovine, getReceptionBovineList, updateReceptionBovine} from "~/services/reception-bovine";
import {computed, onMounted, reactive, ref, watch} from "vue";
import {getReception, updateReception} from "~/services/reception";
const toast = useToast()
const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([])
const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0)
const initialBovineQuantities = ref<Record<string, number | null>>({})
const initialOtherQuantity = ref<number | null>(0)
const auth = useAuthStore()
const props = defineProps<{
idReception: number
isValidate: boolean
}>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const receptionIri = computed(() =>
receptionId ? `/api/receptions/${receptionId}` : null
)
const totalBovines = computed(() => {
const base = Object.values(bovineQuantities).reduce((sum, value) => {
return sum + (value ?? 0)
@@ -69,6 +61,20 @@ const totalBovines = computed(() => {
return base + (otherQuantity.value ?? 0)
})
const hasBovineChanged = () => {
if ((initialOtherQuantity.value ?? 0) !== (otherQuantity.value ?? 0)) {
return true
}
for (const [key, value] of Object.entries(bovineQuantities)) {
if ((initialBovineQuantities.value[key] ?? 0) !== (value ?? 0)) {
return true
}
}
return false
}
const loadBovineType = async () => {
isLoadingBovineType.value = true
try {
@@ -111,6 +117,8 @@ watch(
? Number(existingOther)
: 0
otherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0
initialBovineQuantities.value = {...selectionMap}
initialOtherQuantity.value = otherQuantity.value ?? 0
},
{immediate: true}
)
@@ -151,7 +159,6 @@ async function syncBovineSelections(receptionIri: string) {
continue
}
if (existingMap.has(bovineTypeId)) {
// Déjà à jour
continue
}
await createReceptionBovine({
@@ -162,7 +169,18 @@ async function syncBovineSelections(receptionIri: string) {
}
}
async function validate() {
watch(
() => props.isValidate,
async (val) => {
if (!val) return
await runValidate()
}
)
const runValidate = async () => {
if (!hasBovineChanged()) {
return
}
const receptionIri = `/api/receptions/${reception.id}`
// @TODO Ajouter un composable pour le toaster qui gère les key i18n
if (totalBovines.value > 52) {
toast.error({
@@ -172,12 +190,16 @@ async function validate() {
return
}
await syncBovineSelections(receptionIri.value)
await syncBovineSelections(receptionIri)
await updateReception(receptionId, {
merchandiseType: null,
merchandiseDetail: null,
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
})
initialBovineQuantities.value = {...bovineQuantities}
initialOtherQuantity.value = otherQuantity.value ?? 0
}
</script>

View File

@@ -1,33 +1,29 @@
<template>
<form @submit.prevent="validate">
<div class="flex flex-col items-center gap-16">
<form>
<div
class="flex flex-col gap-16 items-center w-full">
<UiTextInput
class="flex flex-col w-full">
<div class="w-full col-start-1 row-start-1">
<UiRadioGroup
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
label="Type de marchandises"
:value="reception.merchandiseType?.label"
wrapper-class="w-[550px]"
:disabled="true"
:options="merchandiseTypes.map((type) => ({
value: String(type.id),
label: type.label
}))"
input-class="accent-primary-700 focus:ring-primary-700"
item-class="text-primary-700/50 [&:has(input:checked)]:text-primary-700"
option-label-class="uppercase"
wrapper-class="w-full uppercase "
group-class="grid grid-cols-[335px_337px_355px_200px] w-[160px_160px_200px_200px] mt-9 mb-7"
:disabled="!auth.isAdmin"
/>
<div
v-if="merchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
>
<UiTextInput
id="merchandise-detail"
:disabled="!auth.isAdmin"
v-model="merchandiseDetail"
label="Préciser"
placeholder="Précisions complémentaires"
:maxlength="255"
/>
</div>
<div class=" w-full grid grid-cols-[3fr_1fr] gap-[80px] col-start-2 row-start-1 ">
<div
v-if="merchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly"
class="flex gap-[187px]"
>
<div
v-for="building in buildings"
@@ -38,28 +34,44 @@
:value="String(building.id)"
:label="building.label"
:disabled="!auth.isAdmin"
label-class="text-xl"
input-class="accent-primary-700 focus:ring-primary-700"
label-class="text-xl uppercase"
/>
</div>
</div>
<div
v-if="merchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[225px]"
>
<UiTextInput
id="merchandise-detail"
:disabled="!auth.isAdmin"
v-model="merchandiseDetail"
placeholder="Préciser"
:maxlength="255"
/>
</div>
</div>
<div
v-if="merchandiseTypeId && isGranule"
class="flex flex-col gap-10 w-full max-w-[1100px]"
class="flex flex-col gap-10 w-full col-start-2 row-start-1 "
>
<div class="grid grid-cols-1 gap-10 md:grid-cols-4">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="font-bold uppercase">{{ type.label }}</p>
<div class="grid grid-cols-1 md:grid-cols-[max-content_max-content_max-content_max-content] justify-between">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="mb-1 font-medium uppercase">{{ type.label }}</p>
<div
v-for="building in buildings"
:key="building.id"
class="flex items-center gap-2 text-lg"
class="flex items-center text-lg"
>
<UiCheckbox
v-model="selectedPelletBuildingIds[String(type.id)]"
:value="String(building.id)"
:label="building.label"
:disabled="!auth.isAdmin"
input-class="accent-primary-700 focus:ring-primary-700"
label-class="text-lg"
/>
</div>
@@ -67,19 +79,11 @@
</div>
</div>
</div>
<UiButton
v-if="auth.isAdmin"
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import {computed, onMounted, ref} from 'vue'
import {computed, onMounted, ref, watch} from 'vue'
import {getBuildingList} from '~/services/building'
import {getMerchandiseTypeList} from '~/services/merchandise-type'
import type {MerchandiseTypeData} from '~/services/dto/merchandise-type-data'
@@ -102,12 +106,17 @@ const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('')
const auth = useAuthStore()
const initialMerchandiseTypeId = ref<string | null>(null)
const initialMerchandiseDetail = ref<string | null>(null)
const initialBuildingIds = ref<string[]>([])
const initialPelletSelections = ref<Record<string, string[]>>({})
const props = defineProps<{
idReception: number
isValidate: boolean
}>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const merchandiseTypeId = await reception.receptionType?.id
const merchandiseTypeId = reception.receptionType?.id
// Extrait l'ID d'une relation depuis un IRI ou un objet complet.
const getRelationId = (value: unknown): string | null => {
@@ -142,6 +151,52 @@ const isGranule = computed(() => selectedMerchandiseType.value?.code === MERCHAN
// Indique si le type est "Autres"
const isAutres = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES)
const hasMerchandiseChanged = () => {
const currentTypeId = selectedMerchandiseTypeId.value || null
if (initialMerchandiseTypeId.value !== currentTypeId) {
return true
}
const currentDetail = isAutres.value ? merchandiseDetail.value.trim() : ''
if ((initialMerchandiseDetail.value ?? '') !== currentDetail) {
return true
}
const currentBuildings = isGranule.value ? [] : [...selectedBuildingIds.value].sort()
const initialBuildings = [...initialBuildingIds.value].sort()
if (currentBuildings.length !== initialBuildings.length) {
return true
}
for (let i = 0; i < currentBuildings.length; i += 1) {
if (currentBuildings[i] !== initialBuildings[i]) {
return true
}
}
const currentPellets = normalizePelletSelections(selectedPelletBuildingIds.value)
const initialPellets = normalizePelletSelections(initialPelletSelections.value)
if (currentPellets.length !== initialPellets.length) {
return true
}
for (let i = 0; i < currentPellets.length; i += 1) {
if (currentPellets[i] !== initialPellets[i]) {
return true
}
}
return false
}
const normalizePelletSelections = (selections: Record<string, string[]>) => {
const pairs: string[] = []
for (const [pelletTypeId, buildingIds] of Object.entries(selections)) {
for (const buildingId of buildingIds) {
pairs.push(`${pelletTypeId}:${buildingId}`)
}
}
return pairs.sort()
}
// Charge les référentiels et hydrate le formulaire depuis la réception
onMounted(async () => {
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
@@ -183,14 +238,36 @@ onMounted(async () => {
}
}
selectedPelletBuildingIds.value = selectionMap
initialMerchandiseTypeId.value = selectedMerchandiseTypeId.value || null
initialMerchandiseDetail.value = isAutres.value ? merchandiseDetail.value.trim() : ''
initialBuildingIds.value = [...selectedBuildingIds.value]
initialPelletSelections.value = JSON.parse(JSON.stringify(selectedPelletBuildingIds.value))
})
// Enregistre les sélections et passe à l'étape suivante
async function validate() {
watch(
() => props.isValidate,
async (val) => {
if (!val) return
await runValidate()
}
)
const runValidate = async () => {
if (!hasMerchandiseChanged()) {
return
}
const receptionIri = `/api/receptions/${reception.id}`
// 1) supprimer toutes les anciennes associations
await clearPelletSelections(receptionIri)
// 2) update reception (type + détails + buildings non-granulé)
await updateReception(reception.id, {
merchandiseDetail: isAutres.value ? merchandiseDetail.value.trim() : null,
merchandiseType: selectedMerchandiseTypeId.value
? `/api/merchandise_types/${selectedMerchandiseTypeId.value}`
: null,
merchandiseDetail: isAutres.value ? merchandiseDetail.value.trim() :
null,
buildings: isGranule.value
? []
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
@@ -198,11 +275,15 @@ async function validate() {
bovinesTypes: null,
})
// 3) si granulé ALORS créer les nouvelles associations
if (isGranule.value) {
await syncPelletSelections(receptionIri)
} else {
await clearPelletSelections(receptionIri)
}
initialMerchandiseTypeId.value = selectedMerchandiseTypeId.value || null
initialMerchandiseDetail.value = isAutres.value ? merchandiseDetail.value.trim() : ''
initialBuildingIds.value = [...selectedBuildingIds.value]
initialPelletSelections.value = JSON.parse(JSON.stringify(selectedPelletBuildingIds.value))
}
// Supprime toutes les associations granulés/bâtiments existantes
@@ -255,4 +336,5 @@ async function syncPelletSelections(receptionIri: string) {
}
}
}
</script>

View File

@@ -1,6 +1,22 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-8">
<form>
<div class="grid grid-cols-3 gap-x-40 gap-y-8 mb-8">
<UiNumberInput
:key="weight.type"
:label="'POIDS'"
labelClass="font-bold uppercase text-xl "
v-model="weight.weight"
:disabled="!auth.isAdmin"
:min="0"
:max="48000"
/>
<UiDateInput
label="Date pesée"
v-model="sharedWeightMeta.weighedAt"
:disabled="!auth.isAdmin"
/>
<UiNumberInput
label="Dsd"
class="col-start-2"
@@ -8,38 +24,7 @@
v-model="sharedWeightMeta.dsd"
:disabled="!auth.isAdmin"
/>
<UiDateInput
label="Date pesée"
v-model="sharedWeightMeta.weighedAt"
:disabled="!auth.isAdmin"
/>
</div>
<div class="grid grid-cols-2 gap-x-40 mb-16">
<UiNumberInput
v-for="weight in form.weights"
:key="weight.type"
:label="getWeightLabel(weight.type)"
labelClass="font-bold uppercase text-xl"
inputClass="w-24"
v-model="weight.weight"
:wrapper-class="weight.type === 'tare' ? 'col-start-1 row-start-1' : 'col-start-2 row-start-1'"
:disabled="!auth.isAdmin"
:min="0"
:max="48000"
/>
</div>
<div class="flex justify-center">
<UiButton
v-if="auth.isAdmin"
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
>
Valider
</UiButton>
</div>
</form>
</template>
@@ -48,12 +33,17 @@ import type {ReceptionFormWeight} from '~/services/dto/reception-data'
import {getReception} from '~/services/reception'
import {updateWeight} from "~/services/weight";
import {useAuthStore} from "~/stores/auth";
import {ref, watch} from "vue";
const props = defineProps<{
idReception: number
weightType: string
isValidate: boolean
}>()
const idReception = props.idReception
const weightType = props.weightType
const auth = useAuthStore()
const form = reactive({
@@ -62,6 +52,10 @@ const form = reactive({
{id: 0, type: 'gross' as const, weight: 0, dsd: null, weighedAt: null}
]
})
const weight = form.weights.find(w => w.type === weightType)
const initialWeight = ref<{ weight: number | null; dsd: number | null; weighedAt: string | null } | null>(null)
// DSD et date de pesée sont partagés entre tare et gross dans l'UI.
const sharedWeightMeta = reactive<{
dsd: number | string | null
@@ -71,8 +65,20 @@ const sharedWeightMeta = reactive<{
weighedAt: null
})
const getWeightLabel = (type: 'tare' | 'gross'): string => {
return type === 'tare' ? 'Pesée à vide' : 'Pesée à plein'
const normalizeMeta = () => {
const sharedDsd =
sharedWeightMeta.dsd === null || sharedWeightMeta.dsd === undefined || sharedWeightMeta.dsd === ''
? null
: Number(sharedWeightMeta.dsd)
const sharedWeighedAt =
sharedWeightMeta.weighedAt === null || sharedWeightMeta.weighedAt === undefined || sharedWeightMeta.weighedAt === ''
? null
: sharedWeightMeta.weighedAt
return {
dsd: Number.isFinite(sharedDsd) ? sharedDsd : null,
weighedAt: sharedWeighedAt
}
}
const hydrateFromReception = (reception: ReceptionFormWeight) => {
@@ -94,6 +100,15 @@ const hydrateFromReception = (reception: ReceptionFormWeight) => {
sharedWeightMeta.dsd = weightWithMeta.dsd ?? null
sharedWeightMeta.weighedAt = weightWithMeta.weighedAt ?? null
}
if (weight) {
const normalized = normalizeMeta()
initialWeight.value = {
weight: weight.weight ?? null,
dsd: normalized.dsd,
weighedAt: normalized.weighedAt
}
}
}
onMounted(async () => {
@@ -101,24 +116,48 @@ onMounted(async () => {
hydrateFromReception(reception)
})
async function validate() {
const sharedDsd =
sharedWeightMeta.dsd === null || sharedWeightMeta.dsd === undefined || sharedWeightMeta.dsd === ''
? null
: Number(sharedWeightMeta.dsd)
const sharedWeighedAt =
sharedWeightMeta.weighedAt === null || sharedWeightMeta.weighedAt === undefined || sharedWeightMeta.weighedAt === ''
? null
: sharedWeightMeta.weighedAt
for (const weight of form.weights) {
if (weight.id) {
await updateWeight(weight.id, {
weight: weight.weight,
dsd: Number.isFinite(sharedDsd) ? sharedDsd : null,
weighedAt: sharedWeighedAt
})
}
watch(
() => props.isValidate,
async (val) => {
if (!val) return
await runValidate()
}
)
const hasChanged = (current: { weight: number | null; dsd: number | null; weighedAt: string | null }) => {
if (!initialWeight.value) {
return true
}
return (
(current.weight ?? null) !== (initialWeight.value.weight ?? null) ||
(current.dsd ?? null) !== (initialWeight.value.dsd ?? null) ||
(current.weighedAt ?? null) !== (initialWeight.value.weighedAt ?? null)
)
}
const runValidate = async () => {
if (!weight?.id) {
return
}
const normalized = normalizeMeta()
const current = {
weight: weight.weight ?? null,
dsd: normalized.dsd,
weighedAt: normalized.weighedAt
}
if (!hasChanged(current)) {
return
}
await updateWeight(weight.id, {
weight: current.weight,
dsd: current.dsd,
weighedAt: current.weighedAt
})
initialWeight.value = current
}
</script>

View File

@@ -1,14 +1,14 @@
<template>
<div :class="wrapperClass">
<label
class="flex items-center gap-2 cursor-pointer text-primary-500"
class="flex items-center gap-2 cursor-pointer text-primary-700"
:class="labelClass"
>
<input
type="checkbox"
:checked="checked"
:disabled="disabled"
:class="['cursor-pointer text-primary-500', inputClass]"
:class="['h-4 w-4 cursor-pointer text-primary-500', inputClass]"
@change="onChange"
>
<span v-if="label">{{ label }}</span>

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-500"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
@@ -14,9 +14,9 @@
:value="modelValue ?? ''"
:disabled="disabled"
v-bind="attrs"
class="border-b border-black justify-self-start text-xl text-primary-500 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass
]"

View File

@@ -1,9 +1,10 @@
// flex row passer en class wraper class flex col ainsi que le wfull 34
<template>
<div :class="['flex flex-row items-center gap-2', wrapperClass]">
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
:for="id"
class="text-xl flex items-center gap-2 text-primary-500"
class="text-xl flex items-center gap-2 text-primary-700"
:class="labelClass"
>
<span
@@ -25,7 +26,7 @@
:step="step"
:disabled="disabled"
v-bind="attrs"
class="border-b border-black text-xl bg-transparent w-16 text-primary-500"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-text',

View File

@@ -2,7 +2,7 @@
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
class="font-bold uppercase text-xl text-primary-500"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
@@ -16,7 +16,7 @@
v-for="option in options"
:key="String(option.value)"
:for="`${id || 'radio'}-${option.value}`"
class="flex items-center gap-2 text-primary-500"
class="flex items-center gap-2 text-primary-700"
:class="itemClass"
>
<input
@@ -27,7 +27,7 @@
:checked="String(modelValue ?? '') === String(option.value)"
:disabled="disabled"
v-bind="attrs"
class="h-4 w-4 border-slate-300 text-primary-500 focus:ring-primary-500"
class="h-4 w-4 border-primary-700/50 text-primary-700 focus:ring-primary-700"
:class="[
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-500"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
@@ -13,9 +13,9 @@
:value="modelValue ?? ''"
:disabled="disabled || loading"
v-bind="attrs"
class="border-b border-black justify-self-start text-xl text-primary-500 py-[6px] bg-transparent"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] bg-transparent"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
selectClass
]"
@@ -28,7 +28,7 @@
v-for="option in options"
:key="option.value"
:value="option.value"
class="text-black"
class="text-primary-700"
>
{{ option.label }}
</option>