Compare commits

..

8 Commits

Author SHA1 Message Date
gitea-actions
2aafa2082a chore: bump version to v0.0.62
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-02-26 14:01:46 +00:00
2b64f024b6 [#327] Afficher modifier une expédition terminée (!34)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #327           |       Afficher modifier une expédition 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é

Reviewed-on: #34
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 14:01:39 +00:00
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
17 changed files with 911 additions and 77 deletions

View File

@@ -53,7 +53,9 @@ Ajouter dans le fichier .env du frontend
* [#278] Plan du site
* [#334] Correctifs
* [#332] Refonte écran réception terminée
* [#327] afficher/modifier écran expédition terminée
### Changed
### Fixed

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.58'
app.version: '0.0.62'

View File

@@ -12,7 +12,7 @@
type="submit"
:disabled="isLoading"
>
<Icon :name="props.address ? 'mdi:check' : 'mdi:plus'" size="28" />
<Icon :name="props.address ? '' : 'mdi:plus'" size="28" />
{{ props.address? "Valider" : "Ajouter" }}
</button>
</div>
@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import { AddressPayload } from "~/services/address"
import type { AddressPayload } from "~/services/address"
const route = useRoute()

View File

@@ -13,7 +13,7 @@
/>
<UiDateInput
label="Date pesée"
label="Date de pesée"
v-model="localWeight.weighedAt"
:disabled="!isAdmin"
/>
@@ -31,7 +31,7 @@
</template>
<script setup lang="ts">
import type {WeightEntryData} from '~/services/dto/reception-data'
import type {WeightEntryData} from '~/services/dto/weight-data'
import {reactive, watch} from "vue";
const props = defineProps<{

View File

@@ -48,8 +48,38 @@ const emit = defineEmits<{
const bovineTypes = ref<BovineTypeData[]>([])
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)
@@ -80,20 +110,33 @@ function syncLocalFromProps() {
watch(
() => props.otherQuantity,
(value) => {
localOtherQuantity.value = value ?? 0
if (isSyncing.value) {
return
}
const next = value ?? 0
isSyncing.value = true
localOtherQuantity.value = next
isSyncing.value = false
}
)
watch(localOtherQuantity, (value) => {
emit('update:otherQuantity', value ?? 0)
if (isSyncing.value) {
return
}
const next = value ?? 0
emit('update:otherQuantity', next)
})
watch(
() => props.modelValue,
() => {
// Hydratation locale uniquement quand le parent change.
syncLocalFromProps()
},
{ deep: true }
{ immediate: true }
)
watch(
@@ -102,7 +145,11 @@ watch(
if (isSyncing.value) {
return
}
emit('update:modelValue', buildEntriesFromLocal())
// 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 }
)
@@ -110,6 +157,5 @@ watch(
onMounted(async () => {
bovineTypes.value = await getBovineTypeList()
syncLocalFromProps()
emit('update:modelValue', buildEntriesFromLocal())
})
</script>

View File

@@ -109,7 +109,8 @@ const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('')
const isHydrating = ref(false)
// Verrou de synchro pour empêcher les aller-retours infinis entre parent et composant.
const isSyncing = ref(false)
const isReady = ref(false)
const selectedMerchandiseType = computed(() =>
@@ -130,6 +131,39 @@ function clonePelletSelections(value: Record<string, string[]>) {
return clone
}
function sorted(values: string[]): string[] {
return [...values].sort()
}
function normalizeModel(value: MerchandiseEntryData): MerchandiseEntryData {
// Normalisation stable pour comparer deux modèles sans faux positifs (ordre des tableaux).
const pellet: Record<string, string[]> = {}
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)
@@ -140,7 +174,7 @@ function ensurePelletKeys() {
}
function hydrateFromModelValue(value: MerchandiseEntryData) {
isHydrating.value = true
isSyncing.value = true
try {
selectedMerchandiseTypeId.value = value.merchandiseTypeId ?? ''
merchandiseDetail.value = value.merchandiseDetail ?? ''
@@ -150,51 +184,71 @@ function hydrateFromModelValue(value: MerchandiseEntryData) {
)
ensurePelletKeys()
} finally {
isHydrating.value = false
isSyncing.value = false
}
}
function emitModelValue() {
emit('update:modelValue', {
merchandiseTypeId: selectedMerchandiseTypeId.value,
merchandiseDetail: merchandiseDetail.value,
selectedBuildingIds: [...selectedBuildingIds.value],
selectedPelletBuildingIds: clonePelletSelections(selectedPelletBuildingIds.value)
})
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)
},
{ deep: true }
{ immediate: true }
)
watch(
[selectedMerchandiseTypeId, selectedBuildingIds, selectedPelletBuildingIds, merchandiseDetail],
() => {
if (isHydrating.value || !isReady.value) {
if (isSyncing.value || !isReady.value) {
return
}
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] = []
}
}
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
}
if (!isAutres.value && merchandiseDetail.value !== '') {
merchandiseDetail.value = ''
}
emitModelValue()
emitCurrentModel()
},
{ deep: true }
)
@@ -211,6 +265,5 @@ onMounted(async () => {
hydrateFromModelValue(props.modelValue)
isReady.value = true
emitModelValue()
})
</script>

View File

@@ -1,6 +1,6 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
<!-- Nom de l'utilisateur -->
<UiSelect
@@ -23,11 +23,14 @@
/>
<!-- Type d'expédition -->
<div class="col-start-1 row-start-4 h-[64px]">
<div class="flex items-end gap-8 justify-between">
<div class="flex w-full items-end gap-[104px]">
<UiRadioGroup
id="shipment-type"
name="shipment-type"
label="Type d'expédition bovine"
input-class="accent-primary-700 focus:ring-primary-700"
wrapper-class=""
group-class="flex flex-row gap-[104px] w-[160px_160px] h-[32px]"
v-model="selectedShipmentTypeId"
:options="bovineShipment.map((type) => ({
value: String(type.id),
@@ -36,7 +39,6 @@
/>
<UiNumberInput
id="shipment-type-quantity"
label="Quantité"
v-model="shipmentQuantity"
:placeholder="0"
:min="0"
@@ -127,7 +129,7 @@
<div class="flex justify-center">
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Valider
</UiButton>
</div>
@@ -447,7 +449,7 @@ watch(
}
)
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => [form.licensePlate, form.carrierId, form.vehicleId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return

View File

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

View File

@@ -10,7 +10,7 @@
:disabled="isLoading || isHydrating"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
>
<Icon :name="isEdit ? 'mdi:check' : 'mdi:plus'" size="28" />
<Icon :name="isEdit ? '' : 'mdi:plus'" size="28" />
{{ isEdit ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>

View File

@@ -10,7 +10,6 @@
type="submit"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2 justify-self-end"
>
<Icon name="mdi:check" size="28" />
Valider
</UiButton>
</div>

View File

@@ -10,7 +10,7 @@
type="submit"
:disabled="isLoading || !auth.isAdmin"
>
<Icon :name="customerId ? 'mdi:check' : 'mdi:plus'" size="28" />
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" />
{{ customerId ? "Valider" : "Ajouter" }}
</UiButton>
</div>

View File

@@ -10,7 +10,7 @@
type="submit"
:disabled="isLoading || !auth.isAdmin"
>
<Icon :name="supplierId ? 'mdi:check' : 'mdi:plus'" size="28" />
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" />
{{ supplierId ? "Valider" : "Ajouter" }}
</UiButton>
</div>

View File

@@ -9,7 +9,7 @@
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
type="submit"
>
<Icon :name="userId ? 'mdi:check' : 'mdi:plus'" size="28" />
<Icon :name="userId ? '' : 'mdi:plus'" size="28" />
{{ userId ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>

View File

@@ -172,13 +172,13 @@
/>
<update-merchandise
v-show="activeTab === 'merchandise' && isMerchandise"
v-if="activeTab === 'merchandise' && isMerchandise"
v-model="merchandiseForm"
:isAdmin="auth.isAdmin"
/>
<update-bovin
v-show="activeTab === 'merchandise' && !isMerchandise"
v-if="activeTab === 'merchandise' && !isMerchandise"
v-model="bovineEntries"
v-model:otherQuantity="bovineOtherQuantity"
:isAdmin="auth.isAdmin"
@@ -190,7 +190,6 @@
type="submit"
class="inline-flex mb-16 items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2 justify-self-end"
>
<Icon name="mdi:check" size="28" />
Valider
</UiButton>
</div>
@@ -203,7 +202,7 @@ import { usePdfPrinter } from '#imports'
import { computed } from 'vue'
import UpdateBovin from '~/components/reception/update-bovin.vue'
import UpdateMerchandise from '~/components/reception/update-merchandise.vue'
import UpdateWeight from '~/components/reception/update-weight.vue'
import UpdateWeight from '~/components/commun/update-weight.vue'
import { getUsers } from '~/services/auth'
import { getCarrierList } from '~/services/carrier'
import type { CarrierData } from '~/services/dto/carrier-data'
@@ -213,8 +212,8 @@ import type {
MerchandiseEntryData,
ReceptionData,
ReceptionFormData,
WeightEntryData
} from '~/services/dto/reception-data'
import type { WeightEntryData } from '~/services/dto/weight-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import type { TruckData } from '~/services/dto/truck-data'
@@ -279,9 +278,25 @@ const isLoadingVehicles = ref(false)
const formIsLoading = ref(false)
const isMerchandise = ref(false)
const isHydrating = ref(false)
const vehicleSyncLock = ref(false)
const idReception = Number(route.params.id)
function runWithVehicleSyncLock(mutator: () => void) {
if (vehicleSyncLock.value) {
return
}
vehicleSyncLock.value = true
try {
mutator()
} finally {
queueMicrotask(() => {
vehicleSyncLock.value = false
})
}
}
const form = reactive<ReceptionFormData>({
identificationNumber: null,
licensePlate: '',
@@ -327,7 +342,7 @@ const filteredVehicles = computed<VehicleData[]>(() => {
watch(
() => idReception,
async (id) => {
if (id === null) {
if (!Number.isFinite(id) || id <= 0) {
return
}
isLoading.value = true
@@ -500,7 +515,7 @@ async function loadTypes() {
try {
receptionTypes.value = await getReceptionTypeList()
} finally {
isLoadingSuppliers.value = false
isLoadingTypes.value = false
}
}
@@ -581,8 +596,16 @@ async function saveWeightEntry(entry: WeightEntryData) {
return
}
// Fallback: if id is missing in local state, reuse existing weight by type.
const reception = await getReception(idReception)
const existingEntry = reception?.weights?.find((weight) => weight.type === entry.type) ?? null
if (existingEntry?.id) {
await updateWeight(existingEntry.id, payload)
return
}
await createWeight({
reception: `api/receptions/${idReception}`,
reception: `/api/receptions/${idReception}`,
...payload
})
}
@@ -785,8 +808,11 @@ async function validate() {
})
}
const refreshedReception = await getReception(idReception)
hydrateFromReception(refreshedReception)
// Évite une réhydratation complète après save (source de cascades de watchers).
// On recharge uniquement les bovins quand on est en mode bovins.
if (!isMerchandise.value) {
await loadBovineEntries(idReception)
}
return
}
@@ -809,12 +835,20 @@ onMounted(async () => {
watch(
() => [form.supplierId, form.addressId, suppliers.value],
() => {
if (isHydrating.value) {
return
}
if (!form.supplierId) {
form.addressId = ''
if (form.addressId !== '') {
form.addressId = ''
}
return
}
if (!form.addressId && supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
const nextAddressId = String(supplierAddresses.value[0].id)
if (form.addressId !== nextAddressId) {
form.addressId = nextAddressId
}
return
}
if (!form.addressId) {
@@ -825,9 +859,14 @@ watch(
)
if (!matches) {
if (supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
const nextAddressId = String(supplierAddresses.value[0].id)
if (form.addressId !== nextAddressId) {
form.addressId = nextAddressId
}
} else {
form.addressId = ''
if (form.addressId !== '') {
form.addressId = ''
}
}
}
},
@@ -837,24 +876,36 @@ watch(
watch(
() => form.carrierId,
() => {
if (isHydrating.value) {
if (isHydrating.value || vehicleSyncLock.value) {
return
}
if (!form.carrierId && idReception == null) {
form.driverId = ''
form.vehicleId = ''
runWithVehicleSyncLock(() => {
form.driverId = ''
form.vehicleId = ''
})
return
}
if (!isLiotCarrier.value && idReception == null) {
form.driverId = ''
form.vehicleId = ''
runWithVehicleSyncLock(() => {
form.driverId = ''
form.vehicleId = ''
})
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
const nextDriverId = String(filteredDrivers.value[0].id)
if (form.driverId !== nextDriverId) {
form.driverId = nextDriverId
}
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
const nextVehicleId = String(filteredVehicles.value[0].id)
if (form.vehicleId !== nextVehicleId) {
runWithVehicleSyncLock(() => {
form.vehicleId = nextVehicleId
})
}
}
},
{ immediate: true }
@@ -863,11 +914,19 @@ watch(
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (isHydrating.value || vehicleSyncLock.value) {
return
}
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
const nextVehicleId = String(filteredVehicles.value[0].id)
if (form.vehicleId !== nextVehicleId) {
runWithVehicleSyncLock(() => {
form.vehicleId = nextVehicleId
})
}
return
}
if (!form.vehicleId) {
@@ -877,7 +936,11 @@ watch(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
if (form.vehicleId !== '') {
runWithVehicleSyncLock(() => {
form.vehicleId = ''
})
}
}
},
{ immediate: true }
@@ -886,6 +949,9 @@ watch(
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (vehicleSyncLock.value) {
return
}
if (!isLiotCarrier.value) {
return
}
@@ -895,9 +961,11 @@ watch(
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
if (selected && form.licensePlate !== selected.plate) {
runWithVehicleSyncLock(() => {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
})
}
}
)
@@ -905,6 +973,12 @@ watch(
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => {
if (vehicleSyncLock.value) {
return
}
if (isHydrating.value) {
return
}
if (!isLiotCarrier.value || form.vehicleId) {
return
}
@@ -912,7 +986,12 @@ watch(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
const nextVehicleId = String(match.id)
if (form.vehicleId !== nextVehicleId) {
runWithVehicleSyncLock(() => {
form.vehicleId = nextVehicleId
})
}
}
}
)

View File

@@ -0,0 +1,639 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<div class="flex items-center justify-between gap-10 relative col-start-1 row-start-1">
<div class="flex flex-row absolute -left-[60px] justify-between">
<Icon @click="router.push('/shipment/finish-shipment')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Expédition {{ form.identificationNumber }}</h1>
<Icon @click="printReceipt" name="mdi:printer-outline" size="44" class="cursor-pointer text-primary-500"/>
</div>
<UiSelect
id="shipment-user"
v-model="form.userId"
label="Nom de l'utilisateur"
:options="users.map((user) => ({
value: String(user.id),
label: user.username
}))"
:loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2"
/>
<UiDateInput
id="shipment-date"
v-model="form.shipmentDate"
label="Date d'expédition"
wrapper-class="col-start-1 row-start-3"
/>
<div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]">
<UiRadioGroup
id="shipment-type"
name="shipment-type"
label="Type d'expédition bovine"
input-class="accent-primary-700 focus:ring-primary-700"
group-class="flex flex-row gap-[104px] w-[160px_160px] h-[32px]"
v-model="selectedShipmentTypeId"
:options="bovineShipment.map((type) => ({
value: String(type.id),
label: type.label
}))"
/>
<UiNumberInput
id="shipment-type-quantity"
v-model="shipmentQuantity"
:placeholder="0"
:min="0"
:max="1200"
:disabled="!selectedShipmentTypeId"
/>
</div>
</div>
<UiSelect
id="shipment-customer"
v-model="form.customerId"
label="Client"
:options="customers.map((customer) => ({
value: String(customer.id),
label: customer.name || `Client #${customer.id}`
}))"
:loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5"
/>
<UiSelect
id="shipment-address"
v-model="form.addressId"
:options="customerAddressOptions"
:disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse"
wrapper-class="col-start-2 row-start-1"
/>
<UiSelect
id="shipment-truck"
v-model="form.truckId"
label="Camion"
:options="trucks.map((truck) => ({
value: String(truck.id),
label: truck.name
}))"
:loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2"
/>
<UiSelect
id="shipment-carrier"
v-model="form.carrierId"
label="Transporteur"
:options="carriers.map((carrier) => ({
value: String(carrier.id),
label: carrier.name
}))"
wrapper-class="col-start-2 row-start-3"
/>
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
/>
</div>
<UiSelect
v-if="isLiotCarrier"
id="shipment-vehicle"
v-model="form.vehicleId"
label="Immatriculation"
:options="filteredVehicles.map((vehicle) => ({
value: String(vehicle.id),
label: vehicle.plate
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4"
/>
<div class="col-start-2 row-start-5 min-h-[72px]">
<UiSelect
v-if="isLiotCarrier"
id="shipment-driver"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
/>
</div>
</div>
<div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
</div>
<div class="mb-12">
<update-weight
v-show="activeTab === 'weights'"
v-model="grossWeight"
v-if="grossWeight"
:isAdmin="authStore.isAdmin"
/>
<update-weight
v-show="activeTab === 'weightsEmpty'"
v-model="tareWeight"
v-if="tareWeight"
:isAdmin="authStore.isAdmin"
/>
</div>
</div>
<div class="flex justify-center">
<UiButton
type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { usePdfPrinter } from '#imports'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import UpdateWeight from '~/components/commun/update-weight.vue'
import { getUsers } from '~/services/auth'
import { getCarrierList } from '~/services/carrier'
import { getCustomerList } from '~/services/customer'
import type { AddressData } from '~/services/dto/address-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { CustomerData } from '~/services/dto/customer-data'
import type { DriverData } from '~/services/dto/driver-data'
import type { ShipmentData, ShipmentFormData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { UserData } from '~/services/dto/user-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
import type { WeightEntryData } from '~/services/dto/weight-data'
import { getDriverList } from '~/services/driver'
import { getShipment, updateShipment } from '~/services/shipment'
import { getShipmentTypeList } from '~/services/shipment-type'
import { getTruckList } from '~/services/truck'
import { getVehicleList } from '~/services/vehicle'
import { createWeight, updateWeight } from '~/services/weight'
import { useAuthStore } from '~/stores/auth'
import { SUPPLIER_CODE } from '~/utils/constants'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { printPdf } = usePdfPrinter()
const users = ref<UserData[]>([])
const customers = ref<CustomerData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const bovineShipment = ref<ShipmentTypeData[]>([])
const currentShipment = ref<ShipmentData | null>(null)
const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0)
const allowAnyLicensePlate = ref(false)
const activeTab = ref<'weightsEmpty' | 'weights'>('weights')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const formIsLoading = ref(false)
const isLoadingUsers = ref(false)
const isLoadingShipmentTypes = ref(false)
const isLoadingCustomers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const isLoadingVehicles = ref(false)
const isLoadingDrivers = ref(false)
const isHydrating = ref(false)
const form = reactive<ShipmentFormData & { identificationNumber: string | null }>({
identificationNumber: null,
userId: '',
shipmentDate: new Date().toISOString().slice(0, 10),
customerId: '',
addressId: '',
truckId: '',
carrierId: '',
driverId: '',
vehicleId: '',
licensePlate: ''
})
const shipmentId = computed(() => {
const id = Number(route.params.id)
return Number.isFinite(id) ? id : null
})
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const isAddressData = (value: unknown): value is AddressData =>
typeof value === 'object' &&
value !== null &&
'id' in value &&
'fullAddress' in value
const customerAddresses = computed<AddressData[]>(() => {
if (!form.customerId) return []
const customerId = Number(form.customerId)
if (!Number.isFinite(customerId) || customerId <= 0) return []
const addresses = customers.value.find((c) => c.id === customerId)?.addresses ?? []
return addresses.filter(isAddressData)
})
const customerAddressOptions = computed(() =>
customerAddresses.value.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadShipmentType = async () => {
isLoadingShipmentTypes.value = true
try {
bovineShipment.value = await getShipmentTypeList()
} finally {
isLoadingShipmentTypes.value = false
}
}
const loadCustomers = async () => {
isLoadingCustomers.value = true
try {
customers.value = await getCustomerList()
} finally {
isLoadingCustomers.value = false
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
function setDefaultUser() {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
function hydrateFromShipment(shipment: ShipmentData | null) {
if (!shipment) {
return
}
isHydrating.value = true
form.identificationNumber = shipment.identificationNumber ?? null
form.licensePlate = shipment.licensePlate ?? ''
form.shipmentDate = shipment.shipmentDate ?? new Date().toISOString().slice(0, 10)
form.userId = shipment.user?.id ? String(shipment.user.id) : form.userId
form.customerId = shipment.customer?.id ? String(shipment.customer.id) : ''
form.addressId = shipment.address?.id ? String(shipment.address.id) : ''
form.truckId = shipment.truck?.id ? String(shipment.truck.id) : ''
form.carrierId = shipment.carrier?.id ? String(shipment.carrier.id) : ''
form.driverId = shipment.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment.vehicle?.id ? String(shipment.vehicle.id) : ''
selectedShipmentTypeId.value = shipment.shipmentType?.id ? String(shipment.shipmentType.id) : ''
shipmentQuantity.value = shipment.nbBovinSend ?? 0
const gross = shipment.weights?.find((weight) => weight.type === 'gross') ?? null
const tare = shipment.weights?.find((weight) => weight.type === 'tare') ?? null
grossWeight.value = gross ? { ...gross } : createEmptyWeightEntry('gross')
tareWeight.value = tare ? { ...tare } : createEmptyWeightEntry('tare')
isHydrating.value = false
}
async function printReceipt() {
if (!import.meta.client || shipmentId.value === null || shipmentId.value <= 0) {
return
}
const customerName =
customers.value.find((customer) => String(customer.id) === form.customerId)?.name ??
'client'
const filename = `${form.identificationNumber || shipmentId.value}_${customerName}_${form.licensePlate || 'immat'}.pdf`
await printPdf(`/shipments/${shipmentId.value}/receipt`, filename)
await new Promise((resolve) => setTimeout(resolve, 600))
}
async function loadShipmentForUpdate() {
if (shipmentId.value === null) {
return
}
const shipment = await getShipment(shipmentId.value)
currentShipment.value = shipment
hydrateFromShipment(shipment)
}
watch(
() => [form.customerId, form.addressId, customers.value],
() => {
if (!form.customerId) {
form.addressId = ''
return
}
if (!form.addressId && customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
return
}
if (!form.addressId) {
return
}
const matches = customerAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{ immediate: true }
)
function applyLiotDefaults() {
if (isHydrating.value) {
return
}
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
}
watch(
() => form.carrierId,
() => {
applyLiotDefaults()
},
{ immediate: true }
)
watch(
() => isHydrating.value,
(value) => {
if (!value) {
applyLiotDefaults()
}
}
)
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{ immediate: true }
)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licensePlate, form.carrierId, form.vehicleId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
function buildPayload() {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedShipmentDate = form.shipmentDate.trim()
const normalizedCustomerId = form.customerId.trim()
const normalizedTruckId = form.truckId.trim()
const normalizedCarrierId = form.carrierId.trim()
const normalizedDriverId = form.driverId.trim()
const normalizedUserId = form.userId.trim()
const normalizedAddressId = form.addressId.trim()
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
const customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
const shipmentTypeIri = normalizedShipmentTypeId
? `/api/shipment_types/${normalizedShipmentTypeId}`
: null
const rawQuantity = Number(shipmentQuantity.value ?? 0)
const normalizedQuantity = Number.isFinite(rawQuantity)
? Math.max(0, Math.trunc(rawQuantity))
: 0
return {
licensePlate: normalizedLicensePlate,
shipmentDate: normalizedShipmentDate,
customer: customerIri,
truck: truckIri,
carrier: carrierIri,
driver: driverIri,
user: userIri,
address: addressIri,
shipmentType: shipmentTypeIri,
nbBovinSend: normalizedQuantity
}
}
function createEmptyWeightEntry(type: 'gross' | 'tare'): WeightEntryData {
return {
type,
dsd: null,
weight: null,
weighedAt: null
}
}
async function saveWeightEntry(entry: WeightEntryData) {
if (!shipmentId.value || entry.weight === null) {
return
}
const payload = {
type: entry.type,
dsd: entry.dsd ?? null,
weight: entry.weight,
weighedAt: entry.weighedAt ?? null
}
if (entry.id) {
await updateWeight(entry.id, payload)
return
}
await createWeight({
shipment: `api/shipments/${shipmentId.value}`,
...payload
})
}
async function validate() {
if (shipmentId.value === null) {
return
}
await updateShipment(shipmentId.value, {
currentStep: currentShipment.value?.currentStep ?? 0,
...buildPayload()
})
await saveWeightEntry(grossWeight.value)
await saveWeightEntry(tareWeight.value)
await loadShipmentForUpdate()
}
onMounted(async () => {
await loadShipmentType()
await loadUsers()
await loadCustomers()
await loadTrucks()
await loadCarriers()
await loadVehicles()
await loadDrivers()
await authStore.ensureSession()
formIsLoading.value = true
setDefaultUser()
await loadShipmentForUpdate()
})
</script>

View File

@@ -2,6 +2,9 @@ import type {CarrierData} from '~/services/dto/carrier-data'
import type {TruckData} from '~/services/dto/truck-data'
import type {CustomerData} from '~/services/dto/customer-data'
import type {AddressData} from "~/services/dto/address-data";
import type {UserData} from '~/services/dto/user-data'
import type {DriverData} from '~/services/dto/driver-data'
import type {VehicleData} from '~/services/dto/vehicle-data'
export interface ShipmentTypeData {
id: number
@@ -20,6 +23,9 @@ export type ShipmentData = {
carrier?: CarrierData | null
truck?: TruckData | null
customer?: CustomerData | null
user?: UserData | null
driver?: DriverData | null
vehicle?: VehicleData | null
shipmentType?: ShipmentTypeData | null
nbBovinSend?: number | null
weights?: WeightShipmentEntryData[] | null

View File

@@ -4,3 +4,11 @@ export interface WeightData {
weighedAt: string | null
type : string | null
}
export interface WeightEntryData {
id?: number
type: 'gross' | 'tare'
dsd: number | null
weight: number | null
weighedAt: string | null
}