fix(front) : retours revue M3 prestataires (consultation, validations, redirection)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m36s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m21s

This commit is contained in:
2026-06-15 11:19:59 +02:00
parent 09cff93660
commit 0023eeae08
8 changed files with 96 additions and 40 deletions
+2 -2
View File
@@ -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"
}
@@ -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<string, unknown>
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 () => {
@@ -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<Record<string, string>[]>([])
// « + 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 {
@@ -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)
@@ -78,9 +78,6 @@
:model-value="contact"
readonly
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('technique.providers.consultation.emptyContacts') }}
</p>
</div>
</template>
@@ -97,9 +94,6 @@
:country-options="countryOptionsFor(view.draft.country)"
readonly
/>
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('technique.providers.consultation.emptyAddresses') }}
</p>
</div>
</template>
@@ -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 }[] {
@@ -85,7 +85,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitContacts"
/>
</div>
@@ -121,7 +121,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAddresses"
/>
</div>
@@ -251,7 +251,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAccounting"
/>
</div>
@@ -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<void> {
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<void> {
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<void> {
const ok = await submitAccounting(
isBankRequired.value,
@@ -461,7 +480,7 @@ async function onSubmitAccounting(): Promise<void> {
}),
)
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('accounting')
}
}
@@ -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)
})
})
@@ -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)
}
/**