From 1ef4215ebf1b5c09bb11cc0b65559733c577bf6d Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 16:21:15 +0200 Subject: [PATCH 01/16] =?UTF-8?q?feat(transport)=20:=20page=20r=C3=A9perto?= =?UTF-8?q?ire=20transporteurs=20(ERP-164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 34 ++ .../__tests__/useCarriersRepository.test.ts | 96 +++++ .../composables/useCarriersRepository.ts | 68 ++++ .../pages/__tests__/carriersIndex.spec.ts | 198 +++++++++ .../transport/pages/carriers/index.vue | 378 ++++++++++++++++++ 5 files changed, 774 insertions(+) create mode 100644 frontend/modules/transport/composables/__tests__/useCarriersRepository.test.ts create mode 100644 frontend/modules/transport/composables/useCarriersRepository.ts create mode 100644 frontend/modules/transport/pages/__tests__/carriersIndex.spec.ts create mode 100644 frontend/modules/transport/pages/carriers/index.vue 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 @@ + + + -- 2.39.5 From 8046de76c64998c665064f71cb2732c3355e835e Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 16:30:38 +0200 Subject: [PATCH 02/16] =?UTF-8?q?feat(transport)=20:=20filtres=20checkbox,?= =?UTF-8?q?=20toggle=20=C2=AB=20Voir=20les=20archiv=C3=A9s=20=C2=BB,=20tra?= =?UTF-8?q?nsporteurs=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', -- 2.39.5 From 597c63bb2e746f206760308e7ed00c3521a9441c Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 16:59:25 +0200 Subject: [PATCH 03/16] chore(frontend) : bump @malio/layer-ui ^1.7.12 + commentaire useSuppliersRepository --- .../composables/useSuppliersRepository.ts | 7 ++++--- frontend/package-lock.json | 20 +++++++++---------- frontend/package.json | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/frontend/modules/commercial/composables/useSuppliersRepository.ts b/frontend/modules/commercial/composables/useSuppliersRepository.ts index 5c05b10..37031ea 100644 --- a/frontend/modules/commercial/composables/useSuppliersRepository.ts +++ b/frontend/modules/commercial/composables/useSuppliersRepository.ts @@ -41,9 +41,10 @@ export interface Supplier { * 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. + * Les filtres (recherche, categories, sites, archives) sont pilotes par la page + * via `setFilters` du composable partage — la remise en page 1 est garantie. + * Cocher « Voir les archivés » envoie `archivedOnly=true` → seules les archives + * sont listees (aligne sur Client). * * 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/package-lock.json b/frontend/package-lock.json index 2344110..299887c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,7 @@ "name": "starseed-frontend", "hasInstallScript": true, "dependencies": { - "@malio/layer-ui": "^1.7.10", + "@malio/layer-ui": "^1.7.12", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", @@ -583,9 +583,9 @@ "license": "MIT" }, "node_modules/@emnapi/core": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", - "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, "dependencies": { @@ -594,9 +594,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", - "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -1866,9 +1866,9 @@ "license": "MIT" }, "node_modules/@malio/layer-ui": { - "version": "1.7.10", - "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz", - "integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==", + "version": "1.7.12", + "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz", + "integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==", "dependencies": { "@nuxt/icon": "^2.2.1", "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/frontend/package.json b/frontend/package.json index b4cf9b0..776fe52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@malio/layer-ui": "^1.7.10", + "@malio/layer-ui": "^1.7.12", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", -- 2.39.5 From 5734aaef5424edd8339a5a6fe6e503f31549a273 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 17:05:21 +0200 Subject: [PATCH 04/16] =?UTF-8?q?feat(transport)=20:=20=C3=A9cran=20ajout?= =?UTF-8?q?=20transporteur=20=E2=80=94=20layout=20+=20formulaire=20princip?= =?UTF-8?q?al=20(ERP-165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 24 +- .../__tests__/useCarrierForm.test.ts | 191 ++++++++++++++++ .../transport/composables/useCarrierForm.ts | 207 ++++++++++++++++++ .../modules/transport/pages/carriers/new.vue | 146 ++++++++++++ .../modules/transport/types/carrierForm.ts | 40 ++++ 5 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts create mode 100644 frontend/modules/transport/composables/useCarrierForm.ts create mode 100644 frontend/modules/transport/pages/carriers/new.vue create mode 100644 frontend/modules/transport/types/carrierForm.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 686b1a7..3bbee0f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -525,7 +525,29 @@ }, "toast": { "error": "Une erreur est survenue. Réessayez.", - "exportError": "L'export du répertoire transporteurs a échoué. Réessayez." + "exportError": "L'export du répertoire transporteurs a échoué. Réessayez.", + "createSuccess": "Transporteur créé avec succès" + }, + "tab": { + "qualimat": "Qualimat", + "addresses": "Adresses", + "contacts": "Contacts", + "prices": "Prix" + }, + "form": { + "title": "Ajouter un transporteur", + "back": "Retour au répertoire", + "submit": "Valider", + "comingSoon": "À venir", + "duplicateName": "Un transporteur actif portant ce nom existe déjà.", + "main": { + "name": "Nom", + "certificationType": "Certification transport", + "isChartered": "Affréter" + }, + "errors": { + "nameRequired": "Le nom du transporteur est obligatoire." + } } } }, diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts new file mode 100644 index 0000000..729c1cb --- /dev/null +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Tests du workflow « Ajouter un transporteur » (M4 Transport, ERP-165). + * + * `useCarrierForm` porte le formulaire principal (Nom + Certification + Affréter) + * et l'orchestration des onglets de création. On vérifie ici le CONTRAT propre à + * la création : + * - pré-check front : nom requis → POST bloqué, erreur inline, aucun appel réseau ; + * - POST /carriers (groupe carrier:write:main) : payload + Accept ld+json + + * toast:false ; au succès, verrouillage + bascule sur l'onglet Qualimat + + * réaffichage du nom normalisé ; + * - 409 doublon (RG-4.12) → erreur inline dédiée sur `name` ; + * - 422 → mapping inline par champ (propertyPath) ; + * - onglets : 4 onglets (Qualimat/Adresses/Contacts/Prix), completeTab + * déverrouille/avance et signale le dernier onglet ; + * - patchCarrier : PATCH partiel, no-op avant création. + */ + +const mockPost = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useApi', () => ({ + get: vi.fn(), + post: mockPost, + put: vi.fn(), + patch: mockPatch, + delete: vi.fn(), +})) +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useToast', () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), +})) + +const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm') + +describe('useCarrierForm', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + }) + + it('front : nom vide → erreur inline sur name, pas de POST', async () => { + const form = useCarrierForm() + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired') + expect(form.mainLocked.value).toBe(false) + }) + + it('front : nom en espaces uniquement → erreur inline, pas de POST', async () => { + const form = useCarrierForm() + form.main.name = ' ' + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired') + }) + + it('POST /carriers avec Accept ld+json, verrouille et bascule sur Qualimat', async () => { + mockPost.mockResolvedValueOnce({ id: 42, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' }) + const form = useCarrierForm() + form.main.name = 'Transports Acme' + form.main.certificationType = 'GMP_PLUS' + form.main.isChartered = true + + const created = await form.submitMain() + + expect(created).toBe(true) + expect(mockPost).toHaveBeenCalledTimes(1) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/carriers') + expect(body).toEqual({ + name: 'Transports Acme', + certificationType: 'GMP_PLUS', + isChartered: true, + }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + + expect(form.carrierId.value).toBe(42) + // RG-4.13 : réaffiche le nom normalisé (UPPERCASE) renvoyé par le serveur. + expect(form.main.name).toBe('TRANSPORTS ACME') + expect(form.mainLocked.value).toBe(true) + expect(form.activeTab.value).toBe('qualimat') + expect(form.unlockedIndex.value).toBe(0) + }) + + it('payload : omet name et certificationType vides, garde isChartered', async () => { + mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [] } } }) + const form = useCarrierForm() + form.main.name = 'X' // nom présent pour passer le pré-check front + // certificationType laissé null → omis pour que la 422 « obligatoire » porte. + + await form.submitMain() + + const body = mockPost.mock.calls[0]?.[1] as Record + expect(body).toEqual({ name: 'X', isChartered: false }) + expect('certificationType' in body).toBe(false) + }) + + it('409 doublon (RG-4.12) : erreur inline dédiée sur name, pas de verrouillage', async () => { + mockPost.mockRejectedValueOnce({ response: { status: 409 } }) + const form = useCarrierForm() + form.main.name = 'Doublon' + form.main.certificationType = 'AUTRE' + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(form.mainErrors.errors.name).toBe('transport.carriers.form.duplicateName') + expect(form.mainLocked.value).toBe(false) + }) + + it('422 : mappe les violations serveur inline par champ', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'certificationType', message: 'Le type de certification est obligatoire.' }] }, + }, + }) + const form = useCarrierForm() + form.main.name = 'Sans Certif' + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(form.mainErrors.errors.certificationType).toBe('Le type de certification est obligatoire.') + expect(form.mainLocked.value).toBe(false) + }) + + it('onglets : 4 clés Qualimat/Adresses/Contacts/Prix', () => { + expect(CARRIER_TAB_KEYS).toEqual(['qualimat', 'addresses', 'contacts', 'prices']) + const form = useCarrierForm() + expect(form.tabKeys.value).toEqual(['qualimat', 'addresses', 'contacts', 'prices']) + // Tous verrouillés tant que le formulaire principal n'est pas validé. + expect(form.unlockedIndex.value).toBe(-1) + }) + + it('completeTab : déverrouille/avance, et signale le dernier onglet du flux', () => { + const form = useCarrierForm() + + // Qualimat → Adresses (pas le dernier). + expect(form.completeTab('qualimat')).toBe(false) + expect(form.isValidated('qualimat')).toBe(true) + expect(form.activeTab.value).toBe('addresses') + expect(form.unlockedIndex.value).toBe(1) + + expect(form.completeTab('addresses')).toBe(false) + expect(form.activeTab.value).toBe('contacts') + + expect(form.completeTab('contacts')).toBe(false) + expect(form.activeTab.value).toBe('prices') + + // Prix = dernier onglet → true (création terminée). + expect(form.completeTab('prices')).toBe(true) + expect(form.isValidated('prices')).toBe(true) + }) + + it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => { + const form = useCarrierForm() + form.editMode.value = true + form.activeTab.value = 'qualimat' + + expect(form.completeTab('qualimat')).toBe(false) + expect(form.isValidated('qualimat')).toBe(false) + expect(form.activeTab.value).toBe('qualimat') + }) + + it('patchCarrier : PATCH /carriers/{id} en mode strict, no-op avant création', async () => { + const form = useCarrierForm() + + await form.patchCarrier({ liotPlates: 'AA-123-BB' }) + expect(mockPatch).not.toHaveBeenCalled() + + mockPost.mockResolvedValueOnce({ id: 9, name: 'ACME', certificationType: 'OVOCOM' }) + form.main.name = 'Acme' + form.main.certificationType = 'OVOCOM' + await form.submitMain() + + await form.patchCarrier({ liotPlates: 'AA-123-BB' }) + expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false }) + }) +}) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts new file mode 100644 index 0000000..ef6326e --- /dev/null +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -0,0 +1,207 @@ +import { reactive, ref } from 'vue' +import { useFormErrors } from '~/shared/composables/useFormErrors' +import { + emptyCarrierMain, + type CarrierMainDraft, + type CarrierMainResponse, +} from '~/modules/transport/types/carrierForm' + +/** + * Workflow de l'écran « Ajouter un transporteur » (M4 Transport, ERP-165) — + * miroir conceptuel de `useSupplierForm` (M2) / `useProviderForm` (M3). + * + * Périmètre ERP-165 : le formulaire PRINCIPAL (pré-onglets) et l'orchestration de + * la barre d'onglets. La création est INCRÉMENTALE (spec-front § Écran Ajouter) : + * - on POST d'abord le formulaire principal (`POST /api/carriers`) ; + * - au succès le bloc principal passe en lecture seule, le 1er onglet (Qualimat) + * se déverrouille et devient actif ; + * - chaque onglet validé déverrouille le suivant (PATCH partiels par groupe de + * sérialisation) et passe en lecture seule. + * + * Les champs conditionnels du formulaire principal (indexation / benne / volume + * si affrété, décharge si AUTRE, cas LIOT) et la saisie assistée QUALIMAT arrivent + * à ERP-166 ; le contenu des onglets Adresses / Contacts / Prix aux tickets + * suivants. Ce composable pose le POST principal, le PATCH partiel et le gating + * des onglets. + * + * État 100 % local à l'instance (refs / reactive) — aucune persistance URL. + */ + +/** + * Clés des onglets du flux de création, dans l'ordre de la barre (spec-front + * § Écran Ajouter). Toujours les 4 (pas de gating par permission au M4, ≠ l'onglet + * Comptabilité du M3). + */ +export const CARRIER_TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices'] as const + +export function useCarrierForm() { + const api = useApi() + const { t } = useI18n() + const toast = useToast() + + // Erreurs de validation par champ (ERP-101) du formulaire principal. + const mainErrors = useFormErrors() + + // ── État du transporteur créé ───────────────────────────────────────────── + const carrierId = ref(null) + const mainLocked = ref(false) + const mainSubmitting = ref(false) + + // ── Formulaire principal ────────────────────────────────────────────────── + const main = reactive(emptyCarrierMain()) + + // ── Onglets : ordre + gating progressif ─────────────────────────────────── + const tabKeys = ref([...CARRIER_TAB_KEYS]) + // Index du dernier onglet déverrouillé (-1 tant que le transporteur n'est pas créé). + const unlockedIndex = ref(-1) + const activeTab = ref(CARRIER_TAB_KEYS[0]) + // Onglets validés (passent en lecture seule). + const validated = reactive>({}) + // Mode MODIFICATION (ticket ultérieur) : navigation libre, pas de verrouillage + // ni de bascule automatique d'onglet à la validation (cf. completeTab). + const editMode = ref(false) + + function isValidated(key: string): boolean { + return validated[key] === true + } + + function tabIndex(key: string): number { + return tabKeys.value.indexOf(key) + } + + /** + * Validation FRONT du formulaire principal : seul le nom est requis côté front + * (RG-4.01). Le back reste la couche autoritaire (ERP-101) — la certification + * obligatoire (sauf LIOT) et les RG conditionnelles sont re-validées serveur et + * remontées en 422 inline, sans pré-check front (qui devrait connaître le cas + * LIOT, hors périmètre ERP-165). + */ + function validateMainFront(): boolean { + let valid = true + if (!main.name?.trim()) { + mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired')) + valid = false + } + return valid + } + + /** + * Payload du POST principal (groupe `carrier:write:main`). `name` et + * `certificationType` sont omis s'ils sont vides afin que la 422 porte la + * violation métier (NotBlank sur le nom, « certification obligatoire » sur la + * certification) sur le champ plutôt qu'une erreur de type. + */ + function buildMainPayload(): Record { + const payload: Record = { + isChartered: main.isChartered, + } + if (main.name?.trim()) { + payload.name = main.name + } + if (main.certificationType) { + payload.certificationType = main.certificationType + } + return payload + } + + /** + * POST /carriers (groupe `carrier:write:main`). Pré-check front (nom), puis + * création. Au succès : verrouille le bloc principal, déverrouille le 1er onglet + * et bascule sur « Qualimat ». Retourne true si créé, false sinon. + */ + async function submitMain(): Promise { + if (mainSubmitting.value) return false + mainErrors.clearErrors() + if (!validateMainFront()) return false + + mainSubmitting.value = true + try { + const created = await api.post('/carriers', buildMainPayload(), { + headers: { Accept: 'application/ld+json' }, + toast: false, + }) + + carrierId.value = created.id + // Réaffiche les valeurs normalisées renvoyées par le serveur (nom en + // UPPERCASE — RG-4.13 ; certification éventuellement forcée). + main.name = created.name ?? main.name + main.certificationType = created.certificationType ?? main.certificationType + + mainLocked.value = true + unlockedIndex.value = 0 + activeTab.value = tabKeys.value[0] ?? CARRIER_TAB_KEYS[0] + toast.success({ title: t('transport.carriers.toast.createSuccess') }) + return true + } + catch (error) { + // 409 = doublon de nom (RG-4.12) → erreur inline dédiée + toast ; + // 422 → mapping inline par champ ; autre → toast de fallback (ERP-101). + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 409) { + const message = t('transport.carriers.form.duplicateName') + mainErrors.setError('name', message) + toast.error({ title: t('transport.carriers.toast.error'), message }) + } + else { + mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') }) + } + return false + } + finally { + mainSubmitting.value = false + } + } + + /** + * PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation + * par appel — spec-back § 2.9). Servira les onglets à champs scalaires des + * tickets suivants. No-op tant que le transporteur n'existe pas. + */ + async function patchCarrier(payload: Record): Promise { + if (carrierId.value === null) return + await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false }) + } + + /** + * Marque un onglet validé (passe en lecture seule), déverrouille et avance à + * l'onglet suivant. Retourne true si c'était le dernier onglet du flux (création + * terminée), false sinon. + */ + function completeTab(key: string): boolean { + // En modification : navigation libre, l'onglet reste éditable après validation. + if (editMode.value) { + return false + } + validated[key] = true + const index = tabIndex(key) + const next = tabKeys.value[index + 1] + if (next === undefined) { + return true + } + unlockedIndex.value = Math.max(unlockedIndex.value, index + 1) + activeTab.value = next + return false + } + + return { + // état + main, + carrierId, + mainLocked, + mainSubmitting, + mainErrors, + // onglets + tabKeys, + activeTab, + unlockedIndex, + validated, + editMode, + isValidated, + // actions + validateMainFront, + buildMainPayload, + submitMain, + patchCarrier, + completeTab, + } +} diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue new file mode 100644 index 0000000..21f0cd5 --- /dev/null +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -0,0 +1,146 @@ + + + diff --git a/frontend/modules/transport/types/carrierForm.ts b/frontend/modules/transport/types/carrierForm.ts new file mode 100644 index 0000000..5fe7c1f --- /dev/null +++ b/frontend/modules/transport/types/carrierForm.ts @@ -0,0 +1,40 @@ +/** + * Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165). + * + * Périmètre ERP-165 = formulaire PRINCIPAL (pré-onglets) uniquement : Nom + + * Certification + Affréter. Les champs conditionnels (indexation / benne / volume + * si affrété, décharge si AUTRE, immatriculations LIOT) et la saisie assistée + * QUALIMAT arrivent à ERP-166 ; les onglets Adresses / Contacts / Prix aux tickets + * suivants. On garde donc volontairement ce draft minimal — il s'étendra. + */ + +/** + * Brouillon du formulaire principal. `certificationType` est un code enum back + * (GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE ; QUALIMAT sera posé par la saisie + * assistée à ERP-166) ou `null` tant que rien n'est choisi. + */ +export interface CarrierMainDraft { + name: string + certificationType: string | null + isChartered: boolean +} + +/** Brouillon principal vide (état initial du formulaire de création). */ +export function emptyCarrierMain(): CarrierMainDraft { + return { + name: '', + certificationType: null, + isChartered: false, + } +} + +/** + * Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie + * le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel. + */ +export interface CarrierMainResponse { + id: number + name: string | null + certificationType: string | null + '@id'?: string +} -- 2.39.5 From f1b18cfbbe6e38abc1b16841dd46ca9a3d6f5fdd Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 17:09:48 +0200 Subject: [PATCH 05/16] =?UTF-8?q?fix(transport)=20:=20centre=20verticaleme?= =?UTF-8?q?nt=20la=20case=20=C2=AB=20Affr=C3=A9ter=20=C2=BB=20sur=20la=20l?= =?UTF-8?q?igne=20de=20champ=20(ERP-165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/transport/pages/carriers/new.vue | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index 21f0cd5..c045581 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -36,13 +36,19 @@ :error="mainErrors.errors.certificationType" @update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)" /> - + +
+ +
-- 2.39.5 From f70e701854bae2008dc7e57169d274e603b9df06 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 17:22:25 +0200 Subject: [PATCH 06/16] =?UTF-8?q?feat(transport)=20:=20saisie=20assist?= =?UTF-8?q?=C3=A9e=20QUALIMAT=20+=20champs=20conditionnels=20(ERP-166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 31 +- .../__tests__/useCarrierForm.test.ts | 150 +++++++++ .../__tests__/useQualimatSearch.test.ts | 55 ++++ .../transport/composables/useCarrierForm.ts | 108 +++++- .../composables/useQualimatSearch.ts | 76 +++++ .../modules/transport/pages/carriers/new.vue | 311 ++++++++++++++++-- .../modules/transport/types/carrierForm.ts | 55 +++- 7 files changed, 738 insertions(+), 48 deletions(-) create mode 100644 frontend/modules/transport/composables/__tests__/useQualimatSearch.test.ts create mode 100644 frontend/modules/transport/composables/useQualimatSearch.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 3bbee0f..d0acd43 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -526,7 +526,12 @@ "toast": { "error": "Une erreur est survenue. Réessayez.", "exportError": "L'export du répertoire transporteurs a échoué. Réessayez.", - "createSuccess": "Transporteur créé avec succès" + "createSuccess": "Transporteur créé avec succès", + "integrateSuccess": "Transporteur QUALIMAT intégré" + }, + "containerType": { + "BENNE": "Benne", + "FOND_MOUVANT": "Fond mouvant" }, "tab": { "qualimat": "Qualimat", @@ -543,7 +548,29 @@ "main": { "name": "Nom", "certificationType": "Certification transport", - "isChartered": "Affréter" + "isChartered": "Affréter", + "indexationRate": "Indexation %", + "containerType": "Benne / Fond mouvant", + "volumeM3": "Volume m³", + "discharge": "Décharge", + "liotPlates": "Immatriculations LIOT", + "liotPlatesHint": "Séparées par « ; »" + }, + "qualimat": { + "search": "Rechercher un transporteur QUALIMAT", + "empty": "Aucun transporteur QUALIMAT trouvé.", + "continue": "Continuer", + "columns": { + "name": "Nom", + "address": "Adresse", + "validityDate": "Date de validité" + }, + "confirm": { + "title": "Intégration QUALIMAT", + "message": "Êtes-vous sûr de vouloir intégrer ce transporteur ?", + "cancel": "Annuler", + "confirm": "Intégrer" + } }, "errors": { "nameRequired": "Le nom du transporteur est obligatoire." diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index 729c1cb..c49816d 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -189,3 +189,153 @@ describe('useCarrierForm', () => { expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false }) }) }) + +describe('useCarrierForm — champs conditionnels (ERP-166)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + }) + + it('cas LIOT (insensible à la casse) : masque la certification, payload réduit', () => { + const form = useCarrierForm() + form.main.name = 'liot' + + expect(form.isLiot.value).toBe(true) + expect(form.showCertification.value).toBe(false) + + form.main.liotPlates = 'AA-123-BB ; CC-456-DD' + expect(form.buildMainPayload()).toEqual({ + name: 'liot', + isChartered: false, + liotPlates: 'AA-123-BB ; CC-456-DD', + }) + }) + + it('LIOT masque les champs conditionnels (affrètement / décharge)', () => { + const form = useCarrierForm() + form.main.name = 'LIOT' + form.main.isChartered = true + form.main.certificationType = 'AUTRE' + + expect(form.showCharteredFields.value).toBe(false) + expect(form.showDischarge.value).toBe(false) + }) + + it('RG-4.03 affrété : indexation / contenant / volume visibles et dans le payload', () => { + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'GMP_PLUS' + form.main.isChartered = true + + expect(form.showCharteredFields.value).toBe(true) + + form.main.indexationRate = '5' + form.main.containerType = 'BENNE' + form.main.volumeM3 = '30' + + expect(form.buildMainPayload()).toEqual({ + name: 'Acme', + certificationType: 'GMP_PLUS', + isChartered: true, + indexationRate: '5', + containerType: 'BENNE', + volumeM3: '30', + }) + }) + + it('RG-4.03 affrété mais champs vides : omis du payload (422 NotBlank back)', () => { + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'GMP_PLUS' + form.main.isChartered = true + + expect(form.buildMainPayload()).toEqual({ + name: 'Acme', + certificationType: 'GMP_PLUS', + isChartered: true, + }) + }) + + it('RG-4.02 AUTRE : décharge visible + dischargeDocument dans le payload si IRI résolu', () => { + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'AUTRE' + + expect(form.showDischarge.value).toBe(true) + + form.main.dischargeDocumentIri = '/api/uploaded_documents/7' + expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' }) + }) +}) + +describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => { + const QUALIMAT_ROW = { + '@id': '/api/qualimat_carriers/42', + id: '42', + name: 'TRANSPORTS QUALIMAT', + siret: '12345678900012', + address: '1 rue du Port', + postalCode: '86000', + city: 'Poitiers', + validityDate: '2027-01-15', + status: 'VALIDE', + } + + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + }) + + it('copie name + certificationType=QUALIMAT (readonly) + IRI + adresse, sans PATCH avant création', async () => { + const form = useCarrierForm() + + const ok = await form.applyQualimatSelection(QUALIMAT_ROW) + + expect(ok).toBe(true) + expect(mockPatch).not.toHaveBeenCalled() + expect(form.main.name).toBe('TRANSPORTS QUALIMAT') + expect(form.main.certificationType).toBe('QUALIMAT') + expect(form.main.qualimatCarrierIri).toBe('/api/qualimat_carriers/42') + expect(form.isQualimat.value).toBe(true) + expect(form.certificationReadonly.value).toBe(true) + expect(form.qualimatAddress.value).toEqual({ + country: 'France', + postalCode: '86000', + city: 'Poitiers', + street: '1 rue du Port', + }) + }) + + it('après création : PATCH /carriers/{id} avec qualimatCarrier + name + certification', async () => { + mockPost.mockResolvedValueOnce({ id: 9, name: 'X', certificationType: 'GMP_PLUS' }) + mockPatch.mockResolvedValueOnce({}) + const form = useCarrierForm() + form.main.name = 'X' + form.main.certificationType = 'GMP_PLUS' + await form.submitMain() + + const ok = await form.applyQualimatSelection(QUALIMAT_ROW) + + expect(ok).toBe(true) + expect(mockPatch).toHaveBeenCalledWith( + '/carriers/9', + { + qualimatCarrier: '/api/qualimat_carriers/42', + name: 'TRANSPORTS QUALIMAT', + certificationType: 'QUALIMAT', + }, + { toast: false }, + ) + }) + + it('buildMainPayload inclut qualimatCarrier + certificationType QUALIMAT après intégration', async () => { + const form = useCarrierForm() + form.main.name = 'Acme' + await form.applyQualimatSelection(QUALIMAT_ROW) + + expect(form.buildMainPayload()).toMatchObject({ + qualimatCarrier: '/api/qualimat_carriers/42', + certificationType: 'QUALIMAT', + }) + }) +}) diff --git a/frontend/modules/transport/composables/__tests__/useQualimatSearch.test.ts b/frontend/modules/transport/composables/__tests__/useQualimatSearch.test.ts new file mode 100644 index 0000000..339f8d3 --- /dev/null +++ b/frontend/modules/transport/composables/__tests__/useQualimatSearch.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Tests de la saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01). + * + * `useQualimatSearch` interroge `GET /api/qualimat_carriers?search=`. On vérifie le + * CONTRAT (pas le timing du debounce, couvert par `debounce.test.ts`) via `fetchNow` : + * - ressource ciblée + paramètre `search` (trimé) + header `Accept: application/ld+json` ; + * - consommation de l'enveloppe Hydra (`member`) ; + * - échec réseau → résultats vidés, pas de throw (recherche non bloquante). + */ + +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +const { useQualimatSearch } = await import('../useQualimatSearch') + +describe('useQualimatSearch', () => { + beforeEach(() => { + mockGet.mockReset() + }) + + it('fetchNow cible /qualimat_carriers (search trimé, ld+json) et consomme member', async () => { + mockGet.mockResolvedValueOnce({ + member: [{ '@id': '/api/qualimat_carriers/1', id: '1', name: 'ACME', validityDate: '2027-01-01' }], + }) + const q = useQualimatSearch() + + await q.fetchNow(' acme ') + + expect(mockGet).toHaveBeenCalledWith( + '/qualimat_carriers', + { search: 'acme' }, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + expect(q.results.value).toHaveLength(1) + expect(q.results.value[0]?.name).toBe('ACME') + expect(q.loading.value).toBe(false) + }) + + it('échec réseau : résultats vidés, pas de throw', async () => { + mockGet.mockRejectedValueOnce(new Error('network')) + const q = useQualimatSearch() + + await expect(q.fetchNow('x')).resolves.toBeUndefined() + expect(q.results.value).toEqual([]) + expect(q.loading.value).toBe(false) + }) +}) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index ef6326e..dba641f 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -1,10 +1,16 @@ -import { reactive, ref } from 'vue' +import { computed, reactive, ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' import { + emptyCarrierAddressCopy, emptyCarrierMain, + type CarrierAddressCopy, type CarrierMainDraft, type CarrierMainResponse, } from '~/modules/transport/types/carrierForm' +import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch' + +/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */ +const LIOT_NAME = 'LIOT' /** * Workflow de l'écran « Ajouter un transporteur » (M4 Transport, ERP-165) — @@ -50,6 +56,24 @@ export function useCarrierForm() { // ── Formulaire principal ────────────────────────────────────────────────── const main = reactive(emptyCarrierMain()) + // Adresse copiée depuis QUALIMAT à la sélection (alimente l'onglet Adresses, + // ticket ultérieur). Vide tant qu'aucun transporteur QUALIMAT n'est intégré. + const qualimatAddress = ref(emptyCarrierAddressCopy()) + + // ── Affichage conditionnel du formulaire principal (RG-4.01 / 4.02 / 4.03) ── + // Cas LIOT : nom == « LIOT » → seul `liotPlates` est pertinent, le reste masqué. + const isLiot = computed(() => main.name.trim().toUpperCase() === LIOT_NAME) + // Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ». + const isQualimat = computed(() => main.qualimatCarrierIri !== null) + // Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé). + const showCertification = computed(() => !isLiot.value) + const certificationReadonly = computed(() => isQualimat.value || mainLocked.value) + // RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et + // obligatoires si « Affréter » coché — masqués en cas LIOT. + const showCharteredFields = computed(() => main.isChartered && !isLiot.value) + // RG-4.02 : décharge visible et obligatoire si certification == AUTRE (hors LIOT). + const showDischarge = computed(() => main.certificationType === 'AUTRE' && !isLiot.value) + // ── Onglets : ordre + gating progressif ─────────────────────────────────── const tabKeys = ref([...CARRIER_TAB_KEYS]) // Index du dernier onglet déverrouillé (-1 tant que le transporteur n'est pas créé). @@ -92,15 +116,45 @@ export function useCarrierForm() { * certification) sur le champ plutôt qu'une erreur de type. */ function buildMainPayload(): Record { - const payload: Record = { - isChartered: main.isChartered, + // Cas LIOT (RG-4.01) : seul `liotPlates` est pertinent ; les autres champs + // sont masqués côté front et non envoyés (le back stocke ce qu'il reçoit). + if (isLiot.value) { + const payload: Record = { name: main.name, isChartered: false } + if (main.liotPlates.trim()) { + payload.liotPlates = main.liotPlates + } + return payload } - if (main.name?.trim()) { + + const payload: Record = { isChartered: main.isChartered } + if (main.name.trim()) { payload.name = main.name } if (main.certificationType) { payload.certificationType = main.certificationType } + // FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée. + if (main.qualimatCarrierIri) { + payload.qualimatCarrier = main.qualimatCarrierIri + } + // RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand + // absente pour que la 422 « obligatoire » porte sur le champ. + if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) { + payload.dischargeDocument = main.dischargeDocumentIri + } + // RG-4.03 : indexation / contenant / volume envoyés seulement si affrété ; + // omis quand vides pour déclencher la 422 NotBlank inline sur le champ. + if (main.isChartered) { + if (main.indexationRate.trim()) { + payload.indexationRate = main.indexationRate + } + if (main.containerType) { + payload.containerType = main.containerType + } + if (main.volumeM3.trim()) { + payload.volumeM3 = main.volumeM3 + } + } return payload } @@ -162,6 +216,43 @@ export function useCarrierForm() { await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false }) } + /** + * Intègre une ligne QUALIMAT sélectionnée dans l'onglet Qualimat (RG-4.01 / + * § 2.5) : copie le nom, force la certification à « QUALIMAT » (lecture seule), + * pose la FK `qualimatCarrier` (IRI) et copie l'adresse (pour l'onglet Adresses). + * Si le transporteur existe déjà (post-POST, cas nominal de l'onglet), persiste + * la copie via un PATCH partiel `carrier:write:main`. La copie locale a lieu + * dans tous les cas. Retourne true si l'intégration a abouti. + */ + async function applyQualimatSelection(row: QualimatCarrierRow): Promise { + main.name = row.name ?? '' + main.certificationType = 'QUALIMAT' + main.qualimatCarrierIri = row['@id'] + qualimatAddress.value = { + country: 'France', + postalCode: row.postalCode ?? '', + city: row.city ?? '', + street: row.address ?? '', + } + + if (carrierId.value === null) { + return true + } + + try { + await patchCarrier({ + qualimatCarrier: row['@id'], + name: row.name, + certificationType: 'QUALIMAT', + }) + return true + } + catch (error) { + mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') }) + return false + } + } + /** * Marque un onglet validé (passe en lecture seule), déverrouille et avance à * l'onglet suivant. Retourne true si c'était le dernier onglet du flux (création @@ -186,10 +277,18 @@ export function useCarrierForm() { return { // état main, + qualimatAddress, carrierId, mainLocked, mainSubmitting, mainErrors, + // affichage conditionnel + isLiot, + isQualimat, + showCertification, + certificationReadonly, + showCharteredFields, + showDischarge, // onglets tabKeys, activeTab, @@ -202,6 +301,7 @@ export function useCarrierForm() { buildMainPayload, submitMain, patchCarrier, + applyQualimatSelection, completeTab, } } diff --git a/frontend/modules/transport/composables/useQualimatSearch.ts b/frontend/modules/transport/composables/useQualimatSearch.ts new file mode 100644 index 0000000..b14ad34 --- /dev/null +++ b/frontend/modules/transport/composables/useQualimatSearch.ts @@ -0,0 +1,76 @@ +import { ref } from 'vue' +import { debounce } from '~/shared/utils/debounce' +import type { HydraCollection } from '~/shared/utils/api' + +/** + * Ligne du référentiel QUALIMAT renvoyée par la saisie assistée (groupe + * `qualimat:read`). `@id` est l'IRI conservée comme FK `carrier.qualimatCarrier` + * (RG-4.01 / § 2.5) ; `validityDate` pilote le fond rouge de la colonne « Date de + * validité » (RG-4.04). + */ +export interface QualimatCarrierRow { + '@id': string + id: string + name: string | null + siret: string | null + address: string | null + postalCode: string | null + city: string | null + validityDate: string | null + status: string | null +} + +/** Délai de debounce de la recherche (ms) — une requête après la dernière frappe. */ +const SEARCH_DEBOUNCE_MS = 300 + +/** + * Saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01 / spec-back § 4.7). + * + * `GET /api/qualimat_carriers?search=` : référentiel en LECTURE SEULE, lignes + * actives uniquement (filtré côté serveur), recherche fuzzy nom + siret. Alimente + * le tableau de sélection de l'onglet Qualimat ; la ligne choisie est copiée dans + * le formulaire principal (cf. `useCarrierForm.applyQualimatSelection`). + * + * Volontairement PAR INSTANCE (état local à l'écran d'ajout). `search()` est + * debouncé (anti-spam réseau) ; `fetchNow()` expose l'appel immédiat (montage / + * tests). + */ +export function useQualimatSearch() { + const api = useApi() + + const results = ref([]) + const loading = ref(false) + + /** Lance immédiatement la recherche (sans debounce). */ + async function fetchNow(term: string): Promise { + loading.value = true + try { + const data = await api.get>( + '/qualimat_carriers', + { search: term.trim() }, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + results.value = data.member ?? [] + } + catch { + // Échec réseau / 403 : on vide les résultats, pas de toast (la recherche + // assistée est non bloquante — l'utilisateur peut saisir manuellement). + results.value = [] + } + finally { + loading.value = false + } + } + + // Recherche debouncée branchée sur le champ de recherche de l'onglet Qualimat. + const search = debounce((term: string) => { + void fetchNow(term) + }, SEARCH_DEBOUNCE_MS) + + return { + results, + loading, + search, + fetchNow, + } +} diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index c045581..86628a4 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -13,11 +13,10 @@
+ Champs conditionnels (ERP-166) : cas LIOT (nom == LIOT) → seul + « immatriculations » ; certification AUTRE → champ Decharge ; Affreter + coche → indexation / contenant / volume. La certification est en lecture + seule pour un transporteur QUALIMAT (saisie assistee, onglet Qualimat). -->
- + - -
- +
@@ -61,13 +135,76 @@
+ Barre Qualimat · Adresses · Contacts · Prix. Onglet Qualimat = saisie + assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux + tickets suivants (placeholders « A venir »). --> + + + + + + + + +

{{ t('transport.carriers.form.qualimat.confirm.message') }}

+ +