feat(ui): ajoute la pagination et la recherche serveur

This commit is contained in:
2026-01-23 19:35:00 +01:00
parent 9cc7ac10f0
commit 8af8374282
6 changed files with 579 additions and 255 deletions

View File

@@ -0,0 +1,128 @@
<template>
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage <= 1"
@click="goToPage(1)"
>
<IconLucideChevronFirst class="w-4 h-4" />
</button>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
<IconLucideChevronLeft class="w-4 h-4" />
</button>
<template v-for="page in visiblePages" :key="page">
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
<button
v-else
type="button"
class="btn btn-sm"
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
@click="goToPage(page)"
>
{{ page }}
</button>
</template>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
<IconLucideChevronRight class="w-4 h-4" />
</button>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage >= totalPages"
@click="goToPage(totalPages)"
>
<IconLucideChevronLast class="w-4 h-4" />
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalPages: {
type: Number,
required: true
},
maxVisiblePages: {
type: Number,
default: 5
}
})
const emit = defineEmits(['update:currentPage'])
const visiblePages = computed(() => {
const pages = []
const total = props.totalPages
const current = props.currentPage
const maxVisible = props.maxVisiblePages
if (total <= maxVisible + 2) {
for (let i = 1; i <= total; i++) {
pages.push(i)
}
return pages
}
// Always show first page
pages.push(1)
const half = Math.floor(maxVisible / 2)
let start = Math.max(2, current - half)
let end = Math.min(total - 1, current + half)
// Adjust if near start
if (current <= half + 1) {
end = maxVisible
}
// Adjust if near end
if (current >= total - half) {
start = total - maxVisible + 1
}
if (start > 2) {
pages.push('ellipsis-start')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < total - 1) {
pages.push('ellipsis-end')
}
// Always show last page
pages.push(total)
return pages
})
const goToPage = (page) => {
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
emit('update:currentPage', page)
}
}
</script>

View File

@@ -6,6 +6,7 @@ import { useConstructeurs } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
const composants = ref([]) const composants = ref([])
const total = ref(0)
const loading = ref(false) const loading = ref(false)
const extractCollection = (payload) => { const extractCollection = (payload) => {
@@ -24,6 +25,16 @@ const extractCollection = (payload) => {
return [] return []
} }
const extractTotal = (payload, fallbackLength) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
return fallbackLength
}
export function useComposants () { export function useComposants () {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
@@ -65,18 +76,56 @@ export function useComposants () {
return composant return composant
} }
const loadComposants = async () => { /**
* Load composants with pagination and search support
* @param {Object} options - Query options
* @param {string} [options.search] - Search term for name/reference
* @param {number} [options.page=1] - Current page (1-based)
* @param {number} [options.itemsPerPage=30] - Items per page
* @param {string} [options.orderBy='name'] - Field to order by
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
*/
const loadComposants = async (options = {}) => {
loading.value = true loading.value = true
try { try {
const result = await get('/composants') const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc'
} = options
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && search.trim()) {
params.set('name', search.trim())
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/composants?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
composants.value = enrichedItems composants.value = enrichedItems
showInfo(`Chargement de ${composants.value.length} composant(s) réussi`) total.value = extractTotal(result.data, items.length)
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
page,
itemsPerPage
}
}
} }
return result
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des composants:', error) console.error('Erreur lors du chargement des composants:', error)
return { success: false, error: error.message }
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -89,7 +138,8 @@ export function useComposants () {
const result = await post('/composants', normalizedPayload) const result = await post('/composants', normalizedPayload)
if (result.success) { if (result.success) {
const enriched = await withResolvedConstructeurs(result.data) const enriched = await withResolvedConstructeurs(result.data)
composants.value.push(enriched) composants.value.unshift(enriched)
total.value += 1
const displayName = result.data?.name const displayName = result.data?.name
|| composantData?.definition?.name || composantData?.definition?.name
|| composantData?.name || composantData?.name
@@ -134,6 +184,7 @@ export function useComposants () {
if (result.success) { if (result.success) {
const deletedComposant = composants.value.find(comp => comp.id === id) const deletedComposant = composants.value.find(comp => comp.id === id)
composants.value = composants.value.filter(comp => comp.id !== id) composants.value = composants.value.filter(comp => comp.id !== id)
total.value = Math.max(0, total.value - 1)
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`) showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
} }
return result return result
@@ -150,6 +201,7 @@ export function useComposants () {
return { return {
composants, composants,
total,
loading, loading,
loadComposants, loadComposants,
createComposant, createComposant,

View File

@@ -6,6 +6,7 @@ import { useConstructeurs } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
const pieces = ref([]) const pieces = ref([])
const total = ref(0)
const loading = ref(false) const loading = ref(false)
const extractCollection = (payload) => { const extractCollection = (payload) => {
@@ -24,6 +25,16 @@ const extractCollection = (payload) => {
return [] return []
} }
const extractTotal = (payload, fallbackLength) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
return fallbackLength
}
export function usePieces () { export function usePieces () {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
@@ -65,18 +76,58 @@ export function usePieces () {
return piece return piece
} }
const loadPieces = async () => { /**
* Load pieces with pagination and search support
* @param {Object} options - Query options
* @param {string} [options.search] - Search term for name/reference
* @param {number} [options.page=1] - Current page (1-based)
* @param {number} [options.itemsPerPage=30] - Items per page
* @param {string} [options.orderBy='name'] - Field to order by
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
*/
const loadPieces = async (options = {}) => {
loading.value = true loading.value = true
try { try {
const result = await get('/pieces') const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc'
} = options
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && search.trim()) {
// API Platform uses property filters
params.set('name', search.trim())
}
// API Platform OrderFilter syntax: order[field]=direction
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/pieces?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
pieces.value = enrichedItems pieces.value = enrichedItems
showInfo(`Chargement de ${pieces.value.length} pièce(s) réussi`) total.value = extractTotal(result.data, items.length)
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
page,
itemsPerPage
}
}
} }
return result
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des pièces:', error) console.error('Erreur lors du chargement des pièces:', error)
return { success: false, error: error.message }
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -89,7 +140,8 @@ export function usePieces () {
const result = await post('/pieces', normalizedPayload) const result = await post('/pieces', normalizedPayload)
if (result.success) { if (result.success) {
const enriched = await withResolvedConstructeurs(result.data) const enriched = await withResolvedConstructeurs(result.data)
pieces.value.push(enriched) pieces.value.unshift(enriched)
total.value += 1
const displayName = result.data?.name const displayName = result.data?.name
|| pieceData?.definition?.name || pieceData?.definition?.name
|| pieceData?.name || pieceData?.name
@@ -134,6 +186,7 @@ export function usePieces () {
if (result.success) { if (result.success) {
const deletedPiece = pieces.value.find(piece => piece.id === id) const deletedPiece = pieces.value.find(piece => piece.id === id)
pieces.value = pieces.value.filter(piece => piece.id !== id) pieces.value = pieces.value.filter(piece => piece.id !== id)
total.value = Math.max(0, total.value - 1)
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`) showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
} }
return result return result
@@ -150,6 +203,7 @@ export function usePieces () {
return { return {
pieces, pieces,
total,
loading, loading,
loadPieces, loadPieces,
createPiece, createPiece,

View File

@@ -42,6 +42,16 @@ const extractCollection = (payload) => {
return [] return []
} }
const extractTotal = (payload, fallbackLength) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
return fallbackLength
}
export function useProducts () { export function useProducts () {
const { showError } = useToast() const { showError } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
@@ -77,32 +87,62 @@ export function useProducts () {
return product return product
} }
/**
* Load products with pagination and search support
* @param {Object} options - Query options
* @param {string} [options.search] - Search term for name/reference
* @param {number} [options.page=1] - Current page (1-based)
* @param {number} [options.itemsPerPage=30] - Items per page
* @param {string} [options.orderBy='name'] - Field to order by
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
* @param {boolean} [options.force=false] - Force reload even if already loaded
*/
const loadProducts = async (options = {}) => { const loadProducts = async (options = {}) => {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
force = false
} = options
if (loading.value) { if (loading.value) {
return { return {
success: true, success: true,
data: { items: products.value, total: total.value }, data: { items: products.value, total: total.value, page, itemsPerPage },
}
}
if (loaded.value && !options.force) {
return {
success: true,
data: { items: products.value, total: total.value },
} }
} }
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const result = await get('/products?itemsPerPage=100') const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && search.trim()) {
params.set('name', search.trim())
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/products?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
products.value = enrichedItems products.value = enrichedItems
total.value = typeof result.data?.totalItems === 'number' total.value = extractTotal(result.data, items.length)
? result.data.totalItems
: items.length
loaded.value = true loaded.value = true
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
page,
itemsPerPage
}
}
} else if (result.error) { } else if (result.error) {
error.value = result.error error.value = result.error
showError(`Impossible de charger les produits: ${result.error}`) showError(`Impossible de charger les produits: ${result.error}`)

View File

@@ -35,6 +35,7 @@
type="text" type="text"
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…" placeholder="Nom ou référence…"
@input="debouncedSearch"
/> />
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -48,6 +49,7 @@
id="component-catalog-sort" id="component-catalog-sort"
v-model="sortField" v-model="sortField"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="name">Nom</option> <option value="name">Nom</option>
<option value="createdAt">Date de création</option> <option value="createdAt">Date de création</option>
@@ -64,14 +66,33 @@
id="component-catalog-dir" id="component-catalog-dir"
v-model="sortDirection" v-model="sortDirection"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="asc">Ascendant</option> <option value="asc">Ascendant</option>
<option value="desc">Descendant</option> <option value="desc">Descendant</option>
</select> </select>
</div> </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> </div>
<p class="text-xs text-base-content/50 lg:text-right"> <p class="text-xs text-base-content/50 lg:text-right">
{{ visibleComposants.length }} / {{ composantsTotal }} résultat{{ visibleComposants.length > 1 ? 's' : '' }} {{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
</p> </p>
</div> </div>
@@ -83,54 +104,62 @@
Aucun composant n'a encore été créé. Aucun composant n'a encore été créé.
</p> </p>
<p v-else-if="!visibleComposants.length" class="text-sm text-base-content/70"> <p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
Aucun composant ne correspond à votre recherche. Aucun composant ne correspond à votre recherche.
</p> </p>
<div v-else class="overflow-x-auto"> <template v-else>
<table class="table table-sm md:table-md"> <div class="overflow-x-auto">
<thead> <table class="table table-sm md:table-md">
<tr> <thead>
<th class="w-24">Aperçu</th> <tr>
<th>Nom</th> <th class="w-24">Aperçu</th>
<th>Référence</th> <th>Nom</th>
<th>Type de composant</th> <th>Référence</th>
<th>Actions</th> <th>Type de composant</th>
</tr> <th>Actions</th>
</thead> </tr>
<tbody> </thead>
<tr v-for="component in visibleComposants" :key="component.id"> <tbody>
<td class="align-middle"> <tr v-for="component in composantsList" :key="component.id">
<DocumentThumbnail <td class="align-middle">
:document="resolvePrimaryDocument(component)" <DocumentThumbnail
:alt="resolvePreviewAlt(component)" :document="resolvePrimaryDocument(component)"
/> :alt="resolvePreviewAlt(component)"
</td> />
<td>{{ component.name || 'Composant sans nom' }}</td> </td>
<td>{{ component.reference || '' }}</td> <td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ resolveComponentType(component) }}</td> <td>{{ component.reference || '—' }}</td>
<td> <td>{{ resolveComponentType(component) }}</td>
<div class="flex items-center gap-2"> <td>
<NuxtLink <div class="flex items-center gap-2">
:to="`/component/${component.id}/edit`" <NuxtLink
class="btn btn-ghost btn-xs" :to="`/component/${component.id}/edit`"
> class="btn btn-ghost btn-xs"
Modifier >
</NuxtLink> Modifier
<button </NuxtLink>
type="button" <button
class="btn btn-error btn-xs" type="button"
:disabled="loadingComposants" class="btn btn-error btn-xs"
@click="handleDeleteComponent(component)" :disabled="loadingComposants"
> @click="handleDeleteComponent(component)"
Supprimer >
</button> Supprimer
</div> </button>
</td> </div>
</tr> </td>
</tbody> </tr>
</table> </tbody>
</div> </table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div> </div>
</section> </section>
</main> </main>
@@ -144,13 +173,41 @@ import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort' import { usePersistedSort } from '~/composables/usePersistedSort'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { showError } = useToast() const { showError } = useToast()
const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants() const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value) const loadingComposants = computed(() => loadingComposantsRef.value)
// Pagination state
const currentPage = ref(1)
const itemsPerPage = ref(30)
const composantsTotal = computed(() => total.value)
const composantsOnPage = computed(() => composants.value.length)
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
// Search state with debounce
const searchTerm = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchComposants()
}, 300)
}
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'component-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les composants avec les types de composants complets // Enrichir les composants avec les types de composants complets
const composantsList = computed(() => { const composantsList = computed(() => {
return (composants.value || []).map((composant) => { return (composants.value || []).map((composant) => {
@@ -161,13 +218,31 @@ const composantsList = computed(() => {
} }
}) })
}) })
const composantsTotal = computed(() => composantsList.value.length)
const searchTerm = ref('') const fetchComposants = async () => {
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>( await loadComposants({
'component-catalog', search: searchTerm.value,
{ field: 'name', direction: 'asc' }, page: currentPage.value,
) itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value
})
}
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 resolvePrimaryDocument = (component: Record<string, any>) => {
const documents = Array.isArray(component?.documents) ? component.documents : [] const documents = Array.isArray(component?.documents) ? component.documents : []
@@ -230,58 +305,6 @@ const resolveDeleteGuard = (component: Record<string, any>) => {
} }
} }
const resolveComparableName = (component: Record<string, any>) => {
const toComparable = (value?: string | null) =>
(value ?? '').toString().trim().toLowerCase()
return (
toComparable(component?.name) ||
toComparable(component?.reference) ||
toComparable(component?.id)
)
}
const resolveComparableDate = (component: Record<string, any>) => {
const raw = component?.createdAt ?? component?.created_at ?? null
if (!raw) {
return 0
}
const parsed = new Date(raw).getTime()
return Number.isNaN(parsed) ? 0 : parsed
}
const visibleComposants = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const source = composantsList.value || []
const filtered = term
? source.filter((component) => {
const name = (component?.name || '').toLowerCase()
const reference = (component?.reference || '').toLowerCase()
return (
name.includes(term) ||
reference.includes(term)
)
})
: [...source]
const direction = sortDirection.value === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortField.value === 'name') {
return (
resolveComparableName(a).localeCompare(
resolveComparableName(b),
'fr',
{ sensitivity: 'base' }
) * direction
)
}
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
})
})
const handleDeleteComponent = async (component: Record<string, any>) => { const handleDeleteComponent = async (component: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component) const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
@@ -310,11 +333,13 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
} }
await deleteComposant(component.id) await deleteComposant(component.id)
// Reload current page after deletion
fetchComposants()
} }
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadComposants(), fetchComposants(),
loadComponentTypes() loadComponentTypes()
]) ])
}) })

View File

@@ -34,6 +34,7 @@
type="text" type="text"
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…" placeholder="Nom ou référence…"
@input="debouncedSearch"
/> />
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -47,6 +48,7 @@
id="piece-catalog-sort" id="piece-catalog-sort"
v-model="sortField" v-model="sortField"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="name">Nom</option> <option value="name">Nom</option>
<option value="createdAt">Date de création</option> <option value="createdAt">Date de création</option>
@@ -63,14 +65,33 @@
id="piece-catalog-dir" id="piece-catalog-dir"
v-model="sortDirection" v-model="sortDirection"
class="select select-bordered select-sm" class="select select-bordered select-sm"
@change="handleSortChange"
> >
<option value="asc">Ascendant</option> <option value="asc">Ascendant</option>
<option value="desc">Descendant</option> <option value="desc">Descendant</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="piece-catalog-per-page"
>
Par page
</label>
<select
id="piece-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> </div>
<p class="text-xs text-base-content/50 lg:text-right"> <p class="text-xs text-base-content/50 lg:text-right">
{{ visiblePieces.length }} / {{ piecesTotal }} résultat{{ visiblePieces.length > 1 ? 's' : '' }} {{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
</p> </p>
</div> </div>
@@ -82,77 +103,85 @@
Aucune pièce n'a encore été créée. Aucune pièce n'a encore été créée.
</p> </p>
<p v-else-if="!visiblePieces.length" class="text-sm text-base-content/70"> <p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
Aucune pièce ne correspond à votre recherche. Aucune pièce ne correspond à votre recherche.
</p> </p>
<div v-else class="overflow-x-auto"> <template v-else>
<table class="table table-sm md:table-md"> <div class="overflow-x-auto">
<thead> <table class="table table-sm md:table-md">
<tr> <thead>
<th class="w-24">Aperçu</th> <tr>
<th>Nom</th> <th class="w-24">Aperçu</th>
<th>Référence</th> <th>Nom</th>
<th>Fournisseurs</th> <th>Référence</th>
<th>Type de pièce</th> <th>Fournisseurs</th>
<th>Actions</th> <th>Type de pièce</th>
</tr> <th>Actions</th>
</thead> </tr>
<tbody> </thead>
<tr v-for="row in pieceRows" :key="row.piece.id"> <tbody>
<td class="align-middle"> <tr v-for="row in pieceRows" :key="row.piece.id">
<DocumentThumbnail <td class="align-middle">
:document="resolvePrimaryDocument(row.piece)" <DocumentThumbnail
:alt="resolvePreviewAlt(row.piece)" :document="resolvePrimaryDocument(row.piece)"
/> :alt="resolvePreviewAlt(row.piece)"
</td> />
<td>{{ row.piece.name || 'Pièce sans nom' }}</td> </td>
<td>{{ row.piece.reference || '' }}</td> <td>{{ row.piece.name || 'Pièce sans nom' }}</td>
<td> <td>{{ row.piece.reference || '—' }}</td>
<div <td>
v-if="row.suppliers.visible.length" <div
class="flex max-w-[14rem] flex-wrap items-center gap-1" v-if="row.suppliers.visible.length"
:title="row.suppliers.tooltip" class="flex max-w-[14rem] flex-wrap items-center gap-1"
> :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-for="supplier in row.suppliers.visible"
<span :key="supplier"
v-if="row.suppliers.overflow" class="badge badge-ghost badge-sm whitespace-nowrap"
class="badge badge-outline badge-sm" >
> {{ supplier }}
+{{ row.suppliers.overflow }} </span>
</span> <span
</div> v-if="row.suppliers.overflow"
<span v-else></span> class="badge badge-outline badge-sm"
</td> >
<td>{{ resolvePieceType(row.piece) }}</td> +{{ row.suppliers.overflow }}
<td> </span>
<div class="flex items-center gap-2"> </div>
<NuxtLink <span v-else></span>
:to="`/pieces/${row.piece.id}/edit`" </td>
class="btn btn-ghost btn-xs" <td>{{ resolvePieceType(row.piece) }}</td>
> <td>
Modifier <div class="flex items-center gap-2">
</NuxtLink> <NuxtLink
<button :to="`/pieces/${row.piece.id}/edit`"
type="button" class="btn btn-ghost btn-xs"
class="btn btn-error btn-xs" >
:disabled="loadingPieces" Modifier
@click="handleDeletePiece(row.piece)" </NuxtLink>
> <button
Supprimer type="button"
</button> class="btn btn-error btn-xs"
</div> :disabled="loadingPieces"
</td> @click="handleDeletePiece(row.piece)"
</tr> >
</tbody> Supprimer
</table> </button>
</div> </div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div> </div>
</section> </section>
</main> </main>
@@ -160,19 +189,47 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort' import { usePersistedSort } from '~/composables/usePersistedSort'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { showError } = useToast() const { showError } = useToast()
const { pieces, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces() const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value) const loadingPieces = computed(() => loadingPiecesRef.value)
// Pagination state
const currentPage = ref(1)
const itemsPerPage = ref(30)
const piecesTotal = computed(() => total.value)
const piecesOnPage = computed(() => pieces.value.length)
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
// Search state with debounce
const searchTerm = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchPieces()
}, 300)
}
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'pieces-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les pièces avec les types de pièces complets // Enrichir les pièces avec les types de pièces complets
const piecesList = computed(() => { const piecesList = computed(() => {
return (pieces.value || []).map((piece) => { return (pieces.value || []).map((piece) => {
@@ -183,13 +240,31 @@ const piecesList = computed(() => {
} }
}) })
}) })
const piecesTotal = computed(() => piecesList.value.length)
const searchTerm = ref('') const fetchPieces = async () => {
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>( await loadPieces({
'pieces-catalog', search: searchTerm.value,
{ field: 'name', direction: 'asc' }, page: currentPage.value,
) itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value
})
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchPieces()
}
const handleSortChange = () => {
currentPage.value = 1
fetchPieces()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchPieces()
}
const resolvePrimaryDocument = (piece: Record<string, any>) => { const resolvePrimaryDocument = (piece: Record<string, any>) => {
const documents = Array.isArray(piece?.documents) ? piece.documents : [] const documents = Array.isArray(piece?.documents) ? piece.documents : []
@@ -337,60 +412,8 @@ const resolveDeleteGuard = (piece: Record<string, any>) => {
} }
} }
const resolveComparableName = (piece: Record<string, any>) => {
const normalise = (value?: string | null) =>
(value ?? '').toString().trim().toLowerCase()
return (
normalise(piece?.name) ||
normalise(piece?.reference) ||
normalise(piece?.id)
)
}
const resolveComparableDate = (piece: Record<string, any>) => {
const raw = piece?.createdAt ?? piece?.created_at ?? null
if (!raw) {
return 0
}
const timestamp = new Date(raw).getTime()
return Number.isNaN(timestamp) ? 0 : timestamp
}
const visiblePieces = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const source = piecesList.value || []
const filtered = term
? source.filter((piece) => {
const name = (piece?.name || '').toLowerCase()
const reference = (piece?.reference || '').toLowerCase()
return (
name.includes(term) ||
reference.includes(term)
)
})
: [...source]
const direction = sortDirection.value === 'asc' ? 1 : -1
return filtered.sort((a, b) => {
if (sortField.value === 'name') {
return (
resolveComparableName(a).localeCompare(
resolveComparableName(b),
'fr',
{ sensitivity: 'base' }
) * direction
)
}
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
})
})
const pieceRows = computed(() => const pieceRows = computed(() =>
visiblePieces.value.map((piece) => ({ piecesList.value.map((piece) => ({
piece, piece,
suppliers: buildPieceSuppliersDisplay(piece), suppliers: buildPieceSuppliersDisplay(piece),
})), })),
@@ -425,11 +448,13 @@ const handleDeletePiece = async (piece: Record<string, any>) => {
} }
await deletePiece(piece.id) await deletePiece(piece.id)
// Reload current page after deletion
fetchPieces()
} }
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadPieces(), fetchPieces(),
loadPieceTypes() loadPieceTypes()
]) ])
}) })