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

@@ -17,73 +17,48 @@
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div class="flex-1">
<label class="label"><span class="label-text">Recherche</span></label>
<input
v-model="searchTerm"
type="search"
class="input input-bordered w-full"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
>
</div>
<div class="md:w-1/3">
<label class="label"><span class="label-text">Tri</span></label>
<select v-model="sortKey" class="select select-bordered w-full">
<option value="name">
Nom
</option>
<option value="email">
Email
</option>
<option value="phone">
Téléphone
</option>
</select>
</div>
</div>
<DataTable
:columns="columns"
:rows="filteredConstructeurs"
:loading="loading"
:sort="currentSort"
:show-counter="false"
empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé."
@sort="handleSort"
>
<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"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
/>
</label>
</template>
<div v-if="loading" class="py-16 text-center text-sm text-gray-500">
<span class="loading loading-spinner loading-lg mb-2" />
Chargement des fournisseurs...
</div>
<template #cell-phone="{ row }">
{{ formatPhoneDisplay(row.phone) }}
</template>
<div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500">
Aucun fournisseur trouvé.
</div>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</template>
<div v-else class="overflow-x-auto">
<table class="table">
<thead>
<tr class="text-xs uppercase">
<th>Nom</th>
<th>Email</th>
<th>Téléphone</th>
<th class="text-right">
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
<td>{{ constructeur.name }}</td>
<td>{{ constructeur.email || '—' }}</td>
<td>{{ formatPhoneDisplay(constructeur.phone) }}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(row)">
Supprimer
</button>
</div>
</template>
</DataTable>
</div>
</div>
@@ -118,6 +93,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
@@ -130,22 +106,46 @@ const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { showError } = useToast()
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const searchTerm = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc')
const currentSort = computed(() => ({
field: sortKey.value,
direction: sortDir.value,
}))
const handleSort = (sort) => {
sortKey.value = sort.field
sortDir.value = sort.direction
}
const modalOpen = ref(false)
const saving = ref(false)
const editingConstructeur = ref(null)
const form = ref({ name: '', email: '', phone: '' })
const filteredConstructeurs = computed(() => {
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
const sorted = [...constructeurs.value].sort((a, b) => {
const key = sortKey.value
return (a[key] || '').localeCompare(b[key] || '')
if (key === 'createdAt') {
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
}
return dir * (a[key] || '').localeCompare(b[key] || '')
})
if (!searchTerm.value) { return sorted }
const term = searchTerm.value.toLowerCase()
return sorted.filter(item =>
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term))
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
)
})
@@ -153,6 +153,17 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value)
}, 300)
const formatDate = (dateStr) => {
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)
}
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
@@ -161,7 +172,7 @@ const formatPhoneDisplay = (value) => {
return value || '—'
}
function debounce (fn, delay) {
function debounce(fn, delay) {
let timeout
return (...args) => {
clearTimeout(timeout)
@@ -184,7 +195,7 @@ const openEditModal = (constructeur) => {
form.value = {
name: constructeur.name,
email: constructeur.email || '',
phone: constructeur.phone || ''
phone: constructeur.phone || '',
}
modalOpen.value = true
}
@@ -197,7 +208,7 @@ const closeModal = () => {
const saveConstructeur = async () => {
const trimmedName = form.value.name.trim()
const duplicate = constructeurs.value.find(
(c) => c.name.toLowerCase() === trimmedName.toLowerCase()
c => c.name.toLowerCase() === trimmedName.toLowerCase()
&& c.id !== editingConstructeur.value?.id,
)
if (duplicate) {
@@ -212,7 +223,8 @@ const saveConstructeur = async () => {
let result
if (editingConstructeur.value) {
result = await updateConstructeur(editingConstructeur.value.id, payload)
} else {
}
else {
result = await createConstructeur(payload)
}
saving.value = false
@@ -234,6 +246,3 @@ const confirmDelete = async (constructeur) => {
onMounted(() => loadConstructeurs())
</script>
<style scoped>
</style>