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:
Matthieu
2026-03-04 16:05:00 +01:00
parent 89dc2e93b8
commit 6f1bac381d
16 changed files with 1945 additions and 1943 deletions

View File

@@ -26,174 +26,107 @@
</p>
</header>
<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">
<DataTable
:columns="columns"
:rows="componentRows"
:loading="loadingComposants"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun composant n'a encore été créé."
no-results-message="Aucun composant 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="searchTerm"
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
@input="debouncedSearch"
@input="table.debouncedSearch"
/>
</label>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="component-catalog-sort"
>
Trier par
</label>
<select
id="component-catalog-sort"
v-model="sortField"
class="select select-bordered select-sm"
@change="handleSortChange"
>
<option value="name">Nom</option>
<option value="createdAt">Date de création</option>
</select>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.component)"
:alt="resolvePreviewAlt(row.component)"
/>
</template>
<template #cell-name="{ row }">
{{ row.component.name || 'Composant sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.component.reference || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.component.description" class="group relative">
<span class="block cursor-help truncate">{{ row.component.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
</div>
</div>
<span v-else></span>
</template>
<template #cell-typeComposant="{ row }">
<NuxtLink
v-if="row.component.typeComposant?.id"
:to="`/component-category/${row.component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(row.component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(row.component) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="component-catalog-dir"
<NuxtLink
:to="`/component/${row.component.id}/edit`"
class="btn btn-ghost btn-xs"
>
Ordre
</label>
<select
id="component-catalog-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
@change="handleSortChange"
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingComposants"
@click="handleDeleteComponent(row.component)"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
Supprimer
</button>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="component-catalog-per-page"
>
Par page
</label>
<select
id="component-catalog-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handlePerPageChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/50 lg:text-right">
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loadingComposants" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!composantsTotal" class="text-sm text-base-content/70">
Aucun composant n'a encore été créé.
</p>
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
Aucun composant ne correspond à votre recherche.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th class="w-24">Aperçu</th>
<th>Nom</th>
<th>Référence</th>
<th>Description</th>
<th>Type de composant</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="component in composantsList" :key="component.id">
<td class="align-middle">
<DocumentThumbnail
:document="resolvePrimaryDocument(component)"
:alt="resolvePreviewAlt(component)"
/>
</td>
<td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td>
<td class="max-w-xs">
<div v-if="component.description" class="group relative">
<span class="block cursor-help truncate">{{ component.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ component.description }}</p>
</div>
</div>
<span v-else></span>
</td>
<td>
<NuxtLink
v-if="component.typeComposant?.id"
:to="`/component-category/${component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(component) }}</span>
</td>
<td>
<div class="flex items-center gap-2">
<NuxtLink
:to="`/component/${component.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="loadingComposants"
@click="handleDeleteComponent(component)"
>
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import { useUrlState } from '~/composables/useUrlState'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { canEdit } = usePermissions()
@@ -202,175 +135,110 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value)
// State synced with URL query params (preserved on back/forward navigation)
const {
page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 20, type: 'number' },
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
}, {
onRestore: () => fetchComposants(),
})
const table = useDataTable(
{ fetchData: fetchComposants },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
)
const composantsTotal = computed(() => total.value)
const composantsOnPage = computed(() => composants.value.length)
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'description', label: 'Description' },
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
// Search debounce for API calls
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const composantsOnPage = computed(() => componentRows.value.length)
const paginationState = table.pagination(total, composantsOnPage)
const debouncedSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchComposants()
}, 300)
}
// Enrichir les composants avec les types de composants complets
// Enrich composants with full type data
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
return {
...composant,
typeComposant: typeComposant || composant.typeComposant || null
}
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
})
})
const fetchComposants = async () => {
const componentRows = computed(() =>
composantsList.value.map(component => ({
id: component.id,
component,
})),
)
async function fetchComposants() {
await loadComposants({
search: searchTerm.value,
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value as 'asc' | 'desc',
force: true
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.typeComposant || undefined,
force: true,
})
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchComposants()
}
const handleSortChange = () => {
currentPage.value = 1
fetchComposants()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchComposants()
}
const resolvePrimaryDocument = (component: Record<string, any>) => {
const documents = Array.isArray(component?.documents) ? component.documents : []
if (!documents.length) {
return null
}
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) {
return pdf
}
const image = withPath.find((doc) => isImageDocument(doc))
if (image) {
return image
}
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)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (component: Record<string, any>) => {
const parts = [component?.name, component?.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 resolveComponentType = (component: Record<string, any>) => {
const type = component?.typeComposant
if (type?.name) {
return type.name
}
if (component?.typeComposantLabel) {
return component.typeComposantLabel
}
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
return '—'
}
const resolveDeleteGuard = (component: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks)
? component.machineLinks.length
: component?.machineLinksCount ?? 0
const documents = Array.isArray(component?.documents)
? component.documents.length
: component?.documentsCount ?? 0
const customFields = Array.isArray(component?.customFieldValues)
? component.customFieldValues.length
: component?.customFieldValuesCount ?? 0
if (machineLinks > 0) {
blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
}
if (documents > 0) {
blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
}
return {
blockingReasons,
hasCustomFields: customFields > 0,
}
const machineLinks = Array.isArray(component?.machineLinks) ? component.machineLinks.length : component?.machineLinksCount ?? 0
const documents = Array.isArray(component?.documents) ? component.documents.length : component?.documentsCount ?? 0
const customFields = Array.isArray(component?.customFieldValues) ? component.customFieldValues.length : component?.customFieldValuesCount ?? 0
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
return { blockingReasons, hasCustomFields: customFields > 0 }
}
const handleDeleteComponent = async (component: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
if (blockingReasons.length) {
showError(
`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(
', ',
)}. Supprimez ou détachez ces éléments avant de réessayer.`
)
showError(`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
return
}
const componentName = component?.name || 'ce composant'
const confirmLines = [
`Voulez-vous vraiment supprimer ${componentName} ?`,
]
const confirmLines = [`Voulez-vous vraiment supprimer ${componentName} ?`]
if (hasCustomFields) {
confirmLines.push(
'Les valeurs de champs personnalisés associées seront également supprimées.'
)
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) {
return
}
if (!confirmed) return
await deleteComposant(component.id)
// Reload current page after deletion
fetchComposants()
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
}
onMounted(async () => {
await Promise.all([
fetchComposants(),
loadComponentTypes()
])
await Promise.all([fetchComposants(), loadComponentTypes()])
})
</script>