ERP-119 : revue validation front clients + évolutions écran client (types d'adresse, 2e email, saisies manuelles, redirection) (#80)
Auto Tag Develop / tag (push) Successful in 7s

## Contexte
Branche ERP-119 — revue de la validation des formulaires clients (déclencheur : écran « Ajouter un client »), accompagnée de plusieurs évolutions de l'écran client (M1).

## Contenu

### Validation front (clients)
- Boutons « Valider » toujours actifs (retrait du gating de validité) : c'est le back qui renvoie les 422, mappées en rouge par champ.
- Champs requis adossés à une colonne non-nullable : la clé est omise du payload si vide (companyName, RIB, adresse) → 422 NotBlank au lieu d'un 400 de type.
- Onglet Contact : au moins un contact requis (l'amorce vide est soumise → 422 RG-1.05).
- Onglet Adresse : affichage inline des erreurs type / sites / catégories + RG back « au moins un type d'adresse obligatoire ».

### Nouveaux types d'adresse
- Courtier / Distributeur, types autonomes exclusifs : colonnes `is_broker` / `is_distributor` (migration + CHECK miroir d'exclusivité), entité + Callback, et front (select, drapeaux, payloads).

### Saisies manuelles
- Adresse : `allow-create` sur le champ Adresse → saisie libre si la BAN ne propose rien.
- Date de création : `MalioDate :editable` → saisie clavier JJ/MM/AAAA en plus du calendrier.

### 2e email de facturation
- Colonne `billing_email_secondary` (optionnel, max 2), miroir du téléphone secondaire. Bump `@malio/layer-ui` 1.7.8 (prop `addable`).

### Fin d'ajout d'un client
- Redirection vers la liste à la validation du dernier onglet remplissable par le rôle (Adresse pour Bureau/Commerciale, Comptabilité pour Admin) + toast « Client ajouté ». Dérivé de `tabKeys`, sans règle RBAC custom.

## Vérifications
- Back : suites Module/Commercial + Architecture vertes (Client : 124/124). Migrations appliquées (dev + test).
- Front : Vitest vert (272), ESLint OK.

> Note : le hook pré-commit flake aléatoirement (JWT 401 / timeout DB) sur des tests sans rapport (Supplier) ; les commits ont été faits après vérification des suites concernées en isolation.

Reviewed-on: #80
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #80.
This commit is contained in:
2026-06-09 19:47:40 +00:00
committed by Autin
parent b3ab23ee8f
commit 8490de99da
25 changed files with 1011 additions and 245 deletions
@@ -82,7 +82,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!isMainValid || mainSubmitting"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
@@ -114,6 +114,7 @@
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
@@ -178,7 +179,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateContacts || tabSubmitting"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
@@ -216,7 +217,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateAddresses || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
@@ -347,7 +348,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateAccounting || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
@@ -419,8 +420,6 @@ import {
} from '~/modules/commercial/utils/clientEdit'
import {
buildClientFormTabKeys,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -673,17 +672,6 @@ const {
} = useClientFormErrors()
// ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
const relationValid
= main.relationType === null
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
main.relationType = relation
@@ -697,7 +685,7 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
if (businessReadonly.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
@@ -750,9 +738,6 @@ const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last === undefined || isContactNamed(last)
})
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
@@ -774,7 +759,7 @@ function askRemoveContact(index: number): void {
* collection contacts (endpoints client_contact dedies).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
@@ -783,6 +768,11 @@ async function submitContacts(): Promise<void> {
}
removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
// obligatoire » inline (la RG-1.14 n'a pas d'equivalent back au POST).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -805,10 +795,10 @@ async function submitContacts(): Promise<void> {
}
},
error => showError(error),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
contact => contact.id === null && isContactBlank(contact),
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
// (un onglet Contact vide ne doit pas passer en faux succes).
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
// Tant qu'un bloc reste en erreur : pas de toast succes.
if (hasError) return
@@ -823,10 +813,6 @@ async function submitContacts(): Promise<void> {
}
// ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() =>
addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
@@ -859,7 +845,7 @@ function onAddressDegraded(): void {
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
@@ -927,13 +913,6 @@ function onPaymentTypeChange(value: string | number | null): void {
}
}
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true
})
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
@@ -965,7 +944,7 @@ function askRemoveRib(index: number): void {
* 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
@@ -76,7 +76,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!isMainValid || mainSubmitting"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
@@ -109,6 +109,7 @@
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
@@ -140,13 +141,12 @@
<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 clic trop tot, Information etant l'onglet
actif par defaut) OU si aucun champ n'est rempli : onglet
facultatif, mais pas de validation a vide (on passe alors
directement a Contact). -->
actif par defaut). Onglet facultatif : un enregistrement a
vide reste possible, c'est le back qui valide. -->
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null || !canValidateInformation"
:disabled="tabSubmitting || clientId === null"
@click="submitInformation"
/>
</div>
@@ -178,7 +178,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateContacts || tabSubmitting"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
@@ -216,7 +216,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateAddresses || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
@@ -347,7 +347,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateAccounting || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
@@ -391,9 +391,6 @@ import { useClientFormErrors } from '~/modules/commercial/composables/useClientF
import {
buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -402,8 +399,14 @@ import {
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
import {
buildAddressPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/clientEdit'
import {
emptyAddress,
emptyContact,
@@ -517,25 +520,6 @@ watch(showRelationAndTriage, (visible) => {
}
})
// Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant
// devient requis si l'un des deux est choisi (spec fonctionnelle).
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide).
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)
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '')
? null
@@ -551,18 +535,13 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return
if (mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const payload: Record<string, unknown> = {
companyName: main.companyName,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
const created = await api.post<ClientResponse>('/clients', payload, {
// Payload partage avec l'edition (buildMainPayload) : meme logique
// d'omission des requis vides et meme envoi de relationType (ERP-119).
const created = await api.post<ClientResponse>('/clients', buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
@@ -606,6 +585,12 @@ const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
// Dernier onglet REMPLISSABLE par le role (cf. lastFillableTabKey) : deja role-aware
// via tabKeys (accounting present ssi accounting.view, et a la creation « present » =
// « editable » : aucun role createur n'a la Compta en lecture seule). Sa validation
// cloture l'ajout -> redirection vers la liste.
const lastFillableTab = computed(() => lastFillableTabKey(tabKeys.value))
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -633,12 +618,23 @@ function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
function completeTab(key: string): void {
/**
* Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est
* termine : toast final + redirection vers la liste, et on retourne true pour que
* l'appelant n'affiche pas son toast « mis a jour ». Sinon, deverrouille et avance
* a l'onglet suivant, et retourne false.
*/
function completeTab(key: string): boolean {
validated[key] = true
if (key === lastFillableTab.value) {
toast.success({ title: t('commercial.clients.toast.addComplete') })
router.push('/clients')
return true
}
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
return false
}
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
@@ -661,12 +657,9 @@ const information = reactive({
directorName: null as string | null,
})
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
@@ -679,7 +672,7 @@ async function submitInformation(): Promise<void> {
profitAmount: information.profitAmount || null,
directorName: information.directorName || null,
}, { toast: false })
completeTab('information')
if (completeTab('information')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
@@ -701,9 +694,6 @@ const canAddContact = computed(() => {
return last !== undefined && isContactNamed(last)
})
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
@@ -717,9 +707,14 @@ function askRemoveContact(index: number): void {
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on ne les skippe pas -> le bloc vide est POSTe et
// le back renvoie la 422 RG-1.05 « prénom ou nom obligatoire » inline (la
// RG-1.14 n'a pas d'equivalent back au POST, on la materialise via RG-1.05).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -749,14 +744,14 @@ async function submitContacts(): Promise<void> {
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
contact => contact.id === null && isContactBlank(contact),
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
// (un onglet Contact vide ne doit pas passer en faux succes).
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
if (hasError) return
completeTab('contact')
if (completeTab('contact')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
@@ -789,12 +784,6 @@ const countryOptions: RefOption[] = [
{ value: 'Espagne', label: 'Espagne' },
]
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() =>
addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
@@ -824,7 +813,7 @@ function onAddressDegraded(): void {
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
@@ -832,20 +821,8 @@ async function submitAddresses(): Promise<void> {
addresses.value,
addressErrors,
async (address) => {
const body = {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
// Payload partage avec l'edition (buildAddressPayload, ERP-119).
const body = buildAddressPayload(address, isBillingEmailRequired(address))
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
@@ -861,7 +838,7 @@ async function submitAddresses(): Promise<void> {
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
)
if (hasError) return
completeTab('address')
if (completeTab('address')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
@@ -909,16 +886,6 @@ function onPaymentTypeChange(value: string | number | null): void {
}
}
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true
})
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
@@ -947,7 +914,7 @@ function askRemoveRib(index: number): void {
* il n'existe pas d'endpoint /accounting, cf. recon back).
*/
async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
@@ -959,7 +926,8 @@ async function submitAccounting(): Promise<void> {
ribs.value,
ribErrors,
async (rib) => {
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
// Payload partage avec l'edition (buildRibPayload, ERP-119).
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
@@ -997,7 +965,7 @@ async function submitAccounting(): Promise<void> {
return
}
completeTab('accounting')
if (completeTab('accounting')) return
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {