From 79d389834b00c1fdfe8dc81854b807bed9b66fc1 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 22:05:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(front)=20:=20page=20R=C3=A9pertoire=20four?= =?UTF-8?q?nisseurs=20(/suppliers)=20+=20datatable=20+=20filtres=20+=20exp?= =?UTF-8?q?ort=20(ERP-93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 26 ++ .../__tests__/useSuppliersRepository.spec.ts | 85 ++++ .../composables/useSuppliersRepository.ts | 54 +++ .../pages/__tests__/suppliersIndex.spec.ts | 205 +++++++++ .../commercial/pages/suppliers/index.vue | 434 ++++++++++++++++++ 5 files changed, 804 insertions(+) create mode 100644 frontend/modules/commercial/composables/__tests__/useSuppliersRepository.spec.ts create mode 100644 frontend/modules/commercial/composables/useSuppliersRepository.ts create mode 100644 frontend/modules/commercial/pages/__tests__/suppliersIndex.spec.ts create mode 100644 frontend/modules/commercial/pages/suppliers/index.vue diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index bac08d0..9b77a1d 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -49,6 +49,32 @@ "commercial": { "title": "Commercial", "welcome": "Module Commercial", + "suppliers": { + "title": "Répertoire fournisseurs", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun fournisseur pour l'instant.", + "column": { + "companyName": "Nom", + "categories": "Catégories", + "sites": "Site", + "lastActivity": "Dernière activité" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "categories": "Catégories", + "sites": "Sites", + "status": "Statut", + "includeArchived": "Inclure les archivés", + "apply": "Voir les résultats", + "reset": "Réinitialiser" + }, + "toast": { + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export du répertoire fournisseurs a échoué. Réessayez." + } + }, "clients": { "title": "Répertoire clients", "add": "Ajouter", diff --git a/frontend/modules/commercial/composables/__tests__/useSuppliersRepository.spec.ts b/frontend/modules/commercial/composables/__tests__/useSuppliersRepository.spec.ts new file mode 100644 index 0000000..6d5392c --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useSuppliersRepository.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { HydraCollection } from '~/shared/utils/api' +import type { Supplier } from '../useSuppliersRepository' + +// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter +// les appels declenches par usePaginatedList (que useSuppliersRepository enveloppe) +// et controler les reponses. Meme pattern que useClientsRepository.spec.ts. +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +// Import APRES le stub pour que useApi soit bien resolu au top-level du module. +const { useSuppliersRepository } = await import('../useSuppliersRepository') + +/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */ +function makeHydra(total: number): HydraCollection { + return { totalItems: total, member: [] } +} + +describe('useSuppliersRepository', () => { + beforeEach(() => { + mockGet.mockReset() + // 25 items → 3 pages a 10/page : permet de tester la navigation page 2. + mockGet.mockResolvedValue(makeHydra(25)) + }) + + it('cible la ressource /suppliers en page 1 par defaut', async () => { + const repo = useSuppliersRepository() + await repo.fetch() + + expect(mockGet).toHaveBeenLastCalledWith( + '/suppliers', + { page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) + + it('pousse les filtres du drawer (categories multi, sites, archives inclus) et retombe en page 1', async () => { + const repo = useSuppliersRepository() + await repo.fetch() + await repo.goToPage(2) + expect(repo.currentPage.value).toBe(2) + + await repo.setFilters( + { + search: 'acme', + 'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'], + 'siteId[]': ['86', '17'], + includeArchived: true, + }, + { replace: true }, + ) + + expect(repo.currentPage.value).toBe(1) + expect(mockGet).toHaveBeenLastCalledWith( + '/suppliers', + { + search: 'acme', + 'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'], + 'siteId[]': ['86', '17'], + includeArchived: true, + page: 1, + itemsPerPage: 10, + }, + expect.objectContaining({ toast: false }), + ) + }) + + it('repasse a une query propre apres reinitialisation des filtres', async () => { + const repo = useSuppliersRepository() + await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true }) + await repo.setFilters({}, { replace: true }) + + expect(mockGet).toHaveBeenLastCalledWith( + '/suppliers', + { page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) +}) diff --git a/frontend/modules/commercial/composables/useSuppliersRepository.ts b/frontend/modules/commercial/composables/useSuppliersRepository.ts new file mode 100644 index 0000000..5c05b10 --- /dev/null +++ b/frontend/modules/commercial/composables/useSuppliersRepository.ts @@ -0,0 +1,54 @@ +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Site Starseed rattache a une adresse du fournisseur, tel qu'embarque en LISTE + * (groupe site:read) pour la colonne « Site » du Repertoire (badges colores). + * Agrege des adresses cote back via Supplier::getSites() (cf. spec-back M2). + */ +export interface SupplierSite { + id: number + name: string + color: string +} + +/** + * Categorie (type FOURNISSEUR) rattachee au fournisseur, embarquee en LISTE + * (groupe category:read). La colonne « Catégories » affiche le `name` (et non le + * `code` comme au M1 clients — decision spec-front M2 § Datatable). + */ +export interface SupplierCategory { + code: string + name: string +} + +/** + * Vue MINIMALE d'un fournisseur pour le Repertoire (datatable). Volontairement + * partielle : seuls les champs des colonnes + l'id (navigation) sont types ici. + * Le detail complet (onglets) est hors perimetre de cet ecran (ERP-93). + */ +export interface Supplier { + id: number + companyName: string + categories: SupplierCategory[] + sites: SupplierSite[] + /** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */ + updatedAt: string | null + isArchived: boolean +} + +/** + * Repertoire fournisseurs (ERP-93) — simple enveloppe de `usePaginatedList` + * sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais + * de chargement integral en memoire). Miroir de `useClientsRepository` (M1). + * + * Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes + * par la page via `setFilters` du composable partage — la remise en page 1 est + * garantie. + * + * Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau + * est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de + * `usePaginatedList`. Aucun reset au logout a gerer. + */ +export function useSuppliersRepository() { + return usePaginatedList({ url: '/suppliers' }) +} diff --git a/frontend/modules/commercial/pages/__tests__/suppliersIndex.spec.ts b/frontend/modules/commercial/pages/__tests__/suppliersIndex.spec.ts new file mode 100644 index 0000000..5ea48bc --- /dev/null +++ b/frontend/modules/commercial/pages/__tests__/suppliersIndex.spec.ts @@ -0,0 +1,205 @@ +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 autres specs commercial. +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 })) + +// Le repository est lui aussi un auto-import : on controle items + setFilters. +vi.stubGlobal('useSuppliersRepository', () => ({ + items: ref([ + { + id: 7, + companyName: 'ACME', + categories: [{ code: 'NEG', name: 'Négociant' }], + sites: [{ id: 86, name: '86', color: '#123456' }], + updatedAt: '2026-01-15T10:00:00+00:00', + }, + ]), + 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 SuppliersIndex = (await import('../suppliers/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<{ id: number }>).map(it => + h('tr', { 'data-row-id': it.id, 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 InputTextStub = defineComponent({ setup() { return () => h('input') } }) + +function mountPage() { + return mount(SuppliersIndex, { + global: { + stubs: { + PageHeader: PageHeaderStub, + MalioButton: ButtonStub, + MalioDataTable: DataTableStub, + MalioDrawer: DrawerStub, + MalioAccordion: SlotStub, + MalioAccordionItem: SlotStub, + MalioInputText: InputTextStub, + MalioCheckbox: CheckboxStub, + }, + }, + }) +} + +describe('Répertoire fournisseurs (page /suppliers)', () => { + beforeEach(() => { + mockPush.mockReset() + mockApiGet.mockReset().mockResolvedValue({ 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('affiche « + Ajouter » uniquement avec la permission manage', async () => { + mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.manage') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(true) + }) + + it('masque « + Ajouter » sans la permission manage (view seul)', async () => { + mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.view') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(false) + }) + + it('navigue vers la consultation au clic sur une ligne', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('tr[data-row-id="7"]').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/suppliers/7') + }) + + it('charge les categories de type FOURNISSEUR pour le filtre', async () => { + mountPage() + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/categories', + expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }), + expect.objectContaining({ toast: false }), + ) + }) + + it('appelle l\'export XLSX sur /suppliers/export.xlsx en blob', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="commercial.suppliers.export"]').trigger('click') + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/suppliers/export.xlsx', + expect.any(Object), + expect.objectContaining({ responseType: 'blob', toast: false }), + ) + }) + + it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => { + const wrapper = mountPage() + await flushPromises() + + // Coche « Inclure les archivés » puis applique les filtres. + await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) + await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click') + + expect(mockSetFilters).toHaveBeenLastCalledWith( + { includeArchived: true }, + { replace: true }, + ) + // Etat 100 % local (regle n°6) : aucune navigation/query string declenchee. + expect(mockPush).not.toHaveBeenCalled() + }) + + it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => { + const wrapper = mountPage() + await flushPromises() + + await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) + await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click') + + // Le libelle du bouton Filtrer porte le compteur (1 filtre actif). + expect(wrapper.find('[data-label="commercial.suppliers.filters.title (1)"]').exists()).toBe(true) + + // Réinitialiser → query propre (setFilters avec objet vide). + await wrapper.find('[data-label="commercial.suppliers.filters.reset"]').trigger('click') + expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true }) + }) +}) diff --git a/frontend/modules/commercial/pages/suppliers/index.vue b/frontend/modules/commercial/pages/suppliers/index.vue new file mode 100644 index 0000000..ea374ef --- /dev/null +++ b/frontend/modules/commercial/pages/suppliers/index.vue @@ -0,0 +1,434 @@ + + + + +