From 0023eeae0866d9fba28b77aa3c201f4ad77e5165 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 15 Jun 2026 11:19:59 +0200 Subject: [PATCH] fix(front) : retours revue M3 prestataires (consultation, validations, redirection) --- frontend/i18n/locales/fr.json | 4 +- .../__tests__/useProviderForm.test.ts | 15 ++++---- .../technique/composables/useProviderForm.ts | 10 ++++- .../technique/pages/providers/[id]/edit.vue | 4 ++ .../technique/pages/providers/[id]/index.vue | 29 +++++++++------ .../modules/technique/pages/providers/new.vue | 37 ++++++++++++++----- .../forms/__tests__/providerContact.spec.ts | 24 +++++++++--- .../technique/utils/forms/providerContact.ts | 13 ++++++- 8 files changed, 96 insertions(+), 40 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 4db541f..7e1bb0f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -406,8 +406,6 @@ "back": "Retour au répertoire", "loading": "Chargement…", "notFound": "Prestataire introuvable.", - "emptyContacts": "Aucun contact.", - "emptyAddresses": "Aucune adresse.", "confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.", "confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif." }, @@ -429,6 +427,7 @@ "sites": "Site" }, "errors": { + "nameRequired": "Le nom du prestataire est obligatoire.", "siteRequired": "Sélectionnez au moins un site.", "categoryRequired": "Sélectionnez au moins une catégorie." }, @@ -485,6 +484,7 @@ "exportError": "L'export du répertoire prestataires a échoué. Réessayez.", "createSuccess": "Prestataire créé avec succès", "updateSuccess": "Prestataire mis à jour avec succès", + "addComplete": "Prestataire ajouté", "archiveSuccess": "Prestataire archivé avec succès", "restoreSuccess": "Prestataire restauré avec succès" } diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts index 97c3514..a0ab65b 100644 --- a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts +++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts @@ -69,14 +69,14 @@ describe('useProviderForm', () => { permState.accountingManage = false }) - it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => { + it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => { const form = useProviderForm() - form.main.companyName = 'Maintenance Pro' const created = await form.submitMain() expect(created).toBe(false) expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired') expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired') expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired') expect(form.mainLocked.value).toBe(false) @@ -122,18 +122,17 @@ describe('useProviderForm', () => { expect(form.unlockedIndex.value).toBe(0) }) - it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => { - mockPost.mockResolvedValueOnce({ id: 1, companyName: null }) + it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => { const form = useProviderForm() form.main.companyName = ' ' form.main.categoryIris = [CAT_MAINT] form.main.siteIris = [SITE_86] - await form.submitMain() + const created = await form.submitMain() - const body = (mockPost.mock.calls[0] ?? [])[1] as Record - expect(body).not.toHaveProperty('companyName') - expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] }) + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired') }) it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => { diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts index 8b4d821..34e7e91 100644 --- a/frontend/modules/technique/composables/useProviderForm.ts +++ b/frontend/modules/technique/composables/useProviderForm.ts @@ -20,6 +20,7 @@ import { import { buildProviderContactPayload, isProviderContactBlank, + isProviderContactNamed, } from '~/modules/technique/utils/forms/providerContact' import { buildProviderAddressPayload, @@ -111,6 +112,10 @@ export function useProviderForm() { */ function validateMainFront(): boolean { let valid = true + if (!main.companyName?.trim()) { + mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired')) + valid = false + } if (main.siteIris.length === 0) { mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired')) valid = false @@ -299,10 +304,11 @@ export function useProviderForm() { // 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). + // « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU + // prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas). const canAddContact = computed(() => { const last = contacts.value[contacts.value.length - 1] - return last !== undefined && !isProviderContactBlank(last) + return last !== undefined && isProviderContactNamed(last) }) function addContact(): void { diff --git a/frontend/modules/technique/pages/providers/[id]/edit.vue b/frontend/modules/technique/pages/providers/[id]/edit.vue index a60fec5..1340e21 100644 --- a/frontend/modules/technique/pages/providers/[id]/edit.vue +++ b/frontend/modules/technique/pages/providers/[id]/edit.vue @@ -333,6 +333,7 @@ const { provider, loading, error, load } = useProvider(providerId) const { main, + providerId: formProviderId, mainErrors, mainSubmitting, tabSubmitting, @@ -389,6 +390,9 @@ function prefill(): void { const d = provider.value if (!d) return + // Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op). + formProviderId.value = d.id + main.companyName = d.companyName ?? null main.categoryIris = irisOf(d.categories) main.siteIris = irisOf(d.sites) diff --git a/frontend/modules/technique/pages/providers/[id]/index.vue b/frontend/modules/technique/pages/providers/[id]/index.vue index f10e1b3..fb6bfb4 100644 --- a/frontend/modules/technique/pages/providers/[id]/index.vue +++ b/frontend/modules/technique/pages/providers/[id]/index.vue @@ -78,9 +78,6 @@ :model-value="contact" readonly /> -

- {{ t('technique.providers.consultation.emptyContacts') }} -

@@ -97,9 +94,6 @@ :country-options="countryOptionsFor(view.draft.country)" readonly /> -

- {{ t('technique.providers.consultation.emptyAddresses') }} -

@@ -182,6 +176,7 @@ import { siteOptionsOf, } from '~/modules/technique/utils/forms/providerDetail' import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting' +import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm' const { t } = useI18n() const route = useRoute() @@ -222,16 +217,26 @@ const mainSiteIris = computed(() => irisOf(provider.value?.sites)) const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories)) const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites)) -const contacts = computed(() => (provider.value?.contacts ?? []).map(mapContactToDraft)) +// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme +// l'onglet Comptabilite et les autres modules — pas de message « Aucun … »). +const contacts = computed(() => { + const list = (provider.value?.contacts ?? []).map(mapContactToDraft) + return list.length > 0 ? list : [emptyProviderContact()] +}) // Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses). const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts)) // Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques). -const addressViews = computed(() => (provider.value?.addresses ?? []).map(address => ({ - draft: mapAddressToDraft(address), - siteOptions: siteOptionsOf(address.sites), - categoryOptions: categoryOptionsOf(address.categories), -}))) +const addressViews = computed(() => { + const views = (provider.value?.addresses ?? []).map(address => ({ + draft: mapAddressToDraft(address), + siteOptions: siteOptionsOf(address.sites), + categoryOptions: categoryOptionsOf(address.categories), + })) + return views.length > 0 + ? views + : [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }] +}) /** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */ function countryOptionsFor(country: string): { value: string, label: string }[] { diff --git a/frontend/modules/technique/pages/providers/new.vue b/frontend/modules/technique/pages/providers/new.vue index 7054103..5b9862d 100644 --- a/frontend/modules/technique/pages/providers/new.vue +++ b/frontend/modules/technique/pages/providers/new.vue @@ -85,7 +85,7 @@ @@ -121,7 +121,7 @@ @@ -251,7 +251,7 @@ @@ -314,6 +314,7 @@ const referentials = useProviderReferentials() const { main, + providerId, mainLocked, mainSubmitting, mainErrors, @@ -362,15 +363,33 @@ function apiErrorMessage(error: unknown): string { return extractApiErrorMessage(data) || t('technique.providers.toast.error') } +// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite +// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection). +const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1]) + +/** + * Apres validation d'un onglet (creation) : si c'est le dernier onglet du role, + * l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon + * toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab). + */ +function onTabSaved(key: string): void { + if (key === lastFillableTab.value) { + toast.success({ title: t('technique.providers.toast.addComplete') }) + router.push('/providers') + return + } + toast.success({ title: t('technique.providers.toast.updateSuccess') }) +} + // ── Onglet Contact ────────────────────────────────────────────────────────── -/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */ +/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */ async function onSubmitContacts(): Promise { const ok = await submitContacts(error => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(error), })) if (ok) { - toast.success({ title: t('technique.providers.toast.updateSuccess') }) + onTabSaved('contact') } } @@ -413,14 +432,14 @@ function onAddressDegraded(): void { }) } -/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */ +/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */ async function onSubmitAddresses(): Promise { const ok = await submitAddresses(error => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(error), })) if (ok) { - toast.success({ title: t('technique.providers.toast.updateSuccess') }) + onTabSaved('address') } } @@ -450,7 +469,7 @@ function askRemoveRib(index: number): void { askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index)) } -/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */ +/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */ async function onSubmitAccounting(): Promise { const ok = await submitAccounting( isBankRequired.value, @@ -461,7 +480,7 @@ async function onSubmitAccounting(): Promise { }), ) if (ok) { - toast.success({ title: t('technique.providers.toast.updateSuccess') }) + onTabSaved('accounting') } } diff --git a/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts index 721f391..0b8f8a4 100644 --- a/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts +++ b/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts @@ -3,6 +3,7 @@ import { buildProviderContactPayload, hasAtLeastOneFilledContact, isProviderContactBlank, + isProviderContactNamed, } from '../providerContact' import { emptyProviderContact } from '~/modules/technique/types/providerForm' @@ -34,15 +35,28 @@ describe('providerContact helpers', () => { }) }) - describe('hasAtLeastOneFilledContact (RG-3.12)', () => { - it('false si tous les blocs sont vides', () => { - expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false) + describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => { + it('vrai avec un prenom seul ou un nom seul', () => { + expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true) + expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true) }) - it('true des qu\'un bloc porte une donnee', () => { + it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => { + expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false) + expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false) + expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false) + }) + }) + + describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => { + it('false si aucun bloc n\'est nomme', () => { + expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false) + }) + + it('true des qu\'un bloc porte un nom ou prenom', () => { expect(hasAtLeastOneFilledContact([ emptyProviderContact(), - { ...emptyProviderContact(), email: 'a@b.fr' }, + { ...emptyProviderContact(), lastName: 'Dupont' }, ])).toBe(true) }) }) diff --git a/frontend/modules/technique/utils/forms/providerContact.ts b/frontend/modules/technique/utils/forms/providerContact.ts index d8121b9..41f0074 100644 --- a/frontend/modules/technique/utils/forms/providerContact.ts +++ b/frontend/modules/technique/utils/forms/providerContact.ts @@ -32,12 +32,21 @@ export function isProviderContactBlank(contact: ProviderContactFormDraft): boole ].some(isFilled) } +/** + * RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom + * — aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de + * contact valide (la fonction / le telephone / l'email seuls ne suffisent pas). + */ +export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean { + return isFilled(contact.firstName) || isFilled(contact.lastName) +} + /** * RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un - * bloc non vide (au moins un contact valide). + * contact nomme (prenom ou nom). */ export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean { - return contacts.some(contact => !isProviderContactBlank(contact)) + return contacts.some(isProviderContactNamed) } /**