From c1e45cd582f63ca49632558f60c0710963612aa6 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 15 Jun 2026 09:05:07 +0000 Subject: [PATCH] feat(front) : onglet contact prestataire (ERP-142) (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empilée sur ERP-141 (#103). ## Périmètre ERP-142 Onglet **Contact** de l'écran `/providers/new` — saisie multi-contacts (blocs ajoutables) via la sous-ressource contacts. - **`ProviderContactBlock.vue`** (miroir `SupplierContactBlock`) : Nom / Prénom / Fonction / Email / Téléphone (x1, +1 révélable, **max 2**), erreurs 422 par champ (prop `:errors`). - **`useProviderForm`** étendu : état `contacts`, `canAddContact` (RG-3.04), `addContact`/`removeContact`, `submitContacts` (POST `/providers/{id}/contacts` pour les nouveaux, PATCH `/provider_contacts/{id}` pour les existants, groupe `provider:write:contacts`), `submitRows` (erreurs collectées **par ligne**, non bloquant). - **RG-3.04** : « + Nouveau contact » désactivé tant que le bloc courant est vide (≥1 champ parmi prénom/nom/fonction/tél/email — aligné back). - **RG-3.12** : onglet non validable vide ; une amorce vide est soumise pour déclencher la 422 `firstName` inline. - Suppression d'un bloc → modal de confirmation. - Helpers purs `utils/forms/providerContact.ts` (`isProviderContactBlank`, `buildProviderContactPayload`). - i18n `technique.providers.form.contact/confirmDelete` + `toast.updateSuccess`. ## Vérifications - Vitest : 418/418 (16 nouveaux : helpers, bloc, workflow contacts). - ESLint : OK. - `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : bloc Contact rendu, « Nouveau contact » désactivé tant que vide puis activé après saisie, révélation du 2e téléphone (max 2). Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/104 Co-authored-by: tristan Co-committed-by: tristan --- frontend/i18n/locales/fr.json | 20 ++- .../components/ProviderContactBlock.vue | 108 +++++++++++++++ .../__tests__/ProviderContactBlock.spec.ts | 55 ++++++++ .../__tests__/useProviderForm.test.ts | 114 ++++++++++++++++ .../technique/composables/useProviderForm.ts | 126 +++++++++++++++++- .../modules/technique/pages/providers/new.vue | 114 +++++++++++++++- .../modules/technique/types/providerForm.ts | 42 ++++++ .../forms/__tests__/providerContact.spec.ts | 79 +++++++++++ .../technique/utils/forms/providerContact.ts | 57 ++++++++ 9 files changed, 709 insertions(+), 6 deletions(-) create mode 100644 frontend/modules/technique/components/ProviderContactBlock.vue create mode 100644 frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts create mode 100644 frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts create mode 100644 frontend/modules/technique/utils/forms/providerContact.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index bd7348b..c77fadd 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -406,12 +406,30 @@ "errors": { "siteRequired": "Sélectionnez au moins un site.", "categoryRequired": "Sélectionnez au moins une catégorie." + }, + "contact": { + "lastName": "Nom", + "firstName": "Prénom", + "jobTitle": "Fonction", + "email": "Email", + "phonePrimary": "Téléphone", + "phoneSecondary": "Téléphone (2)", + "addPhone": "Ajouter un numéro", + "remove": "Supprimer le contact", + "add": "Nouveau contact" + }, + "confirmDelete": { + "title": "Confirmer la suppression", + "cancel": "Annuler", + "confirm": "Supprimer", + "contact": "Supprimer ce contact ?" } }, "toast": { "error": "Une erreur est survenue. Réessayez.", "exportError": "L'export du répertoire prestataires a échoué. Réessayez.", - "createSuccess": "Prestataire créé avec succès" + "createSuccess": "Prestataire créé avec succès", + "updateSuccess": "Prestataire mis à jour avec succès" } } }, diff --git a/frontend/modules/technique/components/ProviderContactBlock.vue b/frontend/modules/technique/components/ProviderContactBlock.vue new file mode 100644 index 0000000..0db2702 --- /dev/null +++ b/frontend/modules/technique/components/ProviderContactBlock.vue @@ -0,0 +1,108 @@ + + + diff --git a/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts b/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts new file mode 100644 index 0000000..928a2d7 --- /dev/null +++ b/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyProviderContact } from '~/modules/technique/types/providerForm' +import ProviderContactBlock from '../ProviderContactBlock.vue' + +// Auto-imports Nuxt/Vue utilises sans import explicite par le composant. +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) + +/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */ +function errorProbe(testid: string) { + return defineComponent({ + name: `Probe-${testid}`, + props: { + modelValue: { type: [String, Number, null], default: undefined }, + error: { type: String, default: '' }, + label: { type: String, default: '' }, + readonly: { type: Boolean, default: false }, + }, + setup(props) { + return () => h('div', { 'data-testid': testid, 'data-error': props.error }) + }, + }) +} + +function mountBlock(errors?: Record) { + return mount(ProviderContactBlock, { + props: { + modelValue: emptyProviderContact(), + ...(errors ? { errors } : {}), + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioInputPhone: true, + MalioInputText: errorProbe('contact-text'), + MalioInputEmail: errorProbe('contact-email'), + }, + }, + }) +} + +describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => { + it('affiche l\'erreur serveur sur le champ email via la prop errors', () => { + const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' }) + expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.') + }) + + it('laisse les champs sans erreur quand errors est absent', () => { + const wrapper = mountBlock() + expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('') + }) +}) diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts index 9a732c5..86204fa 100644 --- a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts +++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts @@ -41,10 +41,17 @@ vi.stubGlobal('usePermissions', () => ({ })) const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm') +const { emptyProviderContact } = await import('~/modules/technique/types/providerForm') +type ProviderForm = ReturnType const SITE_86 = '/api/sites/1' const CAT_MAINT = '/api/categories/7' +/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */ +function contactAt(form: ProviderForm, index = 0) { + return form.contacts.value[index] ?? emptyProviderContact() +} + describe('useProviderForm', () => { beforeEach(() => { mockPost.mockReset() @@ -190,3 +197,110 @@ describe('useProviderForm', () => { expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false }) }) }) + +describe('useProviderForm — onglet Contact (ERP-142)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + }) + + /** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */ + function createdForm() { + const form = useProviderForm() + form.providerId.value = 7 + return form + } + + it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier 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) + + contactAt(form).lastName = 'Doe' + expect(form.canAddContact.value).toBe(true) + form.addContact() + expect(form.contacts.value).toHaveLength(2) + }) + + it('removeContact retire le bloc et son erreur de ligne', () => { + const form = createdForm() + contactAt(form).lastName = 'Doe' + form.addContact() + form.contactErrors.value = [{}, { lastName: 'x' }] + + form.removeContact(1) + expect(form.contacts.value).toHaveLength(1) + expect(form.contactErrors.value).toHaveLength(1) + }) + + it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => { + mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 }) + const form = createdForm() + contactAt(form).lastName = 'Doe' + + const ok = await form.submitContacts(vi.fn()) + + expect(ok).toBe(true) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/providers/7/contacts') + expect(body).toMatchObject({ lastName: 'Doe' }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + expect(contactAt(form).id).toBe(55) + expect(contactAt(form).iri).toBe('/api/provider_contacts/55') + expect(form.isValidated('contact')).toBe(true) + }) + + it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + contactAt(form).id = 55 + contactAt(form).lastName = 'Doe' + + await form.submitContacts(vi.fn()) + + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false }) + }) + + it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName 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('contact')).toBe(false) + }) + + it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => { + mockPost + .mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 }) + .mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] }, + }, + }) + const form = createdForm() + contactAt(form).lastName = 'Doe' + form.addContact() + contactAt(form, 1).email = 'invalide' + + const ok = await form.submitContacts(vi.fn()) + + expect(ok).toBe(false) + expect(form.contactErrors.value[0]).toBeUndefined() + expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.') + }) +}) diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts index de9afc3..96df01a 100644 --- a/frontend/modules/technique/composables/useProviderForm.ts +++ b/frontend/modules/technique/composables/useProviderForm.ts @@ -1,10 +1,18 @@ -import { computed, reactive, ref } from 'vue' +import { computed, reactive, ref, type Ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' +import { mapViolationsToRecord } from '~/shared/utils/api' import { + emptyProviderContact, emptyProviderMain, + type ProviderContactFormDraft, + type ProviderContactResponse, type ProviderMainDraft, type ProviderMainResponse, } from '~/modules/technique/types/providerForm' +import { + buildProviderContactPayload, + isProviderContactBlank, +} from '~/modules/technique/utils/forms/providerContact' /** * Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) — @@ -182,6 +190,114 @@ export function useProviderForm() { return false } + /** + * Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX : + * on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la + * cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le + * fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne + * true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`. + */ + async function submitRows( + rows: T[], + target: Ref[]>, + saveRow: (row: T, index: number) => Promise, + onUnmappedError: (error: unknown, index: number) => void, + shouldSkip?: (row: T, index: number) => boolean, + ): Promise { + target.value = [] + let hasError = false + for (let index = 0; index < rows.length; index++) { + const row = rows[index] as T + if (shouldSkip?.(row, index)) { + continue + } + try { + await saveRow(row, index) + } + catch (error) { + const response = (error as { response?: { status?: number, _data?: unknown } })?.response + const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {} + if (Object.keys(mapped).length > 0) { + target.value[index] = mapped + } + else { + onUnmappedError(error, index) + } + hasError = true + } + } + return hasError + } + + // ── Onglet Contact (ERP-142) ────────────────────────────────────────────── + const contacts = ref([emptyProviderContact()]) + // Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows. + const contactErrors = ref[]>([]) + + // « + Nouveau contact » desactive tant que le dernier bloc est vide (RG-3.04). + const canAddContact = computed(() => { + const last = contacts.value[contacts.value.length - 1] + return last !== undefined && !isProviderContactBlank(last) + }) + + function addContact(): void { + if (canAddContact.value) { + contacts.value.push(emptyProviderContact()) + } + } + + function removeContact(index: number): void { + contacts.value.splice(index, 1) + contactErrors.value.splice(index, 1) + } + + /** + * Valide l'onglet Contact : POST des nouveaux contacts sur + * /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id} + * (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc + * valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour + * declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un + * onglet vide. Retourne true si l'onglet a ete valide (avance/termine). + */ + async function submitContacts(onError: (error: unknown) => void): Promise { + if (providerId.value === null || tabSubmitting.value) { + return false + } + tabSubmitting.value = true + try { + const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c)) + const hasError = await submitRows( + contacts.value, + contactErrors, + async (contact) => { + const body = buildProviderContactPayload(contact) + if (contact.id === null) { + const created = await api.post( + `/providers/${providerId.value}/contacts`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + contact.id = created.id + contact.iri = created['@id'] ?? null + } + else { + await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false }) + } + }, + onError, + contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact), + ) + if (hasError) { + return false + } + completeTab('contact') + return true + } + finally { + tabSubmitting.value = false + } + } + return { // etat main, @@ -197,11 +313,19 @@ export function useProviderForm() { unlockedIndex, validated, isValidated, + // contacts + contacts, + contactErrors, + canAddContact, + addContact, + removeContact, + submitContacts, // actions validateMainFront, buildMainPayload, submitMain, patchProvider, completeTab, + submitRows, } } diff --git a/frontend/modules/technique/pages/providers/new.vue b/frontend/modules/technique/pages/providers/new.vue index 4d5813a..6a4de9d 100644 --- a/frontend/modules/technique/pages/providers/new.vue +++ b/frontend/modules/technique/pages/providers/new.vue @@ -57,23 +57,77 @@ + Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux + tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. --> - + + + + + + +

{{ confirmModal.message }}

+ +