diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 6425406..c2f04f5 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -1020,6 +1020,37 @@ "duplicate": "Une catégorie nommée « {name} » existe déjà.", "typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez." } + }, + "products": { + "title": "Catalogue produit", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun produit pour l'instant.", + "column": { + "name": "Nom", + "code": "Numéro", + "category": "Catégorie" + }, + "state": { + "PURCHASE": "Acheté", + "SALE": "Vendu", + "OTHER": "Autre" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "category": "Catégorie", + "categoryAll": "Toutes les catégories", + "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 catalogue produit a échoué. Réessayez." + } } } } diff --git a/frontend/modules/catalog/pages/__tests__/productsIndex.spec.ts b/frontend/modules/catalog/pages/__tests__/productsIndex.spec.ts new file mode 100644 index 0000000..fed5455 --- /dev/null +++ b/frontend/modules/catalog/pages/__tests__/productsIndex.spec.ts @@ -0,0 +1,272 @@ +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 les specs M1→M5. +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). +vi.stubGlobal('usePaginatedList', () => ({ + items: ref>>([ + { + id: 34, + code: 'BLE-TENDRE-01', + name: 'Blé tendre', + states: ['PURCHASE', 'SALE'], + manufactured: true, + containsMolasses: true, + category: { id: 12, name: 'Céréales', code: 'CEREALES' }, + sites: [{ id: 1, name: 'Chatellerault', code: '86' }], + storageTypes: [{ id: 9, code: 'TAS', label: 'Tas' }], + }, + ]), + 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 ProductsIndex = (await import('../admin/products.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.name, + 'data-code': it.code, + 'data-category': it.categoryName, + '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(ProductsIndex, { + global: { + stubs: { + PageHeader: PageHeaderStub, + MalioButton: ButtonStub, + MalioDataTable: DataTableStub, + MalioDrawer: DrawerStub, + MalioAccordion: SlotStub, + MalioAccordionItem: SlotStub, + MalioInputText: InputTextStub, + MalioSelect: SelectStub, + MalioCheckbox: CheckboxStub, + }, + }, + }) +} + +describe('Catalogue produit (page /admin/products)', () => { + beforeEach(() => { + mockPush.mockReset() + mockApiGet.mockReset().mockImplementation((url: string) => { + if (url === '/categories') { + return Promise.resolve({ member: [{ '@id': '/api/categories/12', id: 12, name: 'Céréales' }] }) + } + 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 / Numéro / Catégorie sur le JSON réel (§ 4.0.bis)', async () => { + const wrapper = mountPage() + await flushPromises() + const row = wrapper.find('tr[data-row-id="34"]') + expect(row.attributes('data-name')).toBe('Blé tendre') + expect(row.attributes('data-code')).toBe('BLE-TENDRE-01') + expect(row.attributes('data-category')).toBe('Céréales') + }) + + it('affiche « + Ajouter » uniquement avec la permission manage', async () => { + mockCan.mockImplementation((perm: string) => perm === 'catalog.products.manage') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(true) + }) + + it('masque « + Ajouter » sans la permission manage (view seul)', async () => { + mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="admin.products.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="34"]').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit') + }) + + it('navigue vers la création au clic sur « + Ajouter »', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="admin.products.add"]').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/admin/products/new') + }) + + it('appelle l\'export XLSX sur /products/export.xlsx en blob', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="admin.products.export"]').trigger('click') + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/products/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.products.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.products.filters.stateAll"]').setValue('SALE') + await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click') + + expect(mockSetFilters).toHaveBeenLastCalledWith( + { state: 'SALE' }, + { replace: true }, + ) + }) + + it('répercute la catégorie sélectionnée dans setFilters (param categoryId)', async () => { + const wrapper = mountPage() + await flushPromises() + + await wrapper.find('select[data-empty-label="admin.products.filters.categoryAll"]').setValue('12') + await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click') + + expect(mockSetFilters).toHaveBeenLastCalledWith( + { categoryId: '12' }, + { 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.products.filters.apply"]').trigger('click') + + // Le libelle du bouton Filtrer porte le compteur (1 filtre actif). + expect(wrapper.find('[data-label="admin.products.filters.title (1)"]').exists()).toBe(true) + + // Réinitialiser → query propre (setFilters avec objet vide). + await wrapper.find('[data-label="admin.products.filters.reset"]').trigger('click') + expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true }) + }) +}) diff --git a/frontend/modules/catalog/pages/admin/products.vue b/frontend/modules/catalog/pages/admin/products.vue new file mode 100644 index 0000000..7485077 --- /dev/null +++ b/frontend/modules/catalog/pages/admin/products.vue @@ -0,0 +1,377 @@ + + + diff --git a/frontend/modules/catalog/types/product.ts b/frontend/modules/catalog/types/product.ts new file mode 100644 index 0000000..7a5e2e3 --- /dev/null +++ b/frontend/modules/catalog/types/product.ts @@ -0,0 +1,66 @@ +/** + * Types front du module Catalog (M6 — Catalogue produit). + * + * Contrats API consommes : + * - GET /api/products → HydraCollection + * - GET /api/products/{id} → Product + * - GET /api/products/export.xlsx → binaire XLSX (export complet, filtres actifs) + * + * Notes (cf. spec-back § 4.0.bis, contrat JSON capture en ERP-203) : + * - `category` est embarque (objet, pas IRI) ; idem `sites` / `storageTypes` + * (tableaux d'objets bornes). On n'a besoin que de `category.name` en liste. + * - `states` est un tableau de chaines (PURCHASE / SALE / OTHER). + * - `skip_null_values` actif cote back : ne pas presumer la presence des nulls. + */ + +/** Type de categorie embarque dans `category.categoryTypes` (RG-6.05). */ +export interface ProductCategoryType { + id: number + code: string + label: string +} + +/** Categorie embarquee dans un produit (lecture seule, sous-ensemble utile au front). */ +export interface ProductCategory { + id: number + name: string + code: string + categoryTypes?: ProductCategoryType[] +} + +/** Site de disponibilite embarque dans un produit (groupe `site:read`). */ +export interface ProductSite { + id: number + name: string + code: string + postalCode: string + city: string + color: string + fullAddress: string +} + +/** Type de stockage embarque dans un produit (referentiel borne, § 2.4). */ +export interface ProductStorageType { + id: number + code: string + label: string +} + +/** + * Produit metier — tel qu'il est lu depuis l'API. L'entite porte le pattern + * Timestampable+Blamable (cf. spec-back § 2.8). + */ +export interface Product { + id: number + code: string + name: string + /** Etats : sous-ensemble de PURCHASE / SALE / OTHER (RG-6.02). */ + states: string[] + manufactured: boolean + containsMolasses: boolean + category: ProductCategory | null + sites: ProductSite[] + storageTypes: ProductStorageType[] + createdAt: string + updatedAt: string +}