refacto(tables) : composant DataTable global + migration de toutes les tables
- Nouveau composant DataTable réutilisable avec tri par en-têtes, pagination, filtres colonnes - Nouveau composable useDataTable (sort/page/search/perPage/columnFilters + persistance URL) - Migration des 9 tables : constructeurs, comments, admin, pieces-catalog, component-catalog, product-catalog, documents, activity-log, ManagementView (catégories) - Filtres "Type de" server-side (ipartial) pour pièces, composants, produits Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,51 +19,8 @@
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-sort">Trier par</label>
|
||||
<select
|
||||
id="product-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-dir">Ordre</label>
|
||||
<select
|
||||
id="product-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 lg:text-right">
|
||||
{{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="errorMessage"
|
||||
v-if="errorMessage"
|
||||
class="alert alert-error"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -75,96 +32,107 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70">
|
||||
Chargement du catalogue…
|
||||
</p>
|
||||
<DataTable
|
||||
v-else
|
||||
:columns="columns"
|
||||
:rows="productRows"
|
||||
:loading="loadingProducts"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun produit n'a encore été enregistré."
|
||||
no-results-message="Aucun produit ne correspond à votre recherche."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@update:column-filters="table.handleColumnFiltersChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="table.searchTerm.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="table.debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<p v-else-if="!normalizedProducts.length" class="text-sm text-base-content/70">
|
||||
Aucun produit n'a encore été enregistré.
|
||||
</p>
|
||||
<template #cell-preview="{ row }">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.product)"
|
||||
:alt="resolvePreviewAlt(row.product)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70">
|
||||
Aucun produit ne correspond à votre recherche.
|
||||
</p>
|
||||
<template #cell-name="{ row }">
|
||||
<span class="font-medium">{{ row.product.name }}</span>
|
||||
</template>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-16">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de produit</th>
|
||||
<th>Fournisseurs</th>
|
||||
<th class="text-right">Prix indicatif</th>
|
||||
<th class="w-32 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in productRows" :key="row.product.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.product)"
|
||||
:alt="resolvePreviewAlt(row.product)"
|
||||
/>
|
||||
</td>
|
||||
<td class="font-medium">{{ row.product.name }}</td>
|
||||
<td>{{ row.product.reference || '—' }}</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="row.product.typeProduct?.id"
|
||||
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ row.product.typeProduct.name }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
||||
:title="row.suppliers.tooltip"
|
||||
>
|
||||
<span
|
||||
v-for="supplier in row.suppliers.visible"
|
||||
:key="supplier"
|
||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||
>
|
||||
{{ supplier }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.suppliers.overflow"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
+{{ row.suppliers.overflow }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-base-content/50">—</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ formatPrice(row.product.supplierPrice) }}
|
||||
</td>
|
||||
<td class="text-right space-x-2">
|
||||
<NuxtLink
|
||||
:to="`/product/${row.product.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="confirmDelete(row.product)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #cell-reference="{ row }">
|
||||
{{ row.product.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-typeProduct="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.product.typeProduct?.id"
|
||||
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ row.product.typeProduct.name }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-suppliers="{ row }">
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
||||
:title="row.suppliers.tooltip"
|
||||
>
|
||||
<span
|
||||
v-for="supplier in row.suppliers.visible"
|
||||
:key="supplier"
|
||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||
>
|
||||
{{ supplier }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.suppliers.overflow"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
+{{ row.suppliers.overflow }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-base-content/50">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-price="{ row }">
|
||||
{{ formatPrice(row.product.supplierPrice) }}
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<NuxtLink
|
||||
:to="`/product/${row.product.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="confirmDelete(row.product)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -173,24 +141,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useHead } from '#imports'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
useHead(() => ({
|
||||
title: 'Catalogue des produits',
|
||||
}))
|
||||
useHead(() => ({ title: 'Catalogue des produits' }))
|
||||
|
||||
const {
|
||||
products,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
loadProducts,
|
||||
deleteProduct,
|
||||
@@ -198,65 +164,54 @@ const {
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const toast = useToast()
|
||||
|
||||
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
|
||||
q: { default: '', debounce: 300 },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
})
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchProducts },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||
)
|
||||
|
||||
// Enrichir les produits avec les types de produits complets
|
||||
const loadingProducts = computed(() => loading.value)
|
||||
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
|
||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
|
||||
]
|
||||
|
||||
const productsOnPage = computed(() => productRows.value.length)
|
||||
const paginationState = table.pagination(total, productsOnPage)
|
||||
|
||||
// Enrich products with full type data
|
||||
const normalizedProducts = computed(() => {
|
||||
return (Array.isArray(products.value) ? products.value : []).map((product) => {
|
||||
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
|
||||
return {
|
||||
...product,
|
||||
typeProduct: typeProduct || product.typeProduct || null
|
||||
}
|
||||
})
|
||||
})
|
||||
const hasLoaded = computed(() => loaded.value)
|
||||
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const items = normalizedProducts.value.slice()
|
||||
|
||||
const filtered = term
|
||||
? items.filter((product) => {
|
||||
const name = (product?.name || '').toLowerCase()
|
||||
const reference = (product?.reference || '').toLowerCase()
|
||||
const typeName = (product?.typeProduct?.name || '').toLowerCase()
|
||||
return (
|
||||
name.includes(term) ||
|
||||
reference.includes(term) ||
|
||||
typeName.includes(term)
|
||||
)
|
||||
})
|
||||
: items
|
||||
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (sortField.value === 'name') {
|
||||
return (
|
||||
(a?.name || '').localeCompare(b?.name || '', 'fr', { sensitivity: 'base' })
|
||||
) * direction
|
||||
}
|
||||
|
||||
const dateA = a?.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const dateB = b?.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return (dateA - dateB) * direction
|
||||
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
|
||||
})
|
||||
})
|
||||
|
||||
const filteredCount = computed(() => filteredProducts.value.length)
|
||||
const totalCount = computed(() => {
|
||||
const reported = Number(total.value)
|
||||
if (!Number.isFinite(reported) || reported < 0) {
|
||||
return normalizedProducts.value.length
|
||||
}
|
||||
return reported
|
||||
})
|
||||
const productRows = computed(() =>
|
||||
normalizedProducts.value.map(product => ({
|
||||
id: product.id,
|
||||
product,
|
||||
suppliers: buildSuppliersDisplay(product),
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchProducts() {
|
||||
await loadProducts({
|
||||
search: table.searchTerm.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
typeName: table.columnFilters.value.typeProduct || undefined,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
@@ -265,14 +220,9 @@ const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
})
|
||||
|
||||
const formatPrice = (value: any) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
const number = Number(value)
|
||||
if (Number.isNaN(number)) {
|
||||
return '—'
|
||||
}
|
||||
return priceFormatter.format(number)
|
||||
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SUPPLIERS = 3
|
||||
@@ -282,59 +232,34 @@ const resolveProductSuppliers = (product: Record<string, any>) => {
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushName = (maybeName: unknown) => {
|
||||
if (typeof maybeName !== 'string') {
|
||||
return
|
||||
}
|
||||
if (typeof maybeName !== 'string') return
|
||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||
if (!normalized.length) {
|
||||
return
|
||||
}
|
||||
if (!normalized.length) return
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
names.push(normalized)
|
||||
}
|
||||
|
||||
const collectConstructeurs = (value: unknown): void => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(collectConstructeurs)
|
||||
return
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
pushName(value)
|
||||
return
|
||||
}
|
||||
if (!value) return
|
||||
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||
if (typeof value === 'string') { pushName(value); return }
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, any>
|
||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||
if (record?.constructeur) {
|
||||
collectConstructeurs(record.constructeur)
|
||||
}
|
||||
if (Array.isArray(record?.constructeurs)) {
|
||||
collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
}
|
||||
|
||||
const collectFromLabel = (value: unknown): void => {
|
||||
if (typeof value !== 'string') {
|
||||
return
|
||||
}
|
||||
value
|
||||
.split(/[,;\\/•·|]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(pushName)
|
||||
if (typeof value !== 'string') return
|
||||
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||
}
|
||||
|
||||
collectConstructeurs(product?.constructeurs)
|
||||
collectConstructeurs(product?.constructeur)
|
||||
|
||||
collectFromLabel(product?.constructeursLabel)
|
||||
collectFromLabel(product?.supplierLabel)
|
||||
collectFromLabel(product?.suppliers)
|
||||
@@ -346,53 +271,28 @@ const buildSuppliersDisplay = (product: Record<string, any>) => {
|
||||
const suppliers = resolveProductSuppliers(product)
|
||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||
return {
|
||||
suppliers,
|
||||
visible,
|
||||
overflow,
|
||||
tooltip: suppliers.length ? suppliers.join(', ') : '',
|
||||
}
|
||||
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||
}
|
||||
|
||||
const productRows = computed(() =>
|
||||
filteredProducts.value.map((product) => ({
|
||||
product,
|
||||
suppliers: buildSuppliersDisplay(product),
|
||||
})),
|
||||
)
|
||||
|
||||
const resolvePrimaryDocument = (product: Record<string, any>) => {
|
||||
const documents = Array.isArray(product?.documents) ? product.documents : []
|
||||
if (!documents.length) {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) {
|
||||
return normalized[0] ?? null
|
||||
}
|
||||
const images = withPath.filter((doc) => isImageDocument(doc))
|
||||
if (images.length) {
|
||||
return images[0]
|
||||
}
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
return pdf
|
||||
}
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) return normalized[0] ?? null
|
||||
const images = withPath.filter((doc: any) => isImageDocument(doc))
|
||||
if (images.length) return images[0]
|
||||
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||
if (pdf) return pdf
|
||||
return withPath[0]
|
||||
}
|
||||
|
||||
const resolvePreviewAlt = (product: Record<string, any>) => {
|
||||
const parts = [product?.name, product?.reference].filter(Boolean)
|
||||
if (parts.length) {
|
||||
return `Aperçu du document de ${parts.join(' – ')}`
|
||||
}
|
||||
return 'Aperçu du document'
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
await loadProducts({ itemsPerPage: 200, force: true })
|
||||
}
|
||||
const reload = () => fetchProducts()
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -400,10 +300,7 @@ const confirmDelete = async (product: Record<string, any>) => {
|
||||
const confirmed = await confirm({
|
||||
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirmed) return
|
||||
const result = await deleteProduct(product.id)
|
||||
if (result.success) {
|
||||
toast.showSuccess(`Produit "${product.name}" supprimé`)
|
||||
@@ -411,9 +308,6 @@ const confirmDelete = async (product: Record<string, any>) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadProducts({ itemsPerPage: 200, force: true }),
|
||||
loadProductTypes()
|
||||
])
|
||||
await Promise.all([fetchProducts(), loadProductTypes()])
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user