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 @@ + + + + + + update('lastName', v)" + /> + update('firstName', v)" + /> + + + update('jobTitle', v)" + /> + + update('email', v)" + /> + + update('phonePrimary', v)" + @add="revealSecondaryPhone" + /> + + update('phoneSecondary', v)" + /> + + + + 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 @@ - + + + + contacts[index] = v" + @remove="askRemoveContact(index)" + /> + + + + + + + + tabKeys.value.map((key, index) => ({ disabled: index > unlockedIndex.value, }))) -// Onglets dont le contenu arrive aux tickets suivants (Contacts / Prix). -const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat' && key !== 'addresses')) +// Onglets dont le contenu arrive aux tickets suivants (Prix). +const placeholderTabs = computed(() => tabKeys.value.filter( + key => key !== 'qualimat' && key !== 'addresses' && key !== 'contacts', +)) // ── Onglet Adresses (ERP-167) ──────────────────────────────────────────────── // Pays : France garantie en tete meme si /countries echoue (resilience), pour @@ -469,7 +511,7 @@ async function onSubmitAddresses(): Promise { } } -// Modal de confirmation de suppression (bloc adresse). +// Modal de confirmation de suppression (générique : bloc adresse OU contact). const deleteConfirm = reactive({ open: false, action: null as null | (() => void) }) function askRemoveAddress(index: number): void { @@ -477,6 +519,22 @@ function askRemoveAddress(index: number): void { deleteConfirm.open = true } +/** Valide l'onglet Contacts (POST/PATCH par ligne ; avance gérée par le composable). */ +async function onSubmitContacts(): Promise { + const ok = await submitContacts(error => toast.error({ + title: t('transport.carriers.toast.error'), + message: apiErrorMessage(error), + })) + if (ok) { + toast.success({ title: t('transport.carriers.toast.contactSaved') }) + } +} + +function askRemoveContact(index: number): void { + deleteConfirm.action = () => { void removeContact(index) } + deleteConfirm.open = true +} + function runDeleteConfirm(): void { deleteConfirm.action?.() deleteConfirm.action = null diff --git a/frontend/modules/transport/types/carrierForm.ts b/frontend/modules/transport/types/carrierForm.ts index f3d3596..ad7bb59 100644 --- a/frontend/modules/transport/types/carrierForm.ts +++ b/frontend/modules/transport/types/carrierForm.ts @@ -94,6 +94,41 @@ export function emptyCarrierAddress(): CarrierAddressFormDraft { } } +/** + * Brouillon d'un bloc Contact (onglet Contacts, ERP-168) — sous-ressource + * `CarrierContact` (groupe `carrier:write:contacts`). Les téléphones sont saisis + * en `phonePrimary` / `phoneSecondary` côté UI, puis envoyés au back sous forme du + * tableau `phones` (max 2 — RG-4.08). `hasSecondaryPhone` pilote l'affichage du 2e + * numéro (révélé via le bouton « + »). Pas d'`iri` : aucune relation M2M depuis + * l'adresse au M4 (≠ M3). + */ +export interface CarrierContactFormDraft { + /** Id serveur une fois le contact créé (null tant que non persisté). */ + id: number | null + firstName: string | null + lastName: string | null + jobTitle: string | null + phonePrimary: string | null + phoneSecondary: string | null + email: string | null + /** UI : le 2e numéro a été révélé via le bouton « + » (max 2 téléphones). */ + hasSecondaryPhone: boolean +} + +/** Brouillon de contact vide (état initial d'un bloc Contact). */ +export function emptyCarrierContact(): CarrierContactFormDraft { + return { + id: null, + firstName: null, + lastName: null, + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + hasSecondaryPhone: 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. diff --git a/frontend/modules/transport/utils/forms/carrierContact.ts b/frontend/modules/transport/utils/forms/carrierContact.ts new file mode 100644 index 0000000..c006367 --- /dev/null +++ b/frontend/modules/transport/utils/forms/carrierContact.ts @@ -0,0 +1,54 @@ +/** + * Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168) — miroir + * de `providerContact.ts` (M3), avec deux spécificités M4 : + * - RG-4.08 : un bloc est valide dès qu'AU MOINS UN champ est rempli (n'importe + * lequel) — ≠ M3 qui n'exigeait que le nom. + * - les téléphones partent au back dans le tableau virtuel `phones` (max 2), + * pas en `phonePrimary` / `phoneSecondary` (mappés par le CarrierContactProcessor). + * Testables sans Vue ni API. + */ + +import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm' + +/** Vrai si une chaîne porte au moins un caractère non-espace. */ +function isFilled(value: string | null | undefined): boolean { + return value !== null && value !== undefined && value.trim() !== '' +} + +/** + * RG-4.08 : un bloc Contact est VIDE tant qu'aucun de ses champs n'est rempli + * (prénom / nom / fonction / téléphone(s) / email). Sert le gating « + Nouveau + * contact » (on n'ajoute pas de bloc tant que le précédent est vide) et reflète la + * garde back (CarrierContactProcessor + CHECK chk_carrier_contact_filled). + */ +export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean { + return ![ + contact.firstName, + contact.lastName, + contact.jobTitle, + contact.phonePrimary, + contact.phoneSecondary, + contact.email, + ].some(isFilled) +} + +/** + * Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les + * chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont + * regroupés dans le tableau `phones` (numéros non vides, max 2 — RG-4.08) ; le 2e + * numéro n'est inclus que s'il a été révélé (`hasSecondaryPhone`). + */ +export function buildCarrierContactPayload(contact: CarrierContactFormDraft): Record { + const phones = [ + contact.phonePrimary, + contact.hasSecondaryPhone ? contact.phoneSecondary : null, + ].filter((phone): phone is string => isFilled(phone)) + + return { + firstName: contact.firstName || null, + lastName: contact.lastName || null, + jobTitle: contact.jobTitle || null, + email: contact.email || null, + phones, + } +}