fix(front) : corrections review ecran ajouter client (ERP-63)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m2s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m17s

- Relation Distributeur/Courtier : libelles « Depend du distributeur/courtier »,
  select optionnel ; le nom (distributeur ou courtier) devient requis quand la
  relation correspondante est choisie.
- Categorie : au moins 1 obligatoire dans le formulaire principal (aligne sur
  Assert\Count(min:1) du back).
- Bouton « Valider » de l'onglet Information desactive tant que le client n'est
  pas cree (l'onglet est actif par defaut) : evite tout PATCH premature.
- Gestion d'erreur : les toasts d'erreur passent toujours une chaine (corrige un
  crash izitoast sur message undefined) et remontent le message de validation du
  serveur (violations 422) sur tous les onglets.
This commit is contained in:
2026-06-03 10:17:06 +02:00
parent 955f9a436f
commit b3cc7e4ced
2 changed files with 49 additions and 18 deletions
+2 -3
View File
@@ -115,9 +115,8 @@
"addPhone": "Ajouter un numéro",
"categories": "Catégorie",
"relation": "Distributeur / Courtier",
"relationNone": "Aucun",
"relationDistributor": "Distributeur",
"relationBroker": "Courtier",
"relationDistributor": "Dépend du distributeur",
"relationBroker": "Dépend du courtier",
"distributorName": "Nom du distributeur",
"brokerName": "Nom du courtier",
"triageService": "Prestation de triage"
@@ -149,10 +149,13 @@
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Desactive tant que le client n'est pas cree : evite un PATCH
avant le POST si l'utilisateur clique trop tot (le panneau
Information est l'onglet actif par defaut). -->
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || clientId === null"
@click="submitInformation"
/>
</div>
@@ -386,6 +389,7 @@ import {
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
@@ -407,6 +411,17 @@ function goBack(): void {
router.push('/clients')
}
/**
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API.
* Retourne TOUJOURS une chaine (le composant de toast plante sur `undefined`) :
* le message de validation renvoye par le serveur (violations 422 / detail),
* sinon un libelle generique.
*/
function apiErrorMessage(error: unknown): string {
const data = (error as { data?: unknown })?.data
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
}
useHead({ title: t('commercial.clients.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
@@ -433,7 +448,7 @@ const main = reactive({
lastName: null as string | null,
email: null as string | null,
categoryIris: [] as string[],
relationType: 'aucun' as 'aucun' | 'distributeur' | 'courtier',
relationType: null as 'distributeur' | 'courtier' | null,
distributorIri: null as string | null,
brokerIri: null as string | null,
triageService: false,
@@ -450,23 +465,37 @@ function addMainPhone(): void {
}
}
// Pas d'option « Aucun » : le select est vide par defaut (relationType = null).
const relationOptions = computed<RefOption[]>(() => [
{ value: 'aucun', label: t('commercial.clients.form.main.relationNone') },
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
// RG-1.01 : firstName OU lastName, + companyName / email / telephone principal requis.
// Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / email / telephone principal / >= 1 categorie obligatoires ;
// - RG-1.01 : nom OU prenom du contact principal ;
// - relation Distributeur/Courtier obligatoire (un des deux), ET le nom
// correspondant obligatoire selon le choix (spec fonctionnelle).
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
// distributeur/courtier » est choisi, le nom correspondant devient requis.
const relationValid
= main.relationType === null
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& filled(main.email)
&& filled(mainPhones.value[0])
&& (filled(main.firstName) || filled(main.lastName))
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = String(value ?? 'aucun') as typeof main.relationType
const relation = (value === null || value === '')
? null
: (String(value) as 'distributeur' | 'courtier')
main.relationType = relation
// Reinitialise la FK non concernee (une seule remplie a la fois, RG-1.03).
if (relation !== 'distributeur') main.distributorIri = null
@@ -518,11 +547,14 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.createSuccess') })
}
catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → message explicite.
// 409 = doublon nom de societe (RG d'unicite) → message explicite ;
// sinon on remonte le message de validation du serveur (ex: 422).
const status = (error as { response?: { status?: number } })?.response?.status
toast.error({
title: t('commercial.clients.toast.error'),
message: status === 409 ? t('commercial.clients.form.duplicateCompany') : undefined,
message: status === 409
? t('commercial.clients.form.duplicateCompany')
: apiErrorMessage(error),
})
}
finally {
@@ -611,8 +643,8 @@ async function submitInformation(): Promise<void> {
completeTab('information')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch {
toast.error({ title: t('commercial.clients.toast.error') })
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
@@ -685,8 +717,8 @@ async function submitContacts(): Promise<void> {
completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch {
toast.error({ title: t('commercial.clients.toast.error') })
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
@@ -783,8 +815,8 @@ async function submitAddresses(): Promise<void> {
completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch {
toast.error({ title: t('commercial.clients.toast.error') })
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
@@ -878,8 +910,8 @@ async function submitAccounting(): Promise<void> {
completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch {
toast.error({ title: t('commercial.clients.toast.error') })
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false