Compare commits

..

2 Commits

Author SHA1 Message Date
gitea-actions fd430bc123 chore: bump version to v0.1.149
Auto Tag Develop / tag (push) Successful in 10s
Build & Push Docker Image / build (push) Successful in 3m9s
2026-06-24 16:05:04 +00:00
tristan a6b48b1dd1 feat : ERP-196 — refonte des blocs de formulaire (contact, adresse, compta) (#145)
Auto Tag Develop / tag (push) Successful in 11s
## ERP-196 — Refonte des blocs de formulaire

Refonte visuelle des blocs répétables des formulaires (clients, fournisseurs, prestataires, transporteurs), alignée sur les blocs « ticket de pesée » : à plat (sans box-shadow), titre de bloc en noir, séparation par filet noir 1px.

###  Blocs Contact
- Box-shadow / fond blanc / padding latéral retirés
- En-tête `flex justify-between` : titre noir (« Contact 1 »…) à gauche, poubelle `button-class="p-0"` à droite
- 4 colonnes, filet `border-b border-black` entre blocs (pas sous le dernier, prop `last`)
- i18n `contact.title` ajouté pour transporteurs / prestataires
- 9 pages câblées (new / edit / consultation des 4 répertoires)

###  Blocs Adresse
- Même traitement (à plat, titre noir, filet sauf dernier)
- i18n `address.title` pour transporteurs / prestataires
- Transporteur : adresse unique → titre « Adresse » (sans numéro)
- 12 pages câblées

###  Bloc Comptabilité
- Bloc **infos** : titre « Informations » + filet bas (uniquement si des RIB suivent)
- Blocs **RIB** : titre « RIB 1 / RIB 2… » + poubelle `p-0`, filet sauf le dernier
- i18n `accounting.infoTitle` (3 modules) + `accounting.ribTitle` (fournisseurs / prestataires)
- 9 pages câblées (clients / fournisseurs / prestataires)

### Vérifications
- Vitest : 44/44 (specs contact + adresse)
- Eslint : clean sur l'ensemble des composants et pages modifiés

### Commits
- `feat : refonte des blocs contact (ERP-196)`
- `feat : refonte des blocs adresse (ERP-196)`
- `feat : refonte du bloc comptabilité (ERP-196)`

Reviewed-on: #145
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 16:04:52 +00:00
25 changed files with 1182 additions and 975 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.148'
app.version: '0.1.149'
+9
View File
@@ -183,6 +183,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"infoTitle": "Informations",
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
@@ -190,6 +191,7 @@
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
@@ -350,6 +352,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"infoTitle": "Informations",
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
@@ -441,6 +444,7 @@
"categoryRequired": "Sélectionnez au moins une catégorie."
},
"contact": {
"title": "Contact {n}",
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
@@ -452,6 +456,7 @@
"add": "Nouveau contact"
},
"address": {
"title": "Adresse {n}",
"sites": "Sites",
"contacts": "Contact(s) rattaché(s)",
"country": "Pays",
@@ -465,6 +470,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"infoTitle": "Informations",
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
@@ -472,6 +478,7 @@
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
@@ -628,6 +635,7 @@
"uploadFailed": "Le téléversement de la décharge a échoué."
},
"address": {
"title": "Adresse",
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
@@ -637,6 +645,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"contact": {
"title": "Contact {n}",
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
@@ -1,203 +1,211 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
<MalioSelect
:model-value="addressType"
:options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.isProspect"
@update:model-value="onAddressTypeChange"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.clients.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
telephone secondaire) qui coule dans la grille. Sinon un filler comble
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
<MalioInputEmail
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')"
:required="!readonly && !disabled"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly"
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
@update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail"
/>
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
Inutile en consultation masquee (la grille se recompose sans les
champs vides, ERP-193). -->
<div v-else-if="!hideEmpty" aria-hidden="true" />
<MalioInputEmail
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
:model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
/>
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
indisponible, bascule en saisie libre — recuperable : re-saisir le
code postal relance la recherche et repasse en select au succes. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
sa valeur liee, il n'afficherait rien en readonly). allow-create :
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
<MalioSelect
:model-value="addressType"
:options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
:error="errors?.isProspect"
@update:model-value="onAddressTypeChange"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.clients.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
telephone secondaire) qui coule dans la grille. Sinon un filler comble
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
<MalioInputEmail
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')"
:required="!readonly && !disabled"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly"
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
@update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail"
/>
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
Inutile en consultation masquee (la grille se recompose sans les
champs vides, ERP-193). -->
<div v-else-if="!hideEmpty" aria-hidden="true" />
<MalioInputEmail
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
:model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
/>
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
indisponible, bascule en saisie libre — recuperable : re-saisir le
code postal relance la recherche et repasse en select au succes. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
</div>
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
sa valeur liee, il n'afficherait rien en readonly). allow-create :
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div>
</div>
</template>
@@ -230,6 +238,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
@@ -1,84 +1,93 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
@@ -98,6 +107,8 @@ const props = defineProps<{
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -1,189 +1,198 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
<MalioSelect
:model-value="model.addressType"
:options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
<MalioSelect
:model-value="model.addressType"
:options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.suppliers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.suppliers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('commercial.suppliers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.suppliers.form.address.street')"
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.suppliers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.suppliers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.suppliers.form.address.street')"
:model-value="model.city"
:label="t('commercial.suppliers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.suppliers.form.address.street')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
<MalioInputNumber
v-if="!hideEmpty || isFilled(model.bennes)"
:model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
:error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)"
/>
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
Consultation : masquee si non cochee (ERP-193). -->
<MalioCheckbox
v-if="!hideEmpty || isFilled(model.triageProvider)"
id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider"
group-class="self-center"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/>
</div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
<MalioInputNumber
v-if="!hideEmpty || isFilled(model.bennes)"
:model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly"
:disabled="disabled"
:error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)"
/>
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
Consultation : masquee si non cochee (ERP-193). -->
<MalioCheckbox
v-if="!hideEmpty || isFilled(model.triageProvider)"
id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider"
group-class="self-center"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/>
</div>
</template>
@@ -210,6 +219,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
@@ -1,83 +1,92 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
@@ -96,6 +105,8 @@ const props = defineProps<{
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -178,6 +178,7 @@
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -210,6 +211,7 @@
:key="address.id ?? `new-${index}`"
:model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions"
:site-options="siteOptions"
:contact-options="contactOptions"
@@ -244,8 +246,10 @@
editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
@@ -314,21 +318,27 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
@@ -156,6 +156,7 @@
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled
hide-empty
/>
@@ -170,6 +171,7 @@
:key="view.draft.id ?? index"
:model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions"
:site-options="allSiteOptions"
:contact-options="contactOptions"
@@ -183,8 +185,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren"
@@ -239,13 +243,16 @@
</div>
</div>
<!-- Blocs RIB (0..n), lecture seule. -->
<!-- Blocs RIB (0..n), lecture seule.
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in ribs"
:key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label"
@@ -177,6 +177,7 @@
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -209,6 +210,7 @@
:key="index"
:model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
@@ -242,8 +244,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
@@ -312,22 +316,28 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
@@ -147,6 +147,7 @@
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -179,6 +180,7 @@
:key="address.id ?? `new-${index}`"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="mainCategoryOptions"
:site-options="siteOptions"
:contact-options="contactOptions"
@@ -213,8 +215,10 @@
editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
@@ -283,21 +287,27 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
@@ -137,6 +137,7 @@
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled
hide-empty
/>
@@ -151,6 +152,7 @@
:key="view.draft.id ?? index"
:model-value="view.draft"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions"
:site-options="allSiteOptions"
:contact-options="contactOptions"
@@ -164,8 +166,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren"
@@ -220,13 +224,16 @@
</div>
</div>
<!-- Blocs RIB (0..n), lecture seule. -->
<!-- Blocs RIB (0..n), lecture seule.
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in ribs"
:key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label"
@@ -145,6 +145,7 @@
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -177,6 +178,7 @@
:key="index"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
@@ -210,8 +212,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
@@ -280,21 +284,27 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
@@ -1,131 +1,140 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.siteIris)"
:model-value="model.siteIris"
:options="siteOptions"
:label="t('technique.providers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
:label="t('technique.providers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('technique.providers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
@click="$emit('remove')"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.siteIris)"
:model-value="model.siteIris"
:options="siteOptions"
:label="t('technique.providers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
:label="t('technique.providers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')"
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('technique.providers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div>
</div>
</template>
@@ -143,6 +152,8 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: ProviderAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Sites Starseed disponibles. */
siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */
@@ -150,6 +161,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
@@ -1,84 +1,93 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('technique.providers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('technique.providers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: ProviderContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable). */
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -46,6 +46,7 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
return mount(ProviderAddressBlock, {
props: {
modelValue: { ...emptyProviderAddress(), ...overrides },
title: 'Adresse 1',
siteOptions: [],
contactOptions: [],
countryOptions: [],
@@ -29,6 +29,7 @@ function mountBlock(errors?: Record<string, string>) {
return mount(ProviderContactBlock, {
props: {
modelValue: emptyProviderContact(),
title: 'Contact 1',
...(errors ? { errors } : {}),
},
global: {
@@ -72,7 +72,9 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -104,6 +106,8 @@
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
@@ -136,8 +140,10 @@
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
@@ -206,21 +212,27 @@
</div>
</div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
@@ -81,6 +81,8 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled
hide-empty
/>
@@ -94,6 +96,8 @@
v-for="(view, index) in addressViews"
:key="index"
:model-value="view.draft"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:site-options="view.siteOptions"
:contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)"
@@ -108,8 +112,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
@@ -120,13 +126,16 @@
</div>
</div>
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
<!-- Blocs RIB (uniquement si type de reglement = LCR).
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
@@ -73,7 +73,9 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -108,6 +110,8 @@
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
@@ -139,8 +143,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
@@ -210,21 +216,27 @@
</div>
</div>
<!-- Blocs RIB affiches uniquement si type de reglement = LCR (RG-3.08). -->
<!-- Blocs RIB affiches uniquement si type de reglement = LCR (RG-3.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
@@ -1,103 +1,113 @@
<template>
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Pays : prerempli « France » (RG-4.05). -->
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
:label="t('transport.carriers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('transport.carriers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc, en noir (adresse unique, sans suppression). -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
</div>
<MalioInputText
v-if="!hideEmpty || isFilled(model.streetComplement)"
:model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Pays : prerempli « France » (RG-4.05). -->
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
:label="t('transport.carriers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('transport.carriers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<MalioInputText
v-if="!hideEmpty || isFilled(model.streetComplement)"
:model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div>
</template>
@@ -118,8 +128,12 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: CarrierAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
@@ -1,84 +1,93 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('transport.carriers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('transport.carriers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('transport.carriers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName"
:label="t('transport.carriers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName"
:label="t('transport.carriers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('transport.carriers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('transport.carriers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('transport.carriers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('transport.carriers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numéro : révélé à la demande (max 2 téléphones RG-4.08). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('transport.carriers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('transport.carriers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('transport.carriers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('transport.carriers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numéro : révélé à la demande (max 2 téléphones RG-4.08). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('transport.carriers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: CarrierContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icône de suppression (1er bloc non supprimable). */
removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet validé). */
readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -56,6 +56,7 @@ function mountBlock(overrides: Record<string, unknown> = {}) {
return mount(CarrierAddressBlock, {
props: {
modelValue: { ...emptyCarrierAddress(), ...overrides },
title: 'Adresse 1',
countryOptions: [{ value: 'France', label: 'France' }],
},
global: {
@@ -143,6 +143,8 @@
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock
:model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptions"
:errors="addressErrors"
@update:model-value="(v) => address = v"
@@ -160,7 +162,9 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -123,6 +123,8 @@
<!-- Adresse UNIQUE (ERP-172). -->
<CarrierAddressBlock
:model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptionsFor(address.country)"
disabled
hide-empty
@@ -136,6 +138,8 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled
hide-empty
/>
@@ -180,6 +180,8 @@
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock
:model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptions"
:disabled="isQualimat || isValidated('addresses')"
:errors="addressErrors"
@@ -207,7 +209,9 @@
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"