feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception

This commit is contained in:
2026-01-29 16:33:37 +01:00
parent cff80b5ab2
commit 07be7e8d14
26 changed files with 1141 additions and 107 deletions

View File

@@ -0,0 +1,232 @@
<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-8 items-center w-full">
<h1 class="text-4xl uppercase font-bold">Sélectionner des marchandises réceptionnnées</h1>
<div class="flex flex-col w-[550px]">
<label for="merchandise-type" class="font-bold uppercase text-xl mb-2">Type de marchandises</label>
<select
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
class="border-b border-black justify-self-start text-xl pb-[6px] bg-transparent cursor-pointer"
:class="selectedMerchandiseTypeId ? 'text-black' : 'text-neutral-400'"
>
<option value="" disabled class="text-neutral-400">Sélectionner</option>
<option
v-for="type in merchandiseTypes"
:key="type.id"
:value="String(type.id)"
class="text-black"
>
{{ type.label }}
</option>
</select>
</div>
<div
v-if="selectedMerchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
>
<label for="merchandise-detail" class="font-bold uppercase text-xl mb-2">Préciser</label>
<input
id="merchandise-detail"
v-model="merchandiseDetail"
placeholder="Précisions complémentaires"
type="text"
maxlength="255"
class="border-b border-black text-xl pb-[6px] bg-transparent"
>
</div>
<div
v-if="selectedMerchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly"
>
<div
v-for="building in buildings"
:key="building.id"
>
<label
class="flex items-center gap-2 text-xl"
>
<input
v-model="selectedBuildingIds"
type="checkbox"
:value="String(building.id)"
>
<span>{{ building.label }}</span>
</label>
</div>
</div>
<div
v-if="selectedMerchandiseTypeId && isGranule"
class="flex flex-col gap-10 w-full max-w-[900px]"
>
<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>
<label
v-for="building in buildings"
:key="building.id"
class="flex items-center gap-2 text-lg"
>
<input
v-model="selectedPelletBuildingIds[String(type.id)]"
type="checkbox"
:value="String(building.id)"
>
<span>{{ building.label }}</span>
</label>
</div>
</div>
</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 { 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'
const receptionStore = useReceptionStore()
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
const buildings = ref<BuildingData[]>([])
const pelletTypes = ref<PelletTypeData[]>([])
const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('')
const selectedMerchandiseType = computed(() =>
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value)
)
const isGranule = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE)
const isAutres = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES)
onMounted(async () => {
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
getMerchandiseTypeList(),
getBuildingList(),
getPelletTypeList()
])
merchandiseTypes.value = merchandiseTypeList
buildings.value = buildingList
pelletTypes.value = pelletTypeList
const currentId = receptionStore.current?.merchandiseType?.id
if (currentId) {
selectedMerchandiseTypeId.value = String(currentId)
}
merchandiseDetail.value = receptionStore.current?.merchandiseDetail ?? ''
selectedBuildingIds.value =
receptionStore.current?.buildings?.map((building) => String(building.id)) ?? []
const existingPelletSelections = receptionStore.current?.pelletBuildings ?? []
const selectionMap: Record<string, string[]> = {}
for (const selection of existingPelletSelections) {
const pelletTypeId = String(selection.pelletType.id)
const buildingId = String(selection.building.id)
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
})
async function goNext() {
if (!receptionStore.current) {
return
}
const nextStep = receptionStore.current.currentStep + 1
const receptionIri = `/api/receptions/${receptionStore.current.id}`
await receptionStore.updateReception(receptionStore.current.id, {
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}`),
currentStep: nextStep
})
if (isGranule.value) {
await syncPelletSelections(receptionIri)
} else {
await clearPelletSelections(receptionIri)
}
}
async function clearPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
for (const selection of existing) {
await deleteReceptionPelletBuilding(selection.id)
}
}
async function syncPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
const existingMap = new Map<string, number>()
for (const selection of existing) {
const key = `${selection.pelletType.id}:${selection.building.id}`
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>

View File

@@ -1,30 +0,0 @@
<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-4xl 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"
>Peser</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

@@ -6,21 +6,26 @@ export const usePdfPrinter = () => {
const currentReception = receptionStore.current
const printPdf = async (url: string): Promise<void> => {
if (!import.meta.client) {
return
}
const blob = await api.getBlob(url);
const pdfBlob = blob.type === 'application/pdf'
? blob
: new Blob([blob], { type: 'application/pdf' });
const blobUrl = URL.createObjectURL(pdfBlob);
const filename = `${currentReception.identificationNumber}_${currentReception.supplier.name}_${currentReception.licensePlate}.pdf`;
const blob = await api.getBlob(url)
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a');
a.href = url;
// nom du fichier à changer par les infos du store pinia
a.download = `${currentReception.identificationNumber}_${currentReception.supplier.name}_${currentReception.licensePlate}`;
a.href = blobUrl;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
a.remove();
window.open(blobUrl, '_blank', 'noopener,noreferrer')
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000)
window.open(blobUrl, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
return {

View File

@@ -17,6 +17,20 @@
"receptionType": {
"list": "Impossible de récupérer la liste des types de réception."
},
"merchandiseType": {
"list": "Impossible de récupérer la liste des types de marchandises."
},
"building": {
"list": "Impossible de récupérer la liste des bâtiments."
},
"pelletType": {
"list": "Impossible de récupérer la liste des types de granulés."
},
"receptionPelletBuilding": {
"list": "Impossible de récupérer la liste des dépôts de granulés.",
"create": "Impossible d'enregistrer le dépôt de granulés.",
"delete": "Impossible de supprimer le dépôt de granulés."
},
"supplier": {
"list": "Impossible de récupérer la liste des fournisseurs."
},

View File

@@ -16,7 +16,7 @@
</div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
<ReceptionUnloading v-if="storeReception?.currentStep === 2"/>
<ReceptionProductReceived v-if="storeReception?.currentStep === 2"/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
</div>
</template>

View File

@@ -0,0 +1,23 @@
import { useApi } from '~/composables/useApi'
import type { BuildingData } from '~/services/dto/building-data'
export type BuildingListResponse =
| BuildingData[]
| { 'hydra:member'?: BuildingData[] }
export async function getBuildingList(): Promise<BuildingData[]> {
const api = useApi()
const response = await api.get<BuildingListResponse>('buildings', {}, {
toastErrorKey: 'errors.building.list'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

@@ -0,0 +1,5 @@
export interface BuildingData {
id: number
label: string
code: string
}

View File

@@ -0,0 +1,5 @@
export interface MerchandiseTypeData {
id: number
label: string
code: string
}

View File

@@ -0,0 +1,5 @@
export interface PelletTypeData {
id: number
label: string
code: string
}

View File

@@ -1,4 +1,7 @@
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
import type { BuildingData } from '~/services/dto/building-data'
import type { ReceptionPelletBuildingData } from '~/services/dto/reception-pellet-building-data'
import type { UserData } from '~/services/dto/user-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import type { AddressData } from '~/services/dto/address-data'
@@ -15,6 +18,10 @@ export interface ReceptionData {
currentStep: number
isValid: boolean
receptionType?: ReceptionTypeData | null
merchandiseType?: MerchandiseTypeData | null
merchandiseDetail?: string | null
buildings?: BuildingData[] | null
pelletBuildings?: ReceptionPelletBuildingData[] | null
user?: UserData | null
supplier?: SupplierData | null
address?: AddressData | null
@@ -37,6 +44,9 @@ export type ReceptionPayload = {
currentStep?: number
isValid?: boolean
receptionType?: string | null
merchandiseType?: string | null
merchandiseDetail?: string | null
buildings?: string[] | null
user?: string | null
supplier?: string | null
address?: string | null

View File

@@ -0,0 +1,9 @@
import type { BuildingData } from '~/services/dto/building-data'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
export interface ReceptionPelletBuildingData {
id: number
reception?: string
building: BuildingData
pelletType: PelletTypeData
}

View File

@@ -0,0 +1,23 @@
import { useApi } from '~/composables/useApi'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
export type MerchandiseTypeListResponse =
| MerchandiseTypeData[]
| { 'hydra:member'?: MerchandiseTypeData[] }
export async function getMerchandiseTypeList(): Promise<MerchandiseTypeData[]> {
const api = useApi()
const response = await api.get<MerchandiseTypeListResponse>('merchandise_types', {}, {
toastErrorKey: 'errors.merchandiseType.list'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

@@ -0,0 +1,23 @@
import { useApi } from '~/composables/useApi'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
export type PelletTypeListResponse =
| PelletTypeData[]
| { 'hydra:member'?: PelletTypeData[] }
export async function getPelletTypeList(): Promise<PelletTypeData[]> {
const api = useApi()
const response = await api.get<PelletTypeListResponse>('pellet_types', {}, {
toastErrorKey: 'errors.pelletType.list'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

@@ -0,0 +1,51 @@
import { useApi } from '~/composables/useApi'
import type { ReceptionPelletBuildingData } from '~/services/dto/reception-pellet-building-data'
export type ReceptionPelletBuildingListResponse =
| ReceptionPelletBuildingData[]
| { 'hydra:member'?: ReceptionPelletBuildingData[] }
export type ReceptionPelletBuildingPayload = {
reception: string
pelletType: string
building: string
}
export async function getReceptionPelletBuildingList(
receptionIri: string
): Promise<ReceptionPelletBuildingData[]> {
const api = useApi()
const response = await api.get<ReceptionPelletBuildingListResponse>(
'reception_pellet_buildings',
{ reception: receptionIri },
{
toastErrorKey: 'errors.receptionPelletBuilding.list'
}
)
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}
export async function createReceptionPelletBuilding(
payload: ReceptionPelletBuildingPayload
): Promise<ReceptionPelletBuildingData> {
const api = useApi()
return api.post<ReceptionPelletBuildingData>('reception_pellet_buildings', payload, {
toastErrorKey: 'errors.receptionPelletBuilding.create'
})
}
export async function deleteReceptionPelletBuilding(id: number): Promise<void> {
const api = useApi()
await api.delete<void>(`reception_pellet_buildings/${id}`, {}, {
toastErrorKey: 'errors.receptionPelletBuilding.delete'
})
}

View File

@@ -0,0 +1,8 @@
export const RECEPTION_TYPE_CODES = {
MERCHANDISES: 'MARCHANDISES'
} as const
export const MERCHANDISE_TYPE_CODES = {
GRANULE: 'GRANULE',
AUTRES: 'AUTRES'
} as const