feat: ajout du composant datatable sur tous les écrans #48
@@ -5,6 +5,8 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
pagination_client_items_per_page: true
|
||||
pagination_maximum_items_per_page: 100
|
||||
formats:
|
||||
json: ['application/json']
|
||||
jsonld: ['application/ld+json']
|
||||
|
||||
233
frontend/components/ui/UiDataTable.vue
Normal file
233
frontend/components/ui/UiDataTable.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="relative border border-slate-200">
|
||||
<div
|
||||
class="grid items-center gap-6 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key" class="min-w-0">
|
||||
<slot :name="`header-${col.key}`" :column="col">{{ col.label }}</slot>
|
||||
</div>
|
||||
<div v-if="showActions" class="min-w-0">
|
||||
<slot name="header-actions">Actions</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="dimRows ? 'opacity-50 transition-opacity' : ''" :aria-busy="loading || undefined">
|
||||
<template v-if="paginatedItems.length">
|
||||
<div
|
||||
v-for="(item, index) in paginatedItems"
|
||||
:key="item.id ?? index"
|
||||
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
|
||||
:class="rowClickable ? 'hover:bg-slate-50 cursor-pointer' : ''"
|
||||
:style="{ gridTemplateColumns: gridCols }"
|
||||
:role="rowClickable ? 'button' : undefined"
|
||||
:tabindex="rowClickable ? 0 : undefined"
|
||||
@click="onRowClick(item)"
|
||||
@keydown.enter="onRowClick(item)"
|
||||
@keydown.space.prevent="onRowClick(item)"
|
||||
>
|
||||
<div v-for="col in columns" :key="col.key" class="min-w-0 truncate">
|
||||
<slot :name="`cell-${col.key}`" :item="item" :column="col">
|
||||
{{ getNestedValue(item, col.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="showActions" @click.stop>
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="loading"
|
||||
class="flex items-center justify-center border-t border-slate-200 px-4 py-8 text-primary-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<UiLoadingDots />
|
||||
<span class="sr-only">Chargement…</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="border-t border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
|
||||
>
|
||||
<slot name="empty">{{ emptyMessage }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dimRows"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="rounded bg-white/80 px-4 py-2 text-primary-500 shadow">
|
||||
<UiLoadingDots />
|
||||
<span class="sr-only">Chargement…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="flex justify-between pt-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<label :for="perPageId" class="whitespace-nowrap text-sm text-slate-700">Lignes :</label>
|
||||
<select
|
||||
:id="perPageId"
|
||||
:value="currentPerPage"
|
||||
class="h-10 rounded border border-slate-300 bg-white px-2 text-sm text-primary-700"
|
||||
@change="onPerPageChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Pagination" class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
|
||||
:disabled="currentPage <= 1"
|
||||
aria-label="Page précédente"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
|
||||
<span
|
||||
v-if="entry === '...'"
|
||||
class="px-1 text-sm text-slate-400"
|
||||
aria-hidden="true"
|
||||
>…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
|
||||
:class="entry === currentPage
|
||||
? 'bg-primary-500 font-semibold text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100'"
|
||||
:aria-current="entry === currentPage ? 'page' : undefined"
|
||||
@click="goToPage(entry)"
|
||||
>
|
||||
{{ entry }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
|
||||
:disabled="currentPage >= totalPages"
|
||||
aria-label="Page suivante"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
interface Column {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: Column[]
|
||||
items: T[]
|
||||
totalItems?: number
|
||||
page?: number
|
||||
perPage?: number
|
||||
perPageOptions?: number[]
|
||||
rowClickable?: boolean
|
||||
showActions?: boolean
|
||||
emptyMessage?: string
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
totalItems: undefined,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
perPageOptions: () => [10, 25, 50],
|
||||
rowClickable: false,
|
||||
showActions: false,
|
||||
emptyMessage: 'Aucune donnée',
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:page', value: number): void
|
||||
(e: 'update:perPage', value: number): void
|
||||
(e: 'row-click', item: T): void
|
||||
}>()
|
||||
|
||||
const perPageId = useId()
|
||||
|
||||
const currentPage = computed(() => props.page)
|
||||
const currentPerPage = computed(() => props.perPage)
|
||||
|
||||
const isServerSide = computed(() => props.totalItems !== undefined)
|
||||
const total = computed(() => props.totalItems ?? props.items.length)
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / currentPerPage.value))
|
||||
)
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
if (isServerSide.value) return props.items
|
||||
const start = (currentPage.value - 1) * currentPerPage.value
|
||||
return props.items.slice(start, start + currentPerPage.value)
|
||||
})
|
||||
|
||||
const gridCols = computed(() => {
|
||||
const dataCols = props.columns.map(c => c.width ?? '1fr').join(' ')
|
||||
return props.showActions ? `${dataCols} 60px` : dataCols
|
||||
})
|
||||
|
||||
const dimRows = computed(() => props.loading && paginatedItems.value.length > 0)
|
||||
|
||||
const visiblePages = computed<(number | '...')[]>(() => {
|
||||
const tp = totalPages.value
|
||||
const cp = currentPage.value
|
||||
|
||||
if (tp <= 5) {
|
||||
return Array.from({ length: tp }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = []
|
||||
pages.push(1)
|
||||
|
||||
if (cp > 3) pages.push('...')
|
||||
|
||||
const start = Math.max(2, cp - 1)
|
||||
const end = Math.min(tp - 1, cp + 1)
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
|
||||
if (cp < tp - 2) pages.push('...')
|
||||
|
||||
if (tp > 1) pages.push(tp)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (n: number) => {
|
||||
if (n < 1 || n > totalPages.value || n === currentPage.value) return
|
||||
emit('update:page', n)
|
||||
}
|
||||
|
||||
const onPerPageChange = (value: string) => {
|
||||
emit('update:perPage', Number(value))
|
||||
emit('update:page', 1)
|
||||
}
|
||||
|
||||
const onRowClick = (item: T) => {
|
||||
if (!props.rowClickable) return
|
||||
emit('row-click', item)
|
||||
}
|
||||
|
||||
const getNestedValue = (obj: any, path: string): string => {
|
||||
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
|
||||
return value ?? '—'
|
||||
}
|
||||
</script>
|
||||
@@ -14,8 +14,9 @@
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
|
||||
class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent appearance-none"
|
||||
:class="[
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
inputClass
|
||||
@@ -36,12 +37,14 @@ const props = withDefaults(
|
||||
label?: string
|
||||
modelValue: string | null | undefined
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
@@ -54,6 +57,11 @@ const emit = defineEmits<{
|
||||
|
||||
const attrs = useAttrs()
|
||||
const isEmpty = computed(() => !props.modelValue)
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px] uppercase h-[34px]'
|
||||
)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
@@ -13,15 +13,16 @@
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="disabled || loading"
|
||||
v-bind="attrs"
|
||||
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] bg-transparent"
|
||||
class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent"
|
||||
:class="[
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
selectClass
|
||||
]"
|
||||
@change="onChange"
|
||||
>
|
||||
<option value="" disabled class="text-neutral-400">
|
||||
<option value="" class="text-neutral-400">
|
||||
{{ placeholderText }}
|
||||
</option>
|
||||
<option
|
||||
@@ -55,6 +56,7 @@ const props = withDefaults(
|
||||
options: SelectOption[]
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
selectClass?: string
|
||||
@@ -63,6 +65,7 @@ const props = withDefaults(
|
||||
placeholder: 'Sélectionner',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
selectClass: ''
|
||||
@@ -77,6 +80,11 @@ const attrs = useAttrs()
|
||||
|
||||
const isEmpty = computed(() => props.modelValue === '' || props.modelValue === null || props.modelValue === undefined)
|
||||
const placeholderText = computed(() => props.placeholder || 'Sélectionner')
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px]'
|
||||
)
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
:maxlength="maxlength"
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
class="border-b border-black text-xl py-[6px] bg-transparent text-primary-700"
|
||||
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
|
||||
:class="[
|
||||
isEmpty ? 'text-neutral-400' : 'text-black',
|
||||
sizeClass,
|
||||
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||
inputClass
|
||||
]"
|
||||
@@ -40,6 +41,7 @@ const props = withDefaults(
|
||||
placeholder?: string
|
||||
maxlength?: number | string
|
||||
disabled?: boolean
|
||||
size?: 'default' | 'compact'
|
||||
wrapperClass?: string
|
||||
labelClass?: string
|
||||
inputClass?: string
|
||||
@@ -48,6 +50,7 @@ const props = withDefaults(
|
||||
placeholder: '',
|
||||
maxlength: undefined,
|
||||
disabled: false,
|
||||
size: 'default',
|
||||
wrapperClass: '',
|
||||
labelClass: '',
|
||||
inputClass: ''
|
||||
@@ -60,6 +63,11 @@ const emit = defineEmits<{
|
||||
|
||||
const attrs = useAttrs()
|
||||
const isEmpty = computed(() => !props.modelValue)
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'compact'
|
||||
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||
: 'text-xl py-[6px]'
|
||||
)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:maxlength="maxLength"
|
||||
:placeholder="placeholderText"
|
||||
:required="required"
|
||||
class="border-b border-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
|
||||
class="border-b border-primary-700 flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<UiCheckbox
|
||||
|
||||
102
frontend/composables/useDataTableServerState.ts
Normal file
102
frontend/composables/useDataTableServerState.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
type FilterValue = string | number | boolean | null
|
||||
|
||||
export interface UseDataTableServerStateOptions {
|
||||
initialPerPage?: number
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useDataTableServerState<T = Record<string, unknown>>(
|
||||
endpoint: string,
|
||||
initialFilters: Record<string, FilterValue> = {},
|
||||
options: UseDataTableServerStateOptions = {}
|
||||
) {
|
||||
const api = useApi()
|
||||
|
||||
const debounceMs = options.debounceMs ?? 300
|
||||
const initialPerPage = options.initialPerPage ?? 10
|
||||
|
||||
const items = ref<T[]>([]) as { value: T[] }
|
||||
const totalItems = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(initialPerPage)
|
||||
const filters = ref<Record<string, FilterValue>>({ ...initialFilters })
|
||||
const loading = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
|
||||
const buildQueryParams = (): Record<string, string | number | boolean> => {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: page.value,
|
||||
itemsPerPage: perPage.value
|
||||
}
|
||||
for (const [key, value] of Object.entries(filters.value)) {
|
||||
if (value === '' || value === null) continue
|
||||
params[key] = value as string | number | boolean
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const fetchItems = async (): Promise<void> => {
|
||||
const currentToken = ++requestToken
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: T[]; totalItems: number }>(
|
||||
endpoint,
|
||||
buildQueryParams(),
|
||||
{
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' }
|
||||
}
|
||||
)
|
||||
if (currentToken !== requestToken) return
|
||||
items.value = data?.member ?? []
|
||||
totalItems.value = data?.totalItems ?? 0
|
||||
} finally {
|
||||
if (currentToken === requestToken) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reload = (): void => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
void fetchItems()
|
||||
}
|
||||
|
||||
const scheduleReload = (): void => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void fetchItems()
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
watch([page, perPage], () => {
|
||||
reload()
|
||||
})
|
||||
|
||||
watch(filters, () => {
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
}
|
||||
scheduleReload()
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
loading,
|
||||
reload
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,31 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between ">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
|
||||
</div>
|
||||
<div class="mt-7 border border-slate-200 mb-11 ">
|
||||
<div class="grid grid-cols-2 gap-4 text-primary-700 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Nom</div>
|
||||
<div>Code</div>
|
||||
</div>
|
||||
<div v-if="!auth.isAdmin" class="px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
<div v-else-if="bovinList.length === 0" class="px-4 py-6 text-slate-400">
|
||||
Aucun type de bovin.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="bovin in bovinList"
|
||||
:key="bovin.id"
|
||||
class="grid grid-cols-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToBovin(bovin.id)"
|
||||
@keydown.enter="goToBovin(bovin.id)"
|
||||
>
|
||||
<div>{{ bovin.label }}</div>
|
||||
<div>{{ bovin.code }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToBovin"
|
||||
>
|
||||
<template #header-label>
|
||||
<UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
|
||||
</template>
|
||||
<template #header-code>
|
||||
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/bovin"
|
||||
@@ -35,24 +33,37 @@
|
||||
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
|
||||
@click="handleAddClick"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getBovineTypeList } from "~/services/bovine-type"
|
||||
import type { BovineTypeData } from "~/services/dto/bovine-type-data"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const bovinList = ref<BovineTypeData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const goToBovin = (id: number) => {
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineTypeData>(
|
||||
'bovine_types',
|
||||
{
|
||||
label: '',
|
||||
code: ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: 'Nom' },
|
||||
{ key: 'code', label: 'Code' }
|
||||
]
|
||||
|
||||
const goToBovin = (bovin: BovineTypeData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/bovin/${id}`)
|
||||
router.push(`/admin/bovin/${bovin.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
@@ -60,8 +71,7 @@ const handleAddClick = (event: Event) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
bovinList.value = await getBovineTypeList()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,51 +1,62 @@
|
||||
<template>
|
||||
|
||||
<div class="flex items-center justify-between ">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToCarrier"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
|
||||
</template>
|
||||
<template #header-code>
|
||||
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 border border-slate-200 mb-11 ">
|
||||
<div class="grid grid-cols-2 gap-4 text-primary-700 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Label</div>
|
||||
<div>Code</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="carrier in carrierList"
|
||||
:key="carrier.id"
|
||||
class="grid grid-cols-2 text-primary-700 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>
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/carrier"
|
||||
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
|
||||
>
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
<Icon name="mdi:plus" size="28" />
|
||||
Ajouter
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {CarrierData} from "~/services/dto/carrier-data";
|
||||
import {getCarrierList} from "~/services/carrier";
|
||||
import type { CarrierData } from '~/services/dto/carrier-data'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const carrierList = ref<CarrierData[]>()
|
||||
const router = useRouter()
|
||||
|
||||
const goToCarrier = (id: number) => {
|
||||
router.push(`/admin/carrier/${id}`)
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<CarrierData>(
|
||||
'carriers',
|
||||
{
|
||||
name: '',
|
||||
code: ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Label' },
|
||||
{ key: 'code', label: 'Code' }
|
||||
]
|
||||
|
||||
const goToCarrier = (carrier: CarrierData) => {
|
||||
router.push(`/admin/carrier/${carrier.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
carrierList.value = await getCarrierList(false)
|
||||
})
|
||||
onMounted(reload)
|
||||
</script>
|
||||
|
||||
@@ -3,37 +3,35 @@
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
>
|
||||
<div>Nom</div>
|
||||
<div>Téléphone</div>
|
||||
<div>Mail</div>
|
||||
<div>Créé par</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"
|
||||
class="grid grid-cols-4 text-primary-700 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="truncate">{{ customer.createdBy?.username || "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToCustomer"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||
</template>
|
||||
<template #header-phone>
|
||||
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||
</template>
|
||||
<template #header-email>
|
||||
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
||||
</template>
|
||||
<template #header-createdBy.username>
|
||||
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/customer"
|
||||
@@ -48,17 +46,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCustomerList } from "~/services/customer"
|
||||
import type { CustomerData } from "~/services/dto/customer-data"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
import type { CustomerData } from '~/services/dto/customer-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const customerList = ref<CustomerData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const goToCustomer = (id: number) => {
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<CustomerData>(
|
||||
'customers',
|
||||
{
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
'createdBy.username': ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'phone', label: 'Téléphone' },
|
||||
{ key: 'email', label: 'Mail' },
|
||||
{ key: 'createdBy.username', label: 'Créé par' }
|
||||
]
|
||||
|
||||
const goToCustomer = (customer: CustomerData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/customer/${id}`)
|
||||
router.push(`/admin/customer/${customer.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
@@ -66,8 +81,7 @@ const handleAddClick = (event: Event) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
customerList.value = await getCustomerList()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,37 +3,35 @@
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
>
|
||||
<div>Nom</div>
|
||||
<div>Téléphone</div>
|
||||
<div>Mail</div>
|
||||
<div>Créé par</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"
|
||||
class="grid grid-cols-4 text-primary-700 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.phone || "—" }}</div>
|
||||
<div class="truncate">{{ supplier.email || "—" }}</div>
|
||||
<div class="truncate">{{ supplier.createdBy?.username || "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToSupplier"
|
||||
>
|
||||
<template #header-name>
|
||||
<UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
|
||||
</template>
|
||||
<template #header-phone>
|
||||
<UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
|
||||
</template>
|
||||
<template #header-email>
|
||||
<UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
|
||||
</template>
|
||||
<template #header-createdBy.username>
|
||||
<UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<NuxtLink
|
||||
to="/admin/supplier"
|
||||
@@ -48,17 +46,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getSupplierList } from "~/services/supplier"
|
||||
import type { SupplierData } from "~/services/dto/supplier-data"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
import type { SupplierData } from '~/services/dto/supplier-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const supplierList = ref<SupplierData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const goToSupplier = (id: number) => {
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<SupplierData>(
|
||||
'suppliers',
|
||||
{
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
'createdBy.username': ''
|
||||
}
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'phone', label: 'Téléphone' },
|
||||
{ key: 'email', label: 'Mail' },
|
||||
{ key: 'createdBy.username', label: 'Créé par' }
|
||||
]
|
||||
|
||||
const goToSupplier = (supplier: SupplierData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/supplier/${id}`)
|
||||
router.push(`/admin/supplier/${supplier.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
@@ -66,8 +81,7 @@ const handleAddClick = (event: Event) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
supplierList.value = await getSupplierList()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,42 +3,52 @@
|
||||
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
|
||||
<div class="grid grid-cols-3 text-primary-700 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Utilisateur</div>
|
||||
<div>Role</div>
|
||||
<div>Statut</div>
|
||||
</div>
|
||||
<div v-if="userList.length === 0" class="px-4 py-6 text-slate-400">
|
||||
Aucun utilisateur.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="user in userList"
|
||||
:key="user.id"
|
||||
class="grid grid-cols-3 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200 items-center"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToUser(user.id)"
|
||||
@keydown.enter="goToUser(user.id)"
|
||||
>
|
||||
<div>{{ user.username }}</div>
|
||||
<div>{{ getRoleLabels(user.roles) }}</div>
|
||||
<div>
|
||||
<span
|
||||
v-if="user.isLocked"
|
||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
|
||||
>Verrouillé</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
|
||||
>Actif</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="auth.isAdmin" class="mt-7 mb-11">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToUser"
|
||||
>
|
||||
<template #header-username>
|
||||
<UiTextInput
|
||||
v-model="filters.username"
|
||||
placeholder="Utilisateur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-roles>
|
||||
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-isLocked>
|
||||
<UiSelect
|
||||
v-model="filters.isLocked"
|
||||
placeholder="Statut"
|
||||
:options="statusOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-roles="{ item }">
|
||||
{{ getRoleLabels(item.roles) }}
|
||||
</template>
|
||||
<template #cell-isLocked="{ item }">
|
||||
<span
|
||||
v-if="item.isLocked"
|
||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
|
||||
>Verrouillé</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
|
||||
>Actif</span>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
|
||||
Acces reserve aux administrateurs.
|
||||
Accès réservé aux administrateurs.
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
@@ -55,19 +65,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from "~/services/dto/user-data"
|
||||
import { getAdminUsers } from "~/services/auth"
|
||||
import { ROLE } from "~/utils/constants"
|
||||
import { useAuthStore } from "~/stores/auth"
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { ROLE } from '~/utils/constants'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const userList = ref<UserData[]>([])
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
|
||||
const roleLabelByValue = new Map(ROLE.map(role => [role.value, role.label]))
|
||||
|
||||
const goToUser = (id: number) => {
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<UserData>(
|
||||
'admin/users',
|
||||
{
|
||||
username: '',
|
||||
isLocked: ''
|
||||
}
|
||||
)
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'false', label: 'Actif' },
|
||||
{ value: 'true', label: 'Verrouillé' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ key: 'username', label: 'Utilisateur' },
|
||||
{ key: 'roles', label: 'Role' },
|
||||
{ key: 'isLocked', label: 'Statut', width: '160px' }
|
||||
]
|
||||
|
||||
const getRoleLabels = (roles?: string[]) => {
|
||||
if (!roles || roles.length === 0) return '---'
|
||||
return roles.map(role => roleLabelByValue.get(role) ?? role).join(', ')
|
||||
}
|
||||
|
||||
const goToUser = (user: UserData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push(`/admin/user/${id}`)
|
||||
router.push(`/admin/user/${user.id}`)
|
||||
}
|
||||
|
||||
const handleAddClick = (event: Event) => {
|
||||
@@ -75,18 +109,7 @@ const handleAddClick = (event: Event) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const getRoleLabels = (roles?: string[]) => {
|
||||
if (!roles || roles.length === 0) {
|
||||
return '---'
|
||||
}
|
||||
|
||||
return roles
|
||||
.map((role) => roleLabelByValue.get(role) ?? role)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.isAdmin) return
|
||||
userList.value = await getAdminUsers()
|
||||
onMounted(() => {
|
||||
if (auth.isAdmin) reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -33,42 +33,51 @@
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border border-slate-200 mb-16">
|
||||
<div
|
||||
class="grid grid-cols-3 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
|
||||
<div class="mt-8 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:row-clickable="auth.isAdmin"
|
||||
empty-message="Aucun bovin dans cette case."
|
||||
@row-click="goToBovine"
|
||||
>
|
||||
<div>Numéro national</div>
|
||||
<div>Poids à l'arrivée (kg)</div>
|
||||
<div>Date d'arrivée</div>
|
||||
</div>
|
||||
<template v-if="bovines.length > 0">
|
||||
<div
|
||||
v-for="bovine in bovines"
|
||||
:key="bovine.id"
|
||||
class="grid grid-cols-3 gap-4 px-4 py-3 text-sm border-t border-slate-200"
|
||||
:class="auth.isAdmin ? 'cursor-pointer hover:bg-slate-50' : ''"
|
||||
:role="auth.isAdmin ? 'button' : undefined"
|
||||
:tabindex="auth.isAdmin ? 0 : undefined"
|
||||
@click="goToBovine(bovine.id)"
|
||||
@keydown.enter="goToBovine(bovine.id)"
|
||||
>
|
||||
<div>{{ bovine.nationalNumber }}</div>
|
||||
<div>{{ bovine.receivedWeight ?? '—' }}</div>
|
||||
<div>{{ formatDate(bovine.arrivalDate) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="px-4 py-3 text-sm border-t border-slate-200 text-slate-500"
|
||||
>
|
||||
Aucun bovin dans cette case.
|
||||
</div>
|
||||
<template #header-nationalNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.nationalNumber"
|
||||
placeholder="Numéro national"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receivedWeight>
|
||||
<UiTextInput
|
||||
v-model="filters.receivedWeight"
|
||||
placeholder="Poids (kg)"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-arrivalDate>
|
||||
<UiDateInput v-model="arrivalDateFilter" size="compact" />
|
||||
</template>
|
||||
<template #cell-arrivalDate="{ item }">
|
||||
{{ formatDate(item.arrivalDate) }}
|
||||
</template>
|
||||
<template #cell-receivedWeight="{ item }">
|
||||
{{ item.receivedWeight ?? '—' }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||
import type { BovineData } from '~/services/dto/bovine-data'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -80,7 +89,44 @@ const caseId = computed(() => Number(route.query.id))
|
||||
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
|
||||
|
||||
const buildingCase = ref<BuildingCaseData | null>(null)
|
||||
const bovines = computed(() => buildingCase.value?.bovines ?? [])
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<BovineData>(
|
||||
'bovines',
|
||||
{
|
||||
buildingCase: '',
|
||||
nationalNumber: '',
|
||||
receivedWeight: '',
|
||||
'arrivalDate[after]': '',
|
||||
'arrivalDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const arrivalDateFilter = computed<string>({
|
||||
get: () => (filters.value['arrivalDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['arrivalDate[after]'] = ''
|
||||
filters.value['arrivalDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['arrivalDate[after]'] = value
|
||||
filters.value['arrivalDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'nationalNumber', label: 'Numéro national' },
|
||||
{ key: 'receivedWeight', label: "Poids à l'arrivée (kg)" },
|
||||
{ key: 'arrivalDate', label: "Date d'arrivée" }
|
||||
]
|
||||
|
||||
const title = computed(() => {
|
||||
if (!buildingCase.value) return ''
|
||||
@@ -114,21 +160,27 @@ const loadCase = async () => {
|
||||
}
|
||||
|
||||
const printCaseReport = async () => {
|
||||
if (!hasCaseId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasCaseId.value) return
|
||||
const filename = `tableau_poids_case_${caseId.value}.pdf`
|
||||
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
|
||||
}
|
||||
|
||||
const goToBovine = (id: number) => {
|
||||
const goToBovine = (bovine: BovineData) => {
|
||||
if (!auth.isAdmin) return
|
||||
router.push({
|
||||
path: '/infrastructure/bovine',
|
||||
query: { id: String(id), caseId: String(caseId.value) }
|
||||
query: { id: String(bovine.id), caseId: String(caseId.value) }
|
||||
})
|
||||
}
|
||||
|
||||
watch(caseId, loadCase, { immediate: true })
|
||||
watch(caseId, (id) => {
|
||||
if (!hasCaseId.value) {
|
||||
filters.value.buildingCase = ''
|
||||
buildingCase.value = null
|
||||
return
|
||||
}
|
||||
filters.value.buildingCase = `/api/building_cases/${id}`
|
||||
loadCase()
|
||||
reload()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -5,41 +5,126 @@
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<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 et heure</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 class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToReception"
|
||||
>
|
||||
<div>{{ reception.identificationNumber}}</div>
|
||||
<div>{{ formatDate(reception.receptionDate) }}</div>
|
||||
<div>{{ reception.supplier?.name }}</div>
|
||||
<div>{{ reception.address?.fullAddress }}</div>
|
||||
<div>{{ reception.receptionType?.label }}</div>
|
||||
<div>{{ formatWeighing(reception) }}</div>
|
||||
</div>
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionDate>
|
||||
<UiDateInput
|
||||
v-model="receptionDateFilter"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['supplier.name']"
|
||||
placeholder="Fournisseur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput
|
||||
:model-value="''"
|
||||
placeholder="Adresse"
|
||||
size="compact"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
<template #header-receptionType.label>
|
||||
<UiSelect
|
||||
v-model="filters['receptionType.id']"
|
||||
placeholder="Type réception"
|
||||
:options="receptionTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-weighing>
|
||||
<UiTextInput
|
||||
:model-value="''"
|
||||
placeholder="Poids"
|
||||
size="compact"
|
||||
disabled
|
||||
/>
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #cell-weighing="{ item }">
|
||||
{{ formatWeighing(item) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const receptionList = ref<ReceptionData[]>()
|
||||
const router = useRouter()
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
|
||||
const receptionTypeOptions = computed(() =>
|
||||
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
isValid: true,
|
||||
'identificationNumber': '',
|
||||
'supplier.name': '',
|
||||
'receptionType.id': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const receptionDateFilter = computed<string>({
|
||||
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['receptionDate[after]'] = ''
|
||||
filters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['receptionDate[after]'] = value
|
||||
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },
|
||||
{ key: 'weighing', label: 'Poids', width: '82px' }
|
||||
]
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
@@ -65,11 +150,12 @@ const formatWeighing = (reception: ReceptionData) => {
|
||||
return `${gross - tare} kg`
|
||||
}
|
||||
|
||||
const goToReception = (id: number) => {
|
||||
router.push(`/reception/update/${id}`)
|
||||
const goToReception = (reception: ReceptionData) => {
|
||||
router.push(`/reception/update/${reception.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
receptionList.value = await getReceptionList(true)
|
||||
receptionTypes.value = await getReceptionTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,43 +1,135 @@
|
||||
<template>
|
||||
<WorkflowWaitingList
|
||||
title="listes des réceptions en attente"
|
||||
:columns="columns"
|
||||
:items="receptionList ?? []"
|
||||
route-prefix="/reception"
|
||||
:show-actions="auth.isAdmin"
|
||||
>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
</WorkflowWaitingList>
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:show-actions="auth.isAdmin"
|
||||
row-clickable
|
||||
@row-click="goToReception"
|
||||
>
|
||||
<template #header-receptionDate>
|
||||
<UiDateInput v-model="receptionDateFilter" size="compact" />
|
||||
</template>
|
||||
<template #header-supplier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['supplier.name']"
|
||||
placeholder="Fournisseur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-receptionType.label>
|
||||
<UiSelect
|
||||
v-model="filters['receptionType.id']"
|
||||
placeholder="Type réception"
|
||||
:options="receptionTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-carrier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['carrier.name']"
|
||||
placeholder="Transporteur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-licensePlate>
|
||||
<UiTextInput
|
||||
v-model="filters['licensePlate']"
|
||||
placeholder="Immatriculation"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<UiTextInput :model-value="''" placeholder="Actions" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-receptionDate="{ item }">
|
||||
{{ formatDate(item.receptionDate) }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import { getReceptionList, deleteReception } from '~/services/reception'
|
||||
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
|
||||
import { deleteReception } from '~/services/reception'
|
||||
import { getReceptionTypeList } from '~/services/reception-type'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const receptionTypes = ref<ReceptionTypeData[]>([])
|
||||
|
||||
const receptionTypeOptions = computed(() =>
|
||||
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ReceptionData>(
|
||||
'receptions',
|
||||
{
|
||||
isValid: false,
|
||||
'supplier.name': '',
|
||||
'carrier.name': '',
|
||||
'licensePlate': '',
|
||||
'receptionType.id': '',
|
||||
'receptionDate[after]': '',
|
||||
'receptionDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const receptionDateFilter = computed<string>({
|
||||
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['receptionDate[after]'] = ''
|
||||
filters.value['receptionDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['receptionDate[after]'] = value
|
||||
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'receptionDate', label: 'Date et heure' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse' },
|
||||
{ key: 'receptionType.label', label: 'Type réception' },
|
||||
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
|
||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'receptionType.label', label: 'Type réception', width: '1.1fr' },
|
||||
{ key: 'carrier.name', label: 'Transporteur' },
|
||||
{ key: 'licensePlate', label: 'Immatriculation' }
|
||||
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
|
||||
]
|
||||
|
||||
const receptionList = ref<ReceptionData[]>()
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
@@ -51,6 +143,10 @@ const formatDate = (date: string | null) => {
|
||||
})
|
||||
}
|
||||
|
||||
const goToReception = (reception: ReceptionData) => {
|
||||
router.push(`/reception/${reception.id}`)
|
||||
}
|
||||
|
||||
const confirmDelete = async (reception: ReceptionData) => {
|
||||
const confirmed = window.confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer la réception ${reception.identificationNumber ?? `#${reception.id}`} ? Toutes les données liées seront supprimées.`
|
||||
@@ -58,10 +154,11 @@ const confirmDelete = async (reception: ReceptionData) => {
|
||||
if (!confirmed) return
|
||||
|
||||
await deleteReception(reception.id)
|
||||
receptionList.value = receptionList.value?.filter(r => r.id !== reception.id)
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
receptionList.value = await getReceptionList(false)
|
||||
receptionTypes.value = await getReceptionTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,51 +5,148 @@
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<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>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 class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
row-clickable
|
||||
@row-click="goToShipment"
|
||||
>
|
||||
<div>{{ shipment.identificationNumber }}</div>
|
||||
<div>{{ shipment.shipmentDate }}</div>
|
||||
<div>{{ shipment.customer?.name }}</div>
|
||||
<div>{{ shipment.address?.fullAddress }}</div>
|
||||
<div>
|
||||
<template v-if="formatShipmentLines(shipment).length">
|
||||
<template #header-identificationNumber>
|
||||
<UiTextInput
|
||||
v-model="filters.identificationNumber"
|
||||
placeholder="Numéro"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-shipmentDate>
|
||||
<UiDateInput v-model="shipmentDateFilter" size="compact" />
|
||||
</template>
|
||||
<template #header-customer.name>
|
||||
<UiTextInput
|
||||
v-model="filters['customer.name']"
|
||||
placeholder="Client"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-shipmentType.label>
|
||||
<UiSelect
|
||||
v-model="filters['shipmentType.id']"
|
||||
placeholder="Type d'expédition"
|
||||
:options="shipmentTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-weighing>
|
||||
<UiTextInput :model-value="''" placeholder="Poids" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-shipmentDate="{ item }">
|
||||
{{ formatDate(item.shipmentDate) }}
|
||||
</template>
|
||||
<template #cell-shipmentType.label="{ item }">
|
||||
<template v-if="formatShipmentLines(item).length">
|
||||
<div
|
||||
v-for="(line, index) in formatShipmentLines(shipment)"
|
||||
v-for="(line, index) in formatShipmentLines(item)"
|
||||
:key="index"
|
||||
class="leading-5"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>{{ formatWeighing(shipment) }}</div>
|
||||
</div>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
<template #cell-weighing="{ item }">
|
||||
{{ formatWeighing(item) }}
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ShipmentData} from "~/services/dto/shipment-data";
|
||||
import {getShipmentList} from "~/services/shipment";
|
||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const shipmentList = ref<ShipmentData[]>()
|
||||
const router = useRouter()
|
||||
const shipmentTypes = ref<ShipmentTypeData[]>([])
|
||||
|
||||
const shipmentTypeOptions = computed(() =>
|
||||
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ShipmentData>(
|
||||
'shipments',
|
||||
{
|
||||
isValid: true,
|
||||
'identificationNumber': '',
|
||||
'customer.name': '',
|
||||
'shipmentType.id': '',
|
||||
'shipmentDate[after]': '',
|
||||
'shipmentDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const shipmentDateFilter = computed<string>({
|
||||
get: () => (filters.value['shipmentDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['shipmentDate[after]'] = ''
|
||||
filters.value['shipmentDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['shipmentDate[after]'] = value
|
||||
filters.value['shipmentDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||
{ key: 'shipmentDate', label: 'Date', width: '120px' },
|
||||
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'shipmentType.label', label: "Type d'expédition", width: '1.1fr' },
|
||||
{ key: 'weighing', label: 'Poids', width: '82px' }
|
||||
]
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
if (isNaN(d.getTime())) return date
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatShipmentLines = (shipment: ShipmentData) => {
|
||||
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
|
||||
return []
|
||||
}
|
||||
const label = typeof shipment.shipmentType === 'string'
|
||||
? shipment.shipmentType
|
||||
: shipment.shipmentType?.label
|
||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
||||
}
|
||||
|
||||
const formatWeighing = (shipment: ShipmentData) => {
|
||||
const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
|
||||
@@ -62,24 +159,12 @@ const formatWeighing = (shipment: ShipmentData) => {
|
||||
return `${gross - tare} kg`
|
||||
}
|
||||
|
||||
|
||||
const formatShipmentLines = (shipment: ShipmentData) => {
|
||||
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
|
||||
return []
|
||||
}
|
||||
|
||||
const label = typeof shipment.shipmentType === 'string'
|
||||
? shipment.shipmentType
|
||||
: shipment.shipmentType?.label
|
||||
|
||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
||||
}
|
||||
|
||||
const goShipment = (id: number) => {
|
||||
router.push(`/shipment/update/${id}`)
|
||||
const goToShipment = (shipment: ShipmentData) => {
|
||||
router.push(`/shipment/update/${shipment.id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
shipmentList.value = await getShipmentList(true)
|
||||
shipmentTypes.value = await getShipmentTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,55 +1,147 @@
|
||||
<template>
|
||||
<WorkflowWaitingList
|
||||
title="listes des expéditions en attente"
|
||||
:columns="columns"
|
||||
:items="shipmentList ?? []"
|
||||
route-prefix="/shipment"
|
||||
:show-actions="auth.isAdmin"
|
||||
>
|
||||
<template #cell-shipmentDate="{ item }">
|
||||
{{ formatDate(item.shipmentDate) }}
|
||||
</template>
|
||||
<template #cell-shipmentType="{ item }">
|
||||
<template v-if="formatShipmentLines(item).length">
|
||||
<div
|
||||
v-for="(line, index) in formatShipmentLines(item)"
|
||||
:key="index"
|
||||
class="leading-5"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
</WorkflowWaitingList>
|
||||
<div class="flex items-center justify-start gap-10">
|
||||
<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 expéditions en attente</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-[86px]">
|
||||
<div class="mt-6 mb-16">
|
||||
<UiDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:total-items="totalItems"
|
||||
:loading="loading"
|
||||
:show-actions="auth.isAdmin"
|
||||
row-clickable
|
||||
@row-click="goToShipment"
|
||||
>
|
||||
<template #header-shipmentDate>
|
||||
<UiDateInput v-model="shipmentDateFilter" size="compact" />
|
||||
</template>
|
||||
<template #header-customer.name>
|
||||
<UiTextInput
|
||||
v-model="filters['customer.name']"
|
||||
placeholder="Client"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-address.fullAddress>
|
||||
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
|
||||
</template>
|
||||
<template #header-shipmentType.label>
|
||||
<UiSelect
|
||||
v-model="filters['shipmentType.id']"
|
||||
placeholder="Type d'expé."
|
||||
:options="shipmentTypeOptions"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-carrier.name>
|
||||
<UiTextInput
|
||||
v-model="filters['carrier.name']"
|
||||
placeholder="Transporteur"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-licensePlate>
|
||||
<UiTextInput
|
||||
v-model="filters['licensePlate']"
|
||||
placeholder="Immatriculation"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<UiTextInput :model-value="''" placeholder="Actions" size="compact" disabled />
|
||||
</template>
|
||||
<template #cell-shipmentDate="{ item }">
|
||||
{{ formatDate(item.shipmentDate) }}
|
||||
</template>
|
||||
<template #cell-shipmentType.label="{ item }">
|
||||
<template v-if="formatShipmentLines(item).length">
|
||||
<div
|
||||
v-for="(line, index) in formatShipmentLines(item)"
|
||||
:key="index"
|
||||
class="leading-5"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<Icon
|
||||
name="mdi:delete-outline"
|
||||
size="24"
|
||||
class="cursor-pointer text-red-500 hover:text-red-700"
|
||||
@click="confirmDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
</UiDataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ShipmentData } from '~/services/dto/shipment-data'
|
||||
import { getShipmentList, deleteShipment } from '~/services/shipment'
|
||||
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
|
||||
import { deleteShipment } from '~/services/shipment'
|
||||
import { getShipmentTypeList } from '~/services/shipment-type'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const shipmentTypes = ref<ShipmentTypeData[]>([])
|
||||
|
||||
const shipmentTypeOptions = computed(() =>
|
||||
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
|
||||
)
|
||||
|
||||
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
useDataTableServerState<ShipmentData>(
|
||||
'shipments',
|
||||
{
|
||||
isValid: false,
|
||||
'customer.name': '',
|
||||
'carrier.name': '',
|
||||
'licensePlate': '',
|
||||
'shipmentType.id': '',
|
||||
'shipmentDate[after]': '',
|
||||
'shipmentDate[strictly_before]': ''
|
||||
},
|
||||
{ initialPerPage: 10 }
|
||||
)
|
||||
|
||||
const addOneDay = (dateString: string): string => {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
const next = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
return next.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const shipmentDateFilter = computed<string>({
|
||||
get: () => (filters.value['shipmentDate[after]'] as string) ?? '',
|
||||
set: (value: string) => {
|
||||
if (!value) {
|
||||
filters.value['shipmentDate[after]'] = ''
|
||||
filters.value['shipmentDate[strictly_before]'] = ''
|
||||
return
|
||||
}
|
||||
filters.value['shipmentDate[after]'] = value
|
||||
filters.value['shipmentDate[strictly_before]'] = addOneDay(value)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'shipmentDate', label: 'Date et heure' },
|
||||
{ key: 'customer.name', label: 'Client' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse' },
|
||||
{ key: 'shipmentType', label: "Type d'expé." },
|
||||
{ key: 'shipmentDate', label: 'Date et heure', width: '120px' },
|
||||
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
|
||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||
{ key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' },
|
||||
{ key: 'carrier.name', label: 'Transporteur' },
|
||||
{ key: 'licensePlate', label: 'Immatriculation' }
|
||||
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
|
||||
]
|
||||
|
||||
const shipmentList = ref<ShipmentData[]>()
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date.replace(' ', 'T'))
|
||||
@@ -73,6 +165,10 @@ const formatShipmentLines = (shipment: ShipmentData) => {
|
||||
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
|
||||
}
|
||||
|
||||
const goToShipment = (shipment: ShipmentData) => {
|
||||
router.push(`/shipment/${shipment.id}`)
|
||||
}
|
||||
|
||||
const confirmDelete = async (shipment: ShipmentData) => {
|
||||
const confirmed = window.confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer l'expédition ${shipment.identificationNumber ?? `#${shipment.id}`} ? Toutes les données liées seront supprimées.`
|
||||
@@ -80,10 +176,11 @@ const confirmDelete = async (shipment: ShipmentData) => {
|
||||
if (!confirmed) return
|
||||
|
||||
await deleteShipment(shipment.id)
|
||||
shipmentList.value = shipmentList.value?.filter(s => s.id !== shipment.id)
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
shipmentList.value = await getShipmentList(false)
|
||||
shipmentTypes.value = await getShipmentTypeList()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
@@ -19,6 +22,12 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'bovine')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'nationalNumber' => 'ipartial',
|
||||
'buildingCase' => 'exact',
|
||||
'receivedWeight' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
@@ -13,6 +15,10 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'label' => 'ipartial',
|
||||
'code' => 'ipartial',
|
||||
])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
@@ -14,6 +16,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier')]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'ipartial',
|
||||
'code' => 'ipartial',
|
||||
])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -17,6 +19,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'customer')]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'ipartial',
|
||||
'email' => 'ipartial',
|
||||
'phone' => 'ipartial',
|
||||
'createdBy.username' => 'ipartial',
|
||||
])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
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\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
@@ -30,6 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'reception')]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'identificationNumber' => 'ipartial',
|
||||
'supplier.name' => 'ipartial',
|
||||
'carrier.name' => 'ipartial',
|
||||
'licensePlate' => 'ipartial',
|
||||
'receptionType.id' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
|
||||
#[ApiResource(
|
||||
order: ['id' => 'DESC'],
|
||||
operations: [
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
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\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
@@ -30,6 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'shipment')]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'identificationNumber' => 'ipartial',
|
||||
'customer.name' => 'ipartial',
|
||||
'carrier.name' => 'ipartial',
|
||||
'licensePlate' => 'ipartial',
|
||||
'shipmentType.id' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['shipmentDate'])]
|
||||
#[ApiResource(
|
||||
order: ['id' => 'DESC'],
|
||||
operations: [
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -17,6 +19,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'supplier')]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'ipartial',
|
||||
'email' => 'ipartial',
|
||||
'phone' => 'ipartial',
|
||||
'createdBy.username' => 'ipartial',
|
||||
])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
@@ -20,6 +23,8 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'user', schema: 'public')]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['username' => 'ipartial'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isLocked'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
@@ -53,7 +58,8 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
new GetCollection(
|
||||
uriTemplate: '/admin/users',
|
||||
normalizationContext: ['groups' => ['user:read']],
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
paginationEnabled: true
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['user:read']],
|
||||
|
||||
Reference in New Issue
Block a user