Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions 2e50a760c6 chore: bump version to v0.1.150
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 47s
2026-06-24 17:14:00 +00:00
tristan 49e5e5548e feat(front) : refonte à plat des blocs Information (commercial) et Prix (transporteur) (#146)
Auto Tag Develop / tag (push) Successful in 11s
Complète la refonte **ERP-196** (blocs de formulaire à plat : sans box-shadow, titre noir, filet noir 1px) qui avait oublié deux blocs.

## Blocs concernés
- **Bloc « Information »** (Client + Fournisseur, écrans consultation / édition / création — 6 fichiers) : suppression du fond blanc, du box-shadow et du padding latéral → grille à plat pleine largeur. Pas de titre ajouté (le bloc est seul dans son onglet « Information », comme le bloc du haut du ticket de pesée).
- **Bloc « Prix » du transporteur** (`CarrierPriceBlock`) : aligné sur les blocs contact / adresse — à plat, en-tête « Prix N » en noir + poubelle (`button-class="p-0"`), filet noir 1px entre blocs (sauf le dernier via la prop `last`). Câblage `title`/`last` dans les écrans Ajouter / Modifier + clé i18n `carriers.form.price.title`.

## Hors périmètre
La table de **consultation** des prix (lecture seule, avec export) n'est pas un bloc de formulaire et garde sa présentation actuelle.

## Vérifications
- Vitest : suite complète verte (667/667).
- ESLint : clean sur l'ensemble du projet.
- Aucune modif back.

> Pas de numéro de ticket fourni — branche nommée descriptivement, à renommer/rattacher si besoin.

Reviewed-on: #146
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 17:13:48 +00:00
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
26 changed files with 1375 additions and 1150 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.148' app.version: '0.1.150'
+10
View File
@@ -183,6 +183,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -190,6 +191,7 @@
"paymentDelay": "Délai de règlement", "paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement", "paymentType": "Type de règlement",
"bank": "Banque", "bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé", "ribLabel": "Libellé",
"ribBic": "BIC", "ribBic": "BIC",
"ribIban": "IBAN", "ribIban": "IBAN",
@@ -350,6 +352,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -441,6 +444,7 @@
"categoryRequired": "Sélectionnez au moins une catégorie." "categoryRequired": "Sélectionnez au moins une catégorie."
}, },
"contact": { "contact": {
"title": "Contact {n}",
"lastName": "Nom", "lastName": "Nom",
"firstName": "Prénom", "firstName": "Prénom",
"jobTitle": "Fonction", "jobTitle": "Fonction",
@@ -452,6 +456,7 @@
"add": "Nouveau contact" "add": "Nouveau contact"
}, },
"address": { "address": {
"title": "Adresse {n}",
"sites": "Sites", "sites": "Sites",
"contacts": "Contact(s) rattaché(s)", "contacts": "Contact(s) rattaché(s)",
"country": "Pays", "country": "Pays",
@@ -465,6 +470,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -472,6 +478,7 @@
"paymentDelay": "Délai de règlement", "paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement", "paymentType": "Type de règlement",
"bank": "Banque", "bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé", "ribLabel": "Libellé",
"ribBic": "BIC", "ribBic": "BIC",
"ribIban": "IBAN", "ribIban": "IBAN",
@@ -628,6 +635,7 @@
"uploadFailed": "Le téléversement de la décharge a échoué." "uploadFailed": "Le téléversement de la décharge a échoué."
}, },
"address": { "address": {
"title": "Adresse",
"country": "Pays", "country": "Pays",
"postalCode": "Code postal", "postalCode": "Code postal",
"city": "Ville", "city": "Ville",
@@ -637,6 +645,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"contact": { "contact": {
"title": "Contact {n}",
"lastName": "Nom", "lastName": "Nom",
"firstName": "Prénom", "firstName": "Prénom",
"jobTitle": "Fonction", "jobTitle": "Fonction",
@@ -654,6 +663,7 @@
"confirm": "Supprimer" "confirm": "Supprimer"
}, },
"price": { "price": {
"title": "Prix {n}",
"direction": "Sens", "direction": "Sens",
"directionClient": "Client", "directionClient": "Client",
"directionSupplier": "Fournisseur", "directionSupplier": "Fournisseur",
@@ -1,203 +1,211 @@
<template> <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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> (pas de bordure sous le dernier bloc). -->
<MalioButtonIcon <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="removable && !readonly && !disabled" <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
icon="mdi:delete-outline" <div class="flex items-center justify-between">
variant="ghost" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
button-class="absolute top-3 right-3" <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }" <MalioButtonIcon
@click="$emit('remove')" 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) <!-- Grille 4 colonnes des champs de l'adresse. -->
remplacant les 3 cases. Les options encodent les combinaisons valides <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les <!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). --> remplacant les 3 cases. Les options encodent les combinaisons valides
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire + (exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
exclusivite prospect) -> affichee sous le select Type d'adresse. --> drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<MalioSelect <!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
:model-value="addressType" exclusivite prospect) -> affichee sous le select Type d'adresse. -->
:options="addressTypeOptions" <MalioSelect
:label="t('commercial.clients.form.address.addressType')" :model-value="addressType"
:readonly="readonly" :options="addressTypeOptions"
:disabled="disabled" :label="t('commercial.clients.form.address.addressType')"
: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')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:required="!readonly && !disabled" :required="!readonly && !disabled"
:error="errors?.street" :error="errors?.isProspect"
:allow-create="true" @update:model-value="onAddressTypeChange"
: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" <!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
@select="onAddressSelect" <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 <MalioInputText
v-else v-else
:model-value="model.street" :model-value="model.city"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.city')"
: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" :mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:error="errors?.streetComplement" :required="!readonly && !disabled"
@update:model-value="(v: string) => update('streetComplement', v)" :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> </div>
</template> </template>
@@ -230,6 +238,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean disabled?: boolean
@@ -1,84 +1,93 @@
<template> <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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si (pas de bordure sous le dernier bloc). -->
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule. <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<MalioButtonIcon <div class="flex items-center justify-between">
v-if="removable && !readonly && !disabled" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
icon="mdi:delete-outline" <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
variant="ghost" non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
button-class="absolute top-3 right-3" ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<MalioInputText button-class="p-0"
v-if="!hideEmpty || isFilled(model.lastName)" v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
:model-value="model.lastName" @click="$emit('remove')"
:label="t('commercial.clients.form.contact.lastName')" />
:mask="PERSON_NAME_MASK" </div>
:readonly="readonly"
:disabled="disabled" <!-- Grille 4 colonnes des champs du contact. -->
:error="errors?.lastName" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
@update:model-value="(v: string) => update('lastName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.lastName)"
<MalioInputText :model-value="model.lastName"
v-if="!hideEmpty || isFilled(model.firstName)" :label="t('commercial.clients.form.contact.lastName')"
:model-value="model.firstName" :mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.contact.firstName')" :readonly="readonly"
:mask="PERSON_NAME_MASK" :disabled="disabled"
:readonly="readonly" :error="errors?.lastName"
:disabled="disabled" @update:model-value="(v: string) => update('lastName', v)"
:error="errors?.firstName" />
@update:model-value="(v: string) => update('firstName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.firstName)"
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText :model-value="model.firstName"
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la :label="t('commercial.clients.form.contact.firstName')"
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> :mask="PERSON_NAME_MASK"
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> :readonly="readonly"
<MalioInputText :disabled="disabled"
:model-value="model.jobTitle" :error="errors?.firstName"
:label="t('commercial.clients.form.contact.jobTitle')" @update:model-value="(v: string) => update('firstName', v)"
:mask="FREE_TEXT_MASK" />
:readonly="readonly" <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
:disabled="disabled" (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
:error="errors?.jobTitle" cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
@update:model-value="(v: string) => update('jobTitle', v)" <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> </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> </div>
</template> </template>
@@ -98,6 +107,8 @@ const props = defineProps<{
title: string title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */ /** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -1,189 +1,198 @@
<template> <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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : modal de confirmation cote parent. --> (pas de bordure sous le dernier bloc). -->
<MalioButtonIcon <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="removable && !readonly && !disabled" <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
icon="mdi:delete-outline" <div class="flex items-center justify-between">
variant="ghost" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
button-class="absolute top-3 right-3" <!-- Suppression : modal de confirmation cote parent. -->
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }" <MalioButtonIcon
@click="$emit('remove')" 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 <!-- Grille 4 colonnes des champs de l'adresse. -->
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
`addressType`) s'affiche via la prop native :error de MalioSelect. --> <!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
<MalioSelect l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
:model-value="model.addressType" `addressType`) s'affiche via la prop native :error de MalioSelect. -->
:options="addressTypeOptions" <MalioSelect
:label="t('commercial.suppliers.form.address.addressType')" :model-value="model.addressType"
:readonly="readonly" :options="addressTypeOptions"
:disabled="disabled" :label="t('commercial.suppliers.form.address.addressType')"
empty-option-label="" :readonly="readonly"
:required="!readonly && !disabled" :disabled="disabled"
:error="errors?.addressType" empty-option-label=""
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))" :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). --> <!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="model.siteIris" :model-value="model.siteIris"
:options="siteOptions" :options="siteOptions"
:label="t('commercial.suppliers.form.address.sites')" :label="t('commercial.suppliers.form.address.sites')"
:display-tag="true" :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')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:required="!readonly && !disabled" :required="!readonly && !disabled"
:error="errors?.street" :error="errors?.sites"
:allow-create="true" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
: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" <!-- Contacts rattaches (M2M, facultatif). -->
@select="onAddressSelect" <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 <MalioInputText
v-else v-else
:model-value="model.street" :model-value="model.city"
:label="t('commercial.suppliers.form.address.street')" :label="t('commercial.suppliers.form.address.city')"
:mask="ADDRESS_MASK" :mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:required="!readonly && !disabled" :required="!readonly && !disabled"
:error="errors?.street" :error="errors?.city"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('city', v)"
/> />
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
<MalioInputText texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
:model-value="model.streetComplement" <div class="col-span-2">
:label="t('commercial.suppliers.form.address.streetComplement')" <MalioInputAutocomplete
:mask="ADDRESS_MASK" 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" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:error="errors?.streetComplement" :error="errors?.bennes"
@update:model-value="(v: string) => update('streetComplement', v)" @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> </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> </div>
</template> </template>
@@ -210,6 +219,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean disabled?: boolean
@@ -1,83 +1,92 @@
<template> <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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si (pas de bordure sous le dernier bloc). -->
non supprimable (1er bloc, RG-2.13) ou en lecture seule. --> <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
v-if="removable && !readonly && !disabled" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
variant="ghost" <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
button-class="absolute top-3 right-3" non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<MalioInputText button-class="p-0"
v-if="!hideEmpty || isFilled(model.lastName)" v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
:model-value="model.lastName" @click="$emit('remove')"
:label="t('commercial.suppliers.form.contact.lastName')" />
:mask="PERSON_NAME_MASK" </div>
:readonly="readonly"
:disabled="disabled" <!-- Grille 4 colonnes des champs du contact. -->
:error="errors?.lastName" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
@update:model-value="(v: string) => update('lastName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.lastName)"
<MalioInputText :model-value="model.lastName"
v-if="!hideEmpty || isFilled(model.firstName)" :label="t('commercial.suppliers.form.contact.lastName')"
:model-value="model.firstName" :mask="PERSON_NAME_MASK"
:label="t('commercial.suppliers.form.contact.firstName')" :readonly="readonly"
:mask="PERSON_NAME_MASK" :disabled="disabled"
:readonly="readonly" :error="errors?.lastName"
:disabled="disabled" @update:model-value="(v: string) => update('lastName', v)"
:error="errors?.firstName" />
@update:model-value="(v: string) => update('firstName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.firstName)"
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText :model-value="model.firstName"
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la :label="t('commercial.suppliers.form.contact.firstName')"
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> :mask="PERSON_NAME_MASK"
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> :readonly="readonly"
<MalioInputText :disabled="disabled"
:model-value="model.jobTitle" :error="errors?.firstName"
:label="t('commercial.suppliers.form.contact.jobTitle')" @update:model-value="(v: string) => update('firstName', v)"
:mask="FREE_TEXT_MASK" />
:readonly="readonly" <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
:disabled="disabled" (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
:error="errors?.jobTitle" cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
@update:model-value="(v: string) => update('jobTitle', v)" <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> </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> </div>
</template> </template>
@@ -96,6 +105,8 @@ const props = defineProps<{
title: string title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */ /** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -93,7 +93,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 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)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). --> coussin de chaque cote). -->
@@ -178,6 +178,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly" :disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -210,6 +211,7 @@
:key="address.id ?? `new-${index}`" :key="address.id ?? `new-${index}`"
:model-value="address" :model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions" :category-options="addressCategoryOptions"
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -244,8 +246,10 @@
editable uniquement si accounting.manage). --> editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -314,21 +318,27 @@
</div> </div>
</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 <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`" :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 <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> 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 <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -96,7 +96,7 @@
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 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)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). --> coussin de chaque cote). -->
@@ -156,6 +156,7 @@
:key="contact.id ?? index" :key="contact.id ?? index"
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled disabled
hide-empty hide-empty
/> />
@@ -170,6 +171,7 @@
:key="view.draft.id ?? index" :key="view.draft.id ?? index"
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -183,8 +185,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputText
v-if="isFilled(accounting.siren)" v-if="isFilled(accounting.siren)"
:model-value="accounting.siren" :model-value="accounting.siren"
@@ -239,13 +243,16 @@
</div> </div>
</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 <div
v-for="(rib, index) in ribs" v-for="(rib, index) in ribs"
:key="rib.id ?? index" :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 <MalioInputText
v-if="isFilled(rib.label)" v-if="isFilled(rib.label)"
:model-value="rib.label" :model-value="rib.label"
@@ -87,7 +87,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 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)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont <!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
le champ de 40px est centre dans un conteneur h-12 (~4px de le champ de 40px est centre dans un conteneur h-12 (~4px de
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
@@ -177,6 +177,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contact')" :disabled="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -209,6 +210,7 @@
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions" :category-options="addressCategoryOptions"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -242,8 +244,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) --> <!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -312,22 +316,28 @@
</div> </div>
</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 <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :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). --> <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<MalioButtonIcon <div class="flex items-center justify-between">
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
icon="mdi:delete-outline" <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> 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 <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -56,7 +56,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 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)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. --> <!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. -->
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
@@ -147,6 +147,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly" :disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -179,6 +180,7 @@
:key="address.id ?? `new-${index}`" :key="address.id ?? `new-${index}`"
:model-value="address" :model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="mainCategoryOptions" :category-options="mainCategoryOptions"
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -213,8 +215,10 @@
editable uniquement si accounting.manage). --> editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
@@ -283,21 +287,27 @@
</div> </div>
</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 <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`" :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 <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> 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 <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
@@ -71,7 +71,7 @@
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 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)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12). --> sur les inputs (champ 40px centre dans un h-12). -->
<MalioInputTextArea <MalioInputTextArea
@@ -137,6 +137,7 @@
:key="contact.id ?? index" :key="contact.id ?? index"
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled disabled
hide-empty hide-empty
/> />
@@ -151,6 +152,7 @@
:key="view.draft.id ?? index" :key="view.draft.id ?? index"
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -164,8 +166,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputText
v-if="isFilled(accounting.siren)" v-if="isFilled(accounting.siren)"
:model-value="accounting.siren" :model-value="accounting.siren"
@@ -220,13 +224,16 @@
</div> </div>
</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 <div
v-for="(rib, index) in ribs" v-for="(rib, index) in ribs"
:key="rib.id ?? index" :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 <MalioInputText
v-if="isFilled(rib.label)" v-if="isFilled(rib.label)"
:model-value="rib.label" :model-value="rib.label"
@@ -51,7 +51,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 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)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
:label="t('commercial.suppliers.form.information.description')" :label="t('commercial.suppliers.form.information.description')"
@@ -145,6 +145,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contacts')" :disabled="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -177,6 +178,7 @@
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="referentials.categories.value" :category-options="referentials.categories.value"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -210,8 +212,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) --> <!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
@@ -280,21 +284,27 @@
</div> </div>
</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 <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :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 <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> 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 <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
@@ -1,131 +1,140 @@
<template> <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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : modal de confirmation cote parent. --> (pas de bordure sous le dernier bloc). -->
<MalioButtonIcon <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="removable && !readonly && !disabled" <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
icon="mdi:delete-outline" <div class="flex items-center justify-between">
variant="ghost" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
button-class="absolute top-3 right-3" <!-- Suppression : modal de confirmation cote parent. -->
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). --> button-class="p-0"
<MalioSelectCheckbox v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
v-if="!hideEmpty || isFilled(model.siteIris)" @click="$emit('remove')"
: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)"
/> />
</div> </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 <MalioInputText
:model-value="model.streetComplement" v-if="!hideEmpty || isFilled(model.postalCode)"
:label="t('technique.providers.form.address.streetComplement')" :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" :mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:error="errors?.streetComplement" :required="!readonly && !disabled"
@update:model-value="(v: string) => update('streetComplement', v)" :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>
</div> </div>
</template> </template>
@@ -143,6 +152,8 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{ const props = defineProps<{
/** Brouillon de l'adresse (v-model). */ /** Brouillon de l'adresse (v-model). */
modelValue: ProviderAddressFormDraft modelValue: ProviderAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Sites Starseed disponibles. */ /** Sites Starseed disponibles. */
siteOptions: RefOption[] siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */ /** Contacts deja saisis, rattachables a l'adresse. */
@@ -150,6 +161,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean disabled?: boolean
@@ -1,84 +1,93 @@
<template> <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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si (pas de bordure sous le dernier bloc). -->
non supprimable (1er bloc) ou en lecture seule. --> <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
v-if="removable && !readonly && !disabled" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
variant="ghost" <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
button-class="absolute top-3 right-3" non supprimable (1er bloc) ou en lecture seule. -->
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<MalioInputText button-class="p-0"
v-if="!hideEmpty || isFilled(model.lastName)" v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
:model-value="model.lastName" @click="$emit('remove')"
:label="t('technique.providers.form.contact.lastName')" />
:mask="PERSON_NAME_MASK" </div>
:readonly="readonly"
:disabled="disabled" <!-- Grille 4 colonnes des champs du contact. -->
:error="errors?.lastName" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
@update:model-value="(v: string) => update('lastName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.lastName)"
<MalioInputText :model-value="model.lastName"
v-if="!hideEmpty || isFilled(model.firstName)" :label="t('technique.providers.form.contact.lastName')"
:model-value="model.firstName" :mask="PERSON_NAME_MASK"
:label="t('technique.providers.form.contact.firstName')" :readonly="readonly"
:mask="PERSON_NAME_MASK" :disabled="disabled"
:readonly="readonly" :error="errors?.lastName"
:disabled="disabled" @update:model-value="(v: string) => update('lastName', v)"
:error="errors?.firstName" />
@update:model-value="(v: string) => update('firstName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.firstName)"
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText :model-value="model.firstName"
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la :label="t('technique.providers.form.contact.firstName')"
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> :mask="PERSON_NAME_MASK"
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> :readonly="readonly"
<MalioInputText :disabled="disabled"
:model-value="model.jobTitle" :error="errors?.firstName"
:label="t('technique.providers.form.contact.jobTitle')" @update:model-value="(v: string) => update('firstName', v)"
:mask="FREE_TEXT_MASK" />
:readonly="readonly" <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
:disabled="disabled" (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
:error="errors?.jobTitle" cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
@update:model-value="(v: string) => update('jobTitle', v)" <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> </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> </div>
</template> </template>
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{ const props = defineProps<{
/** Brouillon du contact (v-model). */ /** Brouillon du contact (v-model). */
modelValue: ProviderContactFormDraft modelValue: ProviderContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable). */ /** Affiche l'icone de suppression (1er bloc non supprimable). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -46,6 +46,7 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
return mount(ProviderAddressBlock, { return mount(ProviderAddressBlock, {
props: { props: {
modelValue: { ...emptyProviderAddress(), ...overrides }, modelValue: { ...emptyProviderAddress(), ...overrides },
title: 'Adresse 1',
siteOptions: [], siteOptions: [],
contactOptions: [], contactOptions: [],
countryOptions: [], countryOptions: [],
@@ -29,6 +29,7 @@ function mountBlock(errors?: Record<string, string>) {
return mount(ProviderContactBlock, { return mount(ProviderContactBlock, {
props: { props: {
modelValue: emptyProviderContact(), modelValue: emptyProviderContact(),
title: 'Contact 1',
...(errors ? { errors } : {}), ...(errors ? { errors } : {}),
}, },
global: { global: {
@@ -72,7 +72,9 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly" :disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -104,6 +106,8 @@
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
@@ -136,8 +140,10 @@
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). --> <!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
@@ -206,21 +212,27 @@
</div> </div>
</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 <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :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 <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> 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 <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
@@ -81,6 +81,8 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled disabled
hide-empty hide-empty
/> />
@@ -94,6 +96,8 @@
v-for="(view, index) in addressViews" v-for="(view, index) in addressViews"
:key="index" :key="index"
:model-value="view.draft" :model-value="view.draft"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:site-options="view.siteOptions" :site-options="view.siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)" :country-options="countryOptionsFor(view.draft.country)"
@@ -108,8 +112,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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.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 /> <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="" /> <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>
</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 <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :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.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.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 /> <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" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contact')" :disabled="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -108,6 +110,8 @@
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
@@ -139,8 +143,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). --> <!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <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)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
@@ -210,21 +216,27 @@
</div> </div>
</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 <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :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 <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> 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 <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
@@ -1,103 +1,113 @@
<template> <template>
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. --> <!-- 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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Pays : prerempli « France » (RG-4.05). --> (pas de bordure sous le dernier bloc). -->
<MalioSelect <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="!hideEmpty || isFilled(model.country)" <!-- En-tete : titre du bloc, en noir (adresse unique, sans suppression). -->
:model-value="model.country" <div class="flex items-center justify-between">
:options="countryOptions" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
: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> </div>
<MalioInputText <!-- Grille 4 colonnes des champs de l'adresse. -->
v-if="!hideEmpty || isFilled(model.streetComplement)" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
:model-value="model.streetComplement" <!-- Pays : prerempli « France » (RG-4.05). -->
:label="t('transport.carriers.form.address.streetComplement')" <MalioSelect
:mask="ADDRESS_MASK" v-if="!hideEmpty || isFilled(model.country)"
:readonly="readonly" :model-value="model.country"
:disabled="disabled" :options="countryOptions"
:error="errors?.streetComplement" :label="t('transport.carriers.form.address.country')"
@update:model-value="(v: string) => update('streetComplement', v)" :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> </div>
</template> </template>
@@ -118,8 +128,12 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{ const props = defineProps<{
/** Brouillon de l'adresse (v-model). */ /** Brouillon de l'adresse (v-model). */
modelValue: CarrierAddressFormDraft modelValue: CarrierAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean disabled?: boolean
@@ -1,84 +1,93 @@
<template> <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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si (pas de bordure sous le dernier bloc). -->
non supprimable (1er bloc) ou en lecture seule. --> <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
v-if="removable && !readonly && !disabled" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
variant="ghost" <!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
button-class="absolute top-3 right-3" non supprimable (1er bloc) ou en lecture seule. -->
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<MalioInputText button-class="p-0"
v-if="!hideEmpty || isFilled(model.lastName)" v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
:model-value="model.lastName" @click="$emit('remove')"
:label="t('transport.carriers.form.contact.lastName')" />
:mask="PERSON_NAME_MASK" </div>
:readonly="readonly"
:disabled="disabled" <!-- Grille 4 colonnes des champs du contact. -->
:error="errors?.lastName" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
@update:model-value="(v: string) => update('lastName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.lastName)"
<MalioInputText :model-value="model.lastName"
v-if="!hideEmpty || isFilled(model.firstName)" :label="t('transport.carriers.form.contact.lastName')"
:model-value="model.firstName" :mask="PERSON_NAME_MASK"
:label="t('transport.carriers.form.contact.firstName')" :readonly="readonly"
:mask="PERSON_NAME_MASK" :disabled="disabled"
:readonly="readonly" :error="errors?.lastName"
:disabled="disabled" @update:model-value="(v: string) => update('lastName', v)"
:error="errors?.firstName" />
@update:model-value="(v: string) => update('firstName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.firstName)"
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false) :model-value="model.firstName"
renvoie `class` sur l'input interne, pas sur la cellule de grille. --> :label="t('transport.carriers.form.contact.firstName')"
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> :mask="PERSON_NAME_MASK"
<MalioInputText :readonly="readonly"
:model-value="model.jobTitle" :disabled="disabled"
:label="t('transport.carriers.form.contact.jobTitle')" :error="errors?.firstName"
:mask="FREE_TEXT_MASK" @update:model-value="(v: string) => update('firstName', v)"
:readonly="readonly" />
:disabled="disabled" <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
:error="errors?.jobTitle" renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
@update:model-value="(v: string) => update('jobTitle', v)" <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> </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> </div>
</template> </template>
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{ const props = defineProps<{
/** Brouillon du contact (v-model). */ /** Brouillon du contact (v-model). */
modelValue: CarrierContactFormDraft modelValue: CarrierContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icône de suppression (1er bloc non supprimable). */ /** Affiche l'icône de suppression (1er bloc non supprimable). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet validé). */ /** Bloc en lecture seule (onglet validé). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -1,190 +1,199 @@
<template> <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)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : modal de confirmation côté parent. --> (pas de bordure sous le dernier bloc), aligne sur les blocs contact / adresse. -->
<MalioButtonIcon <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="removable && !readonly && !disabled" <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
icon="mdi:delete-outline" <div class="flex items-center justify-between">
variant="ghost" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
button-class="absolute top-3 right-3" <!-- Suppression : modal de confirmation côté parent. -->
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios button-class="p-0"
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
case « Affréter ». Pas de label de groupe. --> @click="$emit('remove')"
<div> />
<div class="flex h-12 items-center gap-6">
<MalioRadioButton
:model-value="model.direction"
:name="`price-direction-${uid}`"
value="CLIENT"
:label="t('transport.carriers.form.price.directionClient')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="onDirectionChange"
/>
<MalioRadioButton
:model-value="model.direction"
:name="`price-direction-${uid}`"
value="FOURNISSEUR"
:label="t('transport.carriers.form.price.directionSupplier')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="onDirectionChange"
/>
</div>
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
</div> </div>
<!-- Branche CLIENT (RG-4.10). --> <!-- Grille 4 colonnes des champs du prix. -->
<template v-if="model.direction === 'CLIENT'"> <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioSelect <!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
:model-value="model.clientIri" EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
:options="clientOptions" case « Affréter ». Pas de label de groupe. -->
:label="t('transport.carriers.form.price.client')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.client"
@update:model-value="onClientChange"
/>
<MalioSelect
:model-value="model.clientDeliveryAddressIri"
:options="clientAddressOptions"
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.clientDeliveryAddress"
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.departureSiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.departureSite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.departureSite"
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Branche FOURNISSEUR (RG-4.11). -->
<template v-else-if="model.direction === 'FOURNISSEUR'">
<MalioSelect
:model-value="model.supplierIri"
:options="supplierOptions"
:label="t('transport.carriers.form.price.supplier')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplier"
@update:model-value="onSupplierChange"
/>
<MalioSelect
:model-value="model.supplierSupplyAddressIri"
:options="supplierAddressOptions"
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplierSupplyAddress"
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.deliverySiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.deliverySite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.deliverySite"
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Communs (visibles dès qu'un sens est choisi). -->
<template v-if="model.direction !== null">
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
<div> <div>
<div class="flex h-12 items-center gap-4"> <div class="flex h-12 items-center gap-6">
<MalioRadioButton <MalioRadioButton
:model-value="model.containerType" :model-value="model.direction"
:name="`price-container-${uid}`" :name="`price-direction-${uid}`"
value="BENNE" value="CLIENT"
:label="t('transport.carriers.containerType.BENNE')" :label="t('transport.carriers.form.price.directionClient')"
:disabled="readonly || disabled" :disabled="readonly || disabled"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))" @update:model-value="onDirectionChange"
/> />
<MalioRadioButton <MalioRadioButton
:model-value="model.containerType" :model-value="model.direction"
:name="`price-container-${uid}`" :name="`price-direction-${uid}`"
value="FOND_MOUVANT" value="FOURNISSEUR"
:label="t('transport.carriers.containerType.FOND_MOUVANT')" :label="t('transport.carriers.form.price.directionSupplier')"
:disabled="readonly || disabled" :disabled="readonly || disabled"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))" @update:model-value="onDirectionChange"
/> />
</div> </div>
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p> <p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
</div> </div>
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). --> <!-- Branche CLIENT (RG-4.10). -->
<div> <template v-if="model.direction === 'CLIENT'">
<div class="flex h-12 items-center gap-4"> <MalioSelect
<MalioRadioButton :model-value="model.clientIri"
:model-value="model.pricingUnit" :options="clientOptions"
:name="`price-unit-${uid}`" :label="t('transport.carriers.form.price.client')"
value="FORFAIT" empty-option-label=""
:label="t('transport.carriers.form.price.pricingForfait')" :required="true"
:disabled="readonly || disabled" :readonly="readonly"
group-class="mt-0" :disabled="disabled"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))" :error="errors?.client"
/> @update:model-value="onClientChange"
<MalioRadioButton />
:model-value="model.pricingUnit" <MalioSelect
:name="`price-unit-${uid}`" :model-value="model.clientDeliveryAddressIri"
value="TONNE" :options="clientAddressOptions"
:label="t('transport.carriers.form.price.pricingTonne')" :label="t('transport.carriers.form.price.clientDeliveryAddress')"
:disabled="readonly || disabled" empty-option-label=""
group-class="mt-0" :required="true"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))" :readonly="readonly"
/> :disabled="disabled"
:error="errors?.clientDeliveryAddress"
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.departureSiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.departureSite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.departureSite"
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Branche FOURNISSEUR (RG-4.11). -->
<template v-else-if="model.direction === 'FOURNISSEUR'">
<MalioSelect
:model-value="model.supplierIri"
:options="supplierOptions"
:label="t('transport.carriers.form.price.supplier')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplier"
@update:model-value="onSupplierChange"
/>
<MalioSelect
:model-value="model.supplierSupplyAddressIri"
:options="supplierAddressOptions"
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplierSupplyAddress"
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.deliverySiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.deliverySite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.deliverySite"
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Communs (visibles dès qu'un sens est choisi). -->
<template v-if="model.direction !== null">
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="model.containerType"
:name="`price-container-${uid}`"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/>
<MalioRadioButton
:model-value="model.containerType"
:name="`price-container-${uid}`"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/>
</div>
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
</div> </div>
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
</div>
<MalioInputAmount <!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
:model-value="model.price" <div>
:label="t('transport.carriers.form.price.price')" <div class="flex h-12 items-center gap-4">
:required="true" <MalioRadioButton
:readonly="readonly" :model-value="model.pricingUnit"
:disabled="disabled" :name="`price-unit-${uid}`"
:error="errors?.price" value="FORFAIT"
@update:model-value="(v: string) => update('price', v)" :label="t('transport.carriers.form.price.pricingForfait')"
/> :disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/>
<MalioRadioButton
:model-value="model.pricingUnit"
:name="`price-unit-${uid}`"
value="TONNE"
:label="t('transport.carriers.form.price.pricingTonne')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/>
</div>
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
</div>
<MalioSelect <MalioInputAmount
:model-value="model.priceState" :model-value="model.price"
:options="priceStateOptions" :label="t('transport.carriers.form.price.price')"
:label="t('transport.carriers.form.price.priceState')" :required="true"
empty-option-label="" :readonly="readonly"
:required="true" :disabled="disabled"
:readonly="readonly" :error="errors?.price"
:disabled="disabled" @update:model-value="(v: string) => update('price', v)"
:error="errors?.priceState" />
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
/> <MalioSelect
</template> :model-value="model.priceState"
:options="priceStateOptions"
:label="t('transport.carriers.form.price.priceState')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.priceState"
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
/>
</template>
</div>
</div> </div>
</template> </template>
@@ -200,6 +209,10 @@ interface SelectOption {
const props = defineProps<{ const props = defineProps<{
/** Brouillon du prix (v-model). */ /** Brouillon du prix (v-model). */
modelValue: CarrierPriceFormDraft modelValue: CarrierPriceFormDraft
/** Titre du bloc (ex: « Prix 1 »). */
title: string
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Clients disponibles (IRI en value). */ /** Clients disponibles (IRI en value). */
clientOptions: SelectOption[] clientOptions: SelectOption[]
/** Fournisseurs disponibles (IRI en value). */ /** Fournisseurs disponibles (IRI en value). */
@@ -56,6 +56,7 @@ function mountBlock(overrides: Record<string, unknown> = {}) {
return mount(CarrierAddressBlock, { return mount(CarrierAddressBlock, {
props: { props: {
modelValue: { ...emptyCarrierAddress(), ...overrides }, modelValue: { ...emptyCarrierAddress(), ...overrides },
title: 'Adresse 1',
countryOptions: [{ value: 'France', label: 'France' }], countryOptions: [{ value: 'France', label: 'France' }],
}, },
global: { global: {
@@ -143,6 +143,8 @@
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. --> <!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptions" :country-options="countryOptions"
:errors="addressErrors" :errors="addressErrors"
@update:model-value="(v) => address = v" @update:model-value="(v) => address = v"
@@ -160,7 +162,9 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -178,10 +182,12 @@
v-for="(price, index) in prices" v-for="(price, index) in prices"
:key="index" :key="index"
:model-value="price" :model-value="price"
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
:client-options="clientOptions" :client-options="clientOptions"
:supplier-options="supplierOptions" :supplier-options="supplierOptions"
:site-options="siteOptions" :site-options="siteOptions"
removable removable
:last="index === prices.length - 1"
:errors="priceErrors[index]" :errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v" @update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)" @remove="askRemovePrice(index)"
@@ -123,6 +123,8 @@
<!-- Adresse UNIQUE (ERP-172). --> <!-- Adresse UNIQUE (ERP-172). -->
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptionsFor(address.country)" :country-options="countryOptionsFor(address.country)"
disabled disabled
hide-empty hide-empty
@@ -136,6 +138,8 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled disabled
hide-empty hide-empty
/> />
@@ -180,6 +180,8 @@
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. --> <!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptions" :country-options="countryOptions"
:disabled="isQualimat || isValidated('addresses')" :disabled="isQualimat || isValidated('addresses')"
:errors="addressErrors" :errors="addressErrors"
@@ -207,7 +209,9 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contacts')" :disabled="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -240,11 +244,13 @@
v-for="(price, index) in prices" v-for="(price, index) in prices"
:key="index" :key="index"
:model-value="price" :model-value="price"
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
:client-options="clientOptions" :client-options="clientOptions"
:supplier-options="supplierOptions" :supplier-options="supplierOptions"
:site-options="siteOptions" :site-options="siteOptions"
:removable="!isValidated('prices')" :removable="!isValidated('prices')"
:disabled="isValidated('prices')" :disabled="isValidated('prices')"
:last="index === prices.length - 1"
:errors="priceErrors[index]" :errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v" @update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)" @remove="askRemovePrice(index)"