Compare commits

...

8 Commits

Author SHA1 Message Date
gitea-actions
47cac04257 chore: bump version to v0.0.61
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-26 09:28:11 +00:00
59d76c5f14 fix : page de modification reception qui crash en prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 10:28:00 +01:00
gitea-actions
c48cc477da chore: bump version to v0.0.60
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-26 08:46:45 +00:00
5967665e9f fix : page de modification reception qui crash en prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-02-26 09:46:34 +01:00
gitea-actions
393c420983 chore: bump version to v0.0.59
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-26 08:34:02 +00:00
456623b403 fix : CHANGELOG.md
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 09:33:52 +01:00
gitea-actions
e2a8e89e55 chore: bump version to v0.0.58
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-02-26 08:25:26 +00:00
92a5c48e5e [#332]Refonte écran réception terminée (!31)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|     #332             |     Refonte écran réception terminée            |

## 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: #31
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-26 08:25:20 +00:00
19 changed files with 1125 additions and 733 deletions

2
.idea/workspace.xml generated
View File

@@ -817,4 +817,4 @@
<option value=".github/prompts" /> <option value=".github/prompts" />
</promptFileLocations> </promptFileLocations>
</component> </component>
</project> </project>

View File

@@ -52,6 +52,9 @@ Ajouter dans le fichier .env du frontend
* [#331] Mettre à jour l'entité Shipment et bovin_shipment * [#331] Mettre à jour l'entité Shipment et bovin_shipment
* [#278] Plan du site * [#278] Plan du site
* [#334] Correctifs * [#334] Correctifs
* [#332] Refonte écran réception terminée
### Changed ### Changed
### Fixed ### Fixed

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.57' app.version: '0.0.61'

View File

@@ -29,7 +29,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AddressPayload } from "~/services/address" import type { AddressPayload } from "~/services/address"
const route = useRoute() const route = useRoute()

View File

@@ -2,17 +2,17 @@
<template> <template>
<NuxtLink :to="link"> <NuxtLink :to="link">
<div class="w-[300px] h-[216px] border border-black rounded-lg p-6 flex flex-col justify-between gap-4"> <div class="w-[300px] h-[216px] border border-primary-700 rounded-lg p-6 flex flex-col justify-between gap-4">
<div class="flex justify-between"> <div class="flex justify-between">
<div class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center"> <div class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center">
<Icon :name="iconName" style="color: black" size="44" /> <Icon :name="iconName" class="!text-primary-700" size="44" />
</div> </div>
<div> <div>
<Icon name="mdi:plus" style="color: black" size="44" /> <Icon name="mdi:plus" style="color: black" size="44" />
</div> </div>
</div> </div>
<div class="uppercase font-bold"> <div class="uppercase font-bold">
<p class="text-3xl text-primary-500"> <p class="text-3xl text-primary-700">
<slot name="label">{{ label }}</slot> <slot name="label">{{ label }}</slot>
</p> </p>
</div> </div>

View File

@@ -1,14 +1,14 @@
<template> <template>
<div <div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS" v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
class="flex flex-col items-center gap-16"> class="flex flex-col gap-16">
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1> <h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
<div <div
class="flex flex-row gap-8 items-center"> class="flex flex-row gap-8 items-center w-full">
<div <div
v-for="type in bovineType" v-for="type in bovineType"
:key="type.id" :key="type.id"
class="mt-8 flex flex-row mb-2 gap-6"> class="mt-8 flex flex-row mb-2 w-full">
<UiNumberInput <UiNumberInput
:id="type.id" :id="type.id"
:label="type.label" :label="type.label"
@@ -17,6 +17,8 @@
:placeholder="0" :placeholder="0"
:min="0" :min="0"
:max="10" :max="10"
class="max-w-[150px]"
wrapper-class="gap-3"
/> />
</div> </div>
<div <div
@@ -24,6 +26,8 @@
<UiNumberInput <UiNumberInput
label="Autres" label="Autres"
v-model="otherQuantity" v-model="otherQuantity"
class="max-w-[80px]"
wrapper-class="gap-3"
/> />
</div> </div>
</div> </div>

View File

@@ -1,183 +1,161 @@
<template> <template>
<form @submit.prevent="validate"> <form>
<div <div class="flex flex-row justify-between gap-x-12 font-bold uppercase mb-8">
class="flex flex-col items-center gap-16">
<div
class="flex flex-row gap-6 items-center">
<div <div
v-for="type in bovineType" v-for="type in bovineTypes"
:key="type.id" :key="type.id"
class="flex flex-row mb-2 gap-6 "> >
<UiNumberInput <UiNumberInput
:label="type.label" :label="type.label"
:code="type.code" :code="type.code"
v-model="bovineQuantities[String(type.id)]" v-model="localQuantities[String(type.id)]"
:disabled="!auth.isAdmin" :disabled="!isAdmin"
:placeholder="0" :placeholder="0"
:min="0" :min="0"
:max="10" :max="10"
wrapperClass="w-44 flex-col"
inputClass="font-medium"
/> />
</div> </div>
<div <UiNumberInput
class=" flex flex-row mb-2 gap-6"> label="Autres"
<UiNumberInput v-model="localOtherQuantity"
label="Autres" :disabled="!isAdmin"
v-model="otherQuantity" wrapperClass="w-44 flex-col"
:disabled="!auth.isAdmin" inputClass="font-medium"
/> />
</div>
</div> </div>
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</UiButton>
</div>
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {BovineTypeData} from "~/services/dto/bovine-type-data"; import { onMounted, reactive, ref, watch } from 'vue'
import {getBovineTypeList} from "~/services/bovine-type"; import { getBovineTypeList } from '~/services/bovine-type'
import { import type { BovineTypeData } from '~/services/dto/bovine-type-data'
createReceptionBovine, import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
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 auth = useAuthStore()
const props = defineProps<{ const props = defineProps<{
idReception: number modelValue: ReceptionBovineTypeData[]
otherQuantity: number | null
isAdmin: boolean
}>() }>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const receptionIri = computed(() => const emit = defineEmits<{
receptionId ? `/api/receptions/${receptionId}` : null (event: 'update:modelValue', value: ReceptionBovineTypeData[]): void
) (event: 'update:otherQuantity', value: number | null): void
const totalBovines = computed(() => { }>()
const base = Object.values(bovineQuantities).reduce((sum, value) => {
return sum + (value ?? 0)
}, 0)
return base + (otherQuantity.value ?? 0)
})
const loadBovineType = async () => { const bovineTypes = ref<BovineTypeData[]>([])
isLoadingBovineType.value = true const localQuantities = reactive<Record<string, number | null>>({})
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
// Verrou pour éviter les boucles props -> local -> emit -> props.
const isSyncing = ref(false)
function entriesEqualByTypeAndQuantity(
left: ReceptionBovineTypeData[],
right: ReceptionBovineTypeData[]
): boolean {
const toMap = (entries: ReceptionBovineTypeData[]) => {
const map = new Map<number, number>()
for (const entry of entries) {
const typeId = entry.bovineType?.id ?? 0
map.set(typeId, entry.quantity ?? 0)
}
return map
}
const a = toMap(left)
const b = toMap(right)
if (a.size !== b.size) {
return false
}
for (const [typeId, quantity] of a.entries()) {
if ((b.get(typeId) ?? 0) !== quantity) {
return false
}
}
return true
}
function buildEntriesFromLocal(): ReceptionBovineTypeData[] {
return bovineTypes.value.map((type) => {
const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id)
return {
id: existing?.id ?? 0,
bovineType: type,
quantity: localQuantities[String(type.id)] ?? 0
}
})
}
function syncLocalFromProps() {
isSyncing.value = true
try { try {
bovineType.value = await getBovineTypeList() for (const key of Object.keys(localQuantities)) {
delete localQuantities[key]
}
for (const type of bovineTypes.value) {
const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id)
localQuantities[String(type.id)] = existing?.quantity ?? 0
}
} finally { } finally {
isLoadingBovineType.value = false isSyncing.value = false
} }
} }
onMounted(async () => {
await loadBovineType()
})
watch( watch(
[() => receptionId, () => bovineType.value], () => props.otherQuantity,
async ([id, types]) => { (value) => {
if (!id || !receptionIri.value || types.length === 0) { if (isSyncing.value) {
return return
} }
const selectionMap: Record<string, number | null> = {} const next = value ?? 0
for (const type of types) { isSyncing.value = true
selectionMap[String(type.id)] = 0 localOtherQuantity.value = next
} isSyncing.value = false
}
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 = reception.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) { watch(localOtherQuantity, (value) => {
const existing = await getReceptionBovineList(receptionIri) if (isSyncing.value) {
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 validate() {
// @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 return
} }
await syncBovineSelections(receptionIri.value) const next = value ?? 0
emit('update:otherQuantity', next)
})
await updateReception(receptionId, { watch(
merchandiseType: null, () => props.modelValue,
merchandiseDetail: null, () => {
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null, // Hydratation locale uniquement quand le parent change.
}) syncLocalFromProps()
} },
{ immediate: true }
)
watch(
localQuantities,
() => {
if (isSyncing.value) {
return
}
// N'émet que si les quantités diffèrent réellement du parent.
const nextEntries = buildEntriesFromLocal()
if (!entriesEqualByTypeAndQuantity(nextEntries, props.modelValue)) {
emit('update:modelValue', nextEntries)
}
},
{ deep: true }
)
onMounted(async () => {
bovineTypes.value = await getBovineTypeList()
syncLocalFromProps()
})
</script> </script>

View File

@@ -1,33 +1,27 @@
<template> <template>
<form @submit.prevent="validate"> <form>
<div class="flex flex-col items-center gap-16"> <div class="flex flex-col">
<div <div class="w-full col-start-1 row-start-1">
class="flex flex-col gap-16 items-center w-full"> <UiRadioGroup
<UiTextInput
id="merchandise-type" id="merchandise-type"
v-model="selectedMerchandiseTypeId" v-model="selectedMerchandiseTypeId"
label="Type de marchandises" label="Type de marchandises"
:value="reception.merchandiseType?.label" :options="merchandiseTypes.map((type) => ({
wrapper-class="w-[550px]" value: String(type.id),
:disabled="true" label: type.label
}))"
input-class="accent-primary-700 focus:ring-primary-700"
option-label-class="uppercase"
wrapper-class="w-full uppercase"
group-class="grid grid-cols-[336px_336px_355px_200px] w-[160px_160px_200px_180px] mt-9 mb-7"
:disabled="!isAdmin"
/> />
<div </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-12 col-start-2 row-start-1">
<div <div
v-if="merchandiseTypeId && !isGranule" v-if="selectedMerchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly" class="flex gap-[218px]"
> >
<div <div
v-for="building in buildings" v-for="building in buildings"
@@ -37,112 +31,228 @@
v-model="selectedBuildingIds" v-model="selectedBuildingIds"
:value="String(building.id)" :value="String(building.id)"
:label="building.label" :label="building.label"
:disabled="!auth.isAdmin" :disabled="!isAdmin"
label-class="text-xl" input-class="accent-primary-700 focus:ring-primary-700"
label-class="text-xl uppercase"
/> />
</div> </div>
</div> </div>
<div <div
v-if="merchandiseTypeId && isGranule" v-if="selectedMerchandiseTypeId && isAutres"
class="flex flex-col gap-10 w-full max-w-[1100px]" class="flex flex-col justify-self-end max-w-[182px]"
> >
<div class="grid grid-cols-1 gap-10 md:grid-cols-4"> <UiTextInput
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4"> id="merchandise-detail"
<p class="font-bold uppercase">{{ type.label }}</p> :disabled="!isAdmin"
<div v-model="merchandiseDetail"
v-for="building in buildings" placeholder="Préciser"
:key="building.id" :maxlength="255"
class="flex items-center gap-2 text-lg" class="h-6"
> />
<UiCheckbox </div>
v-model="selectedPelletBuildingIds[String(type.id)]" </div>
:value="String(building.id)"
:label="building.label" <div
:disabled="!auth.isAdmin" v-if="selectedMerchandiseTypeId && isGranule"
label-class="text-lg" class="flex flex-col gap-10 w-full col-start-2 row-start-1"
/> >
</div> <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 text-lg"
>
<UiCheckbox
v-model="selectedPelletBuildingIds[String(type.id)]"
:value="String(building.id)"
:label="building.label"
:disabled="!isAdmin"
input-class="accent-primary-700 focus:ring-primary-700"
label-class="text-lg"
/>
</div> </div>
</div> </div>
</div> </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> </div>
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, onMounted, ref} from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import {getBuildingList} from '~/services/building' import type { BuildingData } from '~/services/dto/building-data'
import {getMerchandiseTypeList} from '~/services/merchandise-type' import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
import type {MerchandiseTypeData} from '~/services/dto/merchandise-type-data' import type { PelletTypeData } from '~/services/dto/pellet-type-data'
import type {BuildingData} from '~/services/dto/building-data' import type { MerchandiseEntryData } from '~/services/dto/reception-data'
import type {PelletTypeData} from '~/services/dto/pellet-type-data' import { getBuildingList } from '~/services/building'
import {getPelletTypeList} from '~/services/pellet-type' import { getMerchandiseTypeList } from '~/services/merchandise-type'
import { import { getPelletTypeList } from '~/services/pellet-type'
createReceptionPelletBuilding, import { MERCHANDISE_TYPE_CODES } from '~/utils/constants'
deleteReceptionPelletBuilding,
getReceptionPelletBuildingList const props = defineProps<{
} from '~/services/reception-pellet-building' modelValue: MerchandiseEntryData
import {MERCHANDISE_TYPE_CODES} from '~/utils/constants' isAdmin: boolean
import {getReception, updateReception} from "~/services/reception"; }>()
const emit = defineEmits<{
(event: 'update:modelValue', value: MerchandiseEntryData): void
}>()
const merchandiseTypes = ref<MerchandiseTypeData[]>([]) const merchandiseTypes = ref<MerchandiseTypeData[]>([])
const buildings = ref<BuildingData[]>([]) const buildings = ref<BuildingData[]>([])
const pelletTypes = ref<PelletTypeData[]>([]) const pelletTypes = ref<PelletTypeData[]>([])
const selectedMerchandiseTypeId = ref('') const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([]) const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({}) const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('') const merchandiseDetail = ref('')
const auth = useAuthStore() // Verrou de synchro pour empêcher les aller-retours infinis entre parent et composant.
const props = defineProps<{ const isSyncing = ref(false)
idReception: number const isReady = ref(false)
}>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const merchandiseTypeId = await reception.receptionType?.id
// Extrait l'ID d'une relation depuis un IRI ou un objet complet. const selectedMerchandiseType = computed(() =>
const getRelationId = (value: unknown): string | null => { merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value) ?? null
if (!value) { )
return null const isGranule = computed(
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE
)
const isAutres = computed(
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES
)
function clonePelletSelections(value: Record<string, string[]>) {
const clone: Record<string, string[]> = {}
for (const [key, buildingIds] of Object.entries(value)) {
clone[key] = [...buildingIds]
} }
return clone
if (typeof value === 'string') {
const match = value.match(/\/(\d+)$/)
return match ? match[1] : null
}
if (typeof value === 'object' && 'id' in value) {
const record = value as { id?: number | string }
if (typeof record.id === 'number') {
return String(record.id)
}
if (typeof record.id === 'string') {
return record.id
}
}
return null
} }
// Type de marchandise sélectionné dans le select function sorted(values: string[]): string[] {
const selectedMerchandiseType = computed(() => return [...values].sort()
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value) }
)
// Indique si le type est "Granulé" function normalizeModel(value: MerchandiseEntryData): MerchandiseEntryData {
const isGranule = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE) // Normalisation stable pour comparer deux modèles sans faux positifs (ordre des tableaux).
// Indique si le type est "Autres" const pellet: Record<string, string[]> = {}
const isAutres = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES) const pelletKeys = Object.keys(value.selectedPelletBuildingIds ?? {}).sort()
for (const key of pelletKeys) {
pellet[key] = sorted(value.selectedPelletBuildingIds[key] ?? [])
}
return {
merchandiseTypeId: value.merchandiseTypeId ?? '',
merchandiseDetail: value.merchandiseDetail ?? '',
selectedBuildingIds: sorted(value.selectedBuildingIds ?? []),
selectedPelletBuildingIds: pellet
}
}
function buildCurrentModel(): MerchandiseEntryData {
return {
merchandiseTypeId: selectedMerchandiseTypeId.value,
merchandiseDetail: merchandiseDetail.value,
selectedBuildingIds: [...selectedBuildingIds.value],
selectedPelletBuildingIds: clonePelletSelections(selectedPelletBuildingIds.value)
}
}
function isSameModel(left: MerchandiseEntryData, right: MerchandiseEntryData): boolean {
return JSON.stringify(normalizeModel(left)) === JSON.stringify(normalizeModel(right))
}
function ensurePelletKeys() {
for (const pelletType of pelletTypes.value) {
const key = String(pelletType.id)
if (!selectedPelletBuildingIds.value[key]) {
selectedPelletBuildingIds.value[key] = []
}
}
}
function hydrateFromModelValue(value: MerchandiseEntryData) {
isSyncing.value = true
try {
selectedMerchandiseTypeId.value = value.merchandiseTypeId ?? ''
merchandiseDetail.value = value.merchandiseDetail ?? ''
selectedBuildingIds.value = [...(value.selectedBuildingIds ?? [])]
selectedPelletBuildingIds.value = clonePelletSelections(
value.selectedPelletBuildingIds ?? {}
)
ensurePelletKeys()
} finally {
isSyncing.value = false
}
}
function sanitizeLocalState() {
if (isGranule.value) {
if (selectedBuildingIds.value.length > 0) {
selectedBuildingIds.value = []
}
} else {
for (const key of Object.keys(selectedPelletBuildingIds.value)) {
if (selectedPelletBuildingIds.value[key].length > 0) {
selectedPelletBuildingIds.value[key] = []
}
}
}
if (!isAutres.value && merchandiseDetail.value !== '') {
merchandiseDetail.value = ''
}
}
function emitCurrentModel() {
const currentModel = buildCurrentModel()
// Ne pas réémettre si rien n'a changé côté métier.
if (isSameModel(currentModel, props.modelValue)) {
return
}
emit('update:modelValue', currentModel)
}
watch(
() => props.modelValue,
(value) => {
const currentModel = buildCurrentModel()
// Si local == parent, on ignore pour éviter la boucle de réhydratation.
if (isSameModel(currentModel, value)) {
return
}
hydrateFromModelValue(value)
},
{ immediate: true }
)
watch(
[selectedMerchandiseTypeId, selectedBuildingIds, selectedPelletBuildingIds, merchandiseDetail],
() => {
if (isSyncing.value || !isReady.value) {
return
}
const beforeSanitize = buildCurrentModel()
isSyncing.value = true
// Applique les règles métier (granulé / autres) avant émission.
sanitizeLocalState()
isSyncing.value = false
const afterSanitize = buildCurrentModel()
// Si la sanitation a modifié l'état, on laisse le watcher repasser proprement.
if (!isSameModel(beforeSanitize, afterSanitize)) {
return
}
emitCurrentModel()
},
{ deep: true }
)
// Charge les référentiels et hydrate le formulaire depuis la réception
onMounted(async () => { onMounted(async () => {
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([ const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
getMerchandiseTypeList(), getMerchandiseTypeList(),
@@ -153,106 +263,7 @@ onMounted(async () => {
buildings.value = buildingList buildings.value = buildingList
pelletTypes.value = pelletTypeList pelletTypes.value = pelletTypeList
const currentId = reception.merchandiseType?.id hydrateFromModelValue(props.modelValue)
if (currentId) { isReady.value = true
selectedMerchandiseTypeId.value = String(currentId)
}
merchandiseDetail.value = reception.merchandiseDetail ?? ''
selectedBuildingIds.value =
reception.buildings?.map((building) => String(building.id)) ?? []
const existingPelletSelections = reception.pelletBuildings ?? []
const selectionMap: Record<string, string[]> = {}
for (const selection of existingPelletSelections) {
// L'API peut renvoyer les relations comme IRI ou comme objets selon le contexte.
const pelletTypeId = getRelationId(selection.pelletType)
const buildingId = getRelationId(selection.building)
if (!pelletTypeId || !buildingId) {
continue
}
if (!selectionMap[pelletTypeId]) {
selectionMap[pelletTypeId] = []
}
selectionMap[pelletTypeId].push(buildingId)
}
for (const pelletType of pelletTypes.value) {
const key = String(pelletType.id)
if (!selectionMap[key]) {
selectionMap[key] = []
}
}
selectedPelletBuildingIds.value = selectionMap
}) })
// Enregistre les sélections et passe à l'étape suivante
async function validate() {
const receptionIri = `/api/receptions/${reception.id}`
await updateReception(reception.id, {
merchandiseDetail: isAutres.value ? merchandiseDetail.value.trim() : null,
buildings: isGranule.value
? []
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
bovineDetail: null,
bovinesTypes: null,
})
if (isGranule.value) {
await syncPelletSelections(receptionIri)
} else {
await clearPelletSelections(receptionIri)
}
}
// Supprime toutes les associations granulés/bâtiments existantes
async function clearPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
for (const selection of existing) {
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)
const existingMap = new Map<string, number>()
for (const selection of existing) {
// Construit la table de correspondance avec des IDs normalisés pour éviter les doublons.
const pelletTypeId = getRelationId(selection.pelletType)
const buildingId = getRelationId(selection.building)
if (!pelletTypeId || !buildingId) {
continue
}
const key = `${pelletTypeId}:${buildingId}`
existingMap.set(key, selection.id)
}
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})
}
}
const desiredKeys = new Set(desiredEntries.map(
(entry) => `${entry.pelletTypeId}:${entry.buildingId}`
))
for (const [key, id] of existingMap.entries()) {
if (!desiredKeys.has(key)) {
await deleteReceptionPelletBuilding(id)
}
}
for (const entry of desiredEntries) {
const key = `${entry.pelletTypeId}:${entry.buildingId}`
if (!existingMap.has(key)) {
await createReceptionPelletBuilding({
reception: receptionIri,
pelletType: `/api/pellet_types/${entry.pelletTypeId}`,
building: `/api/buildings/${entry.buildingId}`
})
}
}
}
</script> </script>

View File

@@ -1,124 +1,63 @@
<template> <template>
<form @submit.prevent="validate"> <form>
<div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-8"> <div class="grid grid-cols-3 gap-x-40 gap-y-8 mb-8">
<UiNumberInput
:key="localWeight.type"
:label="'POIDS'"
labelClass="font-bold uppercase text-xl "
v-model="localWeight.weight"
:disabled="!isAdmin"
:min="0"
:max="48000"
wrapper-class="flex-col"
/>
<UiDateInput
label="Date pesée"
v-model="localWeight.weighedAt"
:disabled="!isAdmin"
/>
<UiNumberInput <UiNumberInput
label="Dsd" label="Dsd"
class="col-start-2" class="col-start-2"
labelClass="font-bold uppercase" labelClass="font-bold uppercase"
v-model="sharedWeightMeta.dsd" v-model="localWeight.dsd"
:disabled="!auth.isAdmin" :disabled="!isAdmin"
/> wrapper-class="flex-col"
<UiDateInput
label="Date pesée"
v-model="sharedWeightMeta.weighedAt"
:disabled="!auth.isAdmin"
/> />
</div> </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> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {ReceptionFormWeight} from '~/services/dto/reception-data' import type {WeightEntryData} from '~/services/dto/reception-data'
import {getReception} from '~/services/reception' import {reactive, watch} from "vue";
import {updateWeight} from "~/services/weight";
import {useAuthStore} from "~/stores/auth";
const props = defineProps<{ const props = defineProps<{
idReception: number modelValue: WeightEntryData
isAdmin: boolean
}>() }>()
const idReception = props.idReception const emit = defineEmits<{
const auth = useAuthStore() (event: 'update:modelValue', value: WeightEntryData): void
}>()
const form = reactive({ const localWeight = reactive<WeightEntryData>({...props.modelValue})
weights: [
{id: 0, type: 'tare' as const, weight: 0, dsd: null, weighedAt: null},
{id: 0, type: 'gross' as const, weight: 0, dsd: null, weighedAt: null}
]
})
// DSD et date de pesée sont partagés entre tare et gross dans l'UI.
const sharedWeightMeta = reactive<{
dsd: number | string | null
weighedAt: string | null
}>({
dsd: null,
weighedAt: null
})
const getWeightLabel = (type: 'tare' | 'gross'): string => { watch(
return type === 'tare' ? 'Pesée à vide' : 'Pesée à plein' () => props.modelValue,
} (value) => {
Object.assign(localWeight, value)
},
{deep: true}
)
const hydrateFromReception = (reception: ReceptionFormWeight) => { watch(
// On hydrate chaque ligne par son type (tare/gross), sans dépendre d'un index. localWeight,
for (const receptionWeight of reception.weights) { (value) => {
const formWeight = form.weights.find(weight => weight.type === receptionWeight.type) emit('update:modelValue', {...value})
if (formWeight) { },
Object.assign(formWeight, receptionWeight) {deep: true}
} )
}
// On récupère une valeur existante pour préremplir les champs partagés.
const weightWithMeta = reception.weights.find(weight =>
(weight.dsd !== null && weight.dsd !== undefined)
|| (weight.weighedAt !== null && weight.weighedAt !== undefined && weight.weighedAt !== '')
)
if (weightWithMeta) {
sharedWeightMeta.dsd = weightWithMeta.dsd ?? null
sharedWeightMeta.weighedAt = weightWithMeta.weighedAt ?? null
}
}
onMounted(async () => {
const reception = await getReception(idReception)
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
})
}
}
}
</script> </script>

View File

@@ -1,14 +1,14 @@
<template> <template>
<div :class="wrapperClass"> <div :class="wrapperClass">
<label <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" :class="labelClass"
> >
<input <input
type="checkbox" type="checkbox"
:checked="checked" :checked="checked"
:disabled="disabled" :disabled="disabled"
:class="['cursor-pointer text-primary-500', inputClass]" :class="['h-4 w-4 cursor-pointer text-primary-500', inputClass]"
@change="onChange" @change="onChange"
> >
<span v-if="label">{{ label }}</span> <span v-if="label">{{ label }}</span>

View File

@@ -3,7 +3,7 @@
<label <label
v-if="label" v-if="label"
:for="id" :for="id"
class="font-bold uppercase text-xl text-primary-500" class="font-bold uppercase text-xl text-primary-700"
:class="labelClass" :class="labelClass"
> >
{{ label }} {{ label }}
@@ -14,9 +14,9 @@
:value="modelValue ?? ''" :value="modelValue ?? ''"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" 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="[ :class="[
isEmpty ? 'text-neutral-400' : 'text-black', isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-pointer', disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass inputClass
]" ]"

View File

@@ -1,9 +1,10 @@
// flex row passer en class wraper class flex col ainsi que le wfull 34
<template> <template>
<div :class="['flex flex-row items-center gap-2', wrapperClass]"> <div :class="['flex', wrapperClass]">
<label <label
v-if="label" v-if="label"
:for="id" :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" :class="labelClass"
> >
<span <span
@@ -25,7 +26,7 @@
:step="step" :step="step"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" 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="[ :class="[
isEmpty ? 'text-neutral-400' : 'text-black', isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-text', disabled ? 'cursor-not-allowed' : 'cursor-text',

View File

@@ -2,7 +2,7 @@
<div :class="['flex flex-col', wrapperClass]"> <div :class="['flex flex-col', wrapperClass]">
<label <label
v-if="label" v-if="label"
class="font-bold uppercase text-xl text-primary-500" class="font-bold uppercase text-xl text-primary-700"
:class="labelClass" :class="labelClass"
> >
{{ label }} {{ label }}
@@ -16,7 +16,7 @@
v-for="option in options" v-for="option in options"
:key="String(option.value)" :key="String(option.value)"
:for="`${id || 'radio'}-${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" :class="itemClass"
> >
<input <input
@@ -27,7 +27,7 @@
:checked="String(modelValue ?? '') === String(option.value)" :checked="String(modelValue ?? '') === String(option.value)"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" 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="[ :class="[
disabled ? 'cursor-not-allowed' : 'cursor-pointer', disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass inputClass

View File

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

View File

@@ -62,7 +62,7 @@ export const useWeighing = ({
}) })
} else { } else {
await createWeight({ await createWeight({
reception: `api/receptions/${reception.value.id}`, reception: `/api/receptions/${reception.value.id}`,
type: mode, type: mode,
dsd: baseDsd, dsd: baseDsd,
weight: baseWeight, weight: baseWeight,
@@ -146,7 +146,7 @@ export const useWeighingShipment = ({
}) })
} else { } else {
await createWeight({ await createWeight({
shipment: `api/shipments/${shipment.value.id}`, shipment: `/api/shipments/${shipment.value.id}`,
type: modeShipment, type: modeShipment,
dsd: baseDsd, dsd: baseDsd,
weight: baseWeight, weight: baseWeight,

File diff suppressed because it is too large Load Diff

View File

@@ -41,9 +41,18 @@ export interface WeightEntryData {
weighedAt: string | null weighedAt: string | null
} }
export interface MerchandiseEntryData {
merchandiseTypeId: string
merchandiseDetail: string
selectedBuildingIds: string[]
selectedPelletBuildingIds: Record<string, string[]>
}
export interface WeightFormData { export interface WeightFormData {
id: number id: number
weight: number weight: number
weighedAt : string
dsd: number
type: 'gross' | 'tare' type: 'gross' | 'tare'
} }
@@ -69,6 +78,7 @@ export type ReceptionPayload = {
} }
export type ReceptionFormData = { export type ReceptionFormData = {
identificationNumber?: null|string,
licensePlate: string licensePlate: string
receptionDate: string receptionDate: string
receptionTypeId: string receptionTypeId: string
@@ -79,6 +89,7 @@ export type ReceptionFormData = {
carrierId: string carrierId: string
driverId: string driverId: string
vehicleId: string vehicleId: string
weight?: ReceptionFormWeight | null
} }
export type ReceptionFormWeight = { export type ReceptionFormWeight = {

View File

@@ -8,8 +8,11 @@ export default <Partial<Config>>{
}, },
colors: { colors: {
primary: { primary: {
700: '#35453C',
500: '#456452', 500: '#456452',
} }
} }
} }
} }

View File

@@ -220,9 +220,15 @@
<tr class="border-bottom"> <tr class="border-bottom">
<td> <td>
{% if reception.receptionType and reception.receptionType.code == 'BOVINS' %}
<strong> <strong>
Type de bovins Type de bovins
</strong> </strong>
{% else %}
<strong>
Type de marchandises
</strong>
{% endif %}
<br><br> <br><br>
<div class="bigtable-notes"> <div class="bigtable-notes">
@@ -263,6 +269,9 @@
<p>Aucun dépôt de granulés renseigné.</p> <p>Aucun dépôt de granulés renseigné.</p>
{% endfor %} {% endfor %}
{% else %} {% else %}
{% if reception.merchandiseType %}
<p><strong>{{ reception.merchandiseType.label }}</strong></p>
{% endif %}
{% set buildingLabels = [] %} {% set buildingLabels = [] %}
{% for building in reception.buildings|default([]) %} {% for building in reception.buildings|default([]) %}
{% set buildingLabels = buildingLabels|merge([building.label]) %} {% set buildingLabels = buildingLabels|merge([building.label]) %}