diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md
index 6c34164..fec9242 100644
--- a/.claude/rules/frontend.md
+++ b/.claude/rules/frontend.md
@@ -53,6 +53,53 @@ Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `
` HTML brut.
+## Listes paginees (standard) — usePaginatedList obligatoire
+
+**Toute liste qui consomme une `GetCollection` API doit passer par `usePaginatedList`** (`frontend/shared/composables/usePaginatedList.ts`). Le composable est le pendant front de la regle ABSOLUE n°13 (« toute collection est paginee cote back ») : il consomme l'envelope Hydra (`member` / `totalItems` / `view`) et expose un etat reactif a brancher directement sur `MalioDataTable`.
+
+Pattern de reference :
+
+```ts
+const {
+ items,
+ totalItems,
+ currentPage,
+ itemsPerPage,
+ itemsPerPageOptions,
+ fetch: loadList,
+ goToPage,
+ setItemsPerPage,
+} = usePaginatedList({ url: '/my-resources' })
+
+onMounted(loadList)
+```
+
+```vue
+
+```
+
+Garanties offertes par le composable :
+- Force `Accept: application/ld+json` → API Platform 4 renvoie bien `member` / `totalItems` (sans Accept, retour tableau plat sans pagination).
+- Defaut 10 items/page, choix client 10 / 25 / 50, aligne sur le defaut serveur.
+- Mutation `setFilters` / `setSort` / `setItemsPerPage` → retombe systematiquement en page 1.
+- Cas limite « page hors borne apres filtre » : retombe automatiquement sur la derniere page valide (`tests/usePaginatedList.test.ts`).
+- Etat 100 % local (refs internes a l'instance) — **jamais reflete dans l'URL**, conformement a la regle « Etat des tableaux — pas de persistance URL » ci-dessous.
+
+A NE PAS faire :
+- Charger une collection complete via `?itemsPerPage=999` pour bypasser la pagination. Le seul cas legitime de retour complet est l'alimentation d'un `` sur un referentiel ≤ quelques dizaines d'entrees, et il passe par `?pagination=false` (echappatoire prevue par `pagination_client_enabled: true`).
+- Reimplementer la pagination prev/next a la main au-dessus de `MalioDataTable` — le composant porte deja le selecteur items/page et les boutons Prev/Next.
+- Persister `page`/`tri`/`filtre` dans la query string — meme regle que pour `` brut (cf. section suivante).
+
## Etat des tableaux — pas de persistance URL
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
diff --git a/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts
index a018863..8c1b071 100644
--- a/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts
+++ b/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import type { Category, CategoryType } from '~/modules/catalog/types/category'
+import type { CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
@@ -28,27 +28,6 @@ const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
-const CAT_A: Category = {
- id: 10,
- name: 'Vis',
- categoryType: TYPE_VENTE,
- deletedAt: null,
- createdAt: '2026-01-01T10:00:00+00:00',
- updatedAt: '2026-01-01T10:00:00+00:00',
- createdBy: null,
- updatedBy: null,
-}
-const CAT_B: Category = {
- id: 11,
- name: 'Boulons',
- categoryType: TYPE_VENTE,
- deletedAt: null,
- createdAt: '2026-01-02T10:00:00+00:00',
- updatedAt: '2026-01-02T10:00:00+00:00',
- createdBy: null,
- updatedBy: null,
-}
-
function makeHydra(items: T[]): HydraCollection {
return {
totalItems: items.length,
@@ -56,113 +35,32 @@ function makeHydra(items: T[]): HydraCollection {
}
}
+/**
+ * Apres ERP-73, `useCategoriesAdmin` ne porte plus la liste paginee des
+ * categories (elle est geree par `usePaginatedList` cote page).
+ * Le composable se concentre sur le referentiel CategoryType (lecture
+ * seule, ≤ 5 entrees connues) charge en une fois via `?pagination=false`.
+ */
describe('useCategoriesAdmin', () => {
beforeEach(() => {
mockGet.mockReset()
// Reset systematique du state singleton entre tests : sans ca,
- // les categories chargees dans un test fuiteraient dans le suivant.
+ // les types charges dans un test fuiteraient dans le suivant.
const { resetCategoriesAdmin } = useCategoriesAdmin()
resetCategoriesAdmin()
})
- describe('fetchAll', () => {
- it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
- mockGet.mockResolvedValueOnce(makeHydra([]))
- const { fetchAll } = useCategoriesAdmin()
-
- await fetchAll()
-
- expect(mockGet).toHaveBeenCalledTimes(1)
- expect(mockGet).toHaveBeenCalledWith(
- '/categories',
- { itemsPerPage: 999 },
- { toast: false },
- )
- })
-
- it('peuple categories.value depuis le champ Hydra member', async () => {
- mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
- const { fetchAll, categories } = useCategoriesAdmin()
-
- await fetchAll()
-
- expect(categories.value).toEqual([CAT_A, CAT_B])
- })
-
- it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
- mockGet.mockResolvedValueOnce(makeHydra([]))
- const { fetchAll } = useCategoriesAdmin()
-
- await fetchAll()
-
- const queryArg = mockGet.mock.calls[0]?.[1] as Record
- expect(queryArg).not.toHaveProperty('includeDeleted')
- })
-
- it('ajoute includeDeleted=true quand demande explicitement', async () => {
- mockGet.mockResolvedValueOnce(makeHydra([]))
- const { fetchAll } = useCategoriesAdmin()
-
- await fetchAll(true)
-
- expect(mockGet).toHaveBeenCalledWith(
- '/categories',
- { itemsPerPage: 999, includeDeleted: 'true' },
- { toast: false },
- )
- })
-
- it('passe loading a true pendant la requete et false apres', async () => {
- let resolveRequest: (v: HydraCollection) => void = () => {}
- mockGet.mockImplementationOnce(
- () => new Promise((resolve) => { resolveRequest = resolve }),
- )
- const { fetchAll, loading } = useCategoriesAdmin()
-
- const pending = fetchAll()
- expect(loading.value).toBe(true)
-
- resolveRequest(makeHydra([]))
- await pending
-
- expect(loading.value).toBe(false)
- })
-
- it('peuple error.value et vide categories en cas d echec', async () => {
- mockGet.mockRejectedValueOnce(new Error('Network down'))
- const { fetchAll, categories, error, loading } = useCategoriesAdmin()
- // Pre-charge volontairement quelque chose pour verifier la purge.
- categories.value = [CAT_A]
-
- await fetchAll()
-
- expect(categories.value).toEqual([])
- expect(error.value).toBe('Network down')
- expect(loading.value).toBe(false)
- })
-
- it('gere une reponse sans champ member (fallback tableau vide)', async () => {
- mockGet.mockResolvedValueOnce({
- totalItems: 0,
- } as unknown as HydraCollection)
- const { fetchAll, categories } = useCategoriesAdmin()
-
- await fetchAll()
-
- expect(categories.value).toEqual([])
- })
- })
-
describe('fetchTypes', () => {
- it('appelle GET /category_types avec itemsPerPage=999', async () => {
+ it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => {
mockGet.mockResolvedValueOnce(makeHydra([]))
const { fetchTypes } = useCategoriesAdmin()
await fetchTypes()
+ expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/category_types',
- { itemsPerPage: 999 },
+ { pagination: 'false' },
{ toast: false },
)
})
@@ -203,48 +101,55 @@ describe('useCategoriesAdmin', () => {
expect(loadingTypes.value).toBe(false)
})
+
+ it('gere une reponse sans champ member (fallback tableau vide)', async () => {
+ mockGet.mockResolvedValueOnce({
+ totalItems: 0,
+ } as unknown as HydraCollection)
+ const { fetchTypes, types } = useCategoriesAdmin()
+
+ await fetchTypes()
+
+ expect(types.value).toEqual([])
+ })
})
describe('resetCategoriesAdmin', () => {
- it('vide categories, types, loading, loadingTypes et error', () => {
- const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
+ it('vide types, loadingTypes et error', () => {
+ const { resetCategoriesAdmin, types, loadingTypes, error }
= useCategoriesAdmin()
// Pre-peuple le state pour verifier la purge effective.
- categories.value = [CAT_A]
types.value = [TYPE_VENTE]
- loading.value = true
loadingTypes.value = true
error.value = 'oops'
resetCategoriesAdmin()
- expect(categories.value).toEqual([])
expect(types.value).toEqual([])
- expect(loading.value).toBe(false)
expect(loadingTypes.value).toBe(false)
expect(error.value).toBeNull()
})
})
describe('singleton', () => {
- it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
+ it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
// Les fonctions sont reinstanciees a chaque appel mais les refs
// doivent etre rigoureusement les memes (state au niveau module).
- expect(a.categories).toBe(b.categories)
expect(a.types).toBe(b.types)
- expect(a.loading).toBe(b.loading)
+ expect(a.loadingTypes).toBe(b.loadingTypes)
+ expect(a.error).toBe(b.error)
})
it('une mutation via une instance est visible depuis une autre instance', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
- a.categories.value = [CAT_A]
+ a.types.value = [TYPE_VENTE]
- expect(b.categories.value).toEqual([CAT_A])
+ expect(b.types.value).toEqual([TYPE_VENTE])
})
})
})
diff --git a/frontend/modules/catalog/composables/useCategoriesAdmin.ts b/frontend/modules/catalog/composables/useCategoriesAdmin.ts
index 181326c..ff85c49 100644
--- a/frontend/modules/catalog/composables/useCategoriesAdmin.ts
+++ b/frontend/modules/catalog/composables/useCategoriesAdmin.ts
@@ -1,96 +1,56 @@
/**
- * Composable d'administration des categories (M0 — Gestion des categories).
+ * Composable de chargement du referentiel CategoryType (M0 — Gestion des
+ * categories).
*
- * Centralise le chargement et le state des deux ressources lues par la page
- * `/admin/categories` : la liste des categories et le referentiel
- * CategoryType (utilise dans le select du drawer).
+ * Apres ERP-73 (composable de liste paginee), la liste des categories
+ * elle-meme passe par `usePaginatedList` directement dans
+ * `admin/categories.vue` — c'est un etat propre a la page (pagination,
+ * filtres, tri locaux). Ce composable se concentre donc sur le
+ * referentiel CategoryType : petite collection lue une fois et reutilisee
+ * dans le drawer (select de type) → singleton volontaire pour eviter de
+ * la recharger a chaque ouverture du drawer.
*
- * State singleton au niveau module (meme convention que `useSidebar` /
- * `useModules` / `useAuditLog`) : reset automatique au logout via
- * `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
- * avec state singleton doivent etre reinitialises au logout »), et reset
- * explicite expose via `resetCategoriesAdmin()` appele depuis
- * `modules/core/pages/logout.vue`.
+ * State singleton au niveau module : reset automatique au logout via
+ * `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
+ * explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
*/
import { ref } from 'vue'
-import type { Category, CategoryType } from '~/modules/catalog/types/category'
+import type { CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
import { onAuthSessionCleared } from '~/shared/stores/auth'
/**
- * Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
- * cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
- * toute la liste en un coup. A basculer en pagination serveur quand la
- * volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
+ * CategoryType est un referentiel lecture-seule (RG-1.06) avec une
+ * cardinalite minuscule (≤ 5 entrees connues). On force `pagination=false`
+ * pour recuperer toutes les entrees en un appel et alimenter le select du
+ * drawer sans pagination — echappatoire prevue par
+ * `pagination_client_enabled: true` cote API Platform.
*/
-const HYDRA_NO_PAGINATION = 999
+const NO_PAGINATION_QUERY = { pagination: 'false' } as const
-// State singleton — partage entre tous les composants qui appellent le
-// composable dans la meme session. Les refs sont declarees au niveau module
-// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
-// instance soit creee a chaque appel.
-const categories = ref([])
const types = ref([])
-const loading = ref(false)
const loadingTypes = ref(false)
const error = ref(null)
function resetCategoriesAdminState(): void {
- categories.value = []
types.value = []
- loading.value = false
loadingTypes.value = false
error.value = null
}
-// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
-// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
-// l'ancien. Le logout volontaire (page logout.vue) appelle directement
-// `resetCategoriesAdmin()` ci-dessous.
+// Auto-enregistrement singleton : purge le state sur 401/clearSession
+// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
+// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
+// appelle directement `resetCategoriesAdmin()` ci-dessous.
onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() {
const api = useApi()
/**
- * Charge la liste des categories. Le serveur exclut les soft-deleted par
- * defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
- * serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
- *
- * `includeDeleted=true` permet a un user avec `catalog.categories.manage`
- * de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
- * option mais on l'expose pour la suite (corbeille future).
- *
- * Swallow volontaire : un 403 (user sans permission view) ne doit pas
- * toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
- * la page seulement par URL directe et on affiche un tableau vide propre.
- */
- async function fetchAll(includeDeleted = false): Promise {
- loading.value = true
- error.value = null
- try {
- const query: Record = { itemsPerPage: HYDRA_NO_PAGINATION }
- if (includeDeleted) {
- query.includeDeleted = 'true'
- }
- const data = await api.get>(
- '/categories',
- query,
- { toast: false },
- )
- categories.value = data.member ?? []
- } catch (e) {
- categories.value = []
- error.value = (e as Error)?.message ?? 'Erreur de chargement'
- } finally {
- loading.value = false
- }
- }
-
- /**
- * Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
- * l'ouverture de la page admin pour que le select du drawer ait deja les
- * options pretes au moment de la creation/edition.
+ * Charge le referentiel CategoryType. Appele a l'ouverture de la page
+ * admin pour que le select du drawer ait deja les options pretes au
+ * moment de la creation/edition.
*
* Toast desactive : on stocke l'erreur dans `error` plutot que de
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
@@ -100,7 +60,7 @@ export function useCategoriesAdmin() {
try {
const data = await api.get>(
'/category_types',
- { itemsPerPage: HYDRA_NO_PAGINATION },
+ NO_PAGINATION_QUERY,
{ toast: false },
)
types.value = data.member ?? []
@@ -113,21 +73,18 @@ export function useCategoriesAdmin() {
}
/**
- * Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
- * garantir que la prochaine session reparte sur un state propre meme si
- * `clearSession()` n'a pas ete declenche (cas logout volontaire).
+ * Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
+ * pour garantir que la prochaine session reparte sur un state propre
+ * meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
*/
function resetCategoriesAdmin(): void {
resetCategoriesAdminState()
}
return {
- categories,
types,
- loading,
loadingTypes,
error,
- fetchAll,
fetchTypes,
resetCategoriesAdmin,
}
diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue
index 2acd257..6d4104e 100644
--- a/frontend/modules/catalog/pages/admin/categories.vue
+++ b/frontend/modules/catalog/pages/admin/categories.vue
@@ -13,18 +13,23 @@
-
+
@@ -50,13 +55,27 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
-const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
+const { fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage'))
+// Pagination serveur via le composable partage (#73). Le CategoryProvider
+// applique deja name ASC (RG-1.10) — pas besoin de defaultSort cote front
+// tant qu'aucun OrderFilter n'est expose.
+const {
+ items: categories,
+ totalItems,
+ currentPage,
+ itemsPerPage,
+ itemsPerPageOptions,
+ fetch: fetchCategories,
+ goToPage,
+ setItemsPerPage,
+} = usePaginatedList({ url: '/categories' })
+
const drawerOpen = ref(false)
const selectedCategory = ref(null)
const deleteModalOpen = ref(false)
@@ -118,7 +137,7 @@ async function handleDelete(): Promise {
deleteModalOpen.value = false
categoryToDelete.value = null
drawerOpen.value = false
- await fetchAll()
+ await fetchCategories()
}
} finally {
deleting.value = false
@@ -126,14 +145,14 @@ async function handleDelete(): Promise {
}
function onCategorySaved() {
- fetchAll()
+ fetchCategories()
}
// Chargement initial des deux ressources (liste + referentiel des types).
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
onMounted(() => {
- fetchAll()
+ fetchCategories()
fetchTypes()
})
diff --git a/frontend/modules/core/pages/admin/roles.vue b/frontend/modules/core/pages/admin/roles.vue
index 15d2e20..dbcd881 100644
--- a/frontend/modules/core/pages/admin/roles.vue
+++ b/frontend/modules/core/pages/admin/roles.vue
@@ -13,14 +13,19 @@
-
+
{{ item.code }}
@@ -66,8 +71,17 @@ const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') })
-const roles = ref([])
-const loading = ref(false)
+// Pagination serveur via le composable partage (#73).
+const {
+ items: roles,
+ totalItems,
+ currentPage,
+ itemsPerPage,
+ itemsPerPageOptions,
+ fetch: loadRoles,
+ goToPage,
+ setItemsPerPage,
+} = usePaginatedList({ url: '/roles' })
const columns = [
{ key: 'label', label: t('admin.roles.table.label') },
@@ -102,25 +116,6 @@ const deleteModalOpen = ref(false)
const roleToDelete = ref(null)
const deleting = ref(false)
-// Charger la liste des roles
-async function loadRoles() {
- loading.value = true
- try {
- const data = await api.get<{ member: Role[] }>(
- '/roles',
- {},
- { toast: false },
- )
- roles.value = data.member
- } catch {
- // Reset sur echec pour ne pas afficher de donnees stale (ancienne
- // requete reussie avant une perte reseau ou 403).
- roles.value = []
- } finally {
- loading.value = false
- }
-}
-
function openCreateDrawer() {
selectedRole.value = null
drawerOpen.value = true
diff --git a/frontend/modules/core/pages/admin/users.vue b/frontend/modules/core/pages/admin/users.vue
index f4cc2f7..719a720 100644
--- a/frontend/modules/core/pages/admin/users.vue
+++ b/frontend/modules/core/pages/admin/users.vue
@@ -2,14 +2,19 @@
{{ t('admin.users.title') }}
-
+
can('core.users.manage'))
-const users = ref([])
-const loading = ref(false)
+// Pagination serveur via le composable partage (#73). Le payload `users`
+// reste leger (pas de detail RBAC dans la liste — cf. commentaire colonne
+// "Sites" plus bas) ce qui rend la pagination 10/25/50 par page confortable.
+const {
+ items: users,
+ totalItems,
+ currentPage,
+ itemsPerPage,
+ itemsPerPageOptions,
+ fetch: loadUsers,
+ goToPage,
+ setItemsPerPage,
+} = usePaginatedList({ url: '/users' })
+
const drawerOpen = ref(false)
const selectedUser = ref(null)
@@ -67,21 +83,6 @@ const userItems = computed(() =>
})),
)
-async function loadUsers() {
- loading.value = true
- try {
- const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
- users.value = usersData.member
- } catch {
- // Reset sur echec pour ne pas afficher de donnees stale (ancienne
- // requete reussie avant une perte reseau ou 403). Pas de toast par
- // design ici : on laisse la liste vide parler d'elle-meme.
- users.value = []
- } finally {
- loading.value = false
- }
-}
-
function getUserById(id: number): UserListItem | undefined {
return users.value.find(u => u.id === id)
}
diff --git a/frontend/modules/sites/pages/admin/sites.vue b/frontend/modules/sites/pages/admin/sites.vue
index c30abfc..6bb5f93 100644
--- a/frontend/modules/sites/pages/admin/sites.vue
+++ b/frontend/modules/sites/pages/admin/sites.vue
@@ -13,14 +13,19 @@
-
+
@@ -67,8 +72,20 @@ const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
-const sites = ref([])
-const loading = ref(false)
+// Pagination serveur via le composable partage (#73). Aucun OrderFilter
+// declare cote API Platform sur Site, donc on s'appuie sur le tri par
+// defaut du repository (id ASC). Le composable est neanmoins pret a
+// recevoir un `defaultSort` ou des filtres le jour ou l'API les expose.
+const {
+ items: sites,
+ totalItems,
+ currentPage,
+ itemsPerPage,
+ itemsPerPageOptions,
+ fetch: loadSites,
+ goToPage,
+ setItemsPerPage,
+} = usePaginatedList({ url: '/sites' })
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
@@ -107,24 +124,6 @@ const deleteModalOpen = ref(false)
const siteToDelete = ref(null)
const deleting = ref(false)
-async function loadSites() {
- loading.value = true
- try {
- const data = await api.get<{ member: Site[] }>(
- '/sites',
- { itemsPerPage: 999 },
- { toast: false },
- )
- sites.value = data.member
- } catch {
- // Reset sur echec pour ne pas afficher de donnees stale (ancienne
- // requete reussie avant une perte reseau ou 403).
- sites.value = []
- } finally {
- loading.value = false
- }
-}
-
function openCreateDrawer() {
selectedSite.value = null
drawerOpen.value = true
diff --git a/frontend/shared/composables/__tests__/usePaginatedList.test.ts b/frontend/shared/composables/__tests__/usePaginatedList.test.ts
new file mode 100644
index 0000000..02a5bb0
--- /dev/null
+++ b/frontend/shared/composables/__tests__/usePaginatedList.test.ts
@@ -0,0 +1,377 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { usePaginatedList } from '../usePaginatedList'
+
+const mockApiGet = vi.hoisted(() => vi.fn())
+vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
+
+/**
+ * Tests du composable `usePaginatedList`.
+ *
+ * Couvre les invariants critiques :
+ * - parse Hydra (member / totalItems)
+ * - navigation page (goToPage / next / prev / bornes)
+ * - changement items/page → retour page 1
+ * - mutation filtres / tri → retour page 1
+ * - cas limite : page courante hors borne apres filtre → derniere page valide
+ * - liste vide / page unique
+ * - reset → defaults
+ * - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
+ * - header `Accept: application/ld+json` toujours envoye (besoin du
+ * paginator Hydra cote API Platform 4).
+ */
+describe('usePaginatedList', () => {
+ beforeEach(() => {
+ mockApiGet.mockReset()
+ })
+
+ function mockResponse(member: unknown[], totalItems: number): void {
+ mockApiGet.mockResolvedValueOnce({ member, totalItems })
+ }
+
+ it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
+ mockResponse([{ id: 1 }, { id: 2 }], 42)
+ const list = usePaginatedList<{ id: number }>({ url: '/sites' })
+
+ await list.fetch()
+
+ expect(mockApiGet).toHaveBeenCalledTimes(1)
+ const [url, query, opts] = mockApiGet.mock.calls[0]
+ expect(url).toBe('/sites')
+ expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
+ expect(opts).toMatchObject({
+ toast: false,
+ headers: { Accept: 'application/ld+json' },
+ })
+ expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
+ expect(list.totalItems.value).toBe(42)
+ expect(list.totalPages.value).toBe(5)
+ expect(list.isEmpty.value).toBe(false)
+ expect(list.isSinglePage.value).toBe(false)
+ })
+
+ it('itemsPerPage personnalise est respecte au premier fetch', async () => {
+ mockResponse([], 0)
+ const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
+ await list.fetch()
+ expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
+ expect(list.itemsPerPage.value).toBe(25)
+ })
+
+ it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
+ mockResponse([{ id: 1 }], 30) // page 1
+ const list = usePaginatedList<{ id: number }>({ url: '/users' })
+ await list.fetch()
+
+ mockResponse([{ id: 2 }], 30) // page 2
+ await list.goToPage(2)
+
+ expect(mockApiGet).toHaveBeenCalledTimes(2)
+ expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
+ expect(list.currentPage.value).toBe(2)
+ })
+
+ it('goToPage hors borne sup. est clampe a totalPages', async () => {
+ mockResponse([], 30) // totalPages = 3
+ const list = usePaginatedList({ url: '/roles' })
+ await list.fetch()
+
+ mockResponse([], 30)
+ await list.goToPage(999)
+
+ expect(list.currentPage.value).toBe(3)
+ })
+
+ it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
+ mockResponse([], 30)
+ const list = usePaginatedList({ url: '/roles' })
+ await list.fetch()
+
+ mockApiGet.mockClear()
+ await list.goToPage(-5)
+
+ // Deja en page 1 -> aucun nouvel appel.
+ expect(mockApiGet).toHaveBeenCalledTimes(0)
+ expect(list.currentPage.value).toBe(1)
+ })
+
+ it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
+ mockResponse([], 30) // page 1, totalPages 3
+ const list = usePaginatedList({ url: '/roles' })
+ await list.fetch()
+
+ mockResponse([], 30)
+ await list.nextPage()
+ expect(list.currentPage.value).toBe(2)
+
+ mockResponse([], 30)
+ await list.nextPage()
+ expect(list.currentPage.value).toBe(3)
+
+ // En derniere page -> no-op
+ mockApiGet.mockClear()
+ await list.nextPage()
+ expect(mockApiGet).toHaveBeenCalledTimes(0)
+ expect(list.currentPage.value).toBe(3)
+
+ mockResponse([], 30)
+ await list.prevPage()
+ expect(list.currentPage.value).toBe(2)
+ })
+
+ it('setItemsPerPage revient en page 1 et refetch', async () => {
+ mockResponse([], 100)
+ const list = usePaginatedList({ url: '/users' })
+ await list.fetch()
+ // place-toi page 3
+ mockResponse([], 100)
+ await list.goToPage(3)
+ expect(list.currentPage.value).toBe(3)
+
+ mockResponse([], 100)
+ await list.setItemsPerPage(25)
+
+ expect(list.currentPage.value).toBe(1)
+ expect(list.itemsPerPage.value).toBe(25)
+ expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
+ })
+
+ it('setItemsPerPage no-op si meme valeur', async () => {
+ mockResponse([], 10)
+ const list = usePaginatedList({ url: '/users' })
+ await list.fetch()
+
+ mockApiGet.mockClear()
+ await list.setItemsPerPage(10)
+ expect(mockApiGet).toHaveBeenCalledTimes(0)
+ })
+
+ it('setFilters fusionne et retombe en page 1', async () => {
+ mockResponse([], 100)
+ const list = usePaginatedList({
+ url: '/users',
+ defaultFilters: { active: true },
+ })
+ await list.fetch()
+ mockResponse([], 100)
+ await list.goToPage(2)
+
+ mockResponse([], 100)
+ await list.setFilters({ name: 'alice' })
+
+ expect(list.currentPage.value).toBe(1)
+ expect(list.filters.value).toEqual({ active: true, name: 'alice' })
+ expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
+ page: 1,
+ active: true,
+ name: 'alice',
+ })
+ })
+
+ it('setFilters({ key: undefined }) supprime la cle', async () => {
+ mockResponse([], 100)
+ const list = usePaginatedList({
+ url: '/users',
+ defaultFilters: { name: 'alice' },
+ })
+ await list.fetch()
+
+ mockResponse([], 100)
+ await list.setFilters({ name: undefined })
+
+ expect(list.filters.value).toEqual({})
+ // Le query envoye ne contient plus `name` (compactQuery elimine
+ // aussi les valeurs vides).
+ const q = mockApiGet.mock.calls.at(-1)?.[1] as Record
+ expect(q.name).toBeUndefined()
+ })
+
+ it('setFilters({ replace: true }) remplace integralement', async () => {
+ mockResponse([], 100)
+ const list = usePaginatedList({
+ url: '/users',
+ defaultFilters: { a: 'x' },
+ })
+ await list.fetch()
+
+ mockResponse([], 100)
+ await list.setFilters({ b: 'y' }, { replace: true })
+
+ expect(list.filters.value).toEqual({ b: 'y' })
+ })
+
+ it('setSort envoie order[field]=direction et reset page', async () => {
+ mockResponse([], 100)
+ const list = usePaginatedList({ url: '/users' })
+ await list.fetch()
+ mockResponse([], 100)
+ await list.goToPage(2)
+
+ mockResponse([], 100)
+ await list.setSort({ field: 'username', direction: 'desc' })
+
+ expect(list.currentPage.value).toBe(1)
+ const q = mockApiGet.mock.calls.at(-1)?.[1] as Record
+ expect(q['order[username]']).toBe('desc')
+ })
+
+ it('setSort(null) retire le tri', async () => {
+ mockResponse([], 100)
+ const list = usePaginatedList({
+ url: '/users',
+ defaultSort: { field: 'name', direction: 'asc' },
+ })
+ await list.fetch()
+ // Le tri initial est applique
+ let q = mockApiGet.mock.calls[0][1] as Record
+ expect(q['order[name]']).toBe('asc')
+
+ mockResponse([], 100)
+ await list.setSort(null)
+ q = mockApiGet.mock.calls.at(-1)?.[1] as Record
+ expect(q['order[name]']).toBeUndefined()
+ })
+
+ it('page hors borne apres filtre retombe sur la derniere page valide', async () => {
+ // 1er fetch : page 1 sur une grosse liste
+ mockResponse([], 50) // 5 pages
+ const list = usePaginatedList({ url: '/users' })
+ await list.fetch()
+ mockResponse([], 50)
+ await list.goToPage(5)
+ expect(list.currentPage.value).toBe(5)
+
+ // Application d'un filtre : la nouvelle reponse a 12 items
+ // (donc 2 pages) mais on demande page=5 → l'API renvoie member=[]
+ // et le composable doit refetcher sur page=2.
+ mockApiGet.mockReset()
+ mockApiGet
+ // 1er appel : page=5 alors qu'il ne reste que 2 pages
+ .mockResolvedValueOnce({ member: [], totalItems: 12 })
+ // 2eme appel : refetch automatique sur page=2
+ .mockResolvedValueOnce({ member: [{ id: 1 }, { id: 2 }], totalItems: 12 })
+
+ await list.setFilters({ active: true } as never)
+
+ // setFilters reset page a 1 → c'est le cas standard, pas le hors borne.
+ // Pour declencher le cas hors borne, on doit forcer la page > totalPages.
+ expect(list.currentPage.value).toBe(1)
+ })
+
+ it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
+ // Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
+ // filtres mais entre deux fetchs la donnee a change cote serveur,
+ // la page courante peut devenir hors borne. On force le scenario
+ // en montant manuellement currentPage via goToPage borne, puis en
+ // simulant une reponse plus petite.
+ mockResponse([], 50) // 5 pages
+ const list = usePaginatedList({ url: '/users' })
+ await list.fetch()
+
+ mockResponse([], 50)
+ await list.goToPage(5)
+ expect(list.currentPage.value).toBe(5)
+
+ // Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
+ // le composable doit refetcher sur page=2.
+ mockApiGet.mockReset()
+ mockApiGet
+ .mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
+ .mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
+
+ await list.fetch()
+
+ expect(mockApiGet).toHaveBeenCalledTimes(2)
+ expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
+ expect(list.currentPage.value).toBe(2)
+ expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
+ })
+
+ it('liste vide : isEmpty true, isSinglePage true', async () => {
+ mockResponse([], 0)
+ const list = usePaginatedList({ url: '/users' })
+ await list.fetch()
+
+ expect(list.totalItems.value).toBe(0)
+ expect(list.isEmpty.value).toBe(true)
+ expect(list.isSinglePage.value).toBe(true)
+ expect(list.totalPages.value).toBe(1)
+ })
+
+ it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
+ const list = usePaginatedList({ url: '/users' })
+ expect(list.isEmpty.value).toBe(false)
+ })
+
+ it('reset revient aux defaults', async () => {
+ mockResponse([], 100)
+ const list = usePaginatedList({
+ url: '/users',
+ defaultItemsPerPage: 10,
+ defaultFilters: { a: 'x' },
+ defaultSort: { field: 'name', direction: 'asc' },
+ })
+ await list.fetch()
+
+ mockResponse([], 100)
+ await list.setItemsPerPage(50)
+ mockResponse([], 100)
+ await list.setFilters({ a: 'y' })
+ mockResponse([], 100)
+ await list.setSort({ field: 'id', direction: 'desc' })
+ mockResponse([], 100)
+ await list.goToPage(2)
+ expect(list.currentPage.value).toBe(2)
+
+ mockResponse([], 100)
+ await list.reset()
+
+ expect(list.itemsPerPage.value).toBe(10)
+ expect(list.filters.value).toEqual({ a: 'x' })
+ expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
+ expect(list.currentPage.value).toBe(1)
+ })
+
+ it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
+ mockApiGet.mockRejectedValueOnce(new Error('boom'))
+ const list = usePaginatedList({ url: '/users' })
+
+ await expect(list.fetch()).resolves.toBeUndefined()
+ expect(list.items.value).toEqual([])
+ expect(list.totalItems.value).toBe(0)
+ expect(list.loading.value).toBe(false)
+ // L'erreur est consideree comme un fetch consume -> isEmpty=true.
+ expect(list.isEmpty.value).toBe(true)
+ })
+
+ it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
+ mockResponse([], 0)
+ const list = usePaginatedList({
+ url: '/categories',
+ extraQuery: { includeDeleted: 'true' },
+ })
+ await list.fetch()
+
+ expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
+ })
+
+ it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
+ mockResponse([], 0)
+ const list = usePaginatedList({
+ url: '/users',
+ defaultFilters: { name: '', q: undefined } as never,
+ })
+ await list.fetch()
+
+ const q = mockApiGet.mock.calls[0][1] as Record
+ expect(q.name).toBeUndefined()
+ expect(q.q).toBeUndefined()
+ })
+
+ it('refresh() est un alias de fetch()', async () => {
+ mockResponse([{ id: 1 }], 1)
+ const list = usePaginatedList<{ id: number }>({ url: '/users' })
+
+ await list.refresh()
+ expect(list.items.value).toEqual([{ id: 1 }])
+ })
+})
diff --git a/frontend/shared/composables/usePaginatedList.ts b/frontend/shared/composables/usePaginatedList.ts
new file mode 100644
index 0000000..c248218
--- /dev/null
+++ b/frontend/shared/composables/usePaginatedList.ts
@@ -0,0 +1,327 @@
+import { computed, ref, type Ref } from 'vue'
+import type { HydraCollection } from '~/shared/utils/api'
+
+/**
+ * Composable generique de liste paginee serveur.
+ *
+ * Responsabilites :
+ * - centraliser l'etat tableau (page courante, items/page, tri, filtres,
+ * totalItems, items, loading, error) cote local — JAMAIS dans l'URL,
+ * conformement a la regle ABSOLUE n°6 du CLAUDE.md (« Jamais persister
+ * l'etat de tableau dans l'URL »).
+ * - dialoguer avec une ressource API Platform 4 (Hydra) en passant
+ * `page`, `itemsPerPage` et le tri/filtres en query params.
+ * - exposer une API simple a brancher sur `MalioDataTable`
+ * (props page/perPage/totalItems + events update:page / update:per-page).
+ *
+ * Volontairement **par-instance** (state local a chaque appel) : a la
+ * difference de `useAuditLog` / `useCategoriesAdmin` qui sont des
+ * singletons module-level partages, une liste paginee est propre a son
+ * ecran et ne doit pas etre partagee entre pages (sinon un retour
+ * arriere reprendrait la pagination d'une autre liste).
+ *
+ * Pas de gestion URL : si une page veut un deep link (ex : ouvrir un
+ * detail), elle le fait via sa propre route, pas via la query string
+ * de pagination. Derogation possible uniquement si l'utilisateur le
+ * demande explicitement, cf. CLAUDE.md.
+ */
+
+/**
+ * Direction de tri serveur. API Platform 4 attend `asc` ou `desc` via la
+ * syntaxe `?order[field]=asc`.
+ */
+export type SortDirection = 'asc' | 'desc'
+
+/**
+ * Specification de tri : un seul champ trie a la fois cote front (la
+ * majorite des tableaux Malio n'expose pas le multi-tri). Si null, aucun
+ * `order[...]` n'est envoye et l'API applique son tri par defaut.
+ */
+export interface SortSpec {
+ field: string
+ direction: SortDirection
+}
+
+/**
+ * Type des filtres : un dictionnaire de valeurs serialisables en query
+ * params. Le caller decide du mapping (ex : `{ active: true }`,
+ * `{ 'name[ilike]': 'a' }`). Valeurs `null` / `undefined` / chaines vides
+ * sont automatiquement omises au moment de la requete.
+ */
+export type PaginatedListFilters = Record
+
+export interface UsePaginatedListOptions {
+ /** URL relative au prefix `/api` (ex : `/sites`, `/categories`). */
+ url: string
+ /** Items par page initial. Defaut 10 (aligne avec le defaut serveur). */
+ defaultItemsPerPage?: number
+ /** Options proposees dans le selecteur items/page. Defaut [10, 25, 50]. */
+ itemsPerPageOptions?: number[]
+ /** Filtres initiaux. */
+ defaultFilters?: F
+ /** Tri initial. */
+ defaultSort?: SortSpec | null
+ /**
+ * Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
+ * `groups[]=foo`) injectes a chaque requete. Reactifs si une ref / computed
+ * est fournie via `refresh()` apres mutation.
+ */
+ extraQuery?: Record
+}
+
+export interface UsePaginatedListReturn {
+ /** Items de la page courante. */
+ items: Ref
+ /** Total d'items (toutes pages) renvoye par Hydra. */
+ totalItems: Ref
+ /** Page courante (1-based). */
+ currentPage: Ref
+ /** Taille de page courante. */
+ itemsPerPage: Ref
+ /** Options exposees au selecteur items/page. */
+ itemsPerPageOptions: Ref
+ /** Nombre total de pages (≥ 1). */
+ totalPages: Ref
+ /** Indicateur de chargement (vrai pendant `fetch()`). */
+ loading: Ref
+ /** Vrai apres au moins un fetch reussi avec 0 item. */
+ isEmpty: Ref
+ /** Vrai si la collection tient en une seule page (totalPages <= 1). */
+ isSinglePage: Ref
+ /** Filtres courants (mutation via `setFilters`). */
+ filters: Ref
+ /** Tri courant (mutation via `setSort`). */
+ sort: Ref
+ /** Lance un fetch contre l'API et met a jour items/totalItems. */
+ fetch: () => Promise
+ /** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
+ goToPage: (page: number) => Promise
+ /** Page suivante (no-op si deja en derniere page). */
+ nextPage: () => Promise
+ /** Page precedente (no-op si deja en premiere page). */
+ prevPage: () => Promise
+ /** Change la taille de page et revient en page 1. */
+ setItemsPerPage: (value: number) => Promise
+ /** Applique de nouveaux filtres et revient en page 1. */
+ setFilters: (next: Partial, options?: { replace?: boolean }) => Promise
+ /** Change le tri et revient en page 1. */
+ setSort: (next: SortSpec | null) => Promise
+ /** Reinitialise filtres + tri + page sur les valeurs par defaut. */
+ reset: () => Promise
+ /** Alias de `fetch()` (intention plus claire dans certains contextes). */
+ refresh: () => Promise
+}
+
+/**
+ * Force `application/ld+json` : sous `application/json`, API Platform 4
+ * renvoie un tableau plat sans envelope de pagination — on ne pourrait pas
+ * lire `totalItems` ni `view`. Voir aussi `useAuditLog.ts`.
+ */
+const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
+
+/**
+ * Filtre les entrees nulles/undefined/vides d'un objet de query : evite
+ * d'envoyer `?foo=&bar=null` a l'API qui declencherait parfois des erreurs
+ * de filtre cote Symfony (`FilterInterface::apply` strict).
+ */
+function compactQuery(raw: Record): Record {
+ const out: Record = {}
+ for (const [key, value] of Object.entries(raw)) {
+ if (value === null || value === undefined) continue
+ if (typeof value === 'string' && value === '') continue
+ if (Array.isArray(value) && value.length === 0) continue
+ out[key] = value
+ }
+ return out
+}
+
+export function usePaginatedList(
+ options: UsePaginatedListOptions,
+): UsePaginatedListReturn {
+ const api = useApi()
+
+ const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
+ const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
+ const initialSort = options.defaultSort ?? null
+
+ const items = ref([]) as Ref
+ const totalItems = ref(0)
+ const currentPage = ref(1)
+ const itemsPerPage = ref(defaultItemsPerPage)
+ const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
+ const loading = ref(false)
+ // `hasFetched` evite que `isEmpty` retourne `true` avant le premier
+ // chargement (etat initial = 0 items mais on ne sait pas encore si la
+ // ressource est vide ou en cours de chargement). Un appel reseau au
+ // moins doit avoir abouti pour qu'on annonce une liste « vide ».
+ const hasFetched = ref(false)
+ const filters = ref({ ...initialFilters }) as Ref
+ const sort = ref(initialSort ? { ...initialSort } : null)
+
+ const totalPages = computed(() => {
+ if (totalItems.value <= 0 || itemsPerPage.value <= 0) return 1
+ return Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
+ })
+ const isEmpty = computed(() => hasFetched.value && totalItems.value === 0)
+ const isSinglePage = computed(() => totalPages.value <= 1)
+
+ /**
+ * Construit l'objet query envoye a l'API : pagination + tri + filtres +
+ * extras propres a la ressource. Les filtres `null`/`undefined`/'' sont
+ * elimines pour ne pas polluer l'URL.
+ */
+ function buildQuery(): Record {
+ const query: Record = {
+ page: currentPage.value,
+ itemsPerPage: itemsPerPage.value,
+ }
+ if (sort.value) {
+ // Format API Platform : ?order[field]=asc
+ query[`order[${sort.value.field}]`] = sort.value.direction
+ }
+ if (options.extraQuery) {
+ Object.assign(query, options.extraQuery)
+ }
+ Object.assign(query, filters.value)
+ return compactQuery(query)
+ }
+
+ /**
+ * Lance un fetch et applique la borne haute si necessaire. Si la page
+ * courante depasse `totalPages` apres l'application des filtres (cas
+ * « j'etais en page 5, je filtre, il ne reste qu'une page »), on
+ * rappelle l'API sur la derniere page valide. Un seul niveau de retry
+ * pour eviter une boucle si l'API renvoie des resultats incoherents.
+ */
+ async function fetch(): Promise {
+ loading.value = true
+ try {
+ const data = await api.get>(
+ options.url,
+ buildQuery(),
+ { toast: false, headers: JSONLD_HEADERS },
+ )
+ items.value = data.member ?? []
+ totalItems.value = data.totalItems ?? 0
+
+ const tp = totalItems.value > 0
+ ? Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
+ : 1
+
+ // Si on est hors borne ET qu'il y a au moins une page valide
+ // a viser, on retombe sur la derniere page (cf. cas limite
+ // « page hors borne apres filtre » de la spec #73). On ne
+ // refetch que si la nouvelle page est differente, sinon
+ // boucle infinie potentielle.
+ if (currentPage.value > tp && tp >= 1 && totalItems.value > 0) {
+ currentPage.value = tp
+ const data2 = await api.get>(
+ options.url,
+ buildQuery(),
+ { toast: false, headers: JSONLD_HEADERS },
+ )
+ items.value = data2.member ?? []
+ totalItems.value = data2.totalItems ?? 0
+ }
+
+ hasFetched.value = true
+ } catch {
+ // Swallow volontaire : on remet la liste a vide pour ne pas
+ // afficher de donnees stale. Le composant parent decide de
+ // l'UX (toast / message d'erreur) — pas d'a-priori ici.
+ items.value = []
+ totalItems.value = 0
+ hasFetched.value = true
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function goToPage(page: number): Promise {
+ const tp = totalPages.value
+ const next = Math.max(1, Math.min(page, tp))
+ if (next === currentPage.value) return
+ currentPage.value = next
+ await fetch()
+ }
+
+ async function nextPage(): Promise {
+ if (currentPage.value >= totalPages.value) return
+ await goToPage(currentPage.value + 1)
+ }
+
+ async function prevPage(): Promise {
+ if (currentPage.value <= 1) return
+ await goToPage(currentPage.value - 1)
+ }
+
+ async function setItemsPerPage(value: number): Promise {
+ if (!Number.isFinite(value) || value <= 0) return
+ const rounded = Math.floor(value)
+ if (rounded === itemsPerPage.value) return
+ itemsPerPage.value = rounded
+ currentPage.value = 1
+ await fetch()
+ }
+
+ /**
+ * `replace: false` (defaut) fusionne avec les filtres courants. Une
+ * valeur explicitement `undefined` retire la cle (utile pour effacer
+ * un filtre depuis un champ controle). `replace: true` remplace
+ * integralement l'objet par `next`.
+ */
+ async function setFilters(next: Partial, opts?: { replace?: boolean }): Promise {
+ if (opts?.replace) {
+ filters.value = { ...(next as F) }
+ } else {
+ const merged = { ...filters.value, ...next } as F
+ // Supprime les cles explicitement passees a undefined : sans ce
+ // nettoyage, l'objet `filters` accumulerait des cles fantomes.
+ for (const key of Object.keys(next)) {
+ if (next[key as keyof F] === undefined) {
+ delete (merged as Record)[key]
+ }
+ }
+ filters.value = merged
+ }
+ currentPage.value = 1
+ await fetch()
+ }
+
+ async function setSort(next: SortSpec | null): Promise {
+ sort.value = next ? { ...next } : null
+ currentPage.value = 1
+ await fetch()
+ }
+
+ async function reset(): Promise {
+ filters.value = { ...initialFilters }
+ sort.value = initialSort ? { ...initialSort } : null
+ itemsPerPage.value = defaultItemsPerPage
+ currentPage.value = 1
+ await fetch()
+ }
+
+ return {
+ items,
+ totalItems,
+ currentPage,
+ itemsPerPage,
+ itemsPerPageOptions,
+ totalPages,
+ loading,
+ isEmpty,
+ isSinglePage,
+ filters,
+ sort,
+ fetch,
+ goToPage,
+ nextPage,
+ prevPage,
+ setItemsPerPage,
+ setFilters,
+ setSort,
+ reset,
+ refresh: fetch,
+ }
+}