diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 41d837e..d3d5b70 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -528,7 +528,8 @@ "exportError": "L'export du répertoire transporteurs a échoué. Réessayez.", "createSuccess": "Transporteur créé avec succès", "integrateSuccess": "Transporteur QUALIMAT intégré", - "addressSaved": "Adresse enregistrée" + "addressSaved": "Adresse enregistrée", + "contactSaved": "Contact enregistré" }, "containerType": { "BENNE": "Benne", @@ -591,6 +592,17 @@ "remove": "Supprimer l'adresse", "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." }, + "contact": { + "lastName": "Nom", + "firstName": "Prénom", + "jobTitle": "Fonction", + "phonePrimary": "Téléphone", + "phoneSecondary": "Téléphone (2)", + "addPhone": "Ajouter un numéro", + "email": "Email", + "add": "Nouveau contact", + "remove": "Supprimer le contact" + }, "confirmDelete": { "title": "Supprimer ce bloc", "message": "Cette suppression est définitive. Confirmer ?", diff --git a/frontend/modules/transport/components/CarrierContactBlock.vue b/frontend/modules/transport/components/CarrierContactBlock.vue new file mode 100644 index 0000000..20e0d2c --- /dev/null +++ b/frontend/modules/transport/components/CarrierContactBlock.vue @@ -0,0 +1,108 @@ + + + diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index 01db9c4..e28a5e9 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -37,6 +37,8 @@ vi.stubGlobal('useToast', () => ({ })) const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm') +const { buildCarrierContactPayload, isCarrierContactBlank } = await import('../../utils/forms/carrierContact') +const { emptyCarrierContact } = await import('../../types/carrierForm') describe('useCarrierForm', () => { beforeEach(() => { @@ -538,3 +540,127 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => { expect(form.addresses.value).toHaveLength(1) }) }) + +describe('carrierContact (util) — RG-4.08 + max 2 téléphones', () => { + it('isCarrierContactBlank : vrai si aucun champ, faux dès un champ rempli', () => { + expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true) + expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false) + expect(isCarrierContactBlank({ ...emptyCarrierContact(), phonePrimary: '0102030405' })).toBe(false) + }) + + it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => { + const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' }) + expect(body.phones).toEqual(['0102030405']) + }) + + it('buildCarrierContactPayload : phones = 2 numéros si secondaire révélé', () => { + const body = buildCarrierContactPayload({ + ...emptyCarrierContact(), + phonePrimary: '0102030405', + phoneSecondary: '0605040302', + hasSecondaryPhone: true, + }) + expect(body.phones).toEqual(['0102030405', '0605040302']) + }) + + it('buildCarrierContactPayload : 2e numéro ignoré tant que non révélé', () => { + const body = buildCarrierContactPayload({ + ...emptyCarrierContact(), + phonePrimary: '0102030405', + phoneSecondary: '0605040302', + hasSecondaryPhone: false, + }) + expect(body.phones).toEqual(['0102030405']) + }) +}) + +describe('useCarrierForm — onglet Contacts (ERP-168)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + mockDelete.mockReset() + }) + + /** Transporteur créé, onglet Contacts accessible. */ + function createdForm() { + const form = useCarrierForm() + form.carrierId.value = 7 + return form + } + + it('RG-4.08 : « + Nouveau contact » désactivé tant que le bloc est vide', () => { + const form = createdForm() + expect(form.canAddContact.value).toBe(false) + + // addContact est un no-op tant que le bloc est vide. + form.addContact() + expect(form.contacts.value).toHaveLength(1) + + const first = form.contacts.value[0] + if (first) first.lastName = 'Doe' + expect(form.canAddContact.value).toBe(true) + form.addContact() + expect(form.contacts.value).toHaveLength(2) + }) + + it('submitContacts : POST des nouveaux contacts (phones tableau), capture id, finalise', async () => { + mockPost.mockResolvedValueOnce({ id: 55 }) + const form = createdForm() + const c = form.contacts.value[0] + if (c) { c.firstName = 'Jean'; c.phonePrimary = '0102030405' } + + const ok = await form.submitContacts(vi.fn()) + + expect(ok).toBe(true) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/carriers/7/contacts') + expect(body).toMatchObject({ firstName: 'Jean', phones: ['0102030405'] }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + expect(form.contacts.value[0]?.id).toBe(55) + expect(form.isValidated('contacts')).toBe(true) + }) + + it('submitContacts : PATCH des contacts existants sur /carrier_contacts/{id}', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + const c = form.contacts.value[0] + if (c) { c.id = 55; c.lastName = 'Doe' } + + await form.submitContacts(vi.fn()) + + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false }) + }) + + it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] }, + }, + }) + const form = createdForm() + + const ok = await form.submitContacts(vi.fn()) + + expect(ok).toBe(false) + expect(mockPost).toHaveBeenCalledTimes(1) + expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.') + expect(form.isValidated('contacts')).toBe(false) + }) + + it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => { + mockDelete.mockResolvedValueOnce({}) + const form = createdForm() + const c = form.contacts.value[0] + if (c) { c.id = 90; c.lastName = 'Doe' } + form.addContact() + const c2 = form.contacts.value[1] + if (c2) c2.firstName = 'Jean' + + await form.removeContact(0) + + expect(mockDelete).toHaveBeenCalledWith('/carrier_contacts/90', {}, { toast: false }) + expect(form.contacts.value).toHaveLength(1) + }) +}) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index aa9bb49..7899ea4 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -5,13 +5,16 @@ import { removeCollectionRow } from '~/shared/utils/collectionRow' import { emptyCarrierAddress, emptyCarrierAddressCopy, + emptyCarrierContact, emptyCarrierMain, type CarrierAddressCopy, type CarrierAddressFormDraft, + type CarrierContactFormDraft, type CarrierMainDraft, type CarrierMainResponse, } from '~/modules/transport/types/carrierForm' import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress' +import { buildCarrierContactPayload, isCarrierContactBlank } from '~/modules/transport/utils/forms/carrierContact' import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch' /** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */ @@ -382,6 +385,85 @@ export function useCarrierForm() { } } + // ── Onglet Contacts (ERP-168) ───────────────────────────────────────────── + const contacts = ref([emptyCarrierContact()]) + // Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows. + const contactErrors = ref[]>([]) + + // RG-4.08 : « + Nouveau contact » désactivé tant que le DERNIER bloc est vide + // (aucun champ rempli). + const canAddContact = computed(() => { + const last = contacts.value[contacts.value.length - 1] + return last !== undefined && !isCarrierContactBlank(last) + }) + + function addContact(): void { + if (canAddContact.value) { + contacts.value.push(emptyCarrierContact()) + } + } + + /** Suppression immédiate d'un contact existant (DELETE /carrier_contacts/{id}). */ + async function removeContact(index: number): Promise { + await removeCollectionRow({ + rows: contacts.value, + errors: contactErrors.value, + index, + endpoint: '/carrier_contacts', + deleteRow: url => api.delete(url, {}, { toast: false }), + makeEmpty: emptyCarrierContact, + onError: notifyRemovalError, + }) + } + + /** + * Valide l'onglet Contacts : POST des nouveaux contacts sur + * /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id} + * (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones) + * re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces + * vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de + * finaliser un onglet vide. Retourne true si l'onglet a été validé. + */ + async function submitContacts(onError: (error: unknown) => void): Promise { + if (carrierId.value === null || tabSubmitting.value) { + return false + } + tabSubmitting.value = true + try { + const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c)) + const hasError = await submitRows( + contacts.value, + contactErrors, + async (contact) => { + const body = buildCarrierContactPayload(contact) + if (contact.id === null) { + const created = await api.post<{ id: number }>( + `/carriers/${carrierId.value}/contacts`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + contact.id = created.id + } + else { + await api.patch(`/carrier_contacts/${contact.id}`, body, { toast: false }) + } + }, + onError, + // Amorce vide neuve ignorée s'il reste un autre bloc soumettable ; + // sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName). + contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact), + ) + if (hasError) { + return false + } + completeTab('contacts') + return true + } + finally { + tabSubmitting.value = 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), @@ -480,6 +562,13 @@ export function useCarrierForm() { addAddress, removeAddress, submitAddresses, + // contacts + contacts, + contactErrors, + canAddContact, + addContact, + removeContact, + submitContacts, // actions validateMainFront, buildMainPayload, diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index 848463c..4f0360c 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -207,7 +207,40 @@ - + + + +