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çuNomRéférenceType de composantActions
- - {{ 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çuNomRéférenceFournisseursType de pièceActions
- - {{ row.piece.name || 'Pièce sans nom' }}{{ row.piece.reference || '—' }} -
- +
+ + + + + + + + + + + + + + + + + - - - - -
AperçuNomRéférenceFournisseursType de pièceActions
+ + {{ 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 @@