Compare commits

..

4 Commits

36 changed files with 1053 additions and 1050 deletions

View File

@@ -49,6 +49,7 @@ Ajouter dans le fichier .env du frontend
* fix layout admin * fix layout admin
* Creation page admin listing bovins * Creation page admin listing bovins
* Creation page admin ajout/modification bovins * Creation page admin ajout/modification bovins
* [#332] Refonte écran réception terminée
### Changed ### Changed
### Fixed ### Fixed

View File

@@ -3,8 +3,6 @@ api_platform:
version: 1.0.0 version: 1.0.0
defaults: defaults:
stateless: true stateless: true
pagination_client_items_per_page: true
pagination_maximum_items_per_page: 100
cache_headers: cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin'] vary: ['Content-Type', 'Authorization', 'Origin']
formats: formats:

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content. // This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
namespace Symfony\Component\DependencyInjection\Loader\Configurator; namespace Symfony\Component\DependencyInjection\Loader\Configurator;
@@ -1470,7 +1472,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* mercure?: bool|array{ * mercure?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* hub_url?: scalar|Param|null, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null * hub_url?: scalar|Param|null, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null
* include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false * include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
* }, * },
* messenger?: bool|array{ * messenger?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false

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,13 +1,10 @@
<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 bovineType"
: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"
@@ -16,52 +13,46 @@
:placeholder="0" :placeholder="0"
:min="0" :min="0"
:max="10" :max="10"
wrapperClass="w-44 flex-col"
/> />
</div> </div>
<div <UiNumberInput
class=" flex flex-row mb-2 gap-6"> label="Autres"
<UiNumberInput v-model="otherQuantity"
label="Autres" :disabled="!auth.isAdmin"
v-model="otherQuantity" wrapperClass="w-44 flex-col"
:disabled="!auth.isAdmin" />
/>
</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 type {BovineTypeData} from "~/services/dto/bovine-type-data";
import {getBovineTypeList} from "~/services/bovine-type"; import {getBovineTypeList} from "~/services/bovine-type";
import { import {createReceptionBovine, deleteReceptionBovine, getReceptionBovineList, updateReceptionBovine} from "~/services/reception-bovine";
createReceptionBovine,
deleteReceptionBovine,
getReceptionBovineList,
updateReceptionBovine
} from "~/services/reception-bovine";
import {computed, onMounted, reactive, ref, watch} from "vue"; import {computed, onMounted, reactive, ref, watch} from "vue";
import {getReception, updateReception} from "~/services/reception"; import {getReception, updateReception} from "~/services/reception";
const toast = useToast() const toast = useToast()
const isLoadingBovineType = ref(false) const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([]) const bovineType = ref<BovineTypeData[]>([])
const bovineQuantities = reactive<Record<string, number | null>>({}) const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0) const otherQuantity = ref<number | null>(0)
const initialBovineQuantities = ref<Record<string, number | null>>({})
const initialOtherQuantity = ref<number | null>(0)
const auth = useAuthStore() const auth = useAuthStore()
const props = defineProps<{ const props = defineProps<{
idReception: number idReception: number
isValidate: boolean
}>() }>()
const receptionId = props.idReception const receptionId = props.idReception
const reception = await getReception(receptionId) const reception = await getReception(receptionId)
const receptionIri = computed(() => const receptionIri = computed(() =>
receptionId ? `/api/receptions/${receptionId}` : null receptionId ? `/api/receptions/${receptionId}` : null
) )
const totalBovines = computed(() => { const totalBovines = computed(() => {
const base = Object.values(bovineQuantities).reduce((sum, value) => { const base = Object.values(bovineQuantities).reduce((sum, value) => {
return sum + (value ?? 0) return sum + (value ?? 0)
@@ -69,6 +60,20 @@ const totalBovines = computed(() => {
return base + (otherQuantity.value ?? 0) return base + (otherQuantity.value ?? 0)
}) })
const hasBovineChanged = () => {
if ((initialOtherQuantity.value ?? 0) !== (otherQuantity.value ?? 0)) {
return true
}
for (const [key, value] of Object.entries(bovineQuantities)) {
if ((initialBovineQuantities.value[key] ?? 0) !== (value ?? 0)) {
return true
}
}
return false
}
const loadBovineType = async () => { const loadBovineType = async () => {
isLoadingBovineType.value = true isLoadingBovineType.value = true
try { try {
@@ -107,10 +112,12 @@ watch(
const existingOther = reception.bovineDetail const existingOther = reception.bovineDetail
const parsedOther = const parsedOther =
typeof existingOther === 'string' && existingOther.trim() !== '' typeof existingOther === 'string' && existingOther.trim() !== ''
? Number(existingOther) ? Number(existingOther)
: 0 : 0
otherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0 otherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0
initialBovineQuantities.value = {...selectionMap}
initialOtherQuantity.value = otherQuantity.value ?? 0
}, },
{immediate: true} {immediate: true}
) )
@@ -151,7 +158,6 @@ async function syncBovineSelections(receptionIri: string) {
continue continue
} }
if (existingMap.has(bovineTypeId)) { if (existingMap.has(bovineTypeId)) {
// Déjà à jour
continue continue
} }
await createReceptionBovine({ await createReceptionBovine({
@@ -162,7 +168,18 @@ async function syncBovineSelections(receptionIri: string) {
} }
} }
async function validate() { watch(
() => props.isValidate,
async (val) => {
if (!val) return
await runValidate()
}
)
const runValidate = async () => {
if (!hasBovineChanged()) {
return
}
const receptionIri = `/api/receptions/${reception.id}`
// @TODO Ajouter un composable pour le toaster qui gère les key i18n // @TODO Ajouter un composable pour le toaster qui gère les key i18n
if (totalBovines.value > 52) { if (totalBovines.value > 52) {
toast.error({ toast.error({
@@ -172,12 +189,16 @@ async function validate() {
return return
} }
await syncBovineSelections(receptionIri.value) await syncBovineSelections(receptionIri)
await updateReception(receptionId, { await updateReception(receptionId, {
merchandiseType: null, merchandiseType: null,
merchandiseDetail: null, merchandiseDetail: null,
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null, bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
}) })
initialBovineQuantities.value = {...bovineQuantities}
initialOtherQuantity.value = otherQuantity.value ?? 0
} }
</script> </script>

View File

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

View File

@@ -1,45 +1,32 @@
<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="weight.type"
:label="'POIDS'"
labelClass="font-bold uppercase text-xl "
v-model="weight.weight"
:disabled="!auth.isAdmin"
:min="0"
:max="48000"
wrapper-class="flex-col"
/>
<UiDateInput
label="Date pesée"
v-model="sharedWeightMeta.weighedAt"
:disabled="!auth.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="sharedWeightMeta.dsd"
:disabled="!auth.isAdmin" :disabled="!auth.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>
@@ -48,20 +35,27 @@ import type {ReceptionFormWeight} from '~/services/dto/reception-data'
import {getReception} from '~/services/reception' import {getReception} from '~/services/reception'
import {updateWeight} from "~/services/weight"; import {updateWeight} from "~/services/weight";
import {useAuthStore} from "~/stores/auth"; import {useAuthStore} from "~/stores/auth";
import {ref, watch} from "vue";
const props = defineProps<{ const props = defineProps<{
idReception: number idReception: number
weightType: string
isValidate: boolean
}>() }>()
const idReception = props.idReception
const auth = useAuthStore()
const form = reactive({ const form = reactive({
weights: [ weights: [
{id: 0, type: 'tare' as const, weight: 0, dsd: null, weighedAt: null}, {id: 0, type: 'tare' as const, weight: 0, dsd: null, weighedAt: null},
{id: 0, type: 'gross' as const, weight: 0, dsd: null, weighedAt: null} {id: 0, type: 'gross' as const, weight: 0, dsd: null, weighedAt: null}
] ]
}) })
const idReception = props.idReception
const weightType = props.weightType
const auth = useAuthStore()
const weight = form.weights.find(w => w.type === weightType)
const initialWeight = ref<{ weight: number | null; dsd: number | null; weighedAt: string | null } | null>(null)
// DSD et date de pesée sont partagés entre tare et gross dans l'UI. // DSD et date de pesée sont partagés entre tare et gross dans l'UI.
const sharedWeightMeta = reactive<{ const sharedWeightMeta = reactive<{
dsd: number | string | null dsd: number | string | null
@@ -71,8 +65,20 @@ const sharedWeightMeta = reactive<{
weighedAt: null weighedAt: null
}) })
const getWeightLabel = (type: 'tare' | 'gross'): string => { const normalizeMeta = () => {
return type === 'tare' ? 'Pesée à vide' : 'Pesée à plein' const sharedDsd =
sharedWeightMeta.dsd === null || sharedWeightMeta.dsd === undefined || sharedWeightMeta.dsd === ''
? null
: Number(sharedWeightMeta.dsd)
const sharedWeighedAt =
sharedWeightMeta.weighedAt === null || sharedWeightMeta.weighedAt === undefined || sharedWeightMeta.weighedAt === ''
? null
: sharedWeightMeta.weighedAt
return {
dsd: Number.isFinite(sharedDsd) ? sharedDsd : null,
weighedAt: sharedWeighedAt
}
} }
const hydrateFromReception = (reception: ReceptionFormWeight) => { const hydrateFromReception = (reception: ReceptionFormWeight) => {
@@ -94,6 +100,15 @@ const hydrateFromReception = (reception: ReceptionFormWeight) => {
sharedWeightMeta.dsd = weightWithMeta.dsd ?? null sharedWeightMeta.dsd = weightWithMeta.dsd ?? null
sharedWeightMeta.weighedAt = weightWithMeta.weighedAt ?? null sharedWeightMeta.weighedAt = weightWithMeta.weighedAt ?? null
} }
if (weight) {
const normalized = normalizeMeta()
initialWeight.value = {
weight: weight.weight ?? null,
dsd: normalized.dsd,
weighedAt: normalized.weighedAt
}
}
} }
onMounted(async () => { onMounted(async () => {
@@ -101,24 +116,48 @@ onMounted(async () => {
hydrateFromReception(reception) hydrateFromReception(reception)
}) })
async function validate() { watch(
const sharedDsd = () => props.isValidate,
sharedWeightMeta.dsd === null || sharedWeightMeta.dsd === undefined || sharedWeightMeta.dsd === '' async (val) => {
? null if (!val) return
: Number(sharedWeightMeta.dsd) await runValidate()
const sharedWeighedAt = }
sharedWeightMeta.weighedAt === null || sharedWeightMeta.weighedAt === undefined || sharedWeightMeta.weighedAt === '' )
? null
: sharedWeightMeta.weighedAt const hasChanged = (current: { weight: number | null; dsd: number | null; weighedAt: string | null }) => {
for (const weight of form.weights) { if (!initialWeight.value) {
if (weight.id) { return true
await updateWeight(weight.id, { }
weight: weight.weight, return (
dsd: Number.isFinite(sharedDsd) ? sharedDsd : null, (current.weight ?? null) !== (initialWeight.value.weight ?? null) ||
weighedAt: sharedWeighedAt (current.dsd ?? null) !== (initialWeight.value.dsd ?? null) ||
}) (current.weighedAt ?? null) !== (initialWeight.value.weighedAt ?? null)
} )
}
const runValidate = async () => {
if (!weight?.id) {
return
} }
const normalized = normalizeMeta()
const current = {
weight: weight.weight ?? null,
dsd: normalized.dsd,
weighedAt: normalized.weighedAt
}
if (!hasChanged(current)) {
return
}
await updateWeight(weight.id, {
weight: current.weight,
dsd: current.dsd,
weighedAt: current.weighedAt
})
initialWeight.value = current
} }
</script> </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

@@ -1,436 +0,0 @@
<template>
<div class="mt-6 mx-[6px]">
<table class="w-full border border-slate-300 table-fixed">
<thead class="bg-slate-100 capitalize tracking-wide">
<tr>
<th
v-for="column in normalizedColumns"
:key="column.key"
class="border border-slate-300 px-2 py-1"
>
<div class="flex flex-col gap-1">
<UiSelect
v-if="column.isSearchable && column.type === 'selectTypeReception'"
v-model="searchValues[column.key]"
:placeholder="column.label"
select-class="w-full !text-sm !py-1"
:options="[
{ value: '__all__', label: 'Tous' },
...receptionTypes.map((type) => ({
value: type.label,
label: type.label
}))
]"
/>
<UiSelect
v-else-if="column.isSearchable && column.type === 'selectTypeShipment'"
v-model="searchValues[column.key]"
:placeholder="column.label"
select-class="w-full !text-sm !py-1"
:options="[
{ value: '__all__', label: 'Tous' },
...shipmentTypes.map((type) => ({
value: type.label,
label: type.label
}))
]"
/>
<div v-else-if="column.isSearchable" class="relative">
<UiTextInput
v-model="searchValues[column.key]"
:placeholder="column.label"
input-class="min-w-full !text-sm !py-1 !pr-7"
/>
<Icon
name="gg:search"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-slate-400"
/>
</div>
<span v-else>{{ column.label }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td
class="border border-slate-300 px-2 py-2 whitespace-pre-line"
:colspan="normalizedColumns.length || 1"
>
Chargement...
</td>
</tr>
<tr v-else-if="displayedRows.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-left text-slate-500"
:colspan="normalizedColumns.length || 1"
>
Aucune donnée
</td>
</tr>
<template v-else>
<tr
v-for="(row, rowIndex) in displayedRows"
class="hover:bg-primary-500 hover:bg-opacity-15"
:key="rowIndex"
:class="props.rowClickable ? 'cursor-pointer' : ''"
@click="props.rowClickable ? onRowClick(row) : null"
>
<td
v-for="column in normalizedColumns"
:key="column.key"
class="border border-slate-300 px-2 py-2 whitespace-pre-line "
>
{{ formatColumnValue(row, column) }}
</td>
</tr>
</template>
</tbody>
</table>
<div class="flex items-center justify-between mt-4">
<p class="text-slate-600">
{{ pageLabel }}
</p>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded border border-slate-300 px-2 py-1 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="currentPage <= 1 || loading"
@click="currentPage = currentPage - 1"
>
Précédent
</button>
<button
v-for="(item, index) in paginationItems"
:key="`${item}-${index}`"
type="button"
class="min-w-9 rounded border px-2 py-1"
:class="item === currentPage
? 'border-primary-500 bg-primary-500 text-white'
: 'border-slate-300'"
:disabled="loading || item === '...'"
@click="typeof item === 'number' ? (currentPage = item) : null"
>
{{ item }}
</button>
<button
type="button"
class="rounded border border-slate-300 px-2 py-1 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="currentPage >= totalPages || loading"
@click="currentPage = currentPage + 1"
>
Suivant
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {Row, ColumnConfig, AnyCollection, PaginationItem} from '~/services/dto/datatable-data'
import {useApi} from '~/composables/useApi'
import type {ReceptionTypeData} from '~/services/dto/reception-type-data'
import {getReceptionTypeList} from '~/services/reception-type'
import type {ShipmentTypeData} from "~/services/dto/shipment-data";
import {getShipmentTypeList} from "~/services/shipment-type";
const api = useApi()
const receptionTypes = ref<ReceptionTypeData[]>([])
const shipmentTypes = ref<ShipmentTypeData[]>([])
const loading = ref(false)
const currentPage = ref(1)
const rows = ref<Row[]>([])
const total = ref(0)
const searchValues = reactive<Record<string, string>>({})
const isNestedMode = computed(() => Boolean(props.responsePath))
const effectiveTotal = computed(() => total.value)
const emit = defineEmits<{
rowClick: [row: Row]
}>()
const props = withDefaults(defineProps<{
url?: string
responsePath?: string
columns?: ColumnConfig[]
query?: Record<string, unknown>
itemsPerPage?: number
rowClickable?: boolean
}>(), {
url: '',
responsePath: '',
columns: () => [],
query: () => ({}),
itemsPerPage: 10,
rowClickable: true
})
const displayedRows = computed<Row[]>(() => {
if (!isNestedMode.value) return rows.value
const startIndex = (currentPage.value - 1) * props.itemsPerPage
const endIndex = startIndex + props.itemsPerPage
return rows.value.slice(startIndex, endIndex)
})
onMounted(async () => {
receptionTypes.value = await getReceptionTypeList()
shipmentTypes.value = await getShipmentTypeList()
})
const normalizedColumns = computed(() => {
if (props.columns.length > 0) {
return props.columns.map((column) => ({
key: column.key,
label: column.label ?? column.key,
format: column.format,
isSearchable: column.isSearchable ?? false,
type: column.type
}))
}
if (displayedRows.value.length === 0) {
return []
}
return Object.keys(displayedRows.value[0])
.filter((key) => !key.startsWith('@'))
.map((key) => ({
key,
label: key
}))
})
const totalPages = computed(() => Math.max(1, Math.ceil(effectiveTotal.value / props.itemsPerPage)))
function getVisiblePages(page: number, lastPage: number): number[] {
const candidates = new Set([1, page - 1, page, page + 1, lastPage])
return Array.from(candidates)
.filter((p) => p >= 1 && p <= lastPage)
.sort((a, b) => a - b)
}
function insertEllipses(sortedPages: number[]): PaginationItem[] {
const items: PaginationItem[] = []
for (let i = 0; i < sortedPages.length; i++) {
const current = sortedPages[i]
const previous = sortedPages[i - 1]
if (previous != null && current - previous > 1) {
items.push('...')
}
items.push(current)
}
return items
}
const paginationItems = computed<PaginationItem[]>(() => {
const pages = getVisiblePages(currentPage.value, totalPages.value)
return insertEllipses(pages)
})
const pageLabel = computed(() => {
if (!effectiveTotal.value) return '0 résultat'
const start = (currentPage.value - 1) * props.itemsPerPage + 1
const end = Math.min(currentPage.value * props.itemsPerPage, effectiveTotal.value)
return `${start}-${end} sur ${effectiveTotal.value}`
})
watch(
() => [props.url, props.itemsPerPage, JSON.stringify(props.query ?? {}), props.responsePath],
async () => {
if (currentPage.value !== 1) {
currentPage.value = 1
if (!isNestedMode.value) return
}
await loadPage()
},
{immediate: true}
)
let timeout: ReturnType<typeof setTimeout>
watch(
() => ({...searchValues}),
() => {
clearTimeout(timeout)
timeout = setTimeout(() => {
currentPage.value = 1
if (!isNestedMode.value) loadPage()
}, 750)
},
{deep: true}
)
watch(
() => currentPage.value,
async () => {
if (isNestedMode.value) return
await loadPage()
}
)
watch(
() => [totalPages.value, currentPage.value],
() => {
if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value
}
},
{immediate: true}
)
function buildDateInterval(value: string): { after: string; before: string } | null {
const trimmed = value.trim()
// YYYY
if (/^\d{4}$/.test(trimmed)) {
const year = Number(trimmed)
return {
after: `${year}-01-01`,
before: `${year + 1}-01-01`
}
}
// YYYY-MM
if (/^\d{4}-\d{2}$/.test(trimmed)) {
const [year, month] = trimmed.split('-').map(Number)
const nextMonth = month === 12 ? 1 : month + 1
const nextYear = month === 12 ? year + 1 : year
return {
after: `${year}-${String(month).padStart(2, '0')}-01`,
before: `${nextYear}-${String(nextMonth).padStart(2, '0')}-01`
}
}
// YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const date = new Date(`${trimmed}T00:00:00`)
const nextDay = new Date(date)
nextDay.setDate(date.getDate() + 1)
const yyyy = nextDay.getFullYear()
const mm = String(nextDay.getMonth() + 1).padStart(2, '0')
const dd = String(nextDay.getDate()).padStart(2, '0')
return {
after: trimmed,
before: `${yyyy}-${mm}-${dd}`
}
}
return null
}
// Construit la requête, charge les données et normalise la réponse, puis met à jour rows et total
async function loadPage(): Promise<void> {
if (!props.url) {
rows.value = []
total.value = 0
return
}
loading.value = true
try {
if (isNestedMode.value) {
const response = await api.get<Row>(props.url, props.query, {
headers: {
Accept: 'application/ld+json'
}
})
const nestedRows = readPath(response, props.responsePath)
rows.value = Array.isArray(nestedRows) ? nestedRows as Row[] : []
total.value = rows.value.length
return
}
const searchQuery: Record<string, string> = {}
for (const column of normalizedColumns.value) {
if (!column.isSearchable) continue
const rawValue = searchValues[column.key] ?? ''
const raw = rawValue === '__all__' ? '' : rawValue.trim()
if (!raw) continue
const paramBase = column.key
if (column.type === 'date') {
const interval = buildDateInterval(raw)
if (interval) {
searchQuery[`${paramBase}[after]`] = interval.after
searchQuery[`${paramBase}[before]`] = interval.before
}
continue
}
searchQuery[paramBase] = raw
}
const requestQuery: Record<string, unknown> = {
...props.query,
...searchQuery,
page: currentPage.value,
itemsPerPage: props.itemsPerPage,
}
const response = await api.get<AnyCollection<Row> | Row[]>(props.url, requestQuery, {
headers: {
Accept: 'application/ld+json'
}
})
if (Array.isArray(response)) {
rows.value = response
total.value = response.length
return
}
const mappedRows = response['hydra:member'] ?? response.member ?? response.items ?? []
rows.value = Array.isArray(mappedRows) ? mappedRows : []
total.value = Number(response['hydra:totalItems'] ?? response.totalItems ?? rows.value.length)
} finally {
loading.value = false
}
}
function onRowClick(row: Row): void {
emit('rowClick', row)
}
// Lit une valeur imbriquée dans une ligne à partir d'un chemin de type "objet.sousObjet.cle".
function readPath(source: Row, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => (acc as Row | undefined)?.[key], source)
}
// Formate une valeur brute pour l'affichage dans une cellule (vide, tableau, objet ou valeur simple).
function formatCell(value: unknown): string {
if (value == null || value === '') return '-'
if (Array.isArray(value)) return value.length ? value.map(formatCell).join(', ') : '-'
if (typeof value === 'object') {
const objectValue = value as Row
return String(objectValue.label ?? objectValue.name ?? objectValue.code ?? objectValue.id ?? '[object]')
}
return String(value)
}
function formatColumnValue(
row: Row,
column: { key: string; format?: (value: unknown, row: Row) => string }
): string {
const value = readPath(row, column.key)
if (column.format) {
return column.format(value, row)
}
return formatCell(value)
}
</script>

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

@@ -1,43 +1,53 @@
<template> <template>
<div class="flex items-center justify-between "> <div class="flex items-center justify-between ">
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des transporteurs</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
<NuxtLink <NuxtLink
to="/admin/carrier" to="/admin/carrier"
class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded" class="inline-flex items-center justify-center gap-2 text-xl uppercase bg-primary-500 text-white h-[50px] px-8 rounded"
> >
<Icon name="mdi:plus" size="28"/> <Icon name="mdi:plus" size="28" />
Ajouter Ajouter
</NuxtLink> </NuxtLink>
</div> </div>
<UiDataTable <div class="mt-6 border border-slate-200 mb-16 ">
:columns="columns" <div class="grid grid-cols-2 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
url="carriers" <div>Label</div>
@row-click="onCarrierRowClick" <div>Code</div>
/> </div>
<div
v-for="carrier in carrierList"
:key="carrier.id"
class="grid grid-cols-2 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToCarrier(carrier.id)"
@keydown.enter="goToCarrier(carrier.id)"
>
<div>{{ carrier.name}}</div>
<div>{{ carrier.code }}</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {ColumnConfig, Row} from "~/services/dto/datatable-data"; import type {CarrierData} from "~/services/dto/carrier-data";
import {getCarrierList} from "~/services/carrier";
const carrierList = ref<CarrierData[]>()
const router = useRouter() const router = useRouter()
const columns: ColumnConfig[] = [
{key: "name", label: "Label"},
{key: "code", label: "Code"},
]
const goToCarrier = (id: number) => { const goToCarrier = (id: number) => {
router.push(`/admin/carrier/${id}`) router.push(`/admin/carrier/${id}`)
} }
const onCarrierRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToCarrier(id)
}
definePageMeta({ definePageMeta({
layout: 'default' layout: 'default'
}) })
onMounted(async () => {
carrierList.value = await getCarrierList(false)
})
</script> </script>

View File

@@ -32,15 +32,45 @@
Ajouter Ajouter
</UiButton> </UiButton>
</div> </div>
<UiDataTable <div class="overflow-x-auto mb-10">
class="mb-10" <table class="w-full border-collapse">
:columns="addressColumns" <thead>
:url="customerId !== null ? `customers/${customerId}` : ''" <tr class="text-left border-b border-gray-200">
response-path="addresses" <th class="py-3 pr-4 text-sm uppercase">Libellé</th>
:items-per-page="5" <th class="py-3 pr-4 text-sm uppercase">Rue</th>
:row-clickable="auth.isAdmin" <th class="py-3 pr-4 text-sm uppercase">Complément</th>
@row-click="onAddressRowClick" <th class="py-3 pr-4 text-sm uppercase">Code postal</th>
/> <th class="py-3 pr-4 text-sm uppercase">Ville</th>
<th class="py-3 pr-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="6" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border-b border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<td class="py-3 pr-4">{{ address.label || "—" }}</td>
<td class="py-3 pr-4">{{ address.street || "—" }}</td>
<td class="py-3 pr-4">{{ address.street2 || "—" }}</td>
<td class="py-3 pr-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 pr-4">{{ address.city || "—" }}</td>
<td class="py-3 pr-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</form> </form>
</template> </template>
@@ -48,7 +78,6 @@
import {computed, reactive, ref, watch} from "vue" import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer" import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data" import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
import type {ColumnConfig, Row} from "~/services/dto/datatable-data"
import {useAuthStore} from "~/stores/auth" import {useAuthStore} from "~/stores/auth"
definePageMeta({layout: "default"}) definePageMeta({layout: "default"})
@@ -71,14 +100,6 @@ const form = reactive<CustomerFormData>({
email: "", email: "",
addresses: [], addresses: [],
}) })
const addressColumns: ColumnConfig[] = [
{key: "label", label: "Libellé"},
{key: "street", label: "Rue"},
{key: "street2", label: "Complément"},
{key: "postalCode", label: "Code postal"},
{key: "city", label: "Ville"},
{key: "countryCode", label: "Pays"},
]
const goToAddAddress = () => { const goToAddAddress = () => {
if (customerId.value === null || !auth.isAdmin) return if (customerId.value === null || !auth.isAdmin) return
@@ -101,16 +122,29 @@ const goToEditAddress = (addressId: number | null) => {
}) })
} }
const onAddressRowClick = (row: Row) => {
const id = Number(row.id)
goToEditAddress(Number.isFinite(id) ? id : null)
}
const hydrateFromCustomer = (customer: CustomerData | null) => { const hydrateFromCustomer = (customer: CustomerData | null) => {
if (!customer) return if (!customer) return
form.name = customer.name ?? "" form.name = customer.name ?? ""
form.phone = customer.phone ?? "" form.phone = customer.phone ?? ""
form.email = customer.email ?? "" form.email = customer.email ?? ""
if (!Array.isArray(customer.addresses) || customer.addresses.length === 0) {
form.addresses = []
return
}
if (typeof customer.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = customer.addresses.map((address) => ({
id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
} }
watch( watch(

View File

@@ -12,48 +12,106 @@
</NuxtLink> </NuxtLink>
</div> </div>
<UiDataTable <div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16">
v-if="auth.isAdmin" <div class="max-h-96 overflow-y-auto">
:columns="columns" <div
url="customers" class="sticky top-0 z-10 grid grid-cols-8 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
@row-click="onCustomerRowClick" >
/> <div>Nom</div>
<div>Téléphone</div>
<div>Email</div>
<div>Rue</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</div>
</div>
<div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400">
Aucun client.
</div>
<div v-for="customer in customerList" :key="customer.id">
<div
v-if="!customer.addresses || customer.addresses.length === 0"
class="grid grid-cols-8 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
@click="goToCustomer(customer.id)"
>
<div class="truncate">{{ customer.name || "—" }}</div>
<div class="truncate">{{ customer.phone || "—" }}</div>
<div class="truncate">{{ customer.email || "—" }}</div>
<div class="col-span-1">Pas d'adresse</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
</div>
<template v-else-if="customer.addresses.length > 0">
<div
v-for="(address, idx) in customer.addresses"
:key="address.id ?? `${customer.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-8 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
@click="goToCustomer(customer.id)"
>
<div class="truncate">
{{ idx === 0 ? (customer.name || "") : "" }}
</div>
<div class="truncate">{{ idx === 0 ? (customer.phone || "") : "" }}</div>
<div class="truncate">{{ idx === 0 ? (customer.email || "") : "" }}</div>
<div class="truncate">{{ address.street || "" }}</div>
<div class="truncate">{{ address.street2 || "" }}</div>
<div>{{ address.postalCode || "" }}</div>
<div class="uppercase truncate">{{ address.city || "" }}</div>
<div class="uppercase truncate">{{ address.countryCode || "" }}</div>
</div>
</template>
<template v-else>
<div
class="grid grid-cols-8 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
@click="goToCustomer(customer.id)"
>
<div class="truncate">{{ customer.name || "" }}</div>
<div class="truncate">{{ customer.phone || "" }}</div>
<div class="truncate">{{ customer.email || "" }}</div>
<div class="col-span-5 text-slate-400">
Adresses non chargées
</div>
</div>
</template>
</div>
</div>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400"> <div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs. Accès réservé aux administrateurs.
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnConfig, Row } from "~/services/dto/datatable-data" import { getCustomerList } from "~/services/customer"
import { formatAddresses } from "~/utils/datatable-formatters" import type { CustomerData } from "~/services/dto/customer-data"
import { useAuthStore } from "~/stores/auth" import { useAuthStore } from "~/stores/auth"
definePageMeta({ layout: "default" }) definePageMeta({ layout: "default" })
const customerList = ref<CustomerData[]>([])
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const columns: ColumnConfig[] = [
{ key: "name", label: "Nom", isSearchable:true},
{ key: "phone", label: "Téléphone" },
{ key: "email", label: "Email" },
{ key: "addresses", label: "Adresses", format: formatAddresses },
]
const goToCustomer = (id: number) => { const goToCustomer = (id: number) => {
if (!auth.isAdmin) return if (!auth.isAdmin) return
router.push(`/admin/customer/${id}`) router.push(`/admin/customer/${id}`)
} }
const onCustomerRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToCustomer(id)
}
const handleAddClick = (event: Event) => { const handleAddClick = (event: Event) => {
if (auth.isAdmin) return if (auth.isAdmin) return
event.preventDefault() event.preventDefault()
} }
onMounted(async () => {
if (!auth.isAdmin) return
customerList.value = await getCustomerList()
})
</script> </script>

View File

@@ -32,15 +32,45 @@
Ajouter Ajouter
</UiButton> </UiButton>
</div> </div>
<UiDataTable <div class="overflow-x-auto mb-10">
class="mb-10" <table class="w-full border-collapse">
:columns="addressColumns" <thead>
:url="supplierId !== null ? `suppliers/${supplierId}` : ''" <tr class="text-left border-b border-gray-200">
response-path="addresses" <th class="py-3 pr-4 text-sm uppercase">Libellé</th>
:items-per-page="5" <th class="py-3 pr-4 text-sm uppercase">Rue</th>
:row-clickable="auth.isAdmin" <th class="py-3 pr-4 text-sm uppercase">Complément</th>
@row-click="onAddressRowClick" <th class="py-3 pr-4 text-sm uppercase">Code postal</th>
/> <th class="py-3 pr-4 text-sm uppercase">Ville</th>
<th class="py-3 pr-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="6" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border-b border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<td class="py-3 pr-4">{{ address.label || "—" }}</td>
<td class="py-3 pr-4">{{ address.street || "—" }}</td>
<td class="py-3 pr-4">{{ address.street2 || "—" }}</td>
<td class="py-3 pr-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 pr-4">{{ address.city || "—" }}</td>
<td class="py-3 pr-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</form> </form>
</template> </template>
@@ -48,7 +78,6 @@
import {computed, reactive, ref, watch} from "vue" import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier" import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data" import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
import type {ColumnConfig, Row} from "~/services/dto/datatable-data"
import {useAuthStore} from "~/stores/auth" import {useAuthStore} from "~/stores/auth"
definePageMeta({layout: "default"}) definePageMeta({layout: "default"})
@@ -71,14 +100,6 @@ const form = reactive<SupplierFormData>({
phone: "", phone: "",
addresses: [], addresses: [],
}) })
const addressColumns: ColumnConfig[] = [
{key: "label", label: "Libellé"},
{key: "street", label: "Rue"},
{key: "street2", label: "Complément"},
{key: "postalCode", label: "Code postal"},
{key: "city", label: "Ville"},
{key: "countryCode", label: "Pays"},
]
const goToAddAddress = () => { const goToAddAddress = () => {
if (supplierId.value === null || !auth.isAdmin) return if (supplierId.value === null || !auth.isAdmin) return
@@ -103,16 +124,29 @@ const goToEditAddress = (addressId: number | null) => {
}) })
} }
const onAddressRowClick = (row: Row) => {
const id = Number(row.id)
goToEditAddress(Number.isFinite(id) ? id : null)
}
const hydrateFromSupplier = (supplier: SupplierData | null) => { const hydrateFromSupplier = (supplier: SupplierData | null) => {
if (!supplier) return if (!supplier) return
form.name = supplier.name ?? "" form.name = supplier.name ?? ""
form.email = supplier.email ?? "" form.email = supplier.email ?? ""
form.phone = supplier.phone ?? "" form.phone = supplier.phone ?? ""
if (!Array.isArray(supplier.addresses) || supplier.addresses.length === 0) {
form.addresses = []
return
}
if (typeof supplier.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = supplier.addresses.map((address) => ({
id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
} }
watch( watch(

View File

@@ -12,47 +12,102 @@
</NuxtLink> </NuxtLink>
</div> </div>
<UiDataTable <div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16">
v-if="auth.isAdmin" <div class="max-h-96 overflow-y-auto">
:columns="columns" <div
url="suppliers" class="sticky top-0 z-10 grid grid-cols-7 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
@row-click="onSupplierRowClick" >
/> <div>Nom</div>
<div>Mail</div>
<div>Rue</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</div>
</div>
<div v-if="supplierList.length === 0" class="px-4 py-6 text-slate-400">
Aucun fournisseur.
</div>
<div v-for="supplier in supplierList" :key="supplier.id">
<div
v-if="!supplier.addresses || supplier.addresses.length === 0"
class="grid grid-cols-7 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">{{ supplier.name }}</div>
<div class="truncate">{{ supplier.email }}</div>
<div class="col-span-1">Pas d'adresse</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
</div>
<template v-else-if="supplier.addresses.length > 0">
<div
v-for="(address, idx) in supplier.addresses"
:key="address.id ?? `${supplier.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-7 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">
{{ idx === 0 ? supplier.name : "" }}
</div>
<div class="truncate">{{ idx === 0 ? supplier.email : "" }}</div>
<div class="truncate">{{ address.street || "" }}</div>
<div class="truncate">{{ address.street2 || "" }}</div>
<div>{{ address.postalCode || "" }}</div>
<div class="uppercase truncate">{{ address.city || "" }}</div>
<div class="uppercase truncate">{{ address.countryCode || "" }}</div>
</div>
</template>
<template v-else>
<div
class="grid grid-cols-7 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">{{ supplier.name }}</div>
<div class="truncate">{{ supplier.email }}</div>
<div class="col-span-5 text-slate-400">
Adresses non chargées
</div>
</div>
</template>
</div>
</div>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400"> <div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs. Accès réservé aux administrateurs.
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnConfig, Row } from "~/services/dto/datatable-data" import { getSupplierList } from "~/services/supplier"
import {formatAddresses} from "~/utils/datatable-formatters" import type { SupplierData } from "~/services/dto/supplier-data"
import { useAuthStore } from "~/stores/auth" import { useAuthStore } from "~/stores/auth"
definePageMeta({ layout: "default" }) definePageMeta({ layout: "default" })
const supplierList = ref<SupplierData[]>([])
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const columns: ColumnConfig[] = [
{ key: "name", label: "Nom", isSearchable:true },
{ key: "email", label: "Mail" },
{ key: "addresses", label: "Adresses", format: formatAddresses },
]
const goToSupplier = (id: number) => { const goToSupplier = (id: number) => {
if (!auth.isAdmin) return if (!auth.isAdmin) return
router.push(`/admin/supplier/${id}`) router.push(`/admin/supplier/${id}`)
} }
const onSupplierRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
goToSupplier(id)
}
const handleAddClick = (event: Event) => { const handleAddClick = (event: Event) => {
if (auth.isAdmin) return if (auth.isAdmin) return
event.preventDefault() event.preventDefault()
} }
onMounted(async () => {
if (!auth.isAdmin) return
supplierList.value = await getSupplierList()
})
</script> </script>

View File

@@ -11,11 +11,29 @@
</div> </div>
<UiDataTable <div>
:columns="columns" <div class="mt-6 border border-slate-200 mb-16 ">
url="admin/users" <div class="grid grid-cols-3 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
@row-click="onUserRowClick" <div>Username</div>
/> <div>Role</div>
</div>
<div
v-for="user in userList"
:key="user.id"
class="grid grid-cols-3 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t items-center"
role="button"
tabindex="0"
@click="goToUser(user.id)"
>
<div>
{{ user.username }}
</div>
<div>
{{ getRoleLabels(user.roles) }}
</div>
</div>
</div>
</div>
</template> </template>
@@ -24,21 +42,29 @@ definePageMeta({
layout: 'default' layout: 'default'
}) })
import type {UserData} from "~/services/dto/user-data";
import {getAdminUsers} from "~/services/auth";
import {ROLE} from "~/utils/constants"; import {ROLE} from "~/utils/constants";
import type {ColumnConfig, Row} from "~/services/dto/datatable-data";
import {formatRoleLabels} from "~/utils/datatable-formatters";
const userList = ref<UserData[]>([])
const router = useRouter() const router = useRouter()
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label])) const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
const columns: ColumnConfig[] = [ const goToUser = (id: number) => {
{ key: "username", label: "Username" },
{ key: "roles", label: "Role", format: (value) => formatRoleLabels(value, roleLabelByValue) },
]
const onUserRowClick = (row: Row) => {
const id = Number(row.id)
if (!Number.isFinite(id)) return
router.push(`/admin/user/${id}`) router.push(`/admin/user/${id}`)
} }
const getRoleLabels = (roles?: string[]) => {
if (!roles || roles.length === 0) {
return ' ---'
}
return roles
.map((role) => roleLabelByValue.get(role) ?? role)
.join(', ')
}
onMounted(async () => {
userList.value = await getAdminUsers()
})
</script> </script>

View File

@@ -5,12 +5,12 @@
<card-link label="NOUVELLE RÉCEPTION" link="/reception" iconName="mdi:truck-outline" /> <card-link label="NOUVELLE RÉCEPTION" link="/reception" iconName="mdi:truck-outline" />
<card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" /> <card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" />
<card-link label="PLAN DE SITE" link="/" iconName="material-symbols:warehouse-outline-rounded" /> <card-link label="PLAN DE SITE" link="/" iconName="material-symbols:warehouse-outline-rounded" />
<card-link label="" link="/reception/waiting-reception" iconName="mdi:truck-remove-outline"> <card-link link="/reception/waiting-reception" iconName="mdi:truck-remove-outline">
<template #label> <template #label>
Réceptions<br>EN ATTENTE Réceptions<br>EN ATTENTE
</template> </template>
</card-link> </card-link>
<card-link label="" link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container"> <card-link link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container">
<template #label> <template #label>
EXPÉDITIONS<br>EN ATTENTE EXPÉDITIONS<br>EN ATTENTE
</template> </template>
@@ -18,7 +18,7 @@
<card-link label="CASES" link="/" iconName="material-symbols:bottom-sheets-outline" /> <card-link label="CASES" link="/" iconName="material-symbols:bottom-sheets-outline" />
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" /> <card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" /> <card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
<card-link label="" link="/" iconName="mdi:cow"> <card-link link="/" iconName="mdi:cow">
<template #label> <template #label>
PASSEPORT<br>DU BOVIN PASSEPORT<br>DU BOVIN
</template> </template>

View File

@@ -4,36 +4,59 @@
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1>
</div> </div>
<UiDataTable <div class="px-[86px]">
:columns="columns" <div class="mt-6 border border-slate-200 mb-16 ">
url="receptions" <div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
class="ps-20" <div>Numéro</div>
:query="{ isValid: true }" <div>Date</div>
@row-click="goToReception" <div>Fournisseur</div>
/> <div>Adresse</div>
<div>Type réception</div>
<div>Poids</div>
</div>
<div
v-for="reception in receptionList"
:key="reception.id"
class="grid grid-cols-6 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToReception(reception.id)"
>
<div>{{ reception.identificationNumber}}</div>
<div>{{ reception.receptionDate}}</div>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ formatWeighing(reception) }}</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data";
import {getReceptionList} from "~/services/reception";
import type {ShipmentData} from "~/services/dto/shipment-data";
import {formatWeights} from "~/utils/datatable-formatters"; const receptionList = ref<ReceptionData[]>()
const router = useRouter()
type ReceptionRow = { const formatWeighing = (reception: ReceptionData) => {
id?: number | string const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight
if (gross == null || tare == null) {
return '—'
}
return `${gross - tare} kg`
} }
const router = useRouter() const goToReception = (id: number) => {
const columns = [
{ key: 'identificationNumber', label: 'Numero', isSearchable:true },
{ key: 'receptionDate', label: 'Date de livraison', isSearchable: true, type: 'date' },
{ key: 'supplier.name', label: 'Fournisseur', isSearchable: true },
{ key: 'address.fullAddress', label: 'Adresse', isSearchable: true },
{ key: 'receptionType.label', label: 'Type', isSearchable: true, type:'selectTypeReception' },
{ key: 'weights', label: 'Poids', format: formatWeights }
]
const goToReception = (row: ReceptionRow) => {
const id = Number(row?.id)
if (!Number.isFinite(id)) return
router.push(`/reception/update/${id}`) router.push(`/reception/update/${id}`)
} }
onMounted(async () => {
receptionList.value = await getReceptionList(true)
})
</script> </script>

View File

@@ -1,10 +1,15 @@
<template> <template>
<form @submit.prevent="validate"> <form @submit.prevent="validate">
<div class="flex items-center justify-between mt-12 mb-8 "> <div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-[60px]">
<h1 class="font-bold text-5xl uppercase">Réception {{ receptionLoad?.identificationNumber }}</h1> <div class="flex items-center justify-between gap-10 relative">
</div> <div class="flex flex-row absolute -left-[60px] justify-between">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-12"> <Icon @click="router.push('/')" 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">Réception {{ form.identificationNumber }}</h1>
<Icon @click="router.push('/')" name="mdi:printer-outline" size="44" class="cursor-pointer text-primary-500"/>
</div>
<!-- Nom de l'utilisateur --> <!-- Nom de l'utilisateur -->
<UiSelect <UiSelect
id="reception-user" id="reception-user"
@@ -16,7 +21,7 @@
label: user.username label: user.username
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-1" wrapper-class="col-start-1 row-start-2"
/> />
<!-- Date de réception --> <!-- Date de réception -->
<UiDateInput <UiDateInput
@@ -24,7 +29,20 @@
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
v-model="form.receptionDate" v-model="form.receptionDate"
label="Date de réception" label="Date de réception"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-3"
/>
<!-- type de reception -->
<UiSelect
id="reception-supplier"
v-model="form.receptionTypeId"
:disabled="!auth.isAdmin"
label="Type de Réception"
:options="receptionTypes.map((receptionType) => ({
value: String(receptionType.id),
label: receptionType.label
}))"
:loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-4"
/> />
<!-- Fournisseur --> <!-- Fournisseur -->
<UiSelect <UiSelect
@@ -37,7 +55,7 @@
label: supplier.name label: supplier.name
}))" }))"
:loading="isLoadingSuppliers" :loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-5"
/> />
<!-- Adresse fournisseur --> <!-- Adresse fournisseur -->
<UiSelect <UiSelect
@@ -49,7 +67,7 @@
label: address.fullAddress label: address.fullAddress
}))" }))"
:disabled="(isLoadingSuppliers || supplierAddresses.length === 0) && !auth.isAdmin" :disabled="(isLoadingSuppliers || supplierAddresses.length === 0) && !auth.isAdmin"
wrapper-class="col-start-1 row-start-4" wrapper-class="col-start-2 row-start-1"
/> />
<!-- Camion --> <!-- Camion -->
<UiSelect <UiSelect
@@ -62,7 +80,7 @@
label: truck.name label: truck.name
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-2"
/> />
<!-- Transporteur --> <!-- Transporteur -->
<UiSelect <UiSelect
@@ -76,7 +94,7 @@
}))" }))"
:loading="isLoadingCarriers" :loading="isLoadingCarriers"
select-class="h-[34px]" select-class="h-[34px]"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-3"
/> />
<!-- Chauffeur (LIOT) --> <!-- Chauffeur (LIOT) -->
<UiSelect <UiSelect
@@ -89,10 +107,10 @@
label: driver.name label: driver.name
}))" }))"
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-4"
/> />
<!-- Plaque d'immatriculation --> <!-- Plaque d'immatriculation -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-5">
<UiLicensePlateInput <UiLicensePlateInput
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
v-model="form.licensePlate" v-model="form.licensePlate"
@@ -111,30 +129,28 @@
}))" }))"
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="(isLoadingVehicles || filteredVehicles.length === 0) && !auth.isAdmin" :disabled="(isLoadingVehicles || filteredVehicles.length === 0) && !auth.isAdmin"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-5"
/> />
</div> </div>
<div class="flex justify-center mb-2"> <div v-if="formIsLoading">
<UiButton <div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
v-if="auth.isAdmin"
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] mb-16"
>
Enregistrer
</UiButton>
</div>
<div class="flex justify-evenly gap-y-8 gap-x-40 mb-8 border-b border-slate-400">
<h1 <h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 cursor-pointer" class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer "
:class="activeTab === 'weights' ? 'underline' : ''" :class="activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'"
@click="activeTab = 'weights'" @click="activeTab = 'weightsEmpty'"
> >
pesées pesée à plein
</h1> </h1>
<h1 <h1
class="font-bold text-3xl uppercase col-start-2 row-start-1 cursor-pointer" class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer "
:class="activeTab === 'merchandise' ? 'underline' : ''" :class="activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500 ' : 'text-primary-500/50'"
@click="activeTab = 'weights'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer "
:class="activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'"
@click="activeTab = 'merchandise'" @click="activeTab = 'merchandise'"
> >
{{ isMerchandise ? "Marchandise" : "Bovins" }} {{ isMerchandise ? "Marchandise" : "Bovins" }}
@@ -142,22 +158,47 @@
</div> </div>
<update-weight <update-weight
v-if="activeTab === 'weights'" v-show="activeTab === 'weights'"
:idReception="idReception" :idReception="idReception"
:weightType="'tare'"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
:isValidate="isValid"
/> />
<div class="mb-12 ">
<update-weight
v-show="activeTab === 'weightsEmpty'"
:idReception="idReception"
:weightType="'gross'"
:disabled="!auth.isAdmin"
:isValidate="isValid"
/>
<update-merchandise <update-merchandise
v-else-if="activeTab === 'merchandise' && isMerchandise" v-show="activeTab === 'merchandise' && isMerchandise"
:idReception="idReception" :idReception="idReception"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
/> :isValidate="isValidMerchandise"
/>
<update-bovin <update-bovin
v-else v-show="activeTab === 'merchandise' && !isMerchandise"
:idReception="idReception" :idReception="idReception"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
/> :isValidate="isValidBovin"
/>
</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] mb-16"
>
Enregistrer
</UiButton>
</div>
</div>
</form> </form>
</template> </template>
@@ -176,18 +217,21 @@ import type {DriverData} from '~/services/dto/driver-data'
import {getDriverList} from '~/services/driver' import {getDriverList} from '~/services/driver'
import type {VehicleData} from '~/services/dto/vehicle-data' import type {VehicleData} from '~/services/dto/vehicle-data'
import {getVehicleList} from '~/services/vehicle' import {getVehicleList} from '~/services/vehicle'
import {SUPPLIER_CODE} from "~/utils/constants"; import {RECEPTION_TYPE_CODES, SUPPLIER_CODE} from "~/utils/constants";
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine"; import {deleteReceptionBovine, getReceptionBovineList,} from "~/services/reception-bovine";
import type {ReceptionData, ReceptionFormData} from "~/services/dto/reception-data"; import type {ReceptionData, ReceptionFormData} from "~/services/dto/reception-data";
import {getReception} from "~/services/reception"; import {getReception, updateReception} from "~/services/reception";
import UpdateWeight from "~/components/reception/update-weight.vue"; import UpdateWeight from "~/components/reception/update-weight.vue";
import UpdateMerchandise from "~/components/reception/update-merchandise.vue"; import UpdateMerchandise from "~/components/reception/update-merchandise.vue";
import UpdateBovin from "~/components/reception/update-bovin.vue"; import UpdateBovin from "~/components/reception/update-bovin.vue";
import {getReceptionTypeList} from "~/services/reception-type";
import type {ReceptionTypeData} from "~/services/dto/reception-type-data";
import {computed} from "vue";
import {updateWeight} from "~/services/weight";
import {deleteReceptionPelletBuilding, getReceptionPelletBuildingList} from "~/services/reception-pellet-building";
const activeTab = ref<'weights' | 'merchandise'>('weights')
const router = useRouter()
const receptionStore = useReceptionStore()
const form = reactive<ReceptionFormData>({ const form = reactive<ReceptionFormData>({
identificationNumber: null,
licensePlate: '', licensePlate: '',
receptionDate: new Date().toISOString().slice(0, 10), receptionDate: new Date().toISOString().slice(0, 10),
receptionTypeId: '', receptionTypeId: '',
@@ -199,11 +243,17 @@ const form = reactive<ReceptionFormData>({
driverId: '', driverId: '',
vehicleId: '' vehicleId: ''
}) })
const activeTab = ref<'weights' | 'weightsEmpty' | 'merchandise'>('weights')
const router = useRouter()
const receptionStore = useReceptionStore()
const allowAnyLicensePlate = ref(false) const allowAnyLicensePlate = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const users = ref<UserData[]>([]) const users = ref<UserData[]>([])
const isLoadingUsers = ref(false) const isLoadingUsers = ref(false)
const isLoadingTypes = ref(false)
const suppliers = ref<SupplierData[]>([]) const suppliers = ref<SupplierData[]>([])
const receptionTypes = ref<ReceptionTypeData[]>([])
const isLoadingSuppliers = ref(false) const isLoadingSuppliers = ref(false)
const trucks = ref<TruckData[]>([]) const trucks = ref<TruckData[]>([])
const isLoadingTrucks = ref(false) const isLoadingTrucks = ref(false)
@@ -214,23 +264,26 @@ const isLoadingDrivers = ref(false)
const vehicles = ref<VehicleData[]>([]) const vehicles = ref<VehicleData[]>([])
const isLoadingVehicles = ref(false) const isLoadingVehicles = ref(false)
const authStore = useAuthStore() const authStore = useAuthStore()
const isValid = ref(false)
const isValidBovin = ref(false)
const isValidMerchandise = ref(false)
const formIsLoading = ref(false)
const route = useRoute()
const idReception = Number(route.params.id)
const auth = useAuthStore()
const isMerchandise = ref(false)
// Empêche les watchers de reset des champs pendant le remplissage initial // Empêche les watchers de reset des champs pendant le remplissage initial
const isHydrating = ref(false) const isHydrating = ref(false)
const route = useRoute()
const idReception = Number(route.params.id)
const receptionLoad = await getReception(idReception)
const receptionType = receptionLoad.receptionType
const auth = useAuthStore()
const isBtWeight = ref(true)
const isMerchandise = ref(receptionType.code === 'MARCHANDISES')
// Transporteur sélectionné dans le formulaire // Transporteur sélectionné dans le formulaire
const selectedCarrier = computed(() => const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
) )
// Indique si le transporteur est LIOT // Indique si le transporteur est LIOT
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT) const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
// Adresses disponibles pour le fournisseur sélectionné // Adresses disponibles pour le fournisseur sélectionné
const supplierAddresses = computed(() => { const supplierAddresses = computed(() => {
const supplierId = Number(form.supplierId) const supplierId = Number(form.supplierId)
@@ -239,6 +292,7 @@ const supplierAddresses = computed(() => {
} }
return suppliers.value.find((supplier) => supplier.id === supplierId)?.addresses ?? [] return suppliers.value.find((supplier) => supplier.id === supplierId)?.addresses ?? []
}) })
// Chauffeurs filtrés par transporteur (LIOT) // Chauffeurs filtrés par transporteur (LIOT)
const filteredDrivers = computed<DriverData[]>(() => { const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) { if (!form.carrierId) {
@@ -246,6 +300,7 @@ const filteredDrivers = computed<DriverData[]>(() => {
} }
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId) return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
}) })
// Véhicules filtrés par transporteur + type de camion // Véhicules filtrés par transporteur + type de camion
const filteredVehicles = computed<VehicleData[]>(() => { const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) { if (!form.carrierId) {
@@ -259,18 +314,43 @@ const filteredVehicles = computed<VehicleData[]>(() => {
}) })
// Supprime les données bovines si on change de type de réception // Supprime les données bovines si on change de type de réception
const clearReceptionBovines = async (receptionIri: string) => { const clearReceptionBovines = async (receptionId: number) => {
const receptionIri = `/api/receptions/${receptionId}`
const existing = await getReceptionBovineList(receptionIri) const existing = await getReceptionBovineList(receptionIri)
for (const selection of existing) { for (const selection of existing) {
await deleteReceptionBovine(selection.id) await deleteReceptionBovine(selection.id)
} }
} }
const hydrateFromUser = (reception: ReceptionData | null) => { const syncMerchandiseFlag = () => {
const receptionType =
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
isMerchandise.value = receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES
}
const clearReceptionMerchandise = async (receptionId: number) => {
const receptionIri = `/api/receptions/${receptionId}`
// supprime toutes les associations granulés/bâtiments
const existing = await getReceptionPelletBuildingList(receptionIri)
for (const selection of existing) {
await deleteReceptionPelletBuilding(selection.id)
}
// reset des champs marchandise
await updateReception(receptionId, {
merchandiseType: null,
merchandiseDetail: null,
buildings: []
})
}
const hydrateFromReception = (reception: ReceptionData | null) => {
if (!reception) { if (!reception) {
return return
} }
isHydrating.value = true isHydrating.value = true
form.identificationNumber = reception?.identificationNumber ?? ''
form.licensePlate = reception?.licensePlate ?? '' form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10) form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
form.userId = reception?.user?.id form.userId = reception?.user?.id
@@ -291,7 +371,11 @@ const hydrateFromUser = (reception: ReceptionData | null) => {
form.driverId = reception?.driver?.id form.driverId = reception?.driver?.id
? String(reception.driver.id) ? String(reception.driver.id)
: '' : ''
form.receptionTypeId = reception?.receptionType?.id
? String(reception.receptionType.id)
: ''
isHydrating.value = false isHydrating.value = false
syncMerchandiseFlag()
} }
watch( watch(
@@ -302,8 +386,8 @@ watch(
} }
isLoading.value = true isLoading.value = true
try { try {
const user = await getReception(id) const reception = await getReception(id)
hydrateFromUser(user) hydrateFromReception(reception)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -311,6 +395,14 @@ watch(
{immediate: true} {immediate: true}
) )
watch(
() => form.receptionTypeId,
() => {
const receptionType = receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
isMerchandise.value = receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES
}
)
// Charge la liste des users pour le select // Charge la liste des users pour le select
const loadUsers = async () => { const loadUsers = async () => {
isLoadingUsers.value = true isLoadingUsers.value = true
@@ -331,6 +423,16 @@ const loadSuppliers = async () => {
} }
} }
//charge la liste des types pour le select
const loadTypes = async () => {
isLoadingTypes.value = true
try {
receptionTypes.value = await getReceptionTypeList()
} finally {
isLoadingSuppliers.value = false
}
}
// Charge la liste des camions pour le select // Charge la liste des camions pour le select
const loadTrucks = async () => { const loadTrucks = async () => {
isLoadingTrucks.value = true isLoadingTrucks.value = true
@@ -371,7 +473,7 @@ const loadVehicles = async () => {
} }
} }
// On met le user connecté par défaut dans le select // On met l'user connecté par défaut dans le select
const setDefaultUser = () => { const setDefaultUser = () => {
if (form.userId) { if (form.userId) {
return return
@@ -383,6 +485,9 @@ const setDefaultUser = () => {
// On récupère toutes les données des selects au chargement du composant // On récupère toutes les données des selects au chargement du composant
onMounted(async () => { onMounted(async () => {
await loadTypes()
syncMerchandiseFlag()
formIsLoading.value =true
await loadUsers() await loadUsers()
await loadSuppliers() await loadSuppliers()
await loadTrucks() await loadTrucks()
@@ -507,11 +612,10 @@ watch(
} }
} }
) )
// Valide le formulaire et crée/met à jour la réception
async function validate() { async function validate() {
const normalizedLicensePlate = form.licensePlate.trim() const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim() const normalizedReceptionDate = form.receptionDate.trim()
const normalizedReceptionTypeId = form.receptionTypeId.trim()
const normalizedUserId = form.userId.trim() const normalizedUserId = form.userId.trim()
const normalizedSupplierId = form.supplierId.trim() const normalizedSupplierId = form.supplierId.trim()
const normalizedAddressId = form.addressId.trim() const normalizedAddressId = form.addressId.trim()
@@ -536,10 +640,14 @@ async function validate() {
const driverIri = normalizedDriverId const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}` ? `/api/drivers/${normalizedDriverId}`
: null : null
const typeIri = normalizedReceptionTypeId
? `/api/reception_types/${normalizedReceptionTypeId}`
: null
const basePayload = { const basePayload = {
licensePlate: normalizedLicensePlate, licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate, receptionDate: normalizedReceptionDate,
receptionType: typeIri,
user: userIri, user: userIri,
supplier: supplierIri, supplier: supplierIri,
address: addressIri, address: addressIri,
@@ -549,17 +657,27 @@ async function validate() {
const payload = { const payload = {
...basePayload, ...basePayload,
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {}) ...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {}),
} }
if (idReception) { if (idReception) {
const updated = await receptionStore.updateReception(idReception, { isValid.value = true
isMerchandise.value ? (isValidMerchandise.value = true) : (isValidBovin.value = true)
await receptionStore.updateReception(idReception, {
...payload ...payload
}) })
if (updated) {
await router.push(`/reception/update/${updated.id}`) if (isMerchandise.value) {
await clearReceptionBovines(idReception)
} else {
await clearReceptionMerchandise(idReception)
} }
router.push("/reception/finish-reception")
isValid.value = false
isValidBovin.value = false
isValidMerchandise.value = false
await router.push("/reception/finish-reception")
return return
} }
} }

View File

@@ -1,36 +1,51 @@
<template> <template>
<div class="flex items-center justify-start gap-10"> <div class="flex items-center justify-between">
<div class="flex items-center gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> <Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1>
</div>
</div>
<div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16">
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Fournisseur</div>
<div>Adresse</div>
<div>Type réception</div>
<div>Transporteur</div>
<div>Immatriculation</div>
</div>
<div
v-for="reception in receptionList"
:key="reception.id"
class="grid grid-cols-5 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToReception(reception.id)"
@keydown.enter="goToReception(reception.id)"
>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ reception.carrier?.name }}</div>
<div>{{ reception.licensePlate }}</div>
</div>
</div>
</div> </div>
<UiDataTable
:columns="columns"
url="receptions"
:query="{ isValid: false }"
@row-click="goToReception"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data";
import {getReceptionList} from "~/services/reception";
const receptionList = ref<ReceptionData[]>()
const router = useRouter() const router = useRouter()
const goToReception = (id: number) => {
const columns = [
{key: 'supplier.name', label: 'Fournisseur', isSearchable:true},
{ key: 'address.fullAddress', label: 'Adresse', isSearchable: true },
{key: 'carrier.name', label: 'Transporteur', isSearchable:true},
{key: 'receptionType.label', label: 'Type', isSearchable:true, type:'selectTypeReception'},
{key: 'licensePlate', label: 'Immatriculation', isSearchable:true, type:'licensePlate'},
]
type ReceptionRow = {
id?: number | string
}
const goToReception = (row: ReceptionRow) => {
const id = Number(row?.id)
if (!Number.isFinite(id)) return
router.push(`/reception/${id}`) router.push(`/reception/${id}`)
} }
onMounted(async () => {
receptionList.value = await getReceptionList(false)
})
</script> </script>

View File

@@ -4,32 +4,81 @@
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions finie</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions finie</h1>
</div> </div>
<UiDataTable <div class="px-[86px]">
:columns="columns" <div class="mt-6 border border-slate-200 mb-16 ">
url="shipments" <div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
:query="{ isValid: true }" <div>Numéro</div>
@row-click="goToShipment" <div>Date</div>
/> <div>Client</div>
<div>Adresse</div>
<div>Type d'expéditon</div>
<div>Poids</div>
</div>
<div
v-for="shipment in shipmentList"
:key="shipment
.id"
class="grid grid-cols-6 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goShipment(shipment.id)"
>
<div>{{ shipment.identificationNumber }}</div>
<div>{{ shipment.shipmentDate }}</div>
<div>{{ shipment.customer?.name }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatBovinShipmentLines(shipment).length">
<div
v-for="(line, index) in formatBovinShipmentLines(shipment)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
</div>
<div>{{ formatWeighing(shipment) }}</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {formatBovinShipments, formatWeights} from "~/utils/datatable-formatters"; import type {ShipmentData} from "~/services/dto/shipment-data";
import {getShipmentList} from "~/services/shipment";
const shipmentList = ref<ShipmentData[]>()
const router = useRouter() const router = useRouter()
const columns = [
{key: 'identificationNumber', label: 'Numero',isSearchable:true}, const formatWeighing = (shipment: ShipmentData) => {
{key: 'shipmentDate', label: 'Date de livraison',isSearchable:true, type:'date'}, const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
{key: 'customer.name', label: 'Client',isSearchable:true}, const tare = shipment.weights?.find((weight) => weight.type === 'tare')?.weight
{key: 'address.fullAddress', label: 'Adresse',isSearchable:true},
{key: 'bovinShipments', label: 'Type', format:formatBovinShipments}, if (gross == null || tare == null) {
{key: 'weights', label: 'Poids', format: formatWeights} return ''
] }
type ReceptionRow = {
id?: number | string return `${gross - tare} kg`
} }
const goToShipment = (row: ReceptionRow) => {
const id = Number(row?.id) const formatBovinShipmentLines = (shipment: ShipmentData) => {
if (!Number.isFinite(id)) return if (!shipment.bovinShipments?.length) {
return []
}
return shipment.bovinShipments.map((entry) => {
const label = typeof entry.shipmentType === 'string'
? entry.shipmentType
: entry.shipmentType?.label
return `${label ?? ''} : ${entry.nbBovinSend ?? ''}`
})
}
const goShipment = (id: number) => {
router.push(`/shipment/update/${id}`) router.push(`/shipment/update/${id}`)
} }
onMounted(async () => {
shipmentList.value = await getShipmentList(true)
})
</script> </script>

View File

@@ -5,34 +5,69 @@
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1> <h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1>
</div> </div>
</div> </div>
<UiDataTable
:columns="columns" <div class="px-[86px]">
url="shipments" <div class="mt-6 border border-slate-200 mb-16 ">
:query="{ isValid: false }" <div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
@row-click="goToShipment" <div>Client</div>
/> <div>Adresse</div>
<div>Type d'expéditions</div>
<div>Transporteur</div>
<div>Immatriculation</div>
</div>
<div
v-for="shipment in shipmentList"
:key="shipment.id"
class="grid grid-cols-5 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToShipment(shipment.id)"
@keydown.enter="goToShipment(shipment.id)"
>
<div>{{ shipment.customer?.label }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatBovinShipmentLines(shipment).length">
<div
v-for="(line, index) in formatBovinShipmentLines(shipment)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
</div>
<div>{{ shipment.carrier?.name }}</div>
<div>{{ shipment.licencePlate }}</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {formatBovinShipments} from "~/utils/datatable-formatters";
import type {ShipmentData} from "~/services/dto/shipment-data";
import {getShipmentList} from "~/services/shipment";
const shipmentList = ref<ShipmentData[]>()
const router = useRouter() const router = useRouter()
const columns = [ const goToShipment = (id: number) => {
{key: 'customer.name', label: 'Client', isSearchable:true},
{key: 'address.fullAddress', label: 'Adresse', isSearchable:true},
{key: 'carrier.name', label: 'Transporteur', isSearchable:true},
{key: 'bovinShipments', label: 'Type', format:formatBovinShipments},
{key: 'licencePlate', label: 'Immatriculation', isSearchable:true},
]
type ReceptionRow = {
id?: number | string
}
const goToShipment = (row: ReceptionRow) => {
const id = Number(row?.id)
if (!Number.isFinite(id)) return
router.push(`/shipment/${id}`) router.push(`/shipment/${id}`)
} }
const formatBovinShipmentLines = (shipment: ShipmentData) => {
if (!shipment.bovinShipments?.length) {
return []
}
return shipment.bovinShipments.map((entry) => {
const label = typeof entry.shipmentType === 'string'
? entry.shipmentType
: entry.shipmentType?.label
return `${label ?? ''} : ${entry.nbBovinSend ?? ''}`
})
}
onMounted(async () => {
shipmentList.value = await getShipmentList(false)
})
</script> </script>

View File

@@ -1,20 +0,0 @@
export type Row = Record<string, unknown>
export type ColumnConfig = {
key: string
label?: string
format?: (value: unknown, row: Row) => string
isSearchable?: boolean
type?: string
}
type HydraCollection<T> = {
'hydra:member': T[]
'hydra:totalItems': number
}
export type AnyCollection<T> = HydraCollection<T> & {
member?: T[]
items?: T[]
totalItems?: number
}
export type PaginationItem = number | '...'

View File

@@ -69,6 +69,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

View File

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

View File

@@ -1,70 +0,0 @@
export const formatBovinShipments = (value: unknown): string => {
if (!Array.isArray(value) || value.length === 0) return '-'
return value.map((item: any) => {
const label = item?.shipmentType?.label ?? item?.shipmentType?.code ??
'Type inconnu'
const qty = item?.nbBovinSend ?? '-'
return `${label} : ${qty}`
}).join(', ')
}
export const formatWeights = (value: unknown): string => {
if (!Array.isArray(value) || value.length === 0) return '-'
let gross = 0
let tare = 0
for (const item of value as Array<{ type?: string; weight?:
unknown }>) {
const w = Number(item.weight)
if (!Number.isFinite(w)) continue
if (item.type === 'gross') gross += w
else if (item.type === 'tare') tare += w
}
return `${gross - tare} kg`
}
export const formatRoleLabels = (
value: unknown,
roleLabelByValue: Map<string, string>,
): string => {
if (!Array.isArray(value) || value.length === 0) {
return ' - '
}
return value
.map((role) => {
const key = String(role)
return roleLabelByValue.get(key) ?? key
})
.join(', ')
}
export const formatAddresses = (value: unknown): string => {
if (!Array.isArray(value) || value.length === 0) {
return " - "
}
if (typeof value[0] === 'string') {
return 'Adresses non chargées'
}
return value
.map((item) => {
if (!item || typeof item !== 'object') return '-'
const address = item as Record<string, unknown>
const street = String(address.street ?? '').trim()
const street2 = String(address.street2 ?? '').trim()
const postalCode = String(address.postalCode ?? '').trim()
const city = String(address.city ?? '').trim()
const countryCode = String(address.countryCode ?? '').trim().toUpperCase()
const firstLine = [street, street2].filter(Boolean).join(', ')
const secondLine = [postalCode, city].filter(Boolean).join(' ')
const finalLine = [firstLine, secondLine, countryCode].filter(Boolean).join(', ')
return finalLine || '-'
})
.join('\n')
}

View File

@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260218093842 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE address ADD full_address VARCHAR(400)');
$this->addSql('DROP INDEX idx_7049f4507be036fc');
$this->addSql('DROP INDEX uniq_weight_shipment_type');
$this->addSql('DROP INDEX uniq_weight_reception_type');
$this->addSql('ALTER INDEX idx_weight_shipment RENAME TO IDX_7CD55417BE036FC');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE address DROP full_address');
$this->addSql('CREATE INDEX idx_7049f4507be036fc ON bovin_shipment (shipment_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_weight_shipment_type ON weight (shipment_id, type)');
$this->addSql('CREATE UNIQUE INDEX uniq_weight_reception_type ON weight (reception_id, type)');
$this->addSql('ALTER INDEX idx_7cd55417be036fc RENAME TO idx_weight_shipment');
}
}

View File

@@ -16,7 +16,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'address')] #[ORM\Table(name: 'address')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(
@@ -67,10 +66,6 @@ class Address
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])] #[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private string $city = ''; private string $city = '';
#[ORM\Column(length: 400)]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
private string $fullAddress = '';
#[ORM\Column(name: 'country_code', length: 2)] #[ORM\Column(name: 'country_code', length: 2)]
#[Groups(['address:read', 'supplier:read', 'customer:read', 'address:write'])] #[Groups(['address:read', 'supplier:read', 'customer:read', 'address:write'])]
private string $countryCode = ''; private string $countryCode = '';
@@ -170,21 +165,16 @@ class Address
return $this; return $this;
} }
#[Groups(['address:read', 'supplier:read', 'reception:read', 'shipment:read', 'customer:read'])]
public function getFullAddress(): string public function getFullAddress(): string
{ {
return $this->fullAddress; $parts = array_filter([
} $this->street,
$this->street2,
trim(sprintf('%s %s', $this->postalCode, $this->city)),
]);
#[ORM\PrePersist] return implode(', ', $parts);
#[ORM\PreUpdate]
public function updateFullAddress(): void
{
$this->fullAddress = trim(sprintf(
'%s %s %s',
$this->street ?? '',
$this->postalCode ?? '',
$this->city ?? ''
));
} }
/** /**

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -19,9 +17,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'customer')] #[ORM\Table(name: 'customer')]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
@@ -31,15 +29,6 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')] #[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])] #[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'supplier.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licensePlate' => 'ipartial',
'receptionType.label' => 'ipartial',
'address.fullAddress' => 'ipartial',
])]
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
@@ -31,15 +29,6 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'shipment')] #[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])] #[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'customer.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licencePlate' => 'ipartial',
'bovinShipments' => 'ipartial',
'address.fullAddress' => 'ipartial',
])]
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -19,9 +17,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'supplier')] #[ORM\Table(name: 'supplier')]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(