From fd6b7e4c79300ac53a016e66dd5b90693bd9a59b Mon Sep 17 00:00:00 2001 From: Tristan Autin Date: Tue, 30 Jun 2026 10:45:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(catalog)=20:=20M7=20=E2=80=94=20page=20lis?= =?UTF-8?q?te=20des=20stockages=20/admin/storages=20(ERP-216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Page /admin/storages (MalioDataTable + usePaginatedList), colonnes Nom (displayName, RG-7.05) et Site, gate catalog.storages.view - Bouton Ajouter (catalog.storages.manage) → /admin/storages/new, clic ligne → /admin/storages/{id}/edit - Export XLSX via useApi() et drawer de filtres (search, type, état, sites), état 100 % local - Type Storage, libellés i18n, item sidebar « Catalogue stockages » sous Catalogue produits - Tests Vitest de la page (mapping colonnes, gates, navigation, export, filtres) --- config/sidebar.php | 20 +- frontend/i18n/locales/fr.json | 32 +- .../pages/__tests__/storagesIndex.spec.ts | 270 ++++++++++++ .../catalog/pages/admin/storages/index.vue | 386 ++++++++++++++++++ frontend/modules/catalog/types/storage.ts | 50 +++ 5 files changed, 747 insertions(+), 11 deletions(-) create mode 100644 frontend/modules/catalog/pages/__tests__/storagesIndex.spec.ts create mode 100644 frontend/modules/catalog/pages/admin/storages/index.vue create mode 100644 frontend/modules/catalog/types/storage.ts diff --git a/config/sidebar.php b/config/sidebar.php index 82130db..3079d7e 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -144,6 +144,16 @@ return [ 'module' => 'catalog', 'permission' => 'catalog.products.view', ], + // Stockage (M7, ERP-210). Admin-only : gate par `catalog.storages.view` + // et son module owner `catalog`. Reutilise le referentiel StorageType + // du M6. Place juste sous le Catalogue produits (items Catalog groupes). + [ + 'label' => 'sidebar.catalog.storages', + 'to' => '/admin/storages', + 'icon' => 'mdi:warehouse', + 'module' => 'catalog', + 'permission' => 'catalog.storages.view', + ], [ 'label' => 'sidebar.core.roles', 'to' => '/admin/roles', @@ -172,16 +182,6 @@ return [ 'module' => 'catalog', 'permission' => 'catalog.categories.view', ], - // Stockage (M7, ERP-210). Admin-only : gate par `catalog.storages.view` - // et son module owner `catalog`. Reutilise le referentiel StorageType - // du M6. Place pres des autres items Catalog (produits, categories). - [ - 'label' => 'sidebar.catalog.storages', - 'to' => '/admin/storages', - 'icon' => 'mdi:warehouse', - 'module' => 'catalog', - 'permission' => 'catalog.storages.view', - ], [ 'label' => 'sidebar.core.audit_log', 'to' => '/admin/audit-log', diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d2f4458..6fbc69c 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -54,7 +54,7 @@ "catalog": { "categories": "Gestion des catégories", "products": "Catalogue produits", - "storages": "Gestion des stockages" + "storages": "Catalogue stockages" } }, "dashboard": { @@ -1091,6 +1091,36 @@ "createSuccess": "Produit créé avec succès", "updateSuccess": "Produit mis à jour avec succès" } + }, + "storages": { + "title": "Gestion des stockages", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun stockage pour l'instant.", + "column": { + "name": "Nom", + "site": "Site" + }, + "state": { + "RECEPTION": "Réception", + "PRODUCTION": "Production", + "TRIAGE": "Triage" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "type": "Type de stockage", + "typeAll": "Tous les types", + "state": "État", + "stateAll": "Tous les états", + "site": "Sites", + "apply": "Voir les résultats", + "reset": "Réinitialiser" + }, + "toast": { + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export du répertoire stockage a échoué. Réessayez." + } } } } diff --git a/frontend/modules/catalog/pages/__tests__/storagesIndex.spec.ts b/frontend/modules/catalog/pages/__tests__/storagesIndex.spec.ts new file mode 100644 index 0000000..13221e5 --- /dev/null +++ b/frontend/modules/catalog/pages/__tests__/storagesIndex.spec.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, ref } from 'vue' + +// ── Auto-imports Nuxt stubbes globalement ─────────────────────────────────── +// La page ne les importe pas (auto-import) : on les expose en globals pour le +// runtime de test (happy-dom). Meme philosophie que la spec Catalogue produit. +const mockPush = vi.hoisted(() => vi.fn()) +const mockApiGet = vi.hoisted(() => vi.fn()) +const mockCan = vi.hoisted(() => vi.fn()) +const mockSetFilters = vi.hoisted(() => vi.fn()) +const mockFetch = vi.hoisted(() => vi.fn()) +const mockToastError = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useHead', () => undefined) +vi.stubGlobal('useApi', () => ({ get: mockApiGet })) +vi.stubGlobal('useRouter', () => ({ push: mockPush })) +vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() })) +vi.stubGlobal('usePermissions', () => ({ can: mockCan })) +// usePaginatedList est l'auto-import pilotant la liste : on controle items + +// setFilters + fetch. La ligne reproduit le contrat JSON reel (§ 4.0.bis) : +// site et storageType embarques, displayName virtuel (RG-7.05). +vi.stubGlobal('usePaginatedList', () => ({ + items: ref>>([ + { + id: 42, + numero: '12', + states: ['RECEPTION', 'PRODUCTION'], + displayName: 'Cellule 12', + site: { '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86' }, + storageType: { '@id': '/api/storage_types/9', id: 9, code: 'CELLULE', label: 'Cellule' }, + }, + ]), + totalItems: ref(1), + currentPage: ref(1), + itemsPerPage: ref(10), + itemsPerPageOptions: ref([10, 25, 50]), + fetch: mockFetch, + goToPage: vi.fn(), + setItemsPerPage: vi.fn(), + setFilters: mockSetFilters, +})) + +// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques +// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse). +globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake') +globalThis.URL.revokeObjectURL = vi.fn() + +// Import APRES les stubs (la page resout les auto-imports au top-level du module). +const StoragesIndex = (await import('../admin/storages/index.vue')).default + +// ── Stubs de composants ────────────────────────────────────────────────────── +const ButtonStub = defineComponent({ + props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } }, + emits: ['click'], + setup(props, { emit }) { + return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label) + }, +}) + +const DataTableStub = defineComponent({ + props: { items: { type: Array, default: () => [] } }, + emits: ['row-click', 'update:page', 'update:per-page'], + setup(props, { emit }) { + return () => h('div', { 'data-testid': 'datatable' }, + (props.items as Array>).map(it => + h('tr', { + 'data-row-id': it.id, + 'data-name': it.displayName, + 'data-site': it.siteLabel, + 'onClick': () => emit('row-click', it), + }), + ), + ) + }, +}) + +const DrawerStub = defineComponent({ + props: { modelValue: { type: Boolean, default: false } }, + setup(_, { slots }) { + return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) + }, +}) + +const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } }) + +const PageHeaderStub = defineComponent({ + setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) }, +}) + +const CheckboxStub = defineComponent({ + props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } }, + emits: ['update:model-value'], + setup(props, { emit }) { + return () => h('input', { + 'type': 'checkbox', + 'data-id': props.id, + 'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked), + }) + }, +}) + +const SelectStub = defineComponent({ + props: { + modelValue: { type: [String, Number, null] as unknown as () => string | number | null, default: null }, + options: { type: Array, default: () => [] }, + emptyOptionLabel: { type: String, default: '' }, + }, + emits: ['update:model-value'], + setup(props, { emit }) { + return () => h('select', { + 'data-empty-label': props.emptyOptionLabel, + 'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLSelectElement).value), + }, (props.options as Array<{ value: string | number, label: string }>).map(o => + h('option', { value: o.value }, o.label), + )) + }, +}) + +const InputTextStub = defineComponent({ setup() { return () => h('input') } }) + +function mountPage() { + return mount(StoragesIndex, { + global: { + stubs: { + PageHeader: PageHeaderStub, + MalioButton: ButtonStub, + MalioDataTable: DataTableStub, + MalioDrawer: DrawerStub, + MalioAccordion: SlotStub, + MalioAccordionItem: SlotStub, + MalioInputText: InputTextStub, + MalioSelect: SelectStub, + MalioCheckbox: CheckboxStub, + }, + }, + }) +} + +describe('Répertoire stockage (page /admin/storages)', () => { + beforeEach(() => { + mockPush.mockReset() + mockApiGet.mockReset().mockImplementation((url: string) => { + if (url === '/storage_types') { + return Promise.resolve({ member: [{ '@id': '/api/storage_types/9', id: 9, label: 'Cellule' }] }) + } + if (url === '/sites') { + return Promise.resolve({ member: [{ id: 1, name: 'Chatellerault' }] }) + } + return Promise.resolve({ member: [] }) + }) + mockCan.mockReset().mockReturnValue(true) + mockSetFilters.mockReset() + mockFetch.mockReset() + mockToastError.mockReset() + }) + + it('charge la liste au montage', async () => { + mountPage() + await flushPromises() + expect(mockFetch).toHaveBeenCalled() + }) + + it('mappe les colonnes Nom / Site sur le JSON réel (§ 4.0.bis)', async () => { + const wrapper = mountPage() + await flushPromises() + const row = wrapper.find('tr[data-row-id="42"]') + // displayName = libelle type + numero (RG-7.05). + expect(row.attributes('data-name')).toBe('Cellule 12') + // Site formate « Nom (Code) », miroir de l'export back. + expect(row.attributes('data-site')).toBe('Chatellerault (86)') + }) + + it('affiche « + Ajouter » uniquement avec la permission manage', async () => { + mockCan.mockImplementation((perm: string) => perm === 'catalog.storages.manage') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="admin.storages.add"]').exists()).toBe(true) + }) + + it('masque « + Ajouter » sans la permission manage (view seul)', async () => { + mockCan.mockImplementation((perm: string) => perm === 'catalog.storages.view') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="admin.storages.add"]').exists()).toBe(false) + }) + + it('navigue vers l\'édition au clic sur une ligne', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('tr[data-row-id="42"]').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/admin/storages/42/edit') + }) + + it('navigue vers la création au clic sur « + Ajouter »', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="admin.storages.add"]').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/admin/storages/new') + }) + + it('appelle l\'export XLSX sur /storages/export.xlsx en blob', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="admin.storages.export"]').trigger('click') + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/storages/export.xlsx', + expect.any(Object), + expect.objectContaining({ responseType: 'blob', toast: false }), + ) + }) + + it('répercute les sites cochés dans setFilters (filtre multi, clé siteId[])', async () => { + const wrapper = mountPage() + await flushPromises() + + await wrapper.find('input[data-id="filter-site-1"]').setValue(true) + await wrapper.find('[data-label="admin.storages.filters.apply"]').trigger('click') + + expect(mockSetFilters).toHaveBeenLastCalledWith( + { 'siteId[]': ['1'] }, + { replace: true }, + ) + // Etat 100 % local (regle n°6) : aucune navigation/query string declenchee. + expect(mockPush).not.toHaveBeenCalled() + }) + + it('répercute l\'état sélectionné dans setFilters (param state)', async () => { + const wrapper = mountPage() + await flushPromises() + + await wrapper.find('select[data-empty-label="admin.storages.filters.stateAll"]').setValue('RECEPTION') + await wrapper.find('[data-label="admin.storages.filters.apply"]').trigger('click') + + expect(mockSetFilters).toHaveBeenLastCalledWith( + { state: 'RECEPTION' }, + { replace: true }, + ) + }) + + it('répercute le type sélectionné dans setFilters (param storageTypeId)', async () => { + const wrapper = mountPage() + await flushPromises() + + await wrapper.find('select[data-empty-label="admin.storages.filters.typeAll"]').setValue('9') + await wrapper.find('[data-label="admin.storages.filters.apply"]').trigger('click') + + expect(mockSetFilters).toHaveBeenLastCalledWith( + { storageTypeId: '9' }, + { replace: true }, + ) + }) + + it('badge filtres actifs + Réinitialiser vide l\'état appliqué', async () => { + const wrapper = mountPage() + await flushPromises() + + await wrapper.find('input[data-id="filter-site-1"]').setValue(true) + await wrapper.find('[data-label="admin.storages.filters.apply"]').trigger('click') + + // Le libelle du bouton Filtrer porte le compteur (1 filtre actif). + expect(wrapper.find('[data-label="admin.storages.filters.title (1)"]').exists()).toBe(true) + + // Réinitialiser → query propre (setFilters avec objet vide). + await wrapper.find('[data-label="admin.storages.filters.reset"]').trigger('click') + expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true }) + }) +}) diff --git a/frontend/modules/catalog/pages/admin/storages/index.vue b/frontend/modules/catalog/pages/admin/storages/index.vue new file mode 100644 index 0000000..8a3da0e --- /dev/null +++ b/frontend/modules/catalog/pages/admin/storages/index.vue @@ -0,0 +1,386 @@ + + + diff --git a/frontend/modules/catalog/types/storage.ts b/frontend/modules/catalog/types/storage.ts new file mode 100644 index 0000000..4486929 --- /dev/null +++ b/frontend/modules/catalog/types/storage.ts @@ -0,0 +1,50 @@ +/** + * Types front du module Catalog (M7 — Repertoire stockage). + * + * Contrats API consommes : + * - GET /api/storages → HydraCollection + * - GET /api/storages/{id} → Storage + * - GET /api/storages/export.xlsx → binaire XLSX (export complet, filtres actifs) + * + * Notes (cf. spec-back § 4.0.bis, contrat JSON capture en ERP-215) : + * - `site` et `storageType` sont embarques (objets bornes, pas IRI) — embed + * autorise (ne viole pas la regle n°13, ensembles bornes). + * - `displayName` = libelle du type + numero (RG-7.05), expose en lecture seule. + * - `states` est un tableau de chaines (RECEPTION / PRODUCTION / TRIAGE, RG-7.04). + * - `skip_null_values` actif cote back : ne pas presumer la presence des nulls. + */ + +/** Site embarque dans un stockage (groupe `site:read`, sous-ensemble utile au front). */ +export interface StorageSite { + /** IRI Hydra, ex. `/api/sites/1` — utilise pour pre-selectionner le select en edition. */ + '@id': string + id: number + name: string + code: string | null +} + +/** Type de stockage embarque dans un stockage (referentiel borne, groupe `storage_type:read`). */ +export interface StorageStorageType { + /** IRI Hydra, ex. `/api/storage_types/9` — utilise pour pre-selectionner le select en edition. */ + '@id': string + id: number + code: string + label: string +} + +/** + * Stockage metier — tel qu'il est lu depuis l'API. L'entite porte le pattern + * Timestampable+Blamable (cf. spec-back § 2.8). + */ +export interface Storage { + id: number + numero: string + /** Etats : sous-ensemble non vide de RECEPTION / PRODUCTION / TRIAGE (RG-7.04). */ + states: string[] + /** Libelle d'affichage = libelle du type + numero (RG-7.05). */ + displayName: string + site: StorageSite | null + storageType: StorageStorageType | null + createdAt: string + updatedAt: string +}