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

@@ -9,8 +9,24 @@
<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">
<DataTable
:columns="columns"
:rows="entries"
:loading="loading"
:pagination="paginationState"
:show-per-page="true"
:show-counter="true"
:expandable="true"
:expanded-keys="expandedIds"
:can-expand="canExpandRow"
row-key="id"
empty-message="Aucune activité enregistrée."
no-results-message="Aucune activité ne correspond à vos filtres."
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@toggle-expand="toggleExpanded"
>
<template #toolbar>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
@@ -22,7 +38,7 @@
id="activity-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
@change="table.handleFilterChange"
>
<option value="">Tous</option>
<option value="piece">Pièce</option>
@@ -42,7 +58,7 @@
id="activity-action"
v-model="actionFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
@change="table.handleFilterChange"
>
<option value="">Toutes</option>
<option value="create">Création</option>
@@ -50,157 +66,111 @@
<option value="delete">Suppression</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="activity-per-page"
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatHistoryDate(row.createdAt) }}</span>
</template>
<template #cell-action="{ row }">
<span
class="badge badge-sm"
:class="actionBadgeClass(row.action)"
>
{{ historyActionLabel(row.action) }}
</span>
</template>
<template #cell-entityType="{ row }">
<span class="badge badge-ghost badge-sm">
{{ entityTypeLabel(row.entityType) }}
</span>
</template>
<template #cell-entity="{ row }">
<NuxtLink
v-if="row.action !== 'delete'"
:to="entityEditLink(row)"
class="link link-hover link-primary"
>
{{ row.entityName || 'Sans nom' }}
</NuxtLink>
<span v-else class="text-base-content/50 line-through">
{{ row.entityName || 'Sans nom' }}
</span>
<span
v-if="row.entityRef"
class="text-xs text-base-content/50 ml-1"
>
({{ row.entityRef }})
</span>
</template>
<template #cell-author="{ row }">
{{ row.actor?.label || '—' }}
</template>
<template #row-expanded="{ row }">
<div class="space-y-1 text-sm">
<div
v-for="diffEntry in historyDiffEntries(row, globalFieldLabels)"
:key="diffEntry.field"
class="flex gap-2"
>
Par page
</label>
<select
id="activity-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>
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
<span></span>
<span class="text-success">{{ diffEntry.toLabel }}</span>
</div>
</div>
</div>
<p class="text-xs text-base-content/50 lg:text-right">
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!total" class="text-sm text-base-content/70">
Aucune activité enregistrée.
</p>
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
Aucune activité ne correspond à vos filtres.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Type</th>
<th>Entité</th>
<th>Auteur</th>
<th>Détails</th>
</tr>
</thead>
<tbody>
<template v-for="entry in entries" :key="entry.id">
<tr>
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
<td>
<span
class="badge badge-sm"
:class="actionBadgeClass(entry.action)"
>
{{ historyActionLabel(entry.action) }}
</span>
</td>
<td>
<span class="badge badge-ghost badge-sm">
{{ entityTypeLabel(entry.entityType) }}
</span>
</td>
<td>
<NuxtLink
v-if="entry.action !== 'delete'"
:to="entityEditLink(entry)"
class="link link-hover link-primary"
>
{{ entry.entityName || 'Sans nom' }}
</NuxtLink>
<span v-else class="text-base-content/50 line-through">
{{ entry.entityName || 'Sans nom' }}
</span>
<span
v-if="entry.entityRef"
class="text-xs text-base-content/50 ml-1"
>
({{ entry.entityRef }})
</span>
</td>
<td>{{ entry.actor?.label || '—' }}</td>
<td>
<button
v-if="hasDiff(entry)"
type="button"
class="btn btn-ghost btn-xs"
@click="toggleExpanded(entry.id)"
>
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
</button>
<span v-else class="text-xs text-base-content/50"></span>
</td>
</tr>
<tr v-if="expandedIds.has(entry.id)">
<td colspan="6" class="bg-base-200/50 p-4">
<div class="space-y-1 text-sm">
<div
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
:key="diffEntry.field"
class="flex gap-2"
>
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
<span></span>
<span class="text-success">{{ diffEntry.toLabel }}</span>
</div>
</div>
</td>
</tr>
</template>
</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, reactive, ref } from 'vue'
import { computed, onMounted, reactive, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useActivityLog } from '~/composables/useActivityLog'
import type { ActivityLogEntry } from '~/composables/useActivityLog'
import { useDataTable } from '~/composables/useDataTable'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
import Pagination from '~/components/common/Pagination.vue'
const { entries, total, loading, loadActivityLog } = useActivityLog()
const currentPage = ref(1)
const itemsPerPage = ref(50)
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
const table = useDataTable(
{ fetchData: fetchLog },
{
defaultSort: 'createdAt',
defaultDirection: 'desc',
defaultPerPage: 50,
persistToUrl: true,
extraParams: {
entityType: { default: '' },
action: { default: '' },
},
},
)
const entityTypeFilter = ref('')
const actionFilter = ref('')
const entityTypeFilter = table.filters.entityType as Ref<string>
const actionFilter = table.filters.action as Ref<string>
const entriesOnPage = computed(() => entries.value.length)
const paginationState = table.pagination(total, entriesOnPage)
const columns = [
{ key: 'createdAt', label: 'Date' },
{ key: 'action', label: 'Action' },
{ key: 'entityType', label: 'Type' },
{ key: 'entity', label: 'Entité' },
{ key: 'author', label: 'Auteur' },
]
const expandedIds = reactive(new Set<string>())
@@ -209,28 +179,18 @@ const toggleExpanded = (id: string) => {
else expandedIds.add(id)
}
const hasDiff = (entry: ActivityLogEntry) =>
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
const canExpandRow = (row: any) =>
row.diff !== null && row.diff !== undefined && Object.keys(row.diff).length > 0
const fetchLog = () => {
function fetchLog() {
loadActivityLog({
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
entityType: entityTypeFilter.value || undefined,
action: actionFilter.value || undefined,
})
}
const handleFilterChange = () => {
currentPage.value = 1
fetchLog()
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchLog()
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
piece: 'Pièce',
product: 'Produit',