From 8af83742825251b2814554745e98551d8903536d Mon Sep 17 00:00:00 2001
From: matthieu
Date: Fri, 23 Jan 2026 19:35:00 +0100
Subject: [PATCH] feat(ui): ajoute la pagination et la recherche serveur
---
app/components/common/Pagination.vue | 128 ++++++++++++
app/composables/useComposants.js | 60 +++++-
app/composables/usePieces.js | 62 +++++-
app/composables/useProducts.js | 62 ++++--
app/pages/component-catalog.vue | 237 ++++++++++++----------
app/pages/pieces-catalog.vue | 285 +++++++++++++++------------
6 files changed, 579 insertions(+), 255 deletions(-)
create mode 100644 app/components/common/Pagination.vue
diff --git a/app/components/common/Pagination.vue b/app/components/common/Pagination.vue
new file mode 100644
index 0000000..ad6c253
--- /dev/null
+++ b/app/components/common/Pagination.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+ ...
+
+
+
+
+
+
+
+
+
diff --git a/app/composables/useComposants.js b/app/composables/useComposants.js
index 192ce53..138ccb8 100644
--- a/app/composables/useComposants.js
+++ b/app/composables/useComposants.js
@@ -6,6 +6,7 @@ import { useConstructeurs } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
const composants = ref([])
+const total = ref(0)
const loading = ref(false)
const extractCollection = (payload) => {
@@ -24,6 +25,16 @@ const extractCollection = (payload) => {
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 () {
const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi()
@@ -65,18 +76,56 @@ export function useComposants () {
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
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) {
const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
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) {
console.error('Erreur lors du chargement des composants:', error)
+ return { success: false, error: error.message }
} finally {
loading.value = false
}
@@ -89,7 +138,8 @@ export function useComposants () {
const result = await post('/composants', normalizedPayload)
if (result.success) {
const enriched = await withResolvedConstructeurs(result.data)
- composants.value.push(enriched)
+ composants.value.unshift(enriched)
+ total.value += 1
const displayName = result.data?.name
|| composantData?.definition?.name
|| composantData?.name
@@ -134,6 +184,7 @@ export function useComposants () {
if (result.success) {
const deletedComposant = composants.value.find(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`)
}
return result
@@ -150,6 +201,7 @@ export function useComposants () {
return {
composants,
+ total,
loading,
loadComposants,
createComposant,
diff --git a/app/composables/usePieces.js b/app/composables/usePieces.js
index 3e944c4..037ff33 100644
--- a/app/composables/usePieces.js
+++ b/app/composables/usePieces.js
@@ -6,6 +6,7 @@ import { useConstructeurs } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
const pieces = ref([])
+const total = ref(0)
const loading = ref(false)
const extractCollection = (payload) => {
@@ -24,6 +25,16 @@ const extractCollection = (payload) => {
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 () {
const { showSuccess, showError, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi()
@@ -65,18 +76,58 @@ export function usePieces () {
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
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) {
const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
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) {
console.error('Erreur lors du chargement des pièces:', error)
+ return { success: false, error: error.message }
} finally {
loading.value = false
}
@@ -89,7 +140,8 @@ export function usePieces () {
const result = await post('/pieces', normalizedPayload)
if (result.success) {
const enriched = await withResolvedConstructeurs(result.data)
- pieces.value.push(enriched)
+ pieces.value.unshift(enriched)
+ total.value += 1
const displayName = result.data?.name
|| pieceData?.definition?.name
|| pieceData?.name
@@ -134,6 +186,7 @@ export function usePieces () {
if (result.success) {
const deletedPiece = pieces.value.find(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`)
}
return result
@@ -150,6 +203,7 @@ export function usePieces () {
return {
pieces,
+ total,
loading,
loadPieces,
createPiece,
diff --git a/app/composables/useProducts.js b/app/composables/useProducts.js
index 3e04da7..5ed4165 100644
--- a/app/composables/useProducts.js
+++ b/app/composables/useProducts.js
@@ -42,6 +42,16 @@ const extractCollection = (payload) => {
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 () {
const { showError } = useToast()
const { get, post, patch, delete: del } = useApi()
@@ -77,32 +87,62 @@ export function useProducts () {
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 {
+ search = '',
+ page = 1,
+ itemsPerPage = 30,
+ orderBy = 'name',
+ orderDir = 'asc',
+ force = false
+ } = options
+
if (loading.value) {
return {
success: true,
- data: { items: products.value, total: total.value },
- }
- }
- if (loaded.value && !options.force) {
- return {
- success: true,
- data: { items: products.value, total: total.value },
+ data: { items: products.value, total: total.value, page, itemsPerPage },
}
}
loading.value = true
error.value = null
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) {
const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
products.value = enrichedItems
- total.value = typeof result.data?.totalItems === 'number'
- ? result.data.totalItems
- : items.length
+ total.value = extractTotal(result.data, items.length)
loaded.value = true
+ return {
+ success: true,
+ data: {
+ items: enrichedItems,
+ total: total.value,
+ page,
+ itemsPerPage
+ }
+ }
} else if (result.error) {
error.value = result.error
showError(`Impossible de charger les produits: ${result.error}`)
diff --git a/app/pages/component-catalog.vue b/app/pages/component-catalog.vue
index 83321ec..a80f86c 100644
--- a/app/pages/component-catalog.vue
+++ b/app/pages/component-catalog.vue
@@ -35,6 +35,7 @@
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
+ @input="debouncedSearch"
/>
@@ -48,6 +49,7 @@
id="component-catalog-sort"
v-model="sortField"
class="select select-bordered select-sm"
+ @change="handleSortChange"
>
@@ -64,14 +66,33 @@
id="component-catalog-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
+ @change="handleSortChange"
>
+
+
+
+
- {{ visibleComposants.length }} / {{ composantsTotal }} résultat{{ visibleComposants.length > 1 ? 's' : '' }}
+ {{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
@@ -83,54 +104,62 @@
Aucun composant n'a encore été créé.
-
+
Aucun composant ne correspond à votre recherche.
-
-
-
-
- | Aperçu |
- Nom |
- Référence |
- Type de composant |
- Actions |
-
-
-
-
- |
-
- |
- {{ component.name || 'Composant sans nom' }} |
- {{ component.reference || '—' }} |
- {{ resolveComponentType(component) }} |
-
-
-
- Modifier
-
-
-
- |
-
-
-
-
+
+
+
+
+
+ | Aperçu |
+ Nom |
+ Référence |
+ Type de composant |
+ Actions |
+
+
+
+
+ |
+
+ |
+ {{ component.name || 'Composant sans nom' }} |
+ {{ component.reference || '—' }} |
+ {{ resolveComponentType(component) }} |
+
+
+
+ Modifier
+
+
+
+ |
+
+
+
+
+
+
+
@@ -144,13 +173,41 @@ import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
+import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
const { showError } = useToast()
-const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
+const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
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 | 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
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
@@ -161,13 +218,31 @@ const composantsList = computed(() => {
}
})
})
-const composantsTotal = computed(() => composantsList.value.length)
-const searchTerm = ref('')
-const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
- 'component-catalog',
- { field: 'name', direction: 'asc' },
-)
+const fetchComposants = async () => {
+ await loadComposants({
+ search: searchTerm.value,
+ 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) => {
const documents = Array.isArray(component?.documents) ? component.documents : []
@@ -230,58 +305,6 @@ const resolveDeleteGuard = (component: Record) => {
}
}
-const resolveComparableName = (component: Record) => {
- const toComparable = (value?: string | null) =>
- (value ?? '').toString().trim().toLowerCase()
-
- return (
- toComparable(component?.name) ||
- toComparable(component?.reference) ||
- toComparable(component?.id)
- )
-}
-
-const resolveComparableDate = (component: Record) => {
- 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) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
@@ -310,11 +333,13 @@ const handleDeleteComponent = async (component: Record) => {
}
await deleteComposant(component.id)
+ // Reload current page after deletion
+ fetchComposants()
}
onMounted(async () => {
await Promise.all([
- loadComposants(),
+ fetchComposants(),
loadComponentTypes()
])
})
diff --git a/app/pages/pieces-catalog.vue b/app/pages/pieces-catalog.vue
index 146aa54..08f07d0 100644
--- a/app/pages/pieces-catalog.vue
+++ b/app/pages/pieces-catalog.vue
@@ -34,6 +34,7 @@
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
+ @input="debouncedSearch"
/>
@@ -47,6 +48,7 @@
id="piece-catalog-sort"
v-model="sortField"
class="select select-bordered select-sm"
+ @change="handleSortChange"
>
@@ -63,14 +65,33 @@
id="piece-catalog-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
+ @change="handleSortChange"
>
+
+
+
+
- {{ visiblePieces.length }} / {{ piecesTotal }} résultat{{ visiblePieces.length > 1 ? 's' : '' }}
+ {{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
@@ -82,77 +103,85 @@
Aucune pièce n'a encore été créée.
-
+
Aucune pièce ne correspond à votre recherche.
-
-
-
-
- | Aperçu |
- Nom |
- Référence |
- Fournisseurs |
- Type de pièce |
- Actions |
-
-
-
-
- |
-
- |
- {{ row.piece.name || 'Pièce sans nom' }} |
- {{ row.piece.reference || '—' }} |
-
-
-
+
+
+
+
+ | Aperçu |
+ Nom |
+ Référence |
+ Fournisseurs |
+ Type de pièce |
+ Actions |
+
+
+
+
+ |
+
+ |
+ {{ row.piece.name || 'Pièce sans nom' }} |
+ {{ row.piece.reference || '—' }} |
+
+
- {{ supplier }}
-
-
- +{{ row.suppliers.overflow }}
-
-
- —
- |
- {{ resolvePieceType(row.piece) }} |
-
-
-
- Modifier
-
-
-
- |
-
-
-
-
+
+ {{ supplier }}
+
+
+ +{{ row.suppliers.overflow }}
+
+
+ —
+ |
+ {{ resolvePieceType(row.piece) }} |
+
+
+
+ Modifier
+
+
+
+ |
+
+
+
+
+
+
+
@@ -160,19 +189,47 @@