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

@@ -11,9 +11,22 @@
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<!-- Filtres -->
<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="comments"
:loading="loadingList"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun commentaire trouvé."
no-results-message="Aucun commentaire trouvé."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
@@ -25,17 +38,11 @@
id="comment-status"
v-model="statusFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
@change="table.handleFilterChange"
>
<option value="open">
Ouverts
</option>
<option value="resolved">
Résolus
</option>
<option value="">
Tous
</option>
<option value="open">Ouverts</option>
<option value="resolved">Résolus</option>
<option value="">Tous</option>
</select>
</div>
@@ -50,186 +57,84 @@
id="comment-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
@change="table.handleFilterChange"
>
<option value="">
Tous
</option>
<option value="machine">
Machine
</option>
<option value="piece">
Pièce
</option>
<option value="composant">
Composant
</option>
<option value="product">
Produit
</option>
<option value="piece_category">
Catégorie pièce
</option>
<option value="component_category">
Catégorie composant
</option>
<option value="product_category">
Catégorie produit
</option>
<option value="machine_skeleton">
Squelette machine
</option>
<option value="">Tous</option>
<option value="machine">Machine</option>
<option value="piece">Pièce</option>
<option value="composant">Composant</option>
<option value="product">Produit</option>
<option value="piece_category">Catégorie pièce</option>
<option value="component_category">Catégorie composant</option>
<option value="product_category">Catégorie produit</option>
<option value="machine_skeleton">Squelette machine</option>
</select>
</div>
</template>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="comment-per-page"
>
Par page
</label>
<select
id="comment-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option :value="20">
20
</option>
<option :value="50">
50
</option>
<option :value="100">
100
</option>
</select>
</div>
</div>
<template #cell-content="{ row }">
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
</template>
<p class="text-xs text-base-content/50 lg:text-right">
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
</p>
</div>
<!-- Loading -->
<div v-if="loadingList" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<!-- Empty states -->
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
Aucun commentaire trouvé.
</p>
<!-- Table -->
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th>Contenu</th>
<th>Type</th>
<th>Item</th>
<th>Auteur</th>
<th>Date</th>
<th>Statut</th>
<th v-if="canEdit">
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="comment in comments"
:key="comment.id"
class="hover"
>
<td class="max-w-xs">
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
</td>
<td>
<span class="badge badge-outline badge-sm">
{{ entityTypeLabel(comment.entityType) }}
</span>
</td>
<td>
<NuxtLink
v-if="getEntityRoute(comment)"
:to="getEntityRoute(comment)!"
class="link link-primary text-sm font-medium"
>
{{ comment.entityName || comment.entityId }}
</NuxtLink>
<span v-else class="text-sm">
{{ comment.entityName || comment.entityId }}
</span>
</td>
<td class="text-sm">
{{ comment.authorName }}
</td>
<td class="text-sm whitespace-nowrap">
{{ formatCommentDate(comment.createdAt) }}
</td>
<td>
<span
class="badge badge-sm"
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
>
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
</span>
</td>
<td v-if="canEdit" @click.stop>
<button
v-if="comment.status === 'open'"
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(comment.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
<span v-else class="text-xs text-base-content/50">
{{ comment.resolvedByName }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
<button
class="btn btn-sm"
:disabled="page <= 1"
@click="goToPage(page - 1)"
>
Précédent
</button>
<span class="flex items-center text-sm text-base-content/70">
Page {{ page }} / {{ totalPages }}
<template #cell-entityType="{ row }">
<span class="badge badge-outline badge-sm">
{{ entityTypeLabel(row.entityType) }}
</span>
<button
class="btn btn-sm"
:disabled="page >= totalPages"
@click="goToPage(page + 1)"
</template>
<template #cell-entity="{ row }">
<NuxtLink
v-if="getEntityRoute(row)"
:to="getEntityRoute(row)!"
class="link link-primary text-sm font-medium"
>
Suivant
{{ row.entityName || row.entityId }}
</NuxtLink>
<span v-else class="text-sm">
{{ row.entityName || row.entityId }}
</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatCommentDate(row.createdAt) }}</span>
</template>
<template #cell-status="{ row }">
<span
class="badge badge-sm"
:class="row.status === 'open' ? 'badge-warning' : 'badge-success'"
>
{{ row.status === 'open' ? 'Ouvert' : 'Résolu' }}
</span>
</template>
<template v-if="canEdit" #cell-actions="{ row }">
<button
v-if="row.status === 'open'"
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(row.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
</div>
</template>
<span v-else class="text-xs text-base-content/50">
{{ row.resolvedByName }}
</span>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComments, type Comment } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions'
import { useDataTable } from '~/composables/useDataTable'
import IconLucideCheck from '~icons/lucide/check'
const { canEdit } = usePermissions()
@@ -241,16 +146,43 @@ const {
const comments = ref<Comment[]>([])
const total = ref(0)
const page = ref(1)
const itemsPerPage = ref(20)
const statusFilter = ref('open')
const entityTypeFilter = ref('')
const loadingList = ref(false)
const loadingList = ref(true)
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
const table = useDataTable(
{ fetchData: loadComments },
{
defaultSort: 'createdAt',
defaultDirection: 'desc',
defaultPerPage: 20,
persistToUrl: true,
extraParams: {
status: { default: 'open' },
entityType: { default: '' },
},
},
)
const statusFilter = table.filters.status as Ref<string>
const entityTypeFilter = table.filters.entityType as Ref<string>
const commentsOnPage = computed(() => comments.value.length)
const paginationState = table.pagination(total, commentsOnPage)
const columns = computed(() => {
const cols = [
{ key: 'content', label: 'Contenu', class: 'max-w-xs' },
{ key: 'entityType', label: 'Type' },
{ key: 'entity', label: 'Item', filterable: true, filterPlaceholder: 'Rechercher…' },
{ key: 'authorName', label: 'Auteur', sortable: true, sortKey: 'authorName' },
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
{ key: 'status', label: 'Statut', sortable: true, sortKey: 'status' },
]
if (canEdit.value) {
cols.push({ key: 'actions', label: 'Actions' })
}
return cols
})
const ENTITY_TYPE_LABELS: Record<string, string> = {
machine: 'Machine',
piece: 'Pièce',
@@ -277,13 +209,16 @@ const formatCommentDate = (dateStr: string): string => {
}).format(date)
}
const loadComments = async () => {
async function loadComments() {
loadingList.value = true
const result = await fetchAllComments({
status: statusFilter.value || undefined,
entityType: entityTypeFilter.value || undefined,
page: page.value,
itemsPerPage: itemsPerPage.value,
entityName: table.columnFilters.value.entity || undefined,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value,
})
if (result.success) {
comments.value = result.data ?? []
@@ -292,16 +227,6 @@ const loadComments = async () => {
loadingList.value = false
}
const handleFilterChange = () => {
page.value = 1
loadComments()
}
const goToPage = (p: number) => {
page.value = p
loadComments()
}
const handleResolve = async (commentId: string) => {
const result = await resolveComment(commentId)
if (result.success) {