diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index cc62b73..d81eb38 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -691,6 +691,26 @@ } } }, + "logistique": { + "weighingTickets": { + "title": "Tickets de pesée", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun ticket de pesée pour l'instant.", + "column": { + "number": "Numéro", + "client": "Client", + "supplier": "Fournisseur", + "other": "Autre", + "date": "Date", + "weight": "Poids" + }, + "toast": { + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export des tickets de pesée a échoué. Réessayez." + } + } + }, "auth": { "login": "Connexion", "logout": "Deconnexion", diff --git a/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts b/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts new file mode 100644 index 0000000..b1a1536 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts @@ -0,0 +1,63 @@ +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la + * LISTE des tickets de pesee. Seul `companyName` alimente les colonnes + * « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` / + * `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`, + * spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page. + */ +export interface WeighingTicketParty { + id: number + companyName: string | null +} + +/** + * Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement + * partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont + * types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve + * de l'ecran Modification (ERP-190) — hors perimetre de cet ecran. + * + * Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier` + * / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null). + * `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0). + * `netWeight` = plein − vide en kg (RG-5.05). + */ +export interface WeighingTicket { + id: number + /** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */ + number: string + /** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */ + client: WeighingTicketParty | null + /** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */ + supplier: WeighingTicketParty | null + /** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */ + otherLabel: string | null + /** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */ + displayDate: string | null + /** Poids net en kg (= plein − vide, RG-5.05) — colonne « Poids ». */ + netWeight: number | null +} + +/** + * Filtres de la liste des tickets de pesee, branches sur les query params de + * `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs + * cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le + * front n'a pas a envoyer le site. + */ +export interface WeighingTicketFilters { + search?: string +} + +/** + * Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de + * `usePaginatedList` sur la ressource `/weighing_tickets` + * (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination + * serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6). + * + * Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de + * singleton) : l'etat tableau est propre a l'ecran et meurt avec lui. + */ +export function useWeighingTicketsRepository() { + return usePaginatedList({ url: '/weighing_tickets' }) +} diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts new file mode 100644 index 0000000..ee5c2ea --- /dev/null +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts @@ -0,0 +1,168 @@ +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→M4. +const mockPush = vi.hoisted(() => vi.fn()) +const mockApiGet = vi.hoisted(() => vi.fn()) +const mockCan = 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 les items renvoyes. +// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values). +vi.stubGlobal('useWeighingTicketsRepository', () => ({ + items: ref([ + { + id: 9, + number: '86-TP-0001', + client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' }, + supplier: null, + otherLabel: null, + displayDate: '2026-06-17T09:12:00+02:00', + netWeight: 7150, + }, + ]), + totalItems: ref(1), + currentPage: ref(1), + itemsPerPage: ref(10), + itemsPerPageOptions: ref([10, 25, 50]), + fetch: mockFetch, + goToPage: vi.fn(), + setItemsPerPage: vi.fn(), + setFilters: vi.fn(), +})) + +// 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 WeighingTicketsIndex = (await import('../weighing-tickets/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) + }, +}) + +// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses +// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes. +const capturedRows = ref>>([]) +const DataTableStub = defineComponent({ + props: { items: { type: Array, default: () => [] } }, + emits: ['row-click', 'update:page', 'update:per-page'], + setup(props, { emit }) { + return () => { + capturedRows.value = props.items as Array> + return h('div', { 'data-testid': 'datatable' }, + (props.items as Array>).map(it => + h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [ + h('td', { 'data-cell': 'displayDate' }, it.displayDate as string), + h('td', { 'data-cell': 'netWeight' }, it.netWeight as string), + h('td', { 'data-cell': 'client' }, it.client as string), + h('td', { 'data-cell': 'supplier' }, it.supplier as string), + ]), + ), + ) + } + }, +}) + +const PageHeaderStub = defineComponent({ + setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) }, +}) + +function mountPage() { + return mount(WeighingTicketsIndex, { + global: { + stubs: { + PageHeader: PageHeaderStub, + MalioButton: ButtonStub, + MalioDataTable: DataTableStub, + }, + }, + }) +} + +describe('Liste des tickets de pesée (page /weighing-tickets)', () => { + beforeEach(() => { + mockPush.mockReset() + mockApiGet.mockReset().mockResolvedValue(new Blob()) + mockCan.mockReset().mockReturnValue(true) + mockFetch.mockReset() + mockToastError.mockReset() + capturedRows.value = [] + }) + + it('charge la liste au montage', async () => { + mountPage() + await flushPromises() + expect(mockFetch).toHaveBeenCalled() + }) + + it('formate la date au format JJ-MM-AAAA', async () => { + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026') + }) + + it('formate le poids net en kg avec separateur de milliers', async () => { + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg') + }) + + it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => { + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE') + expect(wrapper.find('[data-cell="supplier"]').text()).toBe('') + }) + + it('affiche « + Ajouter » uniquement avec la permission manage', async () => { + mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true) + }) + + it('masque « + Ajouter » sans la permission manage (view seul)', async () => { + mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false) + }) + + it('navigue vers la modification au clic sur une ligne', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('tr[data-row-id="9"]').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit') + }) + + it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click') + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/weighing_tickets/export.xlsx', + expect.any(Object), + expect.objectContaining({ responseType: 'blob', toast: false }), + ) + }) +}) diff --git a/frontend/modules/logistique/pages/weighing-tickets/index.vue b/frontend/modules/logistique/pages/weighing-tickets/index.vue new file mode 100644 index 0000000..1bea549 --- /dev/null +++ b/frontend/modules/logistique/pages/weighing-tickets/index.vue @@ -0,0 +1,179 @@ + + +