fix(front) : retours revue M3 prestataires (consultation, validations, redirection)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user