diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index c469f5a..fb80618 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -495,6 +495,40 @@ } } }, + "transport": { + "carriers": { + "title": "Répertoire transporteurs", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun transporteur pour l'instant.", + "column": { + "name": "Nom", + "certification": "Certification", + "validityDate": "Date de validité", + "lastActivity": "Dernière activité" + }, + "certification": { + "QUALIMAT": "QUALIMAT", + "GMP_PLUS": "GMP+", + "OVOCOM": "OVOCOM", + "COMPTE_PROPRE": "Compte-propre", + "AUTRE": "Autre" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "certification": "Certification", + "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 transporteurs a échoué. Réessayez." + } + } + }, "auth": { "login": "Connexion", "logout": "Deconnexion", diff --git a/frontend/modules/transport/composables/__tests__/useCarriersRepository.test.ts b/frontend/modules/transport/composables/__tests__/useCarriersRepository.test.ts new file mode 100644 index 0000000..27a887c --- /dev/null +++ b/frontend/modules/transport/composables/__tests__/useCarriersRepository.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useCarriersRepository, type Carrier } from '../useCarriersRepository' + +const mockApiGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ get: mockApiGet })) + +/** + * Tests du repertoire transporteurs (ERP-164). + * + * `useCarriersRepository` est une fine enveloppe de `usePaginatedList` + * sur `/carriers`. Les invariants generiques de pagination sont deja couverts par + * `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire : + * - la ressource ciblee est bien `/carriers` ; + * - l'enveloppe Hydra (member / totalItems) est consommee ; + * - le header `Accept: application/ld+json` est envoye (sinon API Platform 4 + * renvoie un tableau plat sans pagination) ; + * - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye + * tant que l'utilisateur ne coche pas le filtre (le back masque alors les + * archives — RG-4.04) ; le filtre est bien transmis une fois applique. + */ +describe('useCarriersRepository', () => { + beforeEach(() => { + mockApiGet.mockReset() + }) + + /** Une page de transporteurs Hydra, avec qualimatCarrier embarque (RG-4.04). */ + const PAGE: Carrier[] = [ + { + id: 1, + name: 'TRANSPORTS ACME', + certificationType: 'QUALIMAT', + qualimatCarrier: { + id: '42', + name: 'TRANSPORTS ACME', + validityDate: '2027-01-15', + status: 'VALIDE', + }, + updatedAt: '2026-06-15T08:12:01+02:00', + isArchived: false, + }, + ] + + it('cible /carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => { + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + const repo = useCarriersRepository() + + await repo.fetch() + + expect(mockApiGet).toHaveBeenCalledTimes(1) + const [url, query, opts] = mockApiGet.mock.calls[0] + expect(url).toBe('/carriers') + expect(query).toMatchObject({ page: 1, itemsPerPage: 10 }) + expect(opts).toMatchObject({ + toast: false, + headers: { Accept: 'application/ld+json' }, + }) + expect(repo.items.value).toEqual(PAGE) + expect(repo.totalItems.value).toBe(1) + }) + + it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => { + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + const repo = useCarriersRepository() + + await repo.fetch() + + const query = mockApiGet.mock.calls[0][1] as Record + expect(query.includeArchived).toBeUndefined() + }) + + it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => { + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + const repo = useCarriersRepository() + await repo.fetch() + + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + await repo.setFilters({ includeArchived: true }) + + expect(repo.currentPage.value).toBe(1) + const query = mockApiGet.mock.calls.at(-1)?.[1] as Record + expect(query.includeArchived).toBe(true) + }) + + it('transmet les certifications multiples + la recherche', async () => { + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + const repo = useCarriersRepository() + await repo.fetch() + + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + await repo.setFilters({ search: 'acme', 'certificationType[]': ['QUALIMAT', 'AUTRE'] }) + + const query = mockApiGet.mock.calls.at(-1)?.[1] as Record + expect(query.search).toBe('acme') + expect(query['certificationType[]']).toEqual(['QUALIMAT', 'AUTRE']) + }) +}) diff --git a/frontend/modules/transport/composables/useCarriersRepository.ts b/frontend/modules/transport/composables/useCarriersRepository.ts new file mode 100644 index 0000000..10883b7 --- /dev/null +++ b/frontend/modules/transport/composables/useCarriersRepository.ts @@ -0,0 +1,68 @@ +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Vue MINIMALE du referentiel QUALIMAT embarque (groupe `qualimat:read`) dans la + * LISTE des transporteurs. Seuls les champs consommes par le Repertoire sont + * types : `validityDate` alimente la colonne « Date de validité » (fond rouge si + * perimee — RG-4.04). L'id QUALIMAT est une chaine (colonne BIGINT cote back). + */ +export interface CarrierQualimat { + id: string + name: string | null + /** Date ISO de validite de l'agrement QUALIMAT (date_immutable) — RG-4.04. */ + validityDate: string | null + status: string | null +} + +/** + * Vue MINIMALE d'un transporteur pour le Repertoire (datatable). Volontairement + * partielle : seuls les champs des colonnes + l'id (navigation) sont types ici. + * Le detail complet (onglets Adresses / Contacts / Prix) est hors perimetre de + * cet ecran (ERP-164, ticket #9). + * + * `certificationType` : QUALIMAT | GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE, ou + * `null` dans le cas LIOT (compte-propre interne sans certification — RG-4.01). + * Le libelle affiche est resolu cote front (cle i18n `transport.carriers.certification.*`). + */ +export interface Carrier { + id: number + name: string | null + certificationType: string | null + /** Lien editable vers le referentiel QUALIMAT (null si transporteur non QUALIMAT). */ + qualimatCarrier: CarrierQualimat | null + /** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */ + updatedAt: string | null + isArchived: boolean +} + +/** + * Filtres du Repertoire transporteurs, branches sur les query params de + * `GET /api/carriers` (spec-back § 4.1). Pilotes par la page via `setFilters` : + * - `search` : recherche fuzzy sur le nom ; + * - `certificationType[]` : multi-valeurs (OR cote back) ; + * - `includeArchived` : reintegre les archives (masquees par defaut). + */ +export interface CarrierFilters { + search?: string + 'certificationType[]'?: string[] + includeArchived?: boolean +} + +/** + * Repertoire transporteurs (M4, ERP-164) — simple enveloppe de + * `usePaginatedList` sur la ressource `/carriers` (regle ABSOLUE n°13 : + * pagination serveur obligatoire ; jamais de chargement integral en memoire). + * Miroir de `useSuppliersRepository` (M2) / `useProvidersRepository` (M3). + * + * Les filtres (recherche, certifications, archives) sont pilotes par la page via + * `setFilters` du composable partage — la remise en page 1 est garantie. Par + * defaut AUCUN `includeArchived` n'est envoye : le back masque alors les archives + * (RG-4.04, § 2.4). Cocher « Inclure les archivés » envoie `includeArchived=true`. + * + * 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 useCarriersRepository() { + return usePaginatedList({ url: '/carriers' }) +} diff --git a/frontend/modules/transport/pages/__tests__/carriersIndex.spec.ts b/frontend/modules/transport/pages/__tests__/carriersIndex.spec.ts new file mode 100644 index 0000000..ab51156 --- /dev/null +++ b/frontend/modules/transport/pages/__tests__/carriersIndex.spec.ts @@ -0,0 +1,198 @@ +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/M2/M3. +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('useCarriersRepository', () => ({ + items: ref([ + { + id: 7, + name: 'TRANSPORTS ACME', + certificationType: 'QUALIMAT', + qualimatCarrier: { id: '42', name: 'TRANSPORTS ACME', validityDate: '2027-01-15', status: 'VALIDE' }, + updatedAt: '2026-01-15T10:00:00+00:00', + isArchived: false, + }, + ]), + 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 CarriersIndex = (await import('../carriers/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 SelectCheckboxStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'select-checkbox' }) } }) +const InputTextStub = defineComponent({ setup() { return () => h('input') } }) + +function mountPage() { + return mount(CarriersIndex, { + global: { + stubs: { + PageHeader: PageHeaderStub, + MalioButton: ButtonStub, + MalioDataTable: DataTableStub, + MalioDrawer: DrawerStub, + MalioAccordion: SlotStub, + MalioAccordionItem: SlotStub, + MalioInputText: InputTextStub, + MalioSelectCheckbox: SelectCheckboxStub, + MalioCheckbox: CheckboxStub, + }, + }, + }) +} + +describe('Répertoire transporteurs (page /carriers)', () => { + 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 === 'transport.carriers.manage') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(true) + }) + + it('masque « + Ajouter » sans la permission manage (view seul)', async () => { + mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.view') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="transport.carriers.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('/carriers/7') + }) + + it('appelle l\'export XLSX sur /carriers/export.xlsx en blob', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="transport.carriers.export"]').trigger('click') + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/carriers/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="transport.carriers.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="transport.carriers.filters.apply"]').trigger('click') + + // Le libelle du bouton Filtrer porte le compteur (1 filtre actif). + expect(wrapper.find('[data-label="transport.carriers.filters.title (1)"]').exists()).toBe(true) + + // Réinitialiser → query propre (setFilters avec objet vide). + await wrapper.find('[data-label="transport.carriers.filters.reset"]').trigger('click') + expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true }) + }) +}) diff --git a/frontend/modules/transport/pages/carriers/index.vue b/frontend/modules/transport/pages/carriers/index.vue new file mode 100644 index 0000000..3fe4f36 --- /dev/null +++ b/frontend/modules/transport/pages/carriers/index.vue @@ -0,0 +1,378 @@ + + +