From 8046de76c64998c665064f71cb2732c3355e835e Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 16:30:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(transport)=20:=20filtres=20checkbox,=20tog?= =?UTF-8?q?gle=20=C2=AB=20Voir=20les=20archiv=C3=A9s=20=C2=BB,=20transport?= =?UTF-8?q?eurs=20dans=20Administration=20(ERP-164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/sidebar.php | 31 +++++------ frontend/i18n/locales/fr.json | 2 +- .../__tests__/useCarriersRepository.test.ts | 15 +++--- .../composables/useCarriersRepository.ts | 10 ++-- .../pages/__tests__/carriersIndex.spec.ts | 27 +++++++--- .../transport/pages/carriers/index.vue | 53 +++++++++++-------- frontend/tests/e2e/_fixtures/personas.ts | 7 +-- 7 files changed, 84 insertions(+), 61 deletions(-) diff --git a/config/sidebar.php b/config/sidebar.php index e38e1e0..6192941 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -78,23 +78,6 @@ return [ ], ], ], - // Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire - // transporteurs. L'item est gate par `transport.carriers.view` ; la section - // disparait automatiquement (SidebarProvider) si le module `transport` est - // desactive ou si l'user n'a pas la permission (Compta / Usine). - [ - 'label' => 'sidebar.transport.section', - 'icon' => 'mdi:truck-outline', - 'items' => [ - [ - 'label' => 'sidebar.transport.carriers', - 'to' => '/carriers', - 'icon' => 'mdi:truck-outline', - 'module' => 'transport', - 'permission' => 'transport.carriers.view', - ], - ], - ], // Section "Administration" : regroupe toutes les pages de configuration // applicative (RBAC, users, sites, audit log). // @@ -117,8 +100,20 @@ return [ // individuelles"), ajouter : 'permission' => 'core.admin.access'. [ 'label' => 'sidebar.administration.section', - 'icon' => 'mdi:cog-outline', + 'icon' => 'mdi:file-settings-cog-outline', 'items' => [ + // Transport — Repertoire transporteurs (M4, ERP-164). Rattache a + // l'Administration (premier item) plutot qu'a une section dediee : + // referentiel global de configuration applicative, sans cloisonnement + // par site. Reste gate par sa propre permission `transport.carriers.view` + // (Admin / Bureau / Commerciale) et son module owner `transport`. + [ + 'label' => 'sidebar.transport.carriers', + 'to' => '/carriers', + 'icon' => 'mdi:truck-outline', + 'module' => 'transport', + 'permission' => 'transport.carriers.view', + ], [ 'label' => 'sidebar.core.roles', 'to' => '/admin/roles', diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index fb80618..686b1a7 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -519,7 +519,7 @@ "search": "Recherche", "certification": "Certification", "status": "Statut", - "includeArchived": "Inclure les archivés", + "archivedOnly": "Voir les archivés", "apply": "Voir les résultats", "reset": "Réinitialiser" }, diff --git a/frontend/modules/transport/composables/__tests__/useCarriersRepository.test.ts b/frontend/modules/transport/composables/__tests__/useCarriersRepository.test.ts index 27a887c..3da48a3 100644 --- a/frontend/modules/transport/composables/__tests__/useCarriersRepository.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarriersRepository.test.ts @@ -14,9 +14,10 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet })) * - 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 + * - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` 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. + * archives) ; le filtre « Voir les archivés » est bien transmis une fois + * applique (aligne sur Client / Fournisseur / Prestataire). */ describe('useCarriersRepository', () => { beforeEach(() => { @@ -58,27 +59,27 @@ describe('useCarriersRepository', () => { expect(repo.totalItems.value).toBe(1) }) - it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => { + it('exclut les archives par defaut : aucun archivedOnly 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() + expect(query.archivedOnly).toBeUndefined() }) - it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => { + it('transmet archivedOnly 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 }) + await repo.setFilters({ archivedOnly: true }) expect(repo.currentPage.value).toBe(1) const query = mockApiGet.mock.calls.at(-1)?.[1] as Record - expect(query.includeArchived).toBe(true) + expect(query.archivedOnly).toBe(true) }) it('transmet les certifications multiples + la recherche', async () => { diff --git a/frontend/modules/transport/composables/useCarriersRepository.ts b/frontend/modules/transport/composables/useCarriersRepository.ts index 10883b7..941eee7 100644 --- a/frontend/modules/transport/composables/useCarriersRepository.ts +++ b/frontend/modules/transport/composables/useCarriersRepository.ts @@ -40,12 +40,13 @@ export interface Carrier { * `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). + * - `archivedOnly` : n'affiche QUE les archives (toggle « Voir les archivés », + * aligne sur les autres repertoires M1/M2/M3). */ export interface CarrierFilters { search?: string 'certificationType[]'?: string[] - includeArchived?: boolean + archivedOnly?: boolean } /** @@ -56,8 +57,9 @@ export interface CarrierFilters { * * 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`. + * defaut AUCUN `archivedOnly` n'est envoye : le back masque alors les archives + * (§ 2.4). Cocher « Voir les archivés » envoie `archivedOnly=true` (seules les + * archives sont listees, aligne sur Client / Fournisseur / Prestataire). * * 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 diff --git a/frontend/modules/transport/pages/__tests__/carriersIndex.spec.ts b/frontend/modules/transport/pages/__tests__/carriersIndex.spec.ts index ab51156..f3d0d38 100644 --- a/frontend/modules/transport/pages/__tests__/carriersIndex.spec.ts +++ b/frontend/modules/transport/pages/__tests__/carriersIndex.spec.ts @@ -95,7 +95,6 @@ const CheckboxStub = defineComponent({ }, }) -const SelectCheckboxStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'select-checkbox' }) } }) const InputTextStub = defineComponent({ setup() { return () => h('input') } }) function mountPage() { @@ -109,7 +108,6 @@ function mountPage() { MalioAccordion: SlotStub, MalioAccordionItem: SlotStub, MalioInputText: InputTextStub, - MalioSelectCheckbox: SelectCheckboxStub, MalioCheckbox: CheckboxStub, }, }, @@ -165,27 +163,42 @@ describe('Répertoire transporteurs (page /carriers)', () => { ) }) - it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => { + it('repercute le filtre « Voir 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) + // Coche « Voir les archivés » puis applique les filtres. + await wrapper.find('input[data-id="filter-archived-only"]').setValue(true) await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click') expect(mockSetFilters).toHaveBeenLastCalledWith( - { includeArchived: true }, + { archivedOnly: true }, { replace: true }, ) // Etat 100 % local (regle n°6) : aucune navigation/query string declenchee. expect(mockPush).not.toHaveBeenCalled() }) + it('repercute les certifications cochees dans setFilters (filtre multi)', async () => { + const wrapper = mountPage() + await flushPromises() + + // Coche deux certifications via les cases a cocher (pattern repertoire clients). + await wrapper.find('input[data-id="filter-certification-QUALIMAT"]').setValue(true) + await wrapper.find('input[data-id="filter-certification-AUTRE"]').setValue(true) + await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click') + + expect(mockSetFilters).toHaveBeenLastCalledWith( + { 'certificationType[]': ['QUALIMAT', 'AUTRE'] }, + { replace: true }, + ) + }) + 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('input[data-id="filter-archived-only"]').setValue(true) await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click') // Le libelle du bouton Filtrer porte le compteur (1 filtre actif). diff --git a/frontend/modules/transport/pages/carriers/index.vue b/frontend/modules/transport/pages/carriers/index.vue index 3fe4f36..1e5bdea 100644 --- a/frontend/modules/transport/pages/carriers/index.vue +++ b/frontend/modules/transport/pages/carriers/index.vue @@ -95,24 +95,28 @@ /> - + - +
+ +
- + @@ -263,17 +267,17 @@ const filterDrawerOpen = ref(false) const draftSearch = ref('') const draftCertificationTypes = ref([]) -const draftIncludeArchived = ref(false) +const draftArchivedOnly = ref(false) const appliedSearch = ref('') const appliedCertificationTypes = ref([]) -const appliedIncludeArchived = ref(false) +const appliedArchivedOnly = ref(false) const activeFilterCount = computed(() => { let count = 0 if (appliedSearch.value.trim() !== '') count++ if (appliedCertificationTypes.value.length > 0) count++ - if (appliedIncludeArchived.value) count++ + if (appliedArchivedOnly.value) count++ return count }) @@ -287,10 +291,17 @@ const filterButtonLabel = computed(() => { function openFilters(): void { draftSearch.value = appliedSearch.value draftCertificationTypes.value = [...appliedCertificationTypes.value] - draftIncludeArchived.value = appliedIncludeArchived.value + draftArchivedOnly.value = appliedArchivedOnly.value filterDrawerOpen.value = true } +/** Coche / decoche une certification dans le brouillon (filtre multi). */ +function toggleCertification(code: string, selected: boolean): void { + draftCertificationTypes.value = selected + ? [...draftCertificationTypes.value, code] + : draftCertificationTypes.value.filter(c => c !== code) +} + /** * Construit le payload de filtres serveur a partir de l'etat applique. Cle * `certificationType[]` pour que PHP la parse en tableau (OR cote back). Les @@ -300,7 +311,7 @@ function buildFilterPayload(): Record { const payload: Record = {} if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim() if (appliedCertificationTypes.value.length > 0) payload['certificationType[]'] = [...appliedCertificationTypes.value] - if (appliedIncludeArchived.value) payload.includeArchived = true + if (appliedArchivedOnly.value) payload.archivedOnly = true return payload } @@ -309,7 +320,7 @@ function buildFilterPayload(): Record { function applyFilters(): void { appliedSearch.value = draftSearch.value.trim() appliedCertificationTypes.value = [...draftCertificationTypes.value] - appliedIncludeArchived.value = draftIncludeArchived.value + appliedArchivedOnly.value = draftArchivedOnly.value setFilters(buildFilterPayload(), { replace: true }) filterDrawerOpen.value = false @@ -320,11 +331,11 @@ function applyFilters(): void { function resetFilters(): void { draftSearch.value = '' draftCertificationTypes.value = [] - draftIncludeArchived.value = false + draftArchivedOnly.value = false appliedSearch.value = '' appliedCertificationTypes.value = [] - appliedIncludeArchived.value = false + appliedArchivedOnly.value = false setFilters({}, { replace: true }) } diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index b690cc2..5bd15c0 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -95,10 +95,11 @@ export const personas: Record = { 'technique.providers.accounting.view', 'technique.providers.accounting.manage', 'technique.providers.archive', - // Transport — Repertoire transporteurs (M4, ERP-153). Meme logique : + // Transport — Repertoire transporteurs (M4, ERP-164). Meme logique : // mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE - // n°7). transport.carriers.view n'ajoute pas de lien dans la section - // Administration, donc expectedAdminLinks reste inchange. + // n°7). L'item transporteurs vit desormais dans la section Administration + // (1er item, ERP-164) mais sur la route `/carriers` (hors `/admin/`), + // donc il n'entre pas dans ALL_ADMIN_LINKS : expectedAdminLinks reste inchange. 'transport.carriers.view', 'transport.carriers.manage', 'transport.carriers.archive',