feat : UiDataTable avec pagination server-side et loader
- Composant UiDataTable (pagination, slots header/cell/actions/empty) - Composable useDataTableServerState (token anti-race, debounce filtres) - Migration de la page réceptions finies sur le nouveau pattern - pagination_client_items_per_page activé globalement Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ api_platform:
|
|||||||
stateless: true
|
stateless: true
|
||||||
cache_headers:
|
cache_headers:
|
||||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||||
|
pagination_client_items_per_page: true
|
||||||
|
pagination_maximum_items_per_page: 100
|
||||||
formats:
|
formats:
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
|
|||||||
230
frontend/components/ui/UiDataTable.vue
Normal file
230
frontend/components/ui/UiDataTable.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="relative border border-slate-200">
|
||||||
|
<div
|
||||||
|
class="grid gap-4 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">
|
||||||
|
<slot :name="`header-${col.key}`" :column="col">{{ col.label }}</slot>
|
||||||
|
</div>
|
||||||
|
<div v-if="showActions">Actions</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-4 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">
|
||||||
|
<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="rounded border border-slate-300 bg-white px-2 py-1 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 5,
|
||||||
|
perPageOptions: () => [5, 10, 25],
|
||||||
|
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(() => '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>
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,42 +5,50 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-[86px]">
|
<div class="px-[86px]">
|
||||||
<div class="mt-6 border border-slate-200 mb-16 ">
|
<div class="mt-6 mb-16">
|
||||||
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
<UiDataTable
|
||||||
<div>Numéro</div>
|
v-model:page="page"
|
||||||
<div>Date et heure</div>
|
v-model:per-page="perPage"
|
||||||
<div>Fournisseur</div>
|
:columns="columns"
|
||||||
<div>Adresse</div>
|
:items="items"
|
||||||
<div>Type réception</div>
|
:total-items="totalItems"
|
||||||
<div>Poids</div>
|
:loading="loading"
|
||||||
</div>
|
row-clickable
|
||||||
<div
|
@row-click="goToReception"
|
||||||
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>
|
<template #cell-receptionDate="{ item }">
|
||||||
<div>{{ formatDate(reception.receptionDate) }}</div>
|
{{ formatDate(item.receptionDate) }}
|
||||||
<div>{{ reception.supplier?.name }}</div>
|
</template>
|
||||||
<div>{{ reception.address?.fullAddress }}</div>
|
<template #cell-weighing="{ item }">
|
||||||
<div>{{ reception.receptionType?.label }}</div>
|
{{ formatWeighing(item) }}
|
||||||
<div>{{ formatWeighing(reception) }}</div>
|
</template>
|
||||||
</div>
|
</UiDataTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {ReceptionData} from "~/services/dto/reception-data";
|
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||||
import {getReceptionList} from "~/services/reception";
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
import type {ShipmentData} from "~/services/dto/shipment-data";
|
|
||||||
|
|
||||||
const receptionList = ref<ReceptionData[]>()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { items, totalItems, page, perPage, loading, reload } =
|
||||||
|
useDataTableServerState<ReceptionData>(
|
||||||
|
'receptions',
|
||||||
|
{ isValid: true },
|
||||||
|
{ initialPerPage: 5 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'identificationNumber', label: 'Numéro' },
|
||||||
|
{ key: 'receptionDate', label: 'Date et heure' },
|
||||||
|
{ key: 'supplier.name', label: 'Fournisseur' },
|
||||||
|
{ key: 'address.fullAddress', label: 'Adresse' },
|
||||||
|
{ key: 'receptionType.label', label: 'Type réception' },
|
||||||
|
{ key: 'weighing', label: 'Poids' }
|
||||||
|
]
|
||||||
|
|
||||||
const formatDate = (date: string | null) => {
|
const formatDate = (date: string | null) => {
|
||||||
if (!date) return '—'
|
if (!date) return '—'
|
||||||
const d = new Date(date.replace(' ', 'T'))
|
const d = new Date(date.replace(' ', 'T'))
|
||||||
@@ -65,11 +73,9 @@ const formatWeighing = (reception: ReceptionData) => {
|
|||||||
return `${gross - tare} kg`
|
return `${gross - tare} kg`
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToReception = (id: number) => {
|
const goToReception = (reception: ReceptionData) => {
|
||||||
router.push(`/reception/update/${id}`)
|
router.push(`/reception/update/${reception.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(reload)
|
||||||
receptionList.value = await getReceptionList(true)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user