Compare commits

..

5 Commits

Author SHA1 Message Date
316a20c43a feat : creation du composant datatable (WIP) 2026-02-16 16:05:04 +01:00
d16a81630c Merge branch 'develop' into feat/266-creation-composant-datatable 2026-02-16 08:06:02 +01:00
gitea-actions
67428186f6 chore: bump version to v0.0.47
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-13 16:07:27 +00:00
09d108a1d5 fix : corrections de tous les retours
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
2026-02-13 17:07:15 +01:00
d8c0a8b8e3 feat : creation du composant datatable (WIP) 2026-02-13 16:06:55 +01:00
45 changed files with 734 additions and 307 deletions

View File

@@ -46,7 +46,7 @@ Ajouter dans le fichier .env du frontend
* [#324] Creation page admin listing clients * [#324] Creation page admin listing clients
* [#326] Admin modification creation client * [#326] Admin modification creation client
* [#325] Correction diverses * [#325] Correction diverses
* [#319] Réflexion sur des loaders de type skeleton
### Changed ### Changed
### Fixed ### Fixed

View File

@@ -3,6 +3,8 @@ 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,7 +1,5 @@
<?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;
@@ -1472,7 +1470,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 @var in updates (including delete ones). // Default: false * include_type?: bool|Param, // Always include @type 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

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.46' app.version: '0.0.47'

View File

@@ -2,9 +2,9 @@
<template> <template>
<NuxtLink :to="link"> <NuxtLink :to="link">
<div class="w-[324px] h-[228px] border border-black rounded-md p-6 flex flex-col justify-between"> <div class="w-[324px] h-[228px] border border-black rounded-lg p-6 flex flex-col justify-between">
<div class="flex justify-between"> <div class="flex justify-between">
<div class="rounded-full w-[80px] h-[80px] bg-neutral-400 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" style="color: black" size="44" />
</div> </div>
<div> <div>

View File

@@ -2,7 +2,7 @@
<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 items-center gap-16">
<h1 class="text-4xl uppercase font-bold">Sélection des marchandises réceptionnnées</h1> <h1 class="text-4xl uppercase font-bold">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">
<div <div
@@ -10,6 +10,7 @@
:key="type.id" :key="type.id"
class="mt-8 flex flex-row mb-2 gap-6"> class="mt-8 flex flex-row mb-2 gap-6">
<UiNumberInput <UiNumberInput
:id="type.id"
:label="type.label" :label="type.label"
:code="type.code" :code="type.code"
v-model="bovineQuantities[String(type.id)]" v-model="bovineQuantities[String(type.id)]"
@@ -77,14 +78,14 @@ onMounted(async () => {
}) })
watch( watch(
() => receptionId.value, [() => receptionId.value, () => bovineType.value],
async (id) => { async ([id, types]) => {
if (!id || !receptionIri.value) { if (!id || !receptionIri.value || types.length === 0) {
return return
} }
const selectionMap: Record<string, number | null> = {} const selectionMap: Record<string, number | null> = {}
for (const type of bovineType.value) { for (const type of types) {
selectionMap[String(type.id)] = 0 selectionMap[String(type.id)] = 0
} }

View File

@@ -1,6 +1,5 @@
<template> <template>
<skeletonForm v-if="isPageLoading"/> <form @submit.prevent="validate">
<form v-else @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16"> <div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Réception</h1> <h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Réception</h1>
<!-- Nom de l'utilisateur --> <!-- Nom de l'utilisateur -->
@@ -92,6 +91,7 @@
label: driver.name label: driver.name
}))" }))"
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
v-if="isLiotCarrier"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-4"
/> />
<!-- Plaque d'immatriculation --> <!-- Plaque d'immatriculation -->
@@ -148,7 +148,6 @@ 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 {ReceptionFormData} from "~/services/dto/reception-data"; import type {ReceptionFormData} from "~/services/dto/reception-data";
const isPageLoading = ref(true)
const router = useRouter() const router = useRouter()
const receptionStore = useReceptionStore() const receptionStore = useReceptionStore()
const form = reactive<ReceptionFormData>({ const form = reactive<ReceptionFormData>({
@@ -331,17 +330,15 @@ 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 () => {
receptionTypes.value = await getReceptionTypeList() receptionTypes.value = await getReceptionTypeList()
await loadUsers() await loadUsers()
await loadSuppliers() await loadSuppliers()
await loadTrucks() await loadTrucks()
await loadCarriers() await loadCarriers()
await loadDrivers() await loadDrivers()
await loadVehicles() await loadVehicles()
await authStore.ensureSession() await authStore.ensureSession()
setDefaultUser() setDefaultUser()
//isPageLoading.value = false
}) })
// Ajuste driver/vehicle quand le transporteur change (logique LIOT) // Ajuste driver/vehicle quand le transporteur change (logique LIOT)

View File

@@ -26,7 +26,7 @@
<div <div
v-if="selectedMerchandiseTypeId && !isGranule" v-if="selectedMerchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly" class="flex gap-4 w-[550px] justify-between"
> >
<div <div
v-for="building in buildings" v-for="building in buildings"
@@ -51,13 +51,13 @@
<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 items-center gap-2 text-lg pl-[2px]"
> >
<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"
label-class="text-lg" label-class="text-xl"
/> />
</div> </div>
</div> </div>

View File

@@ -83,14 +83,14 @@ onMounted(async () => {
}) })
watch( watch(
() => receptionId, [() => receptionId, () => bovineType.value],
async (id) => { async ([id, types]) => {
if (!id || !receptionIri.value) { if (!id || !receptionIri.value || types.length === 0) {
return return
} }
const selectionMap: Record<string, number | null> = {} const selectionMap: Record<string, number | null> = {}
for (const type of bovineType.value) { for (const type of types) {
selectionMap[String(type.id)] = 0 selectionMap[String(type.id)] = 0
} }
@@ -105,7 +105,7 @@ watch(
} }
Object.assign(bovineQuantities, selectionMap) Object.assign(bovineQuantities, selectionMap)
const existingOther = await reception.bovineDetail const existingOther = reception.bovineDetail
const parsedOther = const parsedOther =
typeof existingOther === 'string' && existingOther.trim() !== '' typeof existingOther === 'string' && existingOther.trim() !== ''
? Number(existingOther) ? Number(existingOther)

View File

@@ -1,9 +1,10 @@
<template> <template>
<form @submit.prevent="validate"> <form @submit.prevent="validate">
<div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-8"> <div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-8">
<UiTextInput <UiNumberInput
label="Dsd" label="Dsd"
class="col-start-2" class="col-start-2"
labelClass="font-bold uppercase"
v-model="sharedWeightMeta.dsd" v-model="sharedWeightMeta.dsd"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
/> />
@@ -19,9 +20,12 @@
:key="weight.type" :key="weight.type"
:label="getWeightLabel(weight.type)" :label="getWeightLabel(weight.type)"
labelClass="font-bold uppercase text-xl" labelClass="font-bold uppercase text-xl"
inputClass="w-24"
v-model="weight.weight" v-model="weight.weight"
:wrapper-class="weight.type === 'tare' ? 'col-start-1 row-start-1' : 'col-start-2 row-start-1'" :wrapper-class="weight.type === 'tare' ? 'col-start-1 row-start-1' : 'col-start-2 row-start-1'"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
:min="0"
:max="48000"
/> />
</div> </div>

View File

@@ -1,6 +1,5 @@
<template> <template>
<skeletonForm v-if="isPageLoading"/> <form @submit.prevent="validate">
<form v-else @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16"> <div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Expédition</h1> <h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Expédition</h1>
<!-- Nom de l'utilisateur --> <!-- Nom de l'utilisateur -->
@@ -23,24 +22,27 @@
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
/> />
<!-- Type d'expédition --> <!-- Type d'expédition -->
<div class="col-start-1 row-start-4"> <div class="col-start-1 row-start-4 h-[64px]">
<label class="font-bold uppercase text-xl mb-2"> <div class="flex items-end gap-8">
Type d'expédition <UiRadioGroup
</label> id="shipment-type"
<div class="grid grid-cols-2 gap-x-8"> name="shipment-type"
<div label="Type d'expédition"
v-for="type in bovineShipment" v-model="selectedShipmentTypeId"
:key="type.id" :options="bovineShipment.map((type) => ({
class="mt-2 flex flex-row gap-6" value: String(type.id),
> label: type.label
<UiNumberInput }))"
:label="type.label" />
v-model="bovineQuantities[String(type.id)]" <UiNumberInput
:placeholder="0" id="shipment-type-quantity"
:min="0" label="Quantité"
:max="10" v-model="shipmentQuantity"
/> :placeholder="0"
</div> :min="0"
:max="1200"
:disabled="!selectedShipmentTypeId"
/>
</div> </div>
</div> </div>
<!-- Client --> <!-- Client -->
@@ -98,6 +100,7 @@
}))" }))"
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-4"
v-if="isLiotCarrier"
/> />
<!-- Plaque d'immatriculation (hors LIOT) --> <!-- Plaque d'immatriculation (hors LIOT) -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-5"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-5">
@@ -124,7 +127,7 @@
<div class="flex justify-center"> <div class="flex justify-center">
<button <button
type="submit" type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Valider >Valider
</button> </button>
</div> </div>
@@ -149,12 +152,13 @@ import type {ShipmentFormData} from '~/services/dto/shipment-data'
import {SUPPLIER_CODE} from "~/utils/constants" import {SUPPLIER_CODE} from "~/utils/constants"
import {useAuthStore} from '~/stores/auth' import {useAuthStore} from '~/stores/auth'
import {useShipmentStore} from '~/stores/shipment' import {useShipmentStore} from '~/stores/shipment'
import {computed, reactive, ref, watch, onMounted} from 'vue' import { computed, reactive, ref, watch, onMounted } from 'vue'
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data"; import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
import {getShipmentTypeList} from "~/services/shipment-type"; import {getShipmentTypeList} from "~/services/shipment-type";
import { import {
createShipmentBovine, createShipmentBovine,
deleteShipmentBovine, deleteShipmentBovine,
getBovinShipmentList,
updateShipmentBovine updateShipmentBovine
} from "~/services/bovin-shipment"; } from "~/services/bovin-shipment";
@@ -164,7 +168,7 @@ const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([]) const carriers = ref<CarrierData[]>([])
const drivers = ref<DriverData[]>([]) const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([]) const vehicles = ref<VehicleData[]>([])
const isPageLoading = ref(true)
const isLoadingUsers = ref(false) const isLoadingUsers = ref(false)
const isLoadingShipmentTypes = ref(false) const isLoadingShipmentTypes = ref(false)
const isLoadingCustomers = ref(false) const isLoadingCustomers = ref(false)
@@ -177,8 +181,9 @@ const isLoadingDrivers = ref(false)
const authStore = useAuthStore() const authStore = useAuthStore()
const shipmentStore = useShipmentStore() const shipmentStore = useShipmentStore()
const router = useRouter() const router = useRouter()
const bovineQuantities = ref<Record<string, number | null>>({})
const bovineShipment = ref<ShipmentTypeData[]>([]) const bovineShipment = ref<ShipmentTypeData[]>([])
const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0)
// 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
@@ -310,7 +315,6 @@ onMounted(async () => {
await loadDrivers() await loadDrivers()
await authStore.ensureSession() await authStore.ensureSession()
setDefaultUser() setDefaultUser()
isPageLoading.value = false
}) })
// Hydrate le formulaire depuis l'expédition en cours // Hydrate le formulaire depuis l'expédition en cours
watch( watch(
@@ -329,15 +333,21 @@ watch(
form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : '' form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : '' form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : ''
if (!shipment || !shipment.bovinShipments) { if (!shipment || !shipment.bovinShipments) {
bovineQuantities.value = {} selectedShipmentTypeId.value = ''
shipmentQuantity.value = 0
} else { } else {
const next: Record<string, number | null> = {} const selectedEntry = shipment.bovinShipments.find((entry) => {
for (const entry of shipment.bovinShipments) {
const typeId = entry.shipmentType?.id const typeId = entry.shipmentType?.id
if (!typeId) continue return Boolean(typeId) && Number(entry.nbBovinSend ?? 0) > 0
next[String(typeId)] = entry.nbBovinSend ?? null }) ?? shipment.bovinShipments.find((entry) => Boolean(entry.shipmentType?.id))
if (!selectedEntry?.shipmentType?.id) {
selectedShipmentTypeId.value = ''
shipmentQuantity.value = 0
} else {
selectedShipmentTypeId.value = String(selectedEntry.shipmentType.id)
shipmentQuantity.value = selectedEntry.nbBovinSend ?? 0
} }
bovineQuantities.value = next
} }
isHydrating.value = false isHydrating.value = false
}, },
@@ -465,16 +475,22 @@ watch(
} }
) )
const buildDesiredBovinShipments = () => { const buildDesiredBovinShipments = () => {
return bovineShipment.value const typeId = Number(selectedShipmentTypeId.value)
.map((type) => { if (!Number.isFinite(typeId)) {
const raw = bovineQuantities.value[String(type.id)] return []
const quantity = raw === null || raw === undefined ? 0 : Number(raw) }
return { const type = bovineShipment.value.find((entry) => entry.id === typeId)
type, if (!type) {
quantity: Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0 return []
} }
}) const raw = shipmentQuantity.value
.filter((entry) => entry.quantity > 0) const quantity = raw === null || raw === undefined ? 0 : Number(raw)
const normalizedQuantity = Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0
if (normalizedQuantity <= 0) {
return []
}
return [{type, quantity: normalizedQuantity}]
} }
const syncBovinShipments = async ( const syncBovinShipments = async (
shipmentId: number, shipmentId: number,

View File

@@ -0,0 +1,26 @@
<template>
<div class="flex flex-col items-center gap-16">
<h1 class="font-bold text-5xl uppercase">Charment des bovins</h1>
<div
class="w-full flex flex-col items-center justify-center">
<UiLoadingDots />
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="goNext"
>Pesée</button>
</div>
</template>
<script setup lang="ts">
import {useShipmentStore} from "~/stores/shipment";
const shipmentStore = useShipmentStore()
const goNext = async () => {
const nextStep = shipmentStore.current.currentStep + 1
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: nextStep
})
}
</script>

View File

@@ -1,17 +0,0 @@
<template>
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<UiSkeletonBlock height="48px"/>
<div class="flex flex-col gap-2" v-for="i in 9">
<UiSkeletonBlock width="96px"/>
<UiSkeletonBlock width="100%" height="42px"/>
</div>
</div>
<div class="flex justify-center">
<UiSkeletonBlock
width="272px"
height="50px"
/>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -1,13 +0,0 @@
<template>
<div class="ps-20">
<UiSkeletonBlock height="48px" class="my-4"/>
<div class="grid grid-cols-3 justify-evenly bg-slate-100 py-4">
<UiSkeletonBlock v-for="i in 3"/>
</div>
<div
class="grid grid-cols-3 gap-4 px-4 py-4 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
v-for="i in 3">
<UiSkeletonBlock v-for="i in 3"/>
</div>
</div>
</template>

View File

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

View File

@@ -0,0 +1,268 @@
<template>
<div class="mt-6">
<table class="min-w-full border-collapse border border-slate-300">
<thead class="gap-4 bg-slate-100 px-4 py-3 uppercase tracking-wide">
<tr>
<th
v-for="column in normalizedColumns"
:key="column.key"
class="border border-slate-300 px-3 py-2 text-left"
>
<span>{{ column.label }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td
class="border border-slate-300 px-3 py-2 text-center text-slate-500"
:colspan="normalizedColumns.length || 1"
>
Chargement...
</td>
</tr>
<tr v-else-if="rows.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-center text-slate-500"
:colspan="normalizedColumns.length || 1"
>
Aucune donnée
</td>
</tr>
<template v-else>
<tr
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="cursor-pointer"
@click="onRowClick(row)"
>
<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">
<p class="text-sm text-slate-600">
{{ pageLabel }}
</p>
<div class="flex items-center gap-2 mt-4">
<button
type="button"
class="rounded border border-slate-300 px-3 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-3 py-1 disabled:cursor-default"
:class="typeof item === 'number' && 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-3 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">
type Row = Record<string, unknown>
type ColumnConfig = {
key: string
label?: string
format?: (value: unknown, row: Row) => string
}
type HydraCollection<T> = {
'hydra:member': T[]
'hydra:totalItems': number
}
type AnyCollection<T> = HydraCollection<T> & {
member?: T[]
items?: T[]
totalItems?: number
}
const props = withDefaults(defineProps<{
url: string
columns?: ColumnConfig[]
query?: Record<string, unknown>
itemsPerPage?: number
}>(), {
columns: () => [],
query: () => ({}),
itemsPerPage: 10
})
const api = useApi()
const emit = defineEmits<{
rowClick: [row: Row]
}>()
const loading = ref(false)
const currentPage = ref(1)
const rows = ref<Row[]>([])
const total = ref(0)
const normalizedColumns = computed(() => {
if (props.columns.length > 0) {
return props.columns.map((column) => ({
key: column.key,
label: column.label ?? column.key,
format: column.format
}))
}
if (rows.value.length === 0) {
return []
}
return Object.keys(rows.value[0])
.filter((key) => !key.startsWith('@'))
.map((key) => ({
key,
label: key
}))
})
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / props.itemsPerPage)))
const paginationItems = computed<Array<number | '...'>>(() => {
const totalPagesValue = totalPages.value
const page = currentPage.value
if (totalPagesValue <= 7) {
return Array.from({ length: totalPagesValue }, (_, index) => index + 1)
}
const pages = new Set<number>([1, totalPagesValue, page - 1, page, page + 1])
const sortedPages = Array.from(pages)
.filter((value) => value >= 1 && value <= totalPagesValue)
.sort((a, b) => a - b)
const items: Array<number | '...'> = []
for (let i = 0; i < sortedPages.length; i++) {
const value = sortedPages[i]
const previousValue = sortedPages[i - 1]
if (previousValue != null && value - previousValue > 1) {
items.push('...')
}
items.push(value)
}
return items
})
const pageLabel = computed(() => {
if (total.value === 0) {
return '0 resultat'
}
const start = (currentPage.value - 1) * props.itemsPerPage + 1
const end = Math.min(currentPage.value * props.itemsPerPage, total.value)
return `${start}-${end} sur ${total.value}`
})
// Surveille pagination et filtres pour recharger la liste ; si les filtres changent, revient d'abord à la page 1.
watch(
() => ({
page: currentPage.value,
query: props.query,
url: props.url,
itemsPerPage: props.itemsPerPage
}),
async (state, previousState) => {
const queryChanged = JSON.stringify(state.query ?? {}) !== JSON.stringify(previousState?.query ?? {})
if (queryChanged && state.page !== 1) {
currentPage.value = 1
return
}
await loadPage()
},
{immediate: true, deep: true}
)
// Construit la requête, charge les données et normalise la réponse, puis met à jour rows et total
async function loadPage(): Promise<void> {
loading.value = true
try {
const requestQuery: Record<string, unknown> = {
...props.query,
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)
}
// Résout la valeur de colonne pour une ligne et applique un formateur personnalisé s'il existe.
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 mb-2" class="font-bold uppercase text-xl"
:class="labelClass" :class="labelClass"
> >
{{ label }} {{ label }}
@@ -14,7 +14,7 @@
:value="modelValue ?? ''" :value="modelValue ?? ''"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" v-bind="attrs"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase bg-transparent appearance-none h-[34px]" class="border-b border-black justify-self-start text-xl 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-pointer', disabled ? 'cursor-not-allowed' : 'cursor-pointer',

View File

@@ -3,7 +3,7 @@
<label <label
v-if="label" v-if="label"
:for="id" :for="id"
class="text-xl flex items-center" class="text-xl flex items-center gap-2"
:class="labelClass" :class="labelClass"
> >
<span <span
@@ -74,14 +74,41 @@ const emit = defineEmits<{
const attrs = useAttrs() const attrs = useAttrs()
const isEmpty = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '') const isEmpty = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '')
const toNumberOrNull = (value: number | string | undefined) => {
if (value === undefined || value === '') {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const onInput = (event: Event) => { const onInput = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
if (target.value === '') { if (target.value === '') {
emit('update:modelValue', null) emit('update:modelValue', null)
return return
} }
const numeric = Math.max(0, Number(target.value)) const parsed = Number(target.value)
emit('update:modelValue', Number.isNaN(numeric) ? null : numeric) if (!Number.isFinite(parsed)) {
emit('update:modelValue', null)
return
}
const min = toNumberOrNull(props.min)
const max = toNumberOrNull(props.max)
let numeric = parsed
if (min !== null) {
numeric = Math.max(min, numeric)
} else {
numeric = Math.max(0, numeric)
}
if (max !== null) {
numeric = Math.min(max, numeric)
}
target.value = String(numeric)
emit('update:modelValue', numeric)
} }
const onKeydown = (event: KeyboardEvent) => { const onKeydown = (event: KeyboardEvent) => {

View File

@@ -0,0 +1,93 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
class="font-bold uppercase text-xl"
:class="labelClass"
>
{{ label }}
</label>
<div
role="radiogroup"
:aria-label="label || id || 'radio-group'"
:class="['flex items-center gap-6 mt-1', groupClass]"
>
<label
v-for="option in options"
:key="String(option.value)"
:for="`${id || 'radio'}-${option.value}`"
class="flex items-center gap-2"
:class="itemClass"
>
<input
:id="`${id || 'radio'}-${option.value}`"
type="radio"
:name="name || id || 'radio-group'"
:value="String(option.value)"
:checked="String(modelValue ?? '') === String(option.value)"
:disabled="disabled"
v-bind="attrs"
class="h-4 w-4 border-slate-300 text-primary-500 focus:ring-primary-500"
:class="[
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass
]"
@change="onChange"
>
<span class="text-xl" :class="optionLabelClass">
{{ option.label }}
</span>
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { useAttrs } from 'vue'
type RadioOption = {
value: string | number
label: string
}
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue: string | number | null | undefined
options: RadioOption[]
disabled?: boolean
wrapperClass?: string
labelClass?: string
groupClass?: string
itemClass?: string
inputClass?: string
optionLabelClass?: string
}>(),
{
name: '',
label: '',
disabled: false,
wrapperClass: '',
labelClass: '',
groupClass: '',
itemClass: '',
inputClass: '',
optionLabelClass: ''
}
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const attrs = useAttrs()
const onChange = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.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 mb-2" class="font-bold uppercase text-xl"
:class="labelClass" :class="labelClass"
> >
{{ label }} {{ label }}
@@ -13,7 +13,7 @@
: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 pb-[6px] bg-transparent" class="border-b border-black justify-self-start text-xl py-[6px] bg-transparent"
:class="[ :class="[
isEmpty ? 'text-neutral-400' : 'text-black', isEmpty ? 'text-neutral-400' : 'text-black',
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer', disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',

View File

@@ -1,22 +0,0 @@
<template>
<div
:class="['animate-pulse', rounded, customClass]"
:style="{ width, height, background }"
/>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
width?: string
height?: string
rounded?: string
background?: string
customClass?: string
}>(), {
width: '50%',
height: '1rem',
rounded: 'rounded-md',
background: '#e5e7eb',
customClass: ''
})
</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 mb-2" class="font-bold uppercase text-xl"
:class="labelClass" :class="labelClass"
> >
{{ label }} {{ label }}
@@ -16,7 +16,7 @@
:maxlength="maxlength" :maxlength="maxlength"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" v-bind="attrs"
class="border-b border-black text-xl pb-[6px] bg-transparent" class="border-b border-black text-xl py-[6px] bg-transparent"
: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

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-col"> <div class="flex flex-col">
<label :for="inputId" class="font-bold uppercase text-xl mb-2">{{ label }}</label> <label :for="inputId" class="font-bold uppercase text-xl">{{ label }}</label>
<div class="flex items-end gap-8"> <div class="flex items-end gap-8">
<input <input
:id="inputId" :id="inputId"

View File

@@ -1,9 +1,10 @@
export enum StepLabel { export enum StepLabel {
Reception = 'Réception', Reception = 'Réception',
GrossWeighing = 'Pesée à plein', GrossWeighing = 'Pesée à plein',
Selection = 'Sélection réceptionnées', Selection = 'Sélection réception',
TareWeighing = 'Pesée à vide', TareWeighing = 'Pesée à vide',
Shipment = 'Expédition', Shipment = 'Expédition',
ShipmentLoading = 'Chargement',
} }
export const RECEPTION_STEP_LABELS = [ export const RECEPTION_STEP_LABELS = [
@@ -16,5 +17,6 @@ export const RECEPTION_STEP_LABELS = [
export const SHIPMENT_STEP_LABELS = [ export const SHIPMENT_STEP_LABELS = [
StepLabel.Shipment, StepLabel.Shipment,
StepLabel.TareWeighing, StepLabel.TareWeighing,
StepLabel.ShipmentLoading,
StepLabel.GrossWeighing, StepLabel.GrossWeighing,
] ]

View File

@@ -60,8 +60,14 @@
Utilisateurs Utilisateurs
</a> </a>
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/customer/customer-list"> <NuxtLink to="/admin/customer/customer-list" custom v-slot="{ href, navigate }">
Client <a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/customer') ? 'opacity-100' : 'opacity-50'"
>
Clients
</a>
</NuxtLink> </NuxtLink>
</div> </div>

View File

@@ -104,7 +104,7 @@
<main class="mx-auto w-full max-w-[1280px]"> <main class="mx-auto w-full max-w-[1280px]">
<slot/> <slot/>
</main> </main>
<footer class="w-full mt-8 bg-primary-500 p-6"> <footer class="w-full mt-8 bg-primary-500 px-6 py-3">
<p class="font-bold text-white text-right">v{{ version }}</p> <p class="font-bold text-white text-right">v{{ version }}</p>
</footer> </footer>
</div> </div>

View File

@@ -46,6 +46,6 @@ definePageMeta({
}) })
onMounted(async () => { onMounted(async () => {
carrierList.value = await getCarrierList() carrierList.value = await getCarrierList(false)
}) })
</script> </script>

View File

@@ -28,7 +28,7 @@
{{ user.username }} {{ user.username }}
</div> </div>
<div> <div>
{{ user.roles?.join(', ') || ' ---' }} {{ getRoleLabels(user.roles) }}
</div> </div>
</div> </div>
</div> </div>
@@ -42,15 +42,27 @@ definePageMeta({
}) })
import type {UserData} from "~/services/dto/user-data"; import type {UserData} from "~/services/dto/user-data";
import {getAdminUsers, getUsers} from "~/services/auth"; import {getAdminUsers} from "~/services/auth";
import {ROLE} from "~/utils/constants";
const userList = ref<UserData[]>([]) const userList = ref<UserData[]>([])
const router = useRouter() const router = useRouter()
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
const goToUser = (id: number) => { const goToUser = (id: number) => {
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 () => { onMounted(async () => {
userList.value = await getAdminUsers() userList.value = await getAdminUsers()
}) })

View File

@@ -0,0 +1,19 @@
<template>
<UiDataTable
:columns="columns"
url="receptions"
:items-per-page="2"
/>
</template>
<script setup lang="ts">
const columns = [
{key: 'identificationNumber', label: 'Numero'},
{key: 'receptionDate', label: 'Date de livraison'},
{key: 'supplier', label: 'Fournisseur'},
{key: 'address.fullAddress', label: 'Adresse'},
{key: 'receptionType', label: 'Type'},
{key: 'weights', label: 'Poids', format: formatWeights}
]
</script>

View File

@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
</script> </script>
<template> <template>
<div class="flex flex-wrap justify-center mt-8 gap-8 mb-8 md:mb-0"> <div class="flex flex-wrap justify-center mt-8 gap-12 mb-8 md:mb-0">
<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="mdi:warehouse" /> <card-link label="PLAN DE SITE" link="/" iconName="material-symbols:warehouse-outline-rounded" />
<card-link label="RÉCEPTIONS EN ATTENTE" link="/reception/waiting-reception" iconName="mdi:truck-remove-outline" /> <card-link label="RÉCEPTIONS EN ATTENTE" link="/reception/waiting-reception" iconName="mdi:truck-remove-outline" />
<card-link label="EXPÉDITIONS EN ATTENTE" link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container" /> <card-link label="EXPÉDITIONS EN ATTENTE" link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container" />
<card-link label="CASES" link="/" iconName="mdi:cube-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="PASSEPORT DU BOVIN" link="/" iconName="mdi:cow" /> <card-link label="PASSEPORT DU BOVIN" link="/" iconName="mdi:cow" />

View File

@@ -1,29 +1,28 @@
<template> <template>
<div> <div class="flex justify-between h-[52px] mt-16 mb-[80px]">
<div class="flex justify-between h-[52px] mt-6 mb-[80px]"> <div class="flex flex-1 mr-16">
<div class="flex flex-1 mr-16"> <UiStepper
<UiStepper :labels="RECEPTION_STEP_LABELS"
:labels="RECEPTION_STEP_LABELS" :current-step="storeReception?.currentStep ?? 0"
:current-step="storeReception?.currentStep ?? 0" @select="handleStepSelect"
@select="handleStepSelect" />
/>
</div>
<button
type="button"
class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center"
@click="saveAndHold"
>Mettre en attente</button>
</div> </div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/> <button
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/> type="button"
<ReceptionProductReceived class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center"
v-if="storeReception?.currentStep === 2 && @click="saveAndHold"
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/> >Mettre en attente
<ReceptionBovineReceived </button>
v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
</div> </div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
<ReceptionProductReceived
v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
<ReceptionBovineReceived
v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,59 +1,34 @@
<template> <template>
<skeletonTable v-if="isPageLoading"/> <div class="flex items-center justify-start gap-10 mt-16">
<div v-else class="ps-20"> <Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44" class="cursor-pointer"/>
<div class="flex items-center justify-start gap-10"> <h1 class="text-3xl font-bold uppercase">listes des réceptions finie</h1>
<Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44"/>
<h1 class="text-3xl font-bold uppercase">listes des réceptions finie</h1>
</div>
<div class="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Numéro</div>
<div>Date</div>
<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, 'gross') }} | {{ formatWeighing(reception, 'tare') }}</div>
</div>
</div>
</div> </div>
<UiDataTable
:columns="columns"
url="receptions"
:query="{ isValid: true }"
@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 isPageLoading = ref(true) const columns = [
const formatWeighing = (reception: ReceptionData, type: 'gross' | 'tare') => { {key: 'identificationNumber', label: 'Numero'},
const entry = reception.weights?.find((weight) => weight.type === type) {key: 'receptionDate', label: 'Date de livraison'},
if (!entry || entry.weight == null || entry.dsd == null) { {key: 'supplier', label: 'Fournisseur'},
return '—' {key: 'address.fullAddress', label: 'Adresse'},
} {key: 'receptionType', label: 'Type'},
return `${entry.weight} kg` {key: 'weights', label: 'Poids', format: formatWeights}
]
type ReceptionRow = {
id?: number | string
} }
const goToReception = (row: ReceptionRow) => {
const goToReception = (id: number) => { 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)
isPageLoading.value = false
})
</script> </script>

View File

@@ -1,12 +1,12 @@
<template> <template>
<div class="flex items-center justify-between "> <div class="flex items-center justify-between mt-16">
<div class="flex items-center gap-10"> <div class="flex items-center gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44" /> <Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44" class="cursor-pointer"/>
<h1 class="text-3xl font-bold uppercase">listes des réceptions en attente</h1> <h1 class="text-3xl font-bold uppercase">listes des réceptions en attente</h1>
</div> </div>
</div> </div>
<div class="ps-20 " > <div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16 "> <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 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>Fournisseur</div>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="flex justify-between h-[52px] mt-6 mb-[80px]"> <div class="flex justify-between h-[52px] mt-16 mb-[80px]">
<div class="flex flex-1 mr-16"> <div class="flex flex-1 mr-16">
<UiStepper <UiStepper
:labels="SHIPMENT_STEP_LABELS" :labels="SHIPMENT_STEP_LABELS"
@@ -18,7 +18,8 @@
</div> </div>
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/> <ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/> <ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/>
<ShipmentWeight v-if="storeShipment?.currentStep >= 2" mode="tare"/> <ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 3" mode="tare"/>
</div> </div>
</template> </template>

View File

@@ -1,48 +1,15 @@
<template> <template>
<skeletonTable v-if="isPageLoading"/> <div class="flex items-center justify-start gap-10 mt-16">
<div v-else class="ps-20 "> <Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44" class="cursor-pointer"/>
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44"/>
<h1 class="text-3xl font-bold uppercase">listes des expéditions finie</h1> <h1 class="text-3xl font-bold uppercase">listes des expéditions finie</h1>
</div> </div>
<div class="mt-6 border border-slate-200 mb-16 "> <UiDataTable
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> :columns="columns"
<div>Numéro</div> url="shipments"
<div>Date</div> :query="{ isValid: true }"
<div>Client</div> @row-click="goToShipment"
<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="goToshipment(shipment.id)"
>
<div>{{ shipment.identificationNumber }}</div>
<div>{{ shipment.shipmentDate }}</div>
<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>{{ formatWeighing(shipment, 'gross') }} | {{ formatWeighing(shipment, 'tare') }}</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -51,34 +18,20 @@ import {getShipmentList} from "~/services/shipment";
const shipmentList = ref<ShipmentData[]>() const shipmentList = ref<ShipmentData[]>()
const router = useRouter() const router = useRouter()
const isPageLoading = ref(true) const columns = [
const formatWeighing = (shipment: ShipmentData, type: 'gross' | 'tare') => { {key: 'identificationNumber', label: 'Numero'},
const entry = shipment.weights?.find((weight) => weight.type === type) {key: 'shipmentDate', label: 'Date de livraison'},
if (!entry || entry.weight == null || entry.dsd == null) { {key: 'customer', label: 'Client'},
return '' {key: 'address.fullAddress', label: 'Adresse'},
} {key: 'bovinShipments', label: 'Type', format:formatBovinShipments},
return `${entry.weight} kg` {key: 'weights', label: 'Poids', format: formatWeights}
]
type ReceptionRow = {
id?: number | string
} }
const goToShipment = (row: ReceptionRow) => {
const formatBovinShipmentLines = (shipment: ShipmentData) => { const id = Number(row?.id)
if (!shipment.bovinShipments?.length) { if (!Number.isFinite(id)) return
return [] router.push(`/shipment/update/${id}`)
}
return shipment.bovinShipments.map((entry) => {
const label = typeof entry.shipmentType === 'string'
? entry.shipmentType
: entry.shipmentType?.label
return `${label ?? ''} : ${entry.nbBovinSend ?? ''}`
})
} }
const goToshipment = (id: number) => {
//router.push(`/shipment/update/${id}`)
}
onMounted(async () => {
shipmentList.value = await getShipmentList(true)
isPageLoading.value = false
})
</script> </script>

View File

@@ -1,12 +1,12 @@
<template> <template>
<div class="flex items-center justify-between "> <div class="flex items-center justify-between mt-16">
<div class="flex items-center gap-10"> <div class="flex items-center gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44"/> <Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44" class="cursor-pointer"/>
<h1 class="text-3xl font-bold uppercase">listes des expéditions en attente</h1> <h1 class="text-3xl font-bold uppercase">listes des expéditions en attente</h1>
</div> </div>
</div> </div>
<div class="ps-20 "> <div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16 "> <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 class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Client</div> <div>Client</div>

View File

@@ -6,6 +6,7 @@ export interface AddressData {
postalCode: string postalCode: string
city: string city: string
countryCode: string countryCode: string
fullAddress: string
} }
export interface AddressFormData { export interface AddressFormData {

View File

@@ -0,0 +1,41 @@
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 '-'
return value
.map((item: any) => {
const type = item?.type === 'tare'
? 'Poids à vide'
: item?.type === 'gross'
? 'Poids à plein'
: (item?.type ?? 'Poids')
const weight = item?.weight ?? '-'
return `${type}: ${weight}`
})
.join(', ')
}
export const formatPelletBuildings = (value: unknown): string => {
if (!Array.isArray(value) || value.length === 0) return '-'
return value
.map((item: any) => {
const pelletLabel =
item?.pelletType?.label ?? item?.pelletType?.code ?? 'Granule inconnu'
const buildingLabel =
item?.building?.label ?? item?.building?.code ?? 'Bâtiment inconnu'
return `${pelletLabel} : ${buildingLabel}`
})
.join('\n')
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260213114000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Allow only one bovin_shipment row per shipment.';
}
public function up(Schema $schema): void
{
// Keep one row per shipment (latest id), required before adding unique index.
$this->addSql(<<<'SQL'
DELETE FROM bovin_shipment bs
USING (
SELECT id, ROW_NUMBER() OVER (PARTITION BY shipment_id ORDER BY id DESC) AS rn
FROM bovin_shipment
WHERE shipment_id IS NOT NULL
) d
WHERE bs.id = d.id
AND d.rn > 1
SQL);
$this->addSql('DROP INDEX IF EXISTS uniq_bovin_shipment');
$this->addSql('CREATE UNIQUE INDEX uniq_bovin_shipment_one_type ON bovin_shipment (shipment_id)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS uniq_bovin_shipment_one_type');
$this->addSql('CREATE UNIQUE INDEX uniq_bovin_shipment ON bovin_shipment (shipment_id, shipment_type_id)');
}
}

View File

@@ -168,6 +168,7 @@ class SeedCommand extends Command
['label' => 'Foin', 'code' => 'FOIN'], ['label' => 'Foin', 'code' => 'FOIN'],
['label' => 'Paille', 'code' => 'PAILLE'], ['label' => 'Paille', 'code' => 'PAILLE'],
['label' => 'Granule', 'code' => 'GRANULE'], ['label' => 'Granule', 'code' => 'GRANULE'],
['label' => 'Autres', 'code' => 'AUTRES'],
]; ];
foreach ($merchandiseTypes as $type) { foreach ($merchandiseTypes as $type) {
$this->upsertByCode(MerchandiseType::class, $type['code'], static function (MerchandiseType $entity) use ($type) { $this->upsertByCode(MerchandiseType::class, $type['code'], static function (MerchandiseType $entity) use ($type) {

View File

@@ -26,6 +26,7 @@ class ReferenceFixtures extends Fixture
['label' => 'Foin', 'code' => 'FOIN'], ['label' => 'Foin', 'code' => 'FOIN'],
['label' => 'Paille', 'code' => 'PAILLE'], ['label' => 'Paille', 'code' => 'PAILLE'],
['label' => 'Granule', 'code' => 'GRANULE'], ['label' => 'Granule', 'code' => 'GRANULE'],
['label' => 'Autres', 'code' => 'AUTRES'],
]; ];
foreach ($merchandiseTypes as $type) { foreach ($merchandiseTypes as $type) {
$merchandiseType = new MerchandiseType() $merchandiseType = new MerchandiseType()

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity] #[ORM\Entity]
#[ApiFilter(SearchFilter::class, properties: ['shipment' => 'exact'])] #[ApiFilter(SearchFilter::class, properties: ['shipment' => 'exact'])]
#[ORM\UniqueConstraint(name: 'uniq_bovin_shipment', columns: ['shipment_id', 'shipment_type_id'])] #[ORM\UniqueConstraint(name: 'uniq_bovin_shipment_one_type', columns: ['shipment_id'])]
#[ORM\Table(name: 'bovin_shipment')] #[ORM\Table(name: 'bovin_shipment')]
#[ApiResource( #[ApiResource(
operations: [ operations: [

View File

@@ -328,7 +328,7 @@ class Shipment
return; return;
} }
$number = sprintf('P-BR-%04d', $this->id); $number = sprintf('P-BL-%04d', $this->id);
$this->identificationNumber = $number; $this->identificationNumber = $number;
$args->getObjectManager() $args->getObjectManager()

View File

@@ -26,10 +26,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
), ),
new GetCollection( new GetCollection(
normalizationContext: ['groups' => ['supplier:read']], normalizationContext: ['groups' => ['supplier:read']],
),
new GetCollection(
uriTemplate: '/admin/suppliers',
normalizationContext: ['groups' => ['supplier:read']],
security: "is_granted('ROLE_ADMIN')" security: "is_granted('ROLE_ADMIN')"
), ),
new Post( new Post(

View File

@@ -174,7 +174,7 @@
<td style="width:30%; text-align:right; vertical-align:top; font-size: 14px;"> <td style="width:30%; text-align:right; vertical-align:top; font-size: 14px;">
<div style="display:inline-block; width:75mm; line-height:1.3;"> <div style="display:inline-block; width:75mm; line-height:1.3;">
<strong>{{ shipment.customer ? shipment.customer.label : '-' }}</strong><br> <strong>{{ shipment.customer ? shipment.customer.name : '-' }}</strong><br>
<span>{{ shipment.address ? shipment.address.street : '' }}</span><br> <span>{{ shipment.address ? shipment.address.street : '' }}</span><br>
{% if shipment.address and shipment.address.street2 %} {% if shipment.address and shipment.address.street2 %}
<span>{{ shipment.address.street2 }}</span><br> <span>{{ shipment.address.street2 }}</span><br>
@@ -198,7 +198,7 @@
</tr> </tr>
<tr> <tr>
<td style="width:55%; text-align:center;"> <td style="width:55%; text-align:center;">
{{ shipment.customer ? shipment.customer.code : '-' }} {{ shipment.customer ? shipment.customer.name : '-' }}
</td> </td>
<td style="width:20%; text-align:center; white-space:nowrap;"> <td style="width:20%; text-align:center; white-space:nowrap;">
{{ shipment.shipmentDate|date('d/m/Y') }} {{ shipment.shipmentDate|date('d/m/Y') }}
@@ -276,7 +276,7 @@
<td style="width:60%; padding-right:8mm; vertical-align:top;"> <td style="width:60%; padding-right:8mm; vertical-align:top;">
<div class="meta"> <div class="meta">
<p>Transporteur : {{ shipment.carrier ? shipment.carrier.name : '-' }}</p> <p>Transporteur : {{ shipment.carrier ? shipment.carrier.name : '-' }}</p>
<p>Mode de livraison : {{ shipment.truck ? shipment.truck.name : '-' }}</p> <p>Mode d'expédition : {{ shipment.truck ? shipment.truck.name : '-' }}</p>
<p>Immatriculation : {{ shipment.licencePlate ?? '-' }}</p> <p>Immatriculation : {{ shipment.licencePlate ?? '-' }}</p>
</div> </div>
</td> </td>