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", "addPhone": "Ajouter un numéro",
"categories": "Catégorie", "categories": "Catégorie",
"relation": "Distributeur / Courtier", "relation": "Distributeur / Courtier",
"relationNone": "Aucun", "relationDistributor": "Dépend du distributeur",
"relationDistributor": "Distributeur", "relationBroker": "Dépend du courtier",
"relationBroker": "Courtier",
"distributorName": "Nom du distributeur", "distributorName": "Nom du distributeur",
"brokerName": "Nom du courtier", "brokerName": "Nom du courtier",
"triageService": "Prestation de triage" "triageService": "Prestation de triage"
@@ -149,10 +149,13 @@
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <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 <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting" :disabled="tabSubmitting || clientId === null"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -386,6 +389,7 @@ import {
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone' import { formatPhoneFR } from '~/shared/utils/phone'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -407,6 +411,17 @@ function goBack(): void {
router.push('/clients') 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') }) useHead({ title: t('commercial.clients.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting // Gating de la route : la creation est reservee a `manage`. Compta (accounting
@@ -433,7 +448,7 @@ const main = reactive({
lastName: null as string | null, lastName: null as string | null,
email: null as string | null, email: null as string | null,
categoryIris: [] as string[], categoryIris: [] as string[],
relationType: 'aucun' as 'aucun' | 'distributeur' | 'courtier', relationType: null as 'distributeur' | 'courtier' | null,
distributorIri: null as string | null, distributorIri: null as string | null,
brokerIri: null as string | null, brokerIri: null as string | null,
triageService: false, 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[]>(() => [ const relationOptions = computed<RefOption[]>(() => [
{ value: 'aucun', label: t('commercial.clients.form.main.relationNone') },
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { 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 isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== '' 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) return filled(main.companyName)
&& filled(main.email) && filled(main.email)
&& filled(mainPhones.value[0]) && filled(mainPhones.value[0])
&& (filled(main.firstName) || filled(main.lastName)) && (filled(main.firstName) || filled(main.lastName))
&& main.categoryIris.length >= 1
&& relationValid
}) })
async function onRelationChange(value: string | number | null): Promise<void> { 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 main.relationType = relation
// Reinitialise la FK non concernee (une seule remplie a la fois, RG-1.03). // Reinitialise la FK non concernee (une seule remplie a la fois, RG-1.03).
if (relation !== 'distributeur') main.distributorIri = null if (relation !== 'distributeur') main.distributorIri = null
@@ -518,11 +547,14 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.createSuccess') }) toast.success({ title: t('commercial.clients.toast.createSuccess') })
} }
catch (error) { 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 const status = (error as { response?: { status?: number } })?.response?.status
toast.error({ toast.error({
title: t('commercial.clients.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 { finally {
@@ -611,8 +643,8 @@ async function submitInformation(): Promise<void> {
completeTab('information') completeTab('information')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch { catch (error) {
toast.error({ title: t('commercial.clients.toast.error') }) toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -685,8 +717,8 @@ async function submitContacts(): Promise<void> {
completeTab('contact') completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch { catch (error) {
toast.error({ title: t('commercial.clients.toast.error') }) toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -783,8 +815,8 @@ async function submitAddresses(): Promise<void> {
completeTab('address') completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch { catch (error) {
toast.error({ title: t('commercial.clients.toast.error') }) toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -878,8 +910,8 @@ async function submitAccounting(): Promise<void> {
completeTab('accounting') completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch { catch (error) {
toast.error({ title: t('commercial.clients.toast.error') }) toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false